卷 I · 值不动CH 01深度 1/24

你以为你在改它

你写了很多年这样的代码:拿到一个对象,改它一个字段,事情就算办完了。这一章不打算说这样写不好——它想让你看清楚,你以为发生的事,和实际发生的事,差了一层。而那一层里藏着你这些年修过的一大半 bug。

值 vs 位置防御性复制一张跨领域对照表

▷ 先猜一下

下面这段 Java 代码,你写过一千遍:

List<String> names = new ArrayList<>(List.of("Ada", "Alan"));
report(names);
System.out.println(names);

report 是别人写的,签名是 void report(List<String> xs), 文档说它「生成一份报表」。

问:最后那行打印出来的,一定还是 [Ada, Alan] 吗?

A 一定是。Java 是值传递,传进去的是副本 B 不一定。report 可能往里加东西,而签名看不出来 C 不一定,但只要 report 不是多线程的就没事 D 一定是,除非 report 用了反射

先记住你的答案。这一题不难,但你对它的第一反应, 决定了后面 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 确实是副本), 于是你在心里给所有参数都盖了一个「安全」的章。真正咬人的永远是那些 ListMap、以及你自己写的那个 Order

◇ 另存一版

开头那道题的答案是 B:不一定,report 可能往里加东西, 而它的签名 void report(List<String> xs) 一个字都没提这件事。

A 「Java 是值传递所以是副本」——半对,见上面那条错误直觉。 传的是引用的副本,不是对象的副本。

C 「单线程就没事」——多线程只是让这个问题更难复现, 不是它的成因。单线程里一样会被改,只是你更容易查出来。

D 「除非用反射」——不需要反射,一句 xs.add() 就够了。 这一项之所以有人选,是因为「修改别人的数据」听起来像是需要特殊手段的事。 它不需要。它是默认行为。

你进来时:修改一个对象是一个基本动作,偶尔要小心别人也拿着它。

你出去时:修改从来不是基本动作。基本动作是「产生一个新值」, 而「原地改」是一种为了省内存而做的优化——你为这个优化交了很多年的税。

这一章没有推翻你任何一个具体知识:防御性复制还是对的, unmodifiableList 还是有用的。只重建了一条路径—— 它们从「好习惯」变成了「症状」

这一章的一句话

你从来没有改过任何东西——你只是让一个名字,指向了另一个值。 而你这些年写的防御性代码,都是在为「这句话在 Java 里不成立」交税。

下一章回答那个你现在一定在想的问题:不复制怎么可能? 我们会拿一个一百万个元素的向量,改掉第 500,000 个, 然后数一数到底新建了几个节点。答案是 4 个——而整棵树有 32,258 个节点。