你以为你在改它
你写了很多年这样的代码:拿到一个对象,改它一个字段,事情就算办完了。这一章不打算说这样写不好——它想让你看清楚,你以为发生的事,和实际发生的事,差了一层。而那一层里藏着你这些年修过的一大半 bug。
下面这段 Java 代码,你写过一千遍:
List<String> names = new ArrayList<>(List.of("Ada", "Alan"));
report(names);
System.out.println(names);
report 是别人写的,签名是 void report(List<String> xs),
文档说它「生成一份报表」。
问:最后那行打印出来的,一定还是 [Ada, Alan] 吗?
先记住你的答案。这一题不难,但你对它的第一反应, 决定了后面 23 章你会觉得是「学新东西」还是「早该这样」。
一个你已经付了很多年的税
答案是 B,你多半知道。report 拿到的不是副本,是同一个列表。
它想加就能加,想清空就能清空,而调用处那一行 report(names); 看不出任何痕迹。
你知道这件事,所以你早就学会了自保。你写过这些:
// 一、防御性复制:进来的时候抄一份
public Order(List<Item> items) {
this.items = new ArrayList<>(items);
}
// 二、出去的时候包一层
public List<Item> getItems() {
return Collections.unmodifiableList(items);
}
// 三、干脆在文档里喊话
/** @param xs 本方法不会修改 xs。真的。 */
这三行代码里没有一行在解决你的业务问题。它们是税。你交这笔税, 是为了买回一个本该白送的保证:我给你的东西,你看过之后,它还是原来那个。
而且这笔税交得并不彻底。第一种要付出真实的复制开销,数据大了就肉疼;
第二种只挡住了一层,unmodifiableList 里面的 Item 照样可以被改;
第三种根本不是代码,是祈祷。
「传进去的东西可能被改」不是一个偶尔要注意的细节, 它是你所有防御性代码的共同源头。
同一个函数,两种语言
下面这个 demo 里,两边的函数都只有一行,都叫 add-one,都是「往列表里加一个 99」。
点一下切换,看看调用之后原来那个列表怎么样了。
Clojure 那一边,(conj v 99) 没有修改 v。
它返回了一个新的向量,里面有四个元素;而 v 还是那三个。
两个都能用,两个都是完整的、正常的向量。
这时候你的第一反应应该是那句话,而且你应该现在就说出来:
那不就是每次都复制一份吗?一百万个元素也复制?那得多慢。
这个反应完全正确,而且它就是下一章的全部内容。这里先把答案剧透一句: 100 万个元素的向量改掉其中一个,真 Clojure 新建的节点数是 4 个, 不是一百万。为什么能这样,第 2 章会把那棵树拆开给你看。
「值」和「位置」是两样东西
现在把话说准。我们要区分两个词,这两个词整本书都会用。
值(value):42、"Ada"、[1 2 3]。
它不会变,也不可能变。你没法「把 42 改成 43」——你只能换一个数。
没有人会问「42 线程安全吗」,因为这个问题不成立。
位置(place):内存里的一个格子,一个可以往里放东西的坑。
x = 5 是把 5 放进 x 这个坑;x = 6 是把坑里的东西换掉。
坑是会变的,所以两个人同时往一个坑里放东西就要打架。
Java 的 ArrayList 是一个装满了坑的坑。
Clojure 的向量是一个值——就像 42 一样不会变,只不过它里面有一百万个数。
这个区分为什么值钱?因为一旦你手上的东西是值,下面这些问题全部一起消失, 不是变简单,是不成立了:
- 要不要防御性复制?——不用,它不会变。
- 这个对象线程安全吗?——这个问题不成立,值没有线程问题。
- 我遍历的时候别人改了它怎么办?——不会有
ConcurrentModificationException, 你遍历的那个东西谁也改不了。 - 缓存它安全吗?可以当 map 的 key 吗?——安全,可以。
- 它现在是什么状态?——它就是它,没有「现在」这一说。
最后一条听起来像绕口令,第 3 章会专门拆它。
你以为它是 X,其实它是 Y
这一节是这本书存在的理由。上面那件事——「你以为在改一个东西,其实是让名字指向新值」—— 不是 Clojure 的语言特性。它是一层透镜。戴上之后你会发现, 你已经在很多地方见过它了,只是当时不知道它们是同一件事。
| 你以为它是 | 其实它是 | 哪一章 / 哪本书 |
|---|---|---|
| 变量是个盒子,赋值是往里放东西 | 名字是根指针,赋值是让它指向另一个值 | 第 3 章 |
| Git 存的是「这次改了什么」 | 存的是整棵树的快照,和上一版共用几乎所有节点 | 《快照》 |
| 数据库里那一行就是「那行数据」 | 是那一行在某个时刻的值;MVCC 让你读到的是快照 | 第 16 章 |
| 界面每帧重建很浪费 | 新旧两棵树共用绝大部分,只有变的那条路径被重建 | 《重跑》 |
| 撤销功能要「反着做一遍」 | 只要把名字指回上一个值 | 第 3 章 |
| 并发要加锁 | 只有共享的可变才要锁;值不需要 | 第 13 章 |
| 把数据和行为放一起是好设计 | 也把「值」和「身份」揉成了一团,这是时间混乱的根 | 第 3、17 章 |
| 区块链是「加密货币的技术」 | 是一条只能追加的、值的链表 | 《上链》 |
| 你记得的过去是一段录像 | 是一串快照,而且和现在共用大部分结构 | 第 16 章 |
这张表里的每一行,都是同一句话换了个场景:把「会变的东西」换成「一串不变的值 + 一个指针」。 学会这一句,你会在完全不相干的地方认出它——这就是这本书想给你的东西。
你手机上的相册有「最近删除」,你的 IDE 有撤销,你的 Git 有 reflog,
你的数据库有时间点恢复。这四样东西的实现细节完全不同,但它们能存在的原因只有一个:
旧的那一版没有被真的删掉。
反过来说,凡是「改了就找不回来」的系统,都是在某个地方选择了「原地覆盖」。
list.add(x) 就是原地覆盖,只不过覆盖的是一个你不太在意的东西,
所以你从来没把它和「误删了照片」联系起来。
「Java 是值传递,所以传进去的是副本,函数改不了我的东西。」
Java 确实是值传递——但传的是引用的值。 副本是那根指针的副本,不是对象的副本。两根指针指着同一个坑, 谁都能往坑里动手。
这句话之所以有害,不是因为它错得离谱,而是因为它半对:
它对基本类型是对的(int 确实是副本),
于是你在心里给所有参数都盖了一个「安全」的章。真正咬人的永远是那些
List、Map、以及你自己写的那个 Order。
《快照》(Git):那本书的主线是「Git 存的不是 diff,是快照」, 和这一章是同一句话。等你读到第 2 章的结构共享, 会发现 Git 的树对象用的是完全一样的招——改一个文件,只重建从根到它的那条路径。
《同时》(并发):那本书把「同步/异步、阻塞/非阻塞、并发/并行」三组词钉死。 这本书的卷 IV 是它的补角:那些问题里有一大半, 在「共享的东西不会变」的前提下根本不会出现。
开头那道题的答案是 B:不一定,report 可能往里加东西,
而它的签名 void report(List<String> xs) 一个字都没提这件事。
A 「Java 是值传递所以是副本」——半对,见上面那条错误直觉。 传的是引用的副本,不是对象的副本。
C 「单线程就没事」——多线程只是让这个问题更难复现, 不是它的成因。单线程里一样会被改,只是你更容易查出来。
D 「除非用反射」——不需要反射,一句 xs.add() 就够了。
这一项之所以有人选,是因为「修改别人的数据」听起来像是需要特殊手段的事。
它不需要。它是默认行为。
你进来时:修改一个对象是一个基本动作,偶尔要小心别人也拿着它。
你出去时:修改从来不是基本动作。基本动作是「产生一个新值」, 而「原地改」是一种为了省内存而做的优化——你为这个优化交了很多年的税。
这一章没有推翻你任何一个具体知识:防御性复制还是对的,
unmodifiableList 还是有用的。只重建了一条路径——
它们从「好习惯」变成了「症状」。
这一章的一句话
你从来没有改过任何东西——你只是让一个名字,指向了另一个值。 而你这些年写的防御性代码,都是在为「这句话在 Java 里不成立」交税。
下一章回答那个你现在一定在想的问题:不复制怎么可能? 我们会拿一个一百万个元素的向量,改掉第 500,000 个, 然后数一数到底新建了几个节点。答案是 4 个——而整棵树有 32,258 个节点。