章节目录

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

本节阅读量:

可变状态最容易让人误以为“变量名现在可以随意指向任何东西”。本章采用的规则更具体:词法作用域仍然决定名字对应哪个绑定,set! 只替换这个绑定的位置里保存的 Value。

因此,第七章确定的“名字由定义位置决定”没有被推翻。本章改变的是绑定的内容能否更新。

set! 修改已经存在的绑定

语法是:

1
(set! name expr)

它按下面的顺序求值:

  1. 按词法作用域找到 name 对应的 location;
  2. 求值右侧 expr,得到一个 Value;
  3. 把这个 Value 写入刚才找到的 location;
  4. 返回这个 Value,作为整个 set! 的结果。

set! 不会创建新名字。下面的程序没有任何绑定可以提供 missing:

1
(set! missing 42)

解释器会报告:

1
error: undefined variable: missing

对应示例可以直接运行:

1
2
cd code/08_state
./mini run examples/set_undefined_error.lang

右侧先算完,写入随后发生

一个很常见的更新写法是:

1
(set! x (+ x 1))

右侧的 x 读取更新前的 Value。假设 location 中原来是 40,过程是:

1
2
3
4
读取旧值 40
计算 40 + 1,得到 41
把 41 写回同一个 location
返回 41

不会先清空 location,也不会在计算右侧的过程中创建另一个 x。

set! 本身也是有结果的表达式

本书的源语言以表达式为主,所以 set! 不是只能单独出现的语句。运行:

1
./mini run examples/set_result.lang

源码是:

1
2
(let x 0
  (+ (set! x 40) 2))

set! 写入并返回 40,外层加法得到 42。

这条规则让解释器和 IR 都有清楚的返回值,不需要额外发明 unit、void 或未指定结果。

begin 明确两个表达式的先后顺序

语法是:

1
(begin first second)

求值规则只有两步:

  1. 求值 first,丢弃它的结果;
  2. 求值并返回 second。

例如:

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

第二个表达式一定在写入以后读取 x,所以结果是 42。

运行一个更能体现顺序的例子:

1
./mini run examples/begin_order.lang

源码连续更新同一个 location:

1
2
3
4
5
6
(let x 0
  (begin
    (set! x 40)
    (begin
      (set! x (+ x 2))
      x)))

输出是 42。若两个更新顺序颠倒,结果就会不同。

本章的 begin 固定为二元

下面是合法写法:

1
(begin a b)

三个表达式要通过嵌套表示:

1
(begin a (begin b c))

本章不加入可变长度 begin。二元结构已经足以表达任意有限序列,同时让 AST、parser 和 lowering 保持与其他二元节点相近的形状。

空 begin 和只有一个子表达式的 begin 都不是本章语法。

状态让已有的求值顺序变得可观察

前几章已经按源码顺序求值子表达式,只是纯整数程序往往看不出顺序差异。现在某个子表达式可以修改 location,因此这些既有规则成为语言行为的一部分:

  • let 先在旧环境中求右侧,再分配并加入新 location;
  • + 先求左 operand,再求右 operand;
  • record 字段从左到右求值;
  • call 先求并检查 callee,再求 argument;
  • if 仍然只求被选中的分支。

例如:

1
2
3
4
(let x 0
  (record
    (set! x 40)
    x))

第一个字段先写入并返回 40,第二个字段随后读取同一个 location,所以结果是:

1
(record 40 40)

解释器、IR lowering 和汇编生成必须保持同一顺序。状态没有改变词法结构,却让错误地交换两个步骤真正能够改变程序结果。

遮蔽仍然选择最近的词法绑定

两个同名 let 会建立两个不同 location:

1
2
3
4
5
(let x 40
  (begin
    (let x 0
      (set! x 2))
    (+ x 2)))

内层 set! 找到最近的 x,只把内层 location 从 0 改为 2。离开内层 let 后,外层 location 仍保存 40,最终结果是 42。

运行:

1
./mini run examples/set_shadow.lang

这说明 mutation 没有把词法作用域变成动态作用域,也没有把所有同名变量合并成一个全局槽。

闭包共享的是 location

下面两个闭包都使用外层 x:

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

put 只写 x,read 只读 x。它们捕获的是同一个 location,所以 put 的修改会被 read 看见。

1
./mini run examples/two_closures_share.lang

输出:

1
42

如果创建 closure 时复制当前 Value,这两个函数会各自持有 40 的副本,程序就无法得到规定结果。

只写不读也必须捕获

变量只出现在 set! 的目标位置时,仍然使用了那个绑定:

1
2
3
4
5
(let x 0
  (let put (lambda value (set! x value))
    (begin
      (put 42)
      x)))

