卷 V · 扩展CH 18深度 18/24

表达式问题:为什么你总要改别人的代码

上一章那堵墙不是孤例,它是一个可以精确陈述的约束。这一章给它一个名字,画一张 2×2 的表,然后给出一个两边都开着的方案——以及它真实的代价。

加类型 vs 加操作多方法按任意函数派发

▷ 先猜一下

你有一个图形库:三个形状(圆、方、三角),一个操作(求面积)。

现在有两个需求,各来一次:

需求甲:加一个新形状(五边形)
需求乙:加一个新操作(求周长)

问:在面向对象的写法下(每个形状是一个类,面积是类里的方法), 这两个需求哪个更麻烦?

A 甲更麻烦:新形状要实现所有已有方法 B 乙更麻烦:新操作要改所有已有的类 C 一样麻烦,都是 N 处改动 D 两个都不麻烦,这就是 OO 的好处

一张 2×2 的表

答案是 B。把两种范式并排放,问题就清楚了:

面向对象:方法长在类里
                求面积   求周长 ←(要加这一列,得改下面每一行)
      圆          ✓        ✓
      方          ✓        ✓
      三角        ✓        ✓
      ↑ 加一行(新形状):容易,新写一个类,谁都不用动

函数式 + 模式匹配:操作长在函数里
                求面积   求周长 ←(加一列:容易,新写一个函数)
      圆          ✓        ✓
      方          ✓        ✓
      三角        ✓        ✓
      ↑ 加一行(新类型):要改 area、perimeter …每一个函数

两种范式,各开一个方向:

  • 面向对象:加类型容易(新写一个类),加操作难(改所有类)。
  • 函数式 + 模式匹配:加操作容易(新写一个函数),加类型难(改所有函数)。

这个两难有个正式名字。

✎ 术语正名

表达式问题(Expression Problem): 能不能在不修改已有代码不重新编译已有代码、 并且保持类型安全的前提下,既能加新类型,又能加新操作?

这个名字来自 Philip Wadler 1998 年的一封邮件。他把它称为 「一个衡量语言表达能力的老问题的新名字」。

关键在于它不是口水仗:它是一个可以被精确检验的性质。 给你一门语言,摆上这两个需求,看它要改几处——数得出来。

协议怎么两边都开

上一章的机制现在可以正面回答这个问题: 因为方法表既不属于类,也不属于函数,它是独立的第三方。

;; 第一天:一个操作,两个类型
(defprotocol Shape (area [s]))
(defrecord Circle [r]    Shape (area [_] (* 3.14 r r)))
(defrecord Square [side] Shape (area [_] (* side side)))

;; 第二天:加一个新类型 —— 不碰上面任何一行
(defrecord Triangle [b h] Shape (area [_] (/ (* b h) 2)))

;; 第三天:加一个新操作 —— 也不碰上面任何一行
(defprotocol Perim (perim [s]))
(extend-type Circle   Perim (perim [c] (* 6.28 (:r c))))
(extend-type Square   Perim (perim [s] (* 4 (:side s))))
(extend-type Triangle Perim (perim [t] ...))

两个方向都是「新写一段,不改旧的」。

代价在最后一句话上:没有类型安全的那部分保证。 如果第三天你忘了给 Triangle 实现 Perim, 没有人会告诉你——直到运行到那一行,抛出 No implementation of method: perim

而 Wadler 那个问题的完整表述里,「保持类型安全」是要求之一。 所以严格说,Clojure 的答案是「用放弃静态检查换两个方向都开」。 Rust 的 trait、Haskell 的 type class 则是真正在类型安全前提下解决了它—— 代价是更复杂的类型系统。

◆ 可以带走的判断

没有免费的午餐,只有三种付法:
· OO:付「加操作」的钱
· 模式匹配:付「加类型」的钱
· 协议 / trait / type class:付「更复杂的机制」的钱, 而 Clojure 这一支还额外付了「没有穷尽性检查」

多方法:把派发这件事整个交给你

协议只按第一个参数的类型派发。有些问题不长这样。

比如:运费怎么算,取决于会员等级目的地的组合。 这里没有一个「类型」可以承载派发——决定走哪条逻辑的是数据的内容。

defmulti 让你自己写那个「看什么派发」的函数:

(defmulti shipping-fee
  (fn [order] [(:level order) (:region order)]))     ;; ← 派发函数,返回什么都行

(defmethod shipping-fee [:vip :domestic]   [_] 0)
(defmethod shipping-fee [:vip :overseas]   [_] 50)
(defmethod shipping-fee [:normal :domestic] [_] 10)
(defmethod shipping-fee :default            [_] 80)

(shipping-fee {:level :vip :region :overseas})    ;; => 50

