名字、值、时间:被揉成一团的三样东西
如果值都不能变,那我的程序怎么办?银行余额总得变吧。这一章给出回答:会变的不是值,是「这个名字此刻指着哪个值」。把这句话拆开,你会拿到三个词——而 Java 一直用同一个词指代它们全部。
你在看一场足球比赛。有人问你:「利物浦」是什么?
如果你选 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() 之间,它可能变了
}
你检查的是「那根线在某一刻指着的东西」,用的却是「那根线现在指着的东西」。 这两次读之间隔着一整个宇宙。
《快照》(Git):分支是贴纸、commit 是快照, 那本书讲的正是这一章的 Git 版本。 如果你读过它,这一章你其实已经懂了一半,只是没想到能搬到程序里。
《过冲》(反馈控制):那本书讲「你看到的是过去」。 这一章讲的是同一件事的另一面:既然你看到的必然是过去, 那就干脆把「你看到的那个东西」做成一个不会变的快照, 至少保证它是某一个真实时刻的完整样子,而不是几个时刻的碎片。
足球那道题答案是 C。「利物浦」是一个身份, 它在不同时刻指着不同的十一个人。这正是 atom 的定义。
A 「场上那十一个人」——这是值,不是身份。
把它当成球队,就解释不了为什么换人之后还是同一支球队。
这也正是 account.setBalance() 的毛病。
B 「一家公司」——这个答案其实不错, 它抓到了「跨时间存在的东西」。但公司是身份的一个载体, 不是身份本身:球队被卖掉、换了注册主体,人们还是认它是同一支球队。
D 「问题没有意义」——很多人在编程里其实是这个态度: 不去区分,反正能跑。代价是所有关于时间的 bug 都变得没法讨论, 因为你连描述它们的词都没有。
你进来时:一个变量就是一个会变的东西,读它就是读它的状态。
你出去时:会变的只有「名字指着谁」。值不变、身份不变, 变的是二者之间的那根连线;而读,就是把那根线此刻的另一端抓下来。
前两章的结论一个都没动:值不变(第 1 章)、共用不复制(第 2 章)。 这一章只重建了一条路径——「那我的程序怎么变化」这个问题有了答案。
这一章的一句话
值不会变,身份不会变,变的是「此刻这个身份指着哪个值」—— 而这个变化是一次原子的指针替换,没有中间状态。
下一章回到语言本身,处理你从翻开这本书起就想问的问题: 为什么它到处都是括号?答案不是「Lisp 程序员喜欢」。 括号是一张入场券的价格,而买到的东西你在第 19 章才会真正用上—— 在那之前,第 4 章会让你亲手把一段代码读成数据,改掉其中一个节点,再让它跑起来。