有一类 bug 会让你怀疑自己的理智。UI 显示「config saved」。日志也确认了。你启动一次新的 run。它却还是用了旧 config。
这是其中一个 bug 的故事 ------ 一个十三行修复对应一行错误的故事 ------ 以及它揭示出的那个意外普遍的原则。
症状
用户打开 app,把 model 从 gpt-4o 改成更新的东西,点击 save,然后启动一次 fresh run。这个 run 用的还是 gpt-4o。修复这个问题的 patch,在自己的注释里原封不动地写出了这个症状:
"started a new run, still using gpt-4o"
从 app 的视角看,一切都没问题。SaveConfig 没有返回错误,磁盘上的 config 文件是正确的,而且再读 a.cfg.Model 也能拿到新值。从 pipeline 的视角看,也没问题 ------ 它只是在忠实地用自己拿到的 config 执行。只是这两个视角刚好对 config 到底是什么 这件事有分歧。
改动
go
// Before
a.cfg = &cfg
// After
if a.cfg == nil {
a.cfg = &cfg
} else {
*a.cfg = cfg // ← 修复
}
analysis.InvalidateAllCapabilities()
为什么旧代码会失败
把内存想象成一排邮箱。app 启动时,有一个 Config 对象放在其中一个邮箱里。有两个变量保存着这个邮箱的地址:a.cfg(app 的 handle)和 pipeline.cfg(pipeline 的 handle,在构造时传给了它)。
css
a.cfg ─────────┐
├──> 📦 Config{model: "gpt-4o"}
pipeline.cfg ──┘
现在 SaveConfig 运行了。它构造了一个全新的 Config ------ 一个新的邮箱,在内存里的其他地方 ------ 而旧代码做的是:
ini
a.cfg = &cfg
这会改变 a.cfg 指向哪个邮箱。它不会碰原来那个邮箱里的内容。所以:
css
a.cfg ─────────> 📦 Config{model: "gpt-4.1"} (新邮箱)
pipeline.cfg ──> 📦 Config{model: "gpt-4o"} (原邮箱,没被动过)
pipeline 还在看原来的邮箱,而里面仍然写着 gpt-4o。从它的视角看,什么都没有发生。而且确实什么都没发生 ------ 对它的那个邮箱来说。
patch 注释说得很精确:pointer swap "would leave a.pl.cfg dangling on the old Config." 修复方式 *a.cfg = cfg 的意思是:「去 a.cfg 指向的那个邮箱,把它里面的内容覆盖掉。」两个 handle 仍然指向同一个邮箱 ------ 但现在内容已经变了。pipeline 下一次读取时就会看到新的 model。
原则:Rebind vs Mutate
去掉 Go 语法之后,剩下的是一个存在于所有有引用语义语言里的区别。
Rebind 改变的是变量里存的地址。其他变量不关心,因为它们存的地址没有变。你更新的是一个本地标签,而不是这个标签指向的东西。
Mutate 改变的是某个地址里的内容。所有持有这个地址的变量都会看到变化,因为它们都是从同一个地方读。
pipeline 持有的是一个地址。不是变量名。不是抽象意义上的 config。就是一个地址。app 后面怎么改自己的变量 a.cfg,对 pipeline 都是不可见的。唯一能影响 pipeline 读取结果的方式,是改变它持有的那个地址里的内容 ------ 也就是 mutate。
同一个坑,不同语言
一旦掌握这个原则,你会发现它到处都是。
Python 没有 * 或 &,并且假装这个问题不存在。坑是完全一样的:
py
a = [1, 2, 3]
b = a
a = [9, 9, 9] # b 仍然是 [1, 2, 3] ------ 你 rebind 了 a
a = [1, 2, 3]
b = a
a[0] = 999 # b 现在是 [999, 2, 3] ------ 你 mutate 了这个 list
上面的 Go bug,完全等价于 Python 里写了 self.cfg = new_cfg,但你真正想写的是 self.cfg.update(new_cfg)。每个 Python 开发者都至少写过一次这个 bug,只是当时不知道该用什么词描述它。
Go 是那门把这个区别显式化、并强迫你一开始就做选择的语言:
go
a := Config{Model: "x"}; b := a; a.Model = "y" // b.Model 仍然是 "x" (value copy)
a := &Config{Model: "x"}; b := a; a.Model = "y" // b.Model 现在是 "y" (shared pointer)
当你声明 *Config 而不是 Config 时,你就是在选择共享。一旦你选择了共享,rebind vs mutate 就开始变得重要。这里还有一个更隐蔽的坑:slice、map 和 channel 是偷偷像 pointer 的东西 ------ 即使没有显式的 *,它们也共享底层数据。对 slice 做 b := a 会复制 header,但不会复制 backing array,所以 a[0] = 999 对 b 可见,但 a = append(a, ...) 可能会在 append 触发重新分配的那一刻悄悄变得不可见。
Java 按类型切得很清楚:
java
int a = 5; int b = a; a = 10; // b 保持 5
List<Integer> x = new ArrayList<>();
List<Integer> y = x; x.add(1); // y 现在是 [1]
primitive 的行为像 Go 的 value type。object 的行为像 Python ------ 到处都是 reference。同一条规则仍然成立:rebind 是本地的,mutation 是共享的。
这三个语言里的结论都是:重新绑定一个变量,永远不会影响其他任何持有旧 reference 的人。 这不是 Go 的怪癖。这是所有允许两个变量持有同一个地址的 memory model 的属性。
你今天就能用的规则
当你盯着代码,想判断某个改动是否会被同一个 reference 的其他持有者看到时,看 = 左边:
如果左边以 * 开头(在 Go 里),或者是在索引、属性、方法调用某个东西(Python 里的 x[0] = ...、x.field = ...、x.append(...),Java 里的 obj.setField(...)),你就是在 mutating。所有共享这个 reference 的人都会看到它。
如果左边只是一个裸变量名,你就是在 rebinding。只有这个变量的本地 pointer 变了。pipeline 不关心。另一个 thread 不关心。捕获了变量的 closure 不关心。
本文开头的 bug 就发生在你本来想做第一种事,却实际做了第二种事的时候。它是沉默的。compiler 很高兴。tests 也通过了 ------ 直到某个 test 刚好跨过「save config,然后看正在运行的系统到底做了什么」这一步,那个裂缝才会打开。
这个 patch 里十三行解释性注释存在的原因只有一个:下一个读者必须理解这个 * 是在真正发挥作用。没有那条注释,一个出于好意的 refactor 很可能会在一个季度内把 *a.cfg = cfg "清理"回 a.cfg = &cfg,然后这个幽灵又会回来。