状态:set!、box,与「纯」的真实价格
这本书到现在,一直在悄悄地回避一个东西:可变状态。函数式社区常说「不可变更好」,但很少有人把账算清楚 —— 它听起来像一种道德洁癖。这一章不布道。我要给你一个可操作的定义,让你能指着一段代码说清它「纯不纯」;然后把可变状态的真实成本摊开;最后告诉你为什么 Compose 和 React 这两个你天天用的框架,都押注在不可变上 —— 这不是巧合,是同一笔账。
Racket 也有可变状态 —— 只是它让你看得见
你已经在第 5 章的计数器里见过 set! 了。Racket 不是纯函数式语言(Haskell 才是),它有可变状态,只是把它做得显眼:
(define x 10) (set! x 20) ; 那个感叹号在喊:「注意!我在改一个已存在的变量!」 ; 一个显式的可变盒子 (define b (box 0)) (unbox b) ; ⇒ 0 (set-box! b 42) (unbox b) ; ⇒ 42
Scheme 的约定:凡是会「改动已有东西」的函数,名字后面带 !(set!、set-box!、vector-set!)。凡是会「问是非题」的,带 ?(null?、even?)。
这不是语法规则,是命名纪律 —— 它让「这行代码有副作用」这件事写在脸上。你扫一眼代码,所有的 ! 就是所有的「危险动作」。
对比:在 Java 里,list.add(x)、obj.setName(y)、counter++ 全都会改动状态,但看不出来。你得知道每个方法的语义才知道它改没改东西。
一个你可能不知道的事实:Racket 里普通的 list 是不可变的。set-car! 这个老 Lisp 函数,在现代 Racket 里压根不存在(会报 unbound identifier)。想要可变的对儿,得用专门的 mcons(mutable cons)。Racket 把默认值定在了「不可变」这一边 —— 这个选择本身就是这一章的论点。
「纯」的可操作定义:引用透明
「纯函数」这个词被说烂了。这里给你一个能拿去用的定义:
一个表达式是「引用透明」的,当且仅当:你可以把它替换成它的值,而程序的行为完全不变。
这是一个可以动手检验的性质,不是玄学。
看这个检验。纯函数:
(define (double x) (* x 2)) (+ (double 5) (double 5)) ; ⇒ 20 (let ([d (double 5)]) (+ d d)) ; ⇒ 20 ← 把 (double 5) 提出来,一样!
两种写法结果相同 → (double 5) 引用透明。你可以放心地把它算一次存起来、重排、缓存、并行 —— 因为它就等于它的值,别无其他。
现在破坏它:
(define counter 0) (define (impure!) (set! counter (add1 counter)) counter) (+ (impure!) (impure!)) ; ⇒ 3 (1 + 2) ; 重置 counter 后: (let ([d (impure!)]) (+ d d)) ; ⇒ 2 (1 + 1)—— 不一样了!
(impure!) 不能被它的值替换 —— 因为它没有一个固定的值。它每次都不同,还偷偷改了外面的东西。
这个差别不是抽象的哲学问题。它直接决定了你能对代码做什么:
- 能不能缓存?纯的能(memoize),不纯的不能(缓存了就错)。
- 能不能并行?纯的能(无共享状态),不纯的要加锁(第 5 章那个
Ref盒子在多线程下就是竞态)。 - 能不能重排 / 跳过?纯的能(编译器优化、惰性求值),不纯的不能(顺序有意义)。
- 能不能孤立地测试?纯的能(输入定,输出定),不纯的要 mock 掉那些副作用。
「纯」买到的,是「可以随便折腾而不出错」的自由。这就是那笔账的收入栏。
可变状态最贵的那一项:别名
副作用最阴险的成本,不是「值会变」,而是别名(aliasing) —— 两个名字指向同一个可变对象:
(define lst1 (mcons 1 (mcons 2 '()))) (define lst2 lst1) ; ← 这不是拷贝!lst2 和 lst1 是同一个东西 (set-mcar! lst2 999) ; 我只想改 lst2…… (mcar lst1) ; ⇒ 999 ← lst1 也变了!
「我改了 A,B 怎么也变了?」 —— 因为 A 和 B 是同一个可变对象的两个别名。
这个坑在每一门有可变对象的语言里都存在,而且极其常见:
// Kotlin
val a = mutableListOf(1, 2, 3)
val b = a // 别名,不是拷贝
b.add(4)
println(a) // [1, 2, 3, 4] ← a 也变了
// 传参也是别名
fun process(items: MutableList<Int>) { items.clear() } // 调用者的列表被清空了
// 默认参数的经典坑(Python 版更著名)
data class Config(val tags: MutableList<String> = mutableListOf())
// 每个没传 tags 的 Config,是不是共享同一个列表?取决于语言语义 —— 而你得知道
别名 bug 难查,是因为「改动的地方」和「出问题的地方」在代码里离得很远。你在模块 A 改了个对象,模块 Z 的行为变了,中间隔着十层调用。而不可变数据根本没有这个 bug 类别 —— 你「改」不了共享的东西,只能造一个新的。
那笔账的支出栏:不可变不是免费的
这本书不做单方面宣传。不可变有它的成本,而且是真实的:
| 不可变的成本 | 具体是什么 | 缓解 |
|---|---|---|
| 「修改」要造新对象 | 改一个字段 = 复制整个结构(copy()) | 结构共享:持久化数据结构只复制变化的路径,其余共享。Clojure/Scala 的 Vector 就是这么做的 |
| 深层更新很啰嗦 | state.copy(user = state.user.copy(addr = …)) 层层 copy | 光学器件(lens)、Immer(JS)、Arrow Optics(Kotlin) |
| 某些算法天生要变 | 原地排序、图的并查集、动态规划表 | 局部可变,边界不可变 —— 见下 |
| GC 压力 | 大量短命的中间对象 | 逃逸分析、值类型;或在热点处局部用可变 |
Racket 的做法就是这个 —— 它不禁止 set!,它让 set! 显眼。好的模式是:
; 外面看起来是纯函数,里面偷偷用可变来提速 (define (fast-sum lst) (define acc (box 0)) ; 局部的可变 (for-each (lambda (x) (set-box! acc (+ (unbox acc) x))) lst) (unbox acc)) ; 返回一个普通的值 ; 对调用者来说,fast-sum 是引用透明的 —— ; 那个 box 从来没有逃逸出这个函数。
关键词是「逃逸」。可变状态只要不逃出一个小范围,它就不会造成别名 bug、不会破坏外部的引用透明。把可变性关在笼子里,而不是让它自由乱跑。
这正是 Rust 用类型系统强制的东西(所有权 + 借用:可变引用同一时刻只能有一个),也是 Kotlin 用约定建议的东西(val 默认、List 而非 MutableList 暴露给外部)。
为什么 Compose 和 React 都押注不可变
这一章不是理论。你天天在用的两个 UI 框架,它们最核心的设计决定,就是这笔账的直接结果。
问题:UI 框架怎么知道「哪里变了,需要重画」?
如果状态是可变的,它没法便宜地知道。一个对象的字段被改了,框架要么监听每一个字段(昂贵、侵入),要么把整个界面重画一遍(浪费)。
如果状态是不可变的,判断「变没变」变成了一次引用比较:
// Compose:@Immutable / @Stable 让编译器知道「这东西不会变」 // 于是重组时,它只要比较引用:oldState === newState ? // 相同 → 跳过重组(skippable) // 不同 → 重画 // React:同样的逻辑 const memoized = useMemo(() => compute(data), [data]); // ↑ 靠引用相等判断 data 变没变 // 这就是为什么 React 里「不要原地 mutate state」是铁律: setState(prev => [...prev, item]) // ✓ 新数组,引用变了,React 知道 state.push(item) // ✗ 原地改,引用没变,React 看不见
不可变数据的引用透明,让「变没变」这个问题的答案从「昂贵的深比较」坍缩成「一次 ===」。
你在《坩埚》第 13 章看到的那个「copy() 的陷阱与 Compose 的帧率」,根子就在这里:Compose 的强跳过(strong skipping)依赖「参数没变就跳过」,而「没变」是靠引用相等判断的,这又依赖参数是不可变的。一旦你传了个可变对象,编译器无法保证它稳定,只能放弃跳过,你的重组就变多了,帧率就掉了。
所以「React 里别 mutate state」「Compose 里用 @Immutable」这些规则,不是风格偏好。它们是「引用透明买到了廉价的变更检测」这笔账,在 UI 框架上的兑现。
回流
① 默认不可变,需要可变时圈起来。这是能直接执行的规则:
- Kotlin:默认
val;对外暴露List/Map(只读接口),内部才用Mutable*;数据类用copy()更新 - JS/TS:
const+ 展开运算符({...s, x}) /readonly;需要重度不可变时上 Immer - 让可变对象不逃逸:在函数内部用可变提速,返回时给出不可变的值(上面那个
fast-sum模式)
② 用「引用透明」当检验工具,而不是当口号。面对一个函数,问一句:「我能把它的调用换成一个算好的值,而不改变程序行为吗?」不能 → 它有副作用,那么它就不能被缓存、并行、重排、孤立测试。这个判断会指导你把副作用集中到少数几个地方(函数式社区叫「functional core, imperative shell」)。
③ 遇到「我改了 A,B 怎么也变了」,第一反应是「别名」。去查:A 和 B 是不是指向同一个可变对象?这个 bug 太常见了,而它的解药永远是同一个 —— 在边界上拷贝,或者干脆用不可变数据(那样这个 bug 类别直接消失)。
④ 理解你的 UI 框架为什么「挑剔」。Compose 的 @Stable、React 的 useMemo 依赖数组、Redux 的「reducer 必须返回新对象」—— 现在你知道它们是同一件事:用不可变换取廉价的变更检测。照做的时候,你知道自己在为什么付钱。
卷 IV 结束
四章下来,你走完了这本书概念上最陡的一段:
- 续延(第 13 章):「接下来要做的事」,你天天用却没名字的东西
- call/cc(第 14 章):把它抓在手里,手工造 return / 异常 / 生成器
- 三种面孔(第 15 章):异常、生成器、协程是同一块金属;
Continuation<T>的真身 - 状态(第 16 章):可变与不可变的完整账本
你已经把「求值」这台机器从里到外看遍了。
而从下一卷开始,我们要做一件更大的事 —— 不再是理解这台机器,而是改造它。
卷 V:宏。也就是这本书从第 1 章就在预告的那个主题 —— 语言设计权的下放。