卷 I · 值不动CH 03深度 3/24

名字、值、时间:被揉成一团的三样东西

如果值都不能变,那我的程序怎么办?银行余额总得变吧。这一章给出回答:会变的不是值,是「这个名字此刻指着哪个值」。把这句话拆开,你会拿到三个词——而 Java 一直用同一个词指代它们全部。

身份 / 状态 / 值atom时间是一根轴

▷ 先猜一下

你在看一场足球比赛。有人问你:「利物浦」是什么?

A 场上那十一个人 B 一家在英国注册的公司 C 一个名字。它在不同时刻指着不同的十一个人,但它一直是同一支球队 D 这个问题没有意义,「利物浦」就是「利物浦」

如果你选 A:1990 年的利物浦和今天的利物浦没有一个共同球员, 那它们是同一支球队吗?如果不是,为什么奖杯还摆在同一个陈列柜里?

这道题不是脑筋急转弯。你每天写的 account.setBalance(...), 问的就是同一个问题。

一个词,三份工作

看这行你写过无数遍的代码:

account.setBalance(account.getBalance() - 100);

这里的 account 在同时扮演三个角色,而 Java 没有给你词去区分它们:

✎ 术语正名

值(value){余额 500, 户名 "Ada"}。 一个不会变的事实。它不「属于」任何时刻,它就是它。

身份(identity):「Ada 的账户」这个概念。 它跨越时间存在,2019 年的它和今天的它是同一个身份, 尽管中间余额变了八百次。身份不是数据,是一根线

状态(state):某个身份在某一时刻取的那个值。 「Ada 的账户在今天上午十点的状态是 {余额 500}」。 状态永远要带时间戳才说得清。

Java 的 account 这个变量,同时是这三样。 所以「account 现在是什么」这句话,在多线程下根本问不清楚—— 你问的是身份、状态,还是值?

回到足球:正确答案是 C。「利物浦」是一个身份; 2019 年的首发十一人是一个值;「利物浦在 2019 年欧冠决赛的首发」是一个状态。 你从来不会把这三样搞混,因为日常语言逼着你说清楚。 而编程语言没有逼你,于是你把它们都叫 account

会变的只有那根指针

Clojure 的做法直白得几乎粗暴:值放在一边,会变的东西单独装进一个盒子里

(def account (atom {:balance 500 :owner "Ada"}))

@account
;; => {:balance 500, :owner "Ada"}      ← 取出此刻的值

(swap! account update :balance - 100)
;; => {:balance 400, :owner "Ada"}      ← 一个新值

@account
;; => {:balance 400, :owner "Ada"}

看清楚发生了什么:

  • {:balance 500 ...} 这个值从来没有被修改。 如果你在 swap! 之前把它存下来,它现在还是 500。
  • account 这个 atom 是身份。它只有一个,一直是它。
  • @account(读作 deref)取的是此刻的状态: 这个身份现在指着哪个值。
  • 变的只有一件事:atom 里那根指针,从指着旧 map 改成指着新 map。 一次指针赋值,原子的,没有中间状态。

下面这台 demo 把这件事画成一根轴。多按几次, 注意那个红色的名字一直只有一个,而它下面挂着的值越来越多。

◆ 可以带走的判断

「修改」这个动作被拆成了两半:算出新值(纯的、可以随便重试、 天然线程安全)和换掉指针(原子的、瞬间完成、没有中间状态)。 Java 的 setter 把这两半焊在一起,所以它两头的好处都拿不到。

为什么这个拆分值这么多钱

拆开之后,几件原本很难的事变得几乎是免费的。

一、撤销。你不需要「反着做一遍」, 只要把身份指回上一个值。而上一个值一直都在,你只要留着它。

(def history (atom []))
(defn save! [a]
  (swap! history conj @a))     ;; 存的是那一刻的值,不是拷贝——它本来就不会变

;; 撤销就是:
(reset! account (peek @history))

在 Java 里做同一件事,你得先深拷贝一份(否则你存进历史的那个对象后来也被改了), 而深拷贝要么很贵,要么很容易漏一层。第 2 章已经说了, 这里「存一份」的成本是一个引用

二、一致的快照。你想打一份报表, 要读账户的三个字段,而别人正在改它。在 Java 里你得加锁, 否则可能读到「改了一半」的状态。在这里:

