随身带着声明时的环境 — 闭包
一句话总结
树遍历解释器(tree-walking interpreter)为 AST 的每种节点写一个函数,规定“遇到这种节点就计算什么”。名字在环境(environment,一条链:字典里是名字到值的映射,并指向外层环境)中查找,而函数值会携带声明时的环境一起走。这就是闭包。
为什么需要它
第 6 个模块的字节码 VM 和第 8 个模块的机器码虽然快,但离“定义含义”太远。“&& 的左边为假就不计算右边”“块结束后内层的名字就消失”“函数看到的是它被声明之处的变量”,这样的规则,最直截了当的写法就在树解释器里。所以本课程把解释器当作基准。后面的 VM 和原生代码,运行同一个程序时,必须输出与解释器相同的行才算正确。
不过,“直截了当地写”并不等于“容易”。用 Python 写解释器,Python 的含义会悄悄混进来。
- Python 的整数可以无限增大,而 mini 的整数在 64 位处回绕(
9223372036854775807 + 1是负数)。因为机器码就是这样做的。 - Python 的
-7 // 2是 -4,而 mini 的-7 / 2和 C 一样是 -3。 - 在 Python 里
True == 1,而且isinstance(True, int)也为真。在 mini 里,1 == true是运行时错误。
如果定义含义的一方漏掉了这些,后面所有的实现都会按错误的基准来对齐。
工作原理
环境是一条链。每进入一个块、每调用一次函数,就创建一个新环境,并让它指向父环境。查找名字时沿着链向上走,而赋值则修改这个名字所在环境里的那一格——不会在当前环境里新建一个。
fn counter(start) { counter(10) 을 부르면
let n = start; [호출 환경 {start:10, n:10}] → [전역 {counter, …}]
fn next() { n = n + 1; next 를 선언하는 순간, 함수 값 = (next 의 선언, 지금 환경)
return n; }
return next; counter 가 끝나도 호출 환경은 next 가 붙잡고 있어 살아남는다
}
let a = counter(10); a() → 새 환경 {} → 부모는 붙잡아 둔 호출 환경 → n 을 11 로
print a(); print a(); 11, 12 — 같은 칸을 바꾸기 때문에 이어진다
关键在于,函数值的父环境,是声明时的环境,而不是调用时的环境(静态作用域)。如果把调用时的环境作为父环境(动态作用域),a() 就找不到 counter 的 n,而且如果全局里有同名的名字,就会莫名其妙地改掉那个全局的。闭包把环境复制走同样不对——两个抓住同一个环境的函数(一个把值加一,一个读取值)就会看到不同的格子。
return 一次就要跳出好几层。while 里的 if 里的 return,必须直接回到调用的位置。在 Python 中,最简单的办法是抛出一个异常(ReturnSignal),在调用位置捕获。调用结束后,必须把调用深度恢复,而在靠异常跳出的路径上很容易漏掉这一步(这正是需要 finally 的地方)。
运行时错误标在运算符的位置。在 a / b 中,如果 b 是 0,就以 / 的行:列报出 “runtime error: division by zero”。语义分析不知道而放过的类型(作为参数传进来的 true),也在这里被抓住。同时存活的调用超过 200 个,就以 “stack overflow” 停止——如果先碰到了 Python 自己的递归上限,用户看到的就会是 Python 的错误。
在现场相遇的样子
- CPython 的
nonlocal。在 Python 闭包里,要给外层变量赋值,必须写nonlocal。不写的话,赋值会在当前环境中创建一个新名字,这是它的规则——相当于选了与本模块“赋值修改名字所在那一格”正相反的默认值,由此产生的 UnboundLocalError,是 Python 初学者最常遇到的错误之一。 - 循环中的闭包。如果多个闭包抓住 JavaScript 里用
var创建的循环变量,它们看到的都是同一个格子(最后的值)。let之所以改成每次循环都创建新环境,原因就在这里。环境什么时候重新创建,本身就是语言的含义。 - 参考实现。很多语言会在规范文档之外,把一个“慢但直截了当”的解释器设为基准,再用它去对照快速的编译器是否得出同样的结果。本课程评分器做的事也是一样的。
下一项实验要做什么
在 interp.py 中做出值规则(64 位回绕、向 0 截断的除法、区分 bool 和 int)、环境链、表达式求值(短路求值与运行时类型检查)、语句与块、if 和 while、函数与闭包、return 和调用深度,以及从解析一直连到执行的 run_source。评分器会把几百对随机数、固定程序,以及 150 个随机程序打印出的行和运行时错误,与参考对照。