章节目录

第八章:让名字指向可以改变的位置

本节阅读量:

前七章里,let 建立的绑定一旦完成,名字读到的 Value 就不会再变。闭包只要保存自由变量当时的 Value,离开定义位置以后仍然能正确工作。

本章加入可变状态。先看一个会打破旧模型的程序:

1
2
3
4
5
(let x 40
  (let read (lambda ignored x)
    (begin
      (set! x 42)
      (read 0))))

read 创建时,x 是 40;调用它之前,set! 又把 x 改成了 42。语言规定最终结果是:

1
42

如果闭包仍然把整数 40 复制进自己的环境,它就只能返回旧值。要让外层代码和闭包看见同一次修改,双方必须共享某个可以存放 Value 的位置。

第八章因此把变量模型从:

1
name -> Value

改成:

1
name -> Location -> Value

这不是多加一层无关的包装。它把三个原本容易混在一起的概念拆开了:

概念 例子 会不会改变
名字 源码里的 x 同一词法绑定中的拼写不变
位置 x 对应的 location 或 cell 绑定建立后保持共享
值 location 当前保存的 40、42 或 closure 可以被 set! 替换

核心句子是:set! 不会让名字改指另一个绑定,而是替换这个名字当前 location 中保存的 Value。

本章增加两种表达式

表面语言新增:

1
2
(set! name expr)
(begin first second)

set! 修改一个已经存在的词法绑定,并返回写入的新值。begin 先执行 first,丢弃它的结果,再执行并返回 second。有了明确的顺序,读者才能写出“先修改,再读取”的程序。

最小例子是:

1
2
3
4
(let x 1
  (begin
    (set! x 41)
    (+ x 1)))

求值过程可以写成:

1
2
3
4
为 x 建立 location,保存 1
set! 找到同一个 location,写入 41
读取 x,得到 41
计算 41 + 1

结果为 42。

先把程序跑起来

进入第八章代码目录并构建:

1
2
3
cd code/08_state
make
./mini run examples/set.lang

输出:

1
42

再运行开头那个“修改以后由闭包读取”的例子:

1
./mini run examples/captured_read_after_set.lang

输出同样是:

1
42

这个结果验证的不是普通加法,而是外层代码与闭包共享同一个 location。

counter 把共享状态变成函数自己的记忆

运行:

1
./mini run examples/counter.lang

对应程序是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(let make
  (lambda start
    (lambda step
      (begin
        (set! start (+ start step))
        start)))
  (let counter (make 40)
    (begin
      (counter 1)
      (counter 1))))

第一次调用 counter 把 start 从 40 改为 41,第二次从同一个位置读到 41,再改为 42。返回的 closure 不只带着一个旧值;它带着以后仍能读取和修改的 location。

这也是状态开始有用的地方:一个函数可以在多次调用之间保留记忆,而不需要全局变量。

解释器和编译器采用同一语义

两条实现路线使用不同的数据结构,但表达的是同一件事:

1
2
3
4
5
6
7
源码
  ↓ lexer + parser
AST:SetExpr / BeginExpr
  ├─ 解释器:Environment(name -> Location) + Store(Location -> Value)
  └─ 编译器:结构化 IR 的 cell / load / store
                  ↓
             堆上的 cell 对象

解释器中的 Location 是 Store 向量的索引,适合直接说明语义。编译器中的 cell 是真正分配在堆上的对象,closure 捕获的是 tagged cell pointer。二者不必拥有相同的 C++ 类型,只要都保证所有使用者访问同一个位置。

IR 会把隐含的位置写出来

运行:

1
./mini ir examples/set.lang

会看到:

1
2
3
4
5
x0.cell = cell 1
store x0.cell 41
t.0 = load x0.cell
t.1 = t.0 + 1
return t.1

这几行第一次明确区分了位置和值:

1
2
3
4
x0.cell      一个位置
1 / 41       放进位置的 Value
load         从位置读取当前 Value
store        替换位置中的 Value

闭包捕获的也不再是 load 得到的当前内容,而是 x0.cell 本身。后面的 IR 一节会继续展开这条路径。

cell 延续第六章的堆对象协议

第六章建立了共同 heap-object tag 和 header,第七章让 closure 复用它。本章再增加第三种对象:

1
2
3
header kind 1 = record
header kind 2 = closure
header kind 3 = cell

cell 只有一个 Value payload:

1
2
offset 0   header
offset 8   当前 Value

record、closure 和 cell 继续共用 pointer tag 4,具体种类由 header 判断。这一统一布局会成为下一章垃圾回收扫描对象图的基础。

编译和观察退出状态

生成汇编:

1
./mini compile examples/captured_set.lang -o out.s

compile 仍然只输出 x86-64 AT&T 汇编,不负责调用链接器。Linux、WSL 和 Intel Mac 可以手动执行:

1
2
3
cc out.s -o out
./out
echo $?

Apple Silicon Mac 需要生成 x86-64 可执行文件:

1
2
3
cc -arch x86_64 out.s -o out
./out
echo $?

退出状态为 42。mini run 会显示完整 Value;手动链接后的示例仍沿用进程退出状态观察整数结果。

本章正文怎样展开

接下来的内容沿着一次完整实现逐层推进:

  1. 先确定 set!、二元 begin、遮蔽和共享 cell 的语言行为;
  2. 给 AST 和 parser 增加两个节点,并补全自由变量分析;
  3. 在解释器里真正拆开 Environment 与 Store;
  4. 在结构化 IR 中加入 cell、load、store;
  5. 把 cell 落到统一的堆对象布局和 x86-64 指令;
  6. 用验收清单和练习检查共享、独立与递归状态。

每一步都只回答当前层的问题。parser 不负责分配 cell,解释器不需要模拟机器字节偏移,汇编生成器也不重新解释源码名字。

已有能力不会回退

第八章继续支持:

  • 整数、负整数、加法、sub1、变量和 let;
  • eq?、if、函数值、调用和 letrec;
  • 词法闭包、嵌套闭包和外层自由变量捕获;
  • 空及可变长度 record、record?、size 和安全 get;
  • record 身份比较,以及 record 与 closure 互相保存。

第六章的 record 仍然不可变。本章没有 set-field!,set! 的目标只能是变量名。第四章的 cin、cout 实验也不会在这里重新出现。

学完以后应该能回答

读完本章,你应该能解释:

  1. 为什么只捕获当前 Value 无法实现可变闭包;
  2. name、Location 和 Value 分别是什么;
  3. 为什么 set! 修改的是已有 location,而不是创建新绑定;
  4. 为什么每次函数调用都需要新的参数 location;
  5. 为什么两个 closure 可以共享一个 cell,而两次 factory 调用又应得到独立 cell;
  6. 为什么可变 letrec 名字需要让递归 closure 保存 self location;
  7. cell 怎样复用 record 和 closure 已经使用的堆对象协议。

7.8 常见错误与练习

上一节

8.1 语言:修改绑定,但不改变词法作用域

下一节