从 instanceof 到协议
你早就撞过这堵墙:想给一个不是你写的类加个方法。于是你写了一个 StringUtils。这一章说清楚那个工具类为什么解决不了问题,然后不改源码、不继承、不包装,给 String、Long 和 nil 加上同一个方法。
你想给 String、Integer 和你自己的 Order
都加一个 size() 方法,然后写一个函数统一调用它:
int total(List<?> things) {
int sum = 0;
for (Object t : things) sum += /* t 的 size */;
return sum;
}
在 Java 里,你能做到吗?
那堵墙
答案是 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 做了两件事:
- 建一张表:
{类型 → 实现}。 - 定义一个函数
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 的表其实是一个更大问题的切片, 它有个名字,叫表达式问题: 加一个新类型容易,还是加一个新操作容易? 大多数语言只让你在两个方向里选一个开口—— 而下一章会给出一个两边都开着的方案,以及它的代价。