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

从 instanceof 到协议

你早就撞过这堵墙:想给一个不是你写的类加个方法。于是你写了一个 StringUtils。这一章说清楚那个工具类为什么解决不了问题,然后不改源码、不继承、不包装,给 String、Long 和 nil 加上同一个方法。

方法表独立于类给别人的类型加方法协议 vs 接口

▷ 先猜一下

你想给 StringInteger 和你自己的 Order 都加一个 size() 方法,然后写一个函数统一调用它:

int total(List<?> things) {
    int sum = 0;
    for (Object t : things) sum += /* t 的 size */;
    return sum;
}

在 Java 里,你能做到吗?

A 能,写一个 SizeUtils.size(Object) 工具方法 B 能,定义一个 Sized 接口,让三个类都实现它 C 都不行:A 不是多态的,B 改不了 String D 能,用反射按类型分派

那堵墙

答案是 C。两条路各堵一半:

接口这条路interface Sized { int size(); }—— 漂亮,多态,可扩展。唯一的问题是你改不了 String。 它是 JDK 的类,final 的,你没法让它实现你的接口。

工具类这条路

static int size(Object o) {
    if (o instanceof String s) return s.length();
    if (o instanceof Integer i) return i;
    if (o instanceof Order od) return od.getItems().size();
    throw new IllegalArgumentException();
}

能跑,但它不是多态的。这句话的实际含义是:

  • 别人加了一个新类型,得来改你的这个函数—— 而如果这个函数在另一个库里,他改不了。
  • 这个 if 链会一直长下去,而且是线性查找。
  • 漏了一个类型,运行到那一行才炸。

更根本的是:你把「这件事怎么做」的知识,从类型那里搬到了一个中心化的函数里。 开放-封闭原则说的就是这个——你的函数对扩展是封闭的。

◆ 可以带走的判断

这堵墙的名字:方法属于类。 既然方法必须写在类的花括号里,那你改不了的类,就永远加不了方法。

Clojure 的做法是把这个前提拿掉——方法表是一张独立存在的表, 类只是这张表的一个键

协议:一张独立的表

(defprotocol Sized
  (sized [x]))                       ;; 声明:有这么一个方法

;; 现在给一堆我没写过、也改不了的类型加上它
(extend-type java.lang.String  Sized (sized [s] (count s)))
(extend-type java.lang.Long    Sized (sized [n] n))
(extend-type clojure.lang.PersistentVector Sized (sized [v] (count v)))
(extend-type nil               Sized (sized [_] 0))    ;; ← 连 nil 都行

然后它就是多态的了:

注意最后那个 nil。在 Java 里你永远没法「给 null 加一个方法」—— null.size() 必然是 NPE。而这里, nil 只是派发表里的一个普通键, 于是「空的东西大小是 0」可以被表达成一条实现, 而不是每个调用点都要写的判空。

这件事的分量值得说透:那个 if (o == null) 不再散落在你代码的每个角落,它变成了派发表里的一行。

它凭什么能这样

机制很朴素。defprotocol 做了两件事:

  1. 建一张表:{类型 → 实现}
  2. 定义一个函数 sized,它的全部工作就是 「看第一个参数是什么类型,去表里查,调用查到的那个」。

extend-type 就是往表里加一行。仅此而已。

所以「扩展一个我改不了的类型」这件事之所以可能, 是因为要改的不是那个类,是这张表——而这张表是你的。

# Java:方法长在类里,类是别人的,所以你加不了
  ┌─ String ────────┐
  │ length()        │  ← 这个花括号你打不开
  │ substring()     │
  └─────────────────┘

# 协议:表在外面,类只是一个键
  Sized 的派发表 ◇
  ├─ String  → (fn [s] (count s))    ← 你加的
  ├─ Long    → (fn [n] n)            ← 你加的
  └─ nil     → (fn [_] 0)            ← 你加的

和 Java 8 的默认方法、Kotlin 的扩展函数比

这两个你都用过,值得摆在一起:

                     能给别人的类加?  是多态的?   能事后加?
