卷 IV · 续延CH 16深度 16/25

状态:set!、box,与「纯」的真实价格

这本书到现在,一直在悄悄地回避一个东西:可变状态。函数式社区常说「不可变更好」,但很少有人把账算清楚 —— 它听起来像一种道德洁癖。这一章不布道。我要给你一个可操作的定义,让你能指着一段代码说清它「纯不纯」;然后把可变状态的真实成本摊开;最后告诉你为什么 Compose 和 React 这两个你天天用的框架,都押注在不可变上 —— 这不是巧合,是同一笔账。

set!box引用透明可变性别名

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 把默认值定在了「不可变」这一边 —— 这个选择本身就是这一章的论点。

「纯」的可操作定义:引用透明

「纯函数」这个词被说烂了。这里给你一个能拿去用的定义:

◆ 引用透明(referential transparency)

一个表达式是「引用透明」的,当且仅当:你可以把它替换成它的值,而程序的行为完全不变。

这是一个可以动手检验的性质,不是玄学。

看这个检验。纯函数:

(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 也变了!
⚠ 这是你职业生涯里最常见的一类 bug

「我改了 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 章就在预告的那个主题 —— 语言设计权的下放。