(let [snap @account]        ;; 一次读,拿到一个不会变的值
  (report (:balance snap)
          (:owner snap)
          (:limit snap)))    ;; 这三行之间世界随便怎么变,snap 不会变

你拿到的不是「账户」,是「账户在某一瞬间的值」。之后世界怎么变都与你无关。 第 16 章会把这件事量化:同样的交错,读两个位置错了 11 次,读一个值错了 0 次。

三、「过去」不再是要额外保存的东西。 在可变的世界里,历史要专门记;在这里,历史是默认就有的, 你只要不把它扔掉。这正是 Datomic 这类数据库的立足点—— 它不存「当前值」,它存「所有事实 + 时间」,当前值是算出来的。

四种盒子

Clojure 给了四种装身份的盒子,区别只在「怎么协调」:

盒子      协调几个     同步?    典型用途
──────────────────────────────────────────────────────
atom      一个         同步      计数器、缓存、单个状态   ← 90% 的时候用它
ref       多个         同步      转账:两个账户必须一起变(第 15 章)
agent     一个         异步      发出去就不管,串行执行
var       一个         线程内    动态配置、测试时替换(binding)

先记住 atom,其余三个后面会用到。这里的重点不是 API, 是它们都不是「可变的值」,而是「指向不可变值的可变引用」。 这个区别就是这一章的全部。

▸ 在现实里

Git 的分支就是一个 atom。main 是身份, 每个 commit 是值,「main 现在指着哪个 commit」是状态。 git commit 干的事和 swap! 一模一样: 算出一个新值(新的树快照),然后把那根 41 字节的指针挪过去。 git reset --hard HEAD~1 就是 reset!—— 旧 commit 一个字节都没被删,只是没人指着它了。

数据库的 MVCC:一行数据的多个版本同时存在, 每个事务读到的是「某个时刻的快照」。 「行」是身份,每个版本是值,你的事务看到的是状态。

你的身份证:号码是身份,照片上那张脸是某一年的值。 没有人会因为你老了就说「这不是你」——因为日常语言从不把身份和值混为一谈。

✗ 这个直觉是错的

「一个对象就是它现在的样子。所以我拿到 account 这个引用, 就等于拿到了它的状态。」

你拿到的是身份,不是状态。 身份是一根跨越时间的线,你手上那个引用在下一纳秒可能指着完全不同的数据。 真正拿到状态的唯一方式是读一次,把那个值抓在手里—— 而在 Java 里,「抓在手里」这个动作不存在,因为你抓到的还是那根线。

这条直觉是所有「我明明检查过 != null 了啊」的根源:

if (order.getItems() != null) {
    process(order.getItems());   // ← 两次 getItems() 之间,它可能变了
}

你检查的是「那根线在某一刻指着的东西」,用的却是「那根线现在指着的东西」。 这两次读之间隔着一整个宇宙。

◇ 另存一版

足球那道题答案是 C。「利物浦」是一个身份, 它在不同时刻指着不同的十一个人。这正是 atom 的定义。

A 「场上那十一个人」——这是,不是身份。 把它当成球队,就解释不了为什么换人之后还是同一支球队。 这也正是 account.setBalance() 的毛病。

B 「一家公司」——这个答案其实不错, 它抓到了「跨时间存在的东西」。但公司是身份的一个载体, 不是身份本身:球队被卖掉、换了注册主体,人们还是认它是同一支球队。

D 「问题没有意义」——很多人在编程里其实是这个态度: 不去区分,反正能跑。代价是所有关于时间的 bug 都变得没法讨论, 因为你连描述它们的词都没有。

你进来时:一个变量就是一个会变的东西,读它就是读它的状态。

你出去时:会变的只有「名字指着谁」。值不变、身份不变, 变的是二者之间的那根连线;而读,就是把那根线此刻的另一端抓下来

前两章的结论一个都没动:值不变(第 1 章)、共用不复制(第 2 章)。 这一章只重建了一条路径——「那我的程序怎么变化」这个问题有了答案

这一章的一句话

值不会变,身份不会变,变的是「此刻这个身份指着哪个值」—— 而这个变化是一次原子的指针替换,没有中间状态。

下一章回到语言本身,处理你从翻开这本书起就想问的问题: 为什么它到处都是括号?答案不是「Lisp 程序员喜欢」。 括号是一张入场券的价格,而买到的东西你在第 19 章才会真正用上—— 在那之前,第 4 章会让你亲手把一段代码读成数据,改掉其中一个节点,再让它跑起来。