10 · 可变状态:识别共享并限制修改范围
目标:识别数组与引用的共享关系,并让可变累加器局限在一次函数调用内部。
OCaml 支持可变状态,也支持 for、while。函数式编程不是禁止赋值,而是让数据变化尽量容易推理。不可变记录更新会产生新记录;数组赋值则直接修改已有对象。如果两个名字指向同一数组,一个名字的修改能被另一个名字看到。
ref 0 建立保存整数的可变单元,!cell 读取,cell := value 写入。名字 cell 的绑定本身没有变化,变化的是单元里的内容。数组用 [| ... |] 构造,a.(i) 读取,a.(i) <- value 写入,下标从 0 开始。
实验:把共享与复制分开观察
创建 mutation.ml:
let sum_array values =
let total = ref 0 in
Array.iter (fun n -> total := !total + n) values;
!total
let () =
let original = [|25; 35|] in
let shared = original in
let copied = Array.copy original in
shared.(0) <- 10;
Printf.printf "original=%d copied=%d\n" original.(0) copied.(0);
Printf.printf "sum=%d again=%d\n"
(sum_array original) (sum_array original)
先预测 original.(0) 是否还为 25,再运行:
ocaml mutation.ml
预期输出:
original=10 copied=25
sum=45 again=45
shared 只是另一个名字;Array.copy 才建立独立的外层数组。本例元素是整数,所以复制后修改元素互不影响。数组若包含 ref 等可变值,外层复制仍会共享这些内部对象,不是通用深拷贝。
sum_array 每次调用都创建自己的 total,不修改输入,连续调用没有状态残留。这个例子展示局部可变实现如何保留简单接口;StudyLog 的列表汇总用 fold_left 已足够,无须为了“使用新语法”改成数组。
类型推导为什么有时显得保守
第 3 课的 identity 可以用于不同类型,但同一个可变单元不能这次装整数、下次装字符串,还让读取方以为它始终是原来的类型。若你在交互解释器中看见 '_weak1 一类弱类型变量,它表示类型尚未确定且不能任意泛化;首次约束后会固定。不要把它当成 identity 的自由类型参数。更完整的值限制规则见文末手册,本课只要求理解共享可变内容带来的风险。
- 将赋值改为
copied.(0) <- 10,原数组应保持 25,总和为 60。 - 把
total移到函数外再连续调用。结果会积累,调用之间不再独立。不要依赖打印参数的求值顺序判断哪一个先执行;可以先分别用let first = ... in let second = ... in保存结果。 - 恢复主示例,使
total回到函数内部。
独立练习:实现 increased_copy extra values,返回每项增加 extra 的新数组,原数组不变。使用 Array.map:它与 List.map 类似,但产生新数组。
参考实现
追加并运行:
let increased_copy extra values = Array.map (fun n -> n + extra) values
let () =
let before = [|25; 35|] in
let after = increased_copy 5 before in
assert (before = [|25; 35|]);
assert (after = [|30; 40|]);
assert (increased_copy 5 [||] = [||]);
print_endline "mutation checks passed"
若出现数组下标异常,检查 Array.length 与从 0 开始的下标;最后一个合法位置是长度减一。若第二次运行函数结果比第一次大,检查是否把累加器放在了共享作用域。数组、引用和可变字段都应明确谁能修改、修改何时发生。