派发值是任何值:一个向量、一个关键字、一个算出来的数、 甚至一个正则匹配的结果。这比「按类型派发」宽得多—— 类型只是「关于这个值我们能问的问题」里最粗的一个

还有一个额外能力:派发值之间可以有层级关系

(derive ::vip ::member)          ;; vip 是一种 member
(derive ::svip ::vip)            ;; svip 是一种 vip

(defmethod discount ::member [_] 0.95)

(discount {:level ::svip})       ;; => 0.95  ← 沿着关系往上找

这等于是在数据上定义了一个继承体系, 而且是运行期可以改的、和类无关的。 你可以给不是你写的类型建立这种关系,因为它只是一张表。

那什么时候用哪个

情况                              用什么
──────────────────────────────────────────────────────
按类型派发,性能敏感                协议    快(JVM 的方法调用)
按数据内容派发                      多方法  慢一点,但灵活
派发值有层级关系                    多方法  derive / isa?
只有几个固定分支,不需要扩展         case / cond   别过度设计

最后一行很重要。表达式问题只在你确实需要事后扩展时才存在。 如果这三个形状这辈子都不会变,一个 case 就够了, 上协议是给读代码的人加负担。

☕ 写 Java 的你

你解决这个问题的现成工具是访问者模式(Visitor)。 它确实能让「加操作」变容易——新写一个 Visitor 就行。

代价你也熟:加一个新类型时,要改 Visitor 接口, 于是所有已有的 Visitor 实现都得跟着改。 访问者模式不是解决了表达式问题,是把开口从一边换到了另一边

Java 21 的 sealed interface + 模式匹配走的是另一条路: 它承认「类型是封闭的」,换来了穷尽性检查—— 漏了一个分支编译不过。这正好是 Clojure 放弃的那样东西。

两个方向都合理,取决于你的类型集合到底封不封闭。 ADT 那条路的前提是「我知道所有情况」,协议那条路的前提是「我不知道」。

▸ 在现实里
  • 序列化库:这是最经典的场景。 一个 JSON 库不可能预先知道你的类型, 你的类型也不可能预先知道那个 JSON 库。 只有「事后往表里加一行」这条路走得通。
  • 数据库驱动next.jdbc 用协议决定 「这个 Java 类型怎么变成 SQL 参数」, 你可以为自己的类型加一行,不用等库作者。
  • 你的 Android 项目:想给 List<NetworkModel> 加一个 toUiModel()—— 扩展函数能写,但它是静态分派的。如果 UI 层拿到的是 List<Any>, 这条路就断了,你又回到了 when (it) { is A -> ... }
✗ 这个直觉是错的

「表达式问题是个学术问题。实际工作中我加个方法、 改几个类就完事了,没那么严重。」

你能改所有代码的前提下,它确实不严重。 它变严重的场合只有一个,但这个场合极其常见: 代码分属不同的所有者

你用着一个开源库,想给它的类型加个操作——你改不了它。 你写了一个库,用户想给自己的类型接上你的操作——他改不了你。 只要跨过了「谁能改这段代码」的边界,这个问题就从学术问题变成了现实的墙。

这也是为什么它在库和框架的设计里被讨论得最多, 在单体业务代码里几乎不被提起。

◇ 另存一版

答案是 B:在 OO 里,加新操作要改所有已有的类。

A 「新形状要实现所有方法」——要写,但那是 新写,不是修改已有代码。这个区别是整个问题的核心: 新写一段谁也不影响,改一段已有的会波及所有依赖它的人。

C 「一样麻烦」——改动数量可能差不多, 但性质完全不同。一个是「加」,一个是「改」; 一个不需要重新测试旧代码,另一个需要。

D 「都不麻烦」——OO 确实在「加类型」这个方向上很强, 这也是它流行的原因之一(大多数业务系统确实更常加类型)。 但它在另一个方向上是封闭的,而这个封闭不是缺陷,是它的设计选择。

你进来时:「该用继承还是该用函数」是个风格问题,看团队习惯。

你出去时:这是一个可以精确陈述的约束—— 两个扩展方向,大多数语言只开一个口; 选哪个取决于你的类型集合封不封闭,以及代码归谁所有。

上一章的协议机制一个字没变, 这一章只是给它找到了它要解决的那个问题的名字, 并补上了「派发不一定按类型」这条路径。

这一章的一句话

加类型容易还是加操作容易,是一个能数出来的性质; 大多数语言只开一个口,而开两个口的代价是放弃穷尽性检查或者接受更复杂的类型系统。

下一章进入这本书最有名、也最容易被神化的东西。 第 4 章埋的那个伏笔——「代码就是数据」——终于要兑现了。 我们会拿一台真的展开器,看着 (-> 5 inc (* 2)) 在你眼前变成 (* (inc 5) 2): 它没有引入任何新语义,只是把代码重新排了一下