──────────────────────────────────────────────────────────────
Java 接口             ✗(要改类)      ✓            ✗
Java 默认方法         ✗(要改接口)    ✓            ✗
Kotlin 扩展函数       ✓                ✗ 静态分派    ✓
协议                  ✓                ✓            ✓

Kotlin 的扩展函数那一行值得展开,因为它最接近、也最容易被误解:

fun String.sized() = this.length
fun Any.sized() = 0

val x: Any = "hello"
x.sized()      // => 0,不是 5 !

扩展函数是静态分派的:调用哪个版本, 由编译期看到的声明类型决定,不是运行期的实际类型。 所以它写起来像方法,行为上却是个静态工具函数—— 也就是说,它没有解决「多态」那一半问题,只解决了「写起来顺手」那一半

这不是说 Kotlin 做错了:静态分派换来了零开销和可预测性。 但要明白你买到的是什么。

代价

老实说三条:

  • 只按第一个参数派发。想按两个参数的组合派发? 协议做不到——那要用下一章的多方法。
  • 没有穷尽性检查。某个类型没实现,运行到那一行才报 「No implementation of method」。这是动态语言的老账。
  • 可能被人「打补丁」打坏。两个库同时给 String 扩展同一个协议,后加载的赢。 这在 Ruby 世界叫 monkey patching,是真实的风险; 社区的规矩是只扩展你自己拥有的类型或协议之一
▸ 在现实里
  • Rust 的 trait 是同一个思路,而且做得更彻底: impl MyTrait for String,编译期检查,零开销。 它还有一条明确的规矩(孤儿规则)来防止上面说的「打补丁打架」—— trait 和类型至少有一个得是你的。 如果你读过《借还》,这一章对你只是换了个语法。
  • Go 的接口:类型不用声明「我实现了这个接口」, 方法齐了就算实现。这是「结构化」的另一种解法。《留白》里讲过。
  • Haskell 的 type class:这一族思想的源头。 《上游》里有。

四种语言,四种做法,都在绕开同一堵墙:方法属于类。 这堵墙是 1980 年代 OO 的一个具体选择,不是天经地义。

✗ 这个直觉是错的

「给别人的类加方法就是 monkey patching, 是一种脏手段,正经项目不该用。」

真正脏的是 修改已有的行为 (Ruby 里把 String#length 重定义成别的)。 添加一个新方法则是另一回事:没有人依赖那个还不存在的方法。

协议还多了一道保险:扩展是按协议隔离的。 我给 String 加了 Sized/sized, 不影响任何不知道 Sized 的代码—— 它不像 Ruby 那样把方法糊到类本身上。

判断标准:你是在往一张自己的表里加行,还是在改别人表里已有的行? 前者安全,后者危险。

◇ 另存一版

答案是 C:工具方法不是多态的,接口改不了 String。

A 「工具方法」——能跑,但那个 if 链是中心化的: 新类型来了要改你的代码,而别人改不了你的库。能工作 ≠ 可扩展。

B 「定义接口」——方向完全正确, 它正是协议要做的事。差别只在于:接口要求类在定义时就接进来, 而协议允许任何人在任何时候把任何类型接进来。 同一个想法,只差一个「什么时候决定」。

D 「反射」——反射能,不能。 你没法用反射让 String 多出一个方法。

你进来时:能不能给一个类型加方法,取决于你能不能改它的源码。

你出去时:方法表是一张独立的表,类型只是键; 加方法 = 往表里加一行,而表是你的。

第 8 章的 seq 抽象终于有了名字—— 「在使用点问一个问题」正是协议的机制。 这一章只重建了一条路径:多态从哪里来

这一章的一句话

方法不属于类,属于一张独立的派发表;于是「我改不了这个类」 不再意味着「我加不了这个方法」。

下一章把这件事提到一个正式的问题上。上面那张 2×4 的表其实是一个更大问题的切片, 它有个名字,叫表达式问题: 加一个新类型容易,还是加一个新操作容易? 大多数语言只让你在两个方向里选一个开口—— 而下一章会给出一个两边都开着的方案,以及它的代价。