函数体没有普通的 Var(x),但 put 必须找到外层 x 的 location。运行:

1
./mini run examples/write_only_capture.lang

结果是 42。下一节会看到自由变量分析为何必须单独处理 SetExpr::name。

共享不等于所有调用共用一个 cell

闭包需要共享同一个词法绑定;不同的绑定仍必须彼此独立。运行:

1
./mini run examples/independent_counters.lang

程序创建两个 counter:

1
2
3
4
5
6
7
8
9
(let make
  (lambda start
    (lambda step
      (begin
        (set! start (+ start step))
        start)))
  (let a (make 10)
    (let b (make 20)
      (+ (a 1) (b 11)))))

两次调用 make 必须为参数 start 分配两个新 location:

1
2
3
a 捕获的 start:10 -> 11
b 捕获的 start:20 -> 31
最终:11 + 31 = 42

若错误地让所有调用复用一个参数 cell,两个 counter 会互相干扰。共享的边界由词法绑定决定,不是由源码名字 start 决定。

参数修改只影响本次调用建立的位置

参数也是普通可变绑定:

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

结果是 42。每次调用都会为参数建立新的 location;函数返回后,这次参数位置仍可能被函数体创建的内层 closure 保留,但不会自动变成其他调用的参数位置。

对应示例:

1
./mini run examples/parameter_set.lang

set! 可以写入任意已有 Value

location 保存的是统一 Value,不只保存整数。下面先把 f 绑定到整数,再写入 closure:

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

运行:

1
./mini run examples/set_closure_value.lang

结果是 42。源语言仍然是动态类型;本章不会给变量加静态类型标注,也不会规定一个 location 一生只能保存某一种 Value。

letrec 的名字同样可以修改

letrec 建立的函数名也是词法绑定,因此也服从 set!。这个例子特意保留旧 closure 的别名:

1
2
3
4
5
6
7
8
(letrec loop n
  (if (eq? n 0)
      0
      (loop (sub1 n)))
  (let old loop
    (begin
      (set! loop (lambda n 42))
      (old 1))))

求值过程是:

  1. old 保存原来的递归 closure Value;
  2. set! 把 loop 的共享 location 改为一个总是返回 42 的新 closure;
  3. 调用 old 1 进入原来的函数体;
  4. 函数体递归读取名字 loop 时,从共享 location 得到新 closure;
  5. 新 closure 返回 42。
1
./mini run examples/recursive_binding_set.lang

输出是 42。这条语义要求第八章的递归 closure 保存 self location,而不能只在每次调用时临时绑定当前 callee。

self 与参数同名时,参数仍然遮蔽 self

已有的词法规则继续成立:

1
2
3
(letrec f f
  f
  (f 42))

调用时,参数 location 比 self location 更靠内,函数体中的 f 读取参数,结果仍为 42。可变状态不会改变遮蔽优先级。

if 仍然只执行选中的分支

副作用让条件表达式原有的短路规则更容易观察:

1
2
3
4
5
6
(let x 0
  (begin
    (if 1
        (set! x 42)
        (set! x 7))
    x))

只有 then 分支执行,所以结果是 42。编译器虽然会为两个分支都生成代码,运行时却只能沿一条控制流路径修改 cell。

set! 不是 record 字段更新

SetExpr 的目标只能是 identifier。下面不是合法语法:

1
(set! (get box 0) 42)

第六章的 record 继续不可变;本章没有 set-field!。record 可以保存 closure,cell 中也可以保存 record,但这不表示 record payload 获得了原地写入语义。

新增后的语法总表

第八章的表达式集合是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
expr := integer
      | identifier
      | (+ expr expr)
      | (sub1 expr)
      | (let identifier expr expr)
      | (letrec identifier identifier expr expr)
      | (set! identifier expr)
      | (begin expr expr)
      | (eq? expr expr)
      | (if expr expr expr)
      | (lambda identifier expr)
      | (expr expr)
      | (record expr ...)
      | (record? expr)
      | (size expr)
      | (get expr non-negative-integer-literal)

普通 identifier 仍然只是一个或多个英文字母。set! 和 eq? 带有特殊字符,只在列表头部作为固定特殊形式识别。

本章的语义边界

本章支持词法变量的修改、顺序执行以及闭包之间共享状态。它不加入:

  • record 字段更新;
  • 多表达式或空 begin;
  • 全局变量和动态作用域;
  • 静态类型检查;
  • 线程、并发或原子操作;
  • 主动回收 cell、closure 和 record。

现在语言已经能够形成包含环的对象图。怎样回收不可达对象,是下一章垃圾回收要解决的问题。


8.0 让名字指向可以改变的位置

上一节

8.2 AST、parser 与自由变量:写入目标也是一次变量使用

下一节