“Lambda 提升把局部函数移动到顶层,并把它们原先从词法环境取得的变量变成显式参数。本页使用一个严格受限版本:函数组处于同一外层作用域,组内允许递归与互递归,但函数名只能出现在静态可知的直…”
形式陈述
变量绑定已经规定哪些出现属于哪个声明。本页实现这一关系:输入是带源位置的AST,输出把每个声明和变量使用都标成唯一声明 ID 的 resolved AST,或给出未绑定名称错误。它不执行程序,也不替代类型检查。
固定词法作用域、单一变量命名空间、不可变绑定、非递归 let x=e1 in e2、fun x -> e,以及不引入绑定的常量、引用、读写、加法、调用和二元组。let 的 x 只在 e2 中有效;函数形参只在函数体内有效。递归、模块导入、重载、宏卫生另需规则,本算法不靠“通常如此”代替它们。
维护字典
- 变量 x:若 D[x] 非空,输出
Var(top(D[x])),同时保留原名与 span 作诊断;否则在这个使用位置失败 - 非递归 let:先为声明分配新 ID b,但不压入 D;在原 D 中解析 e1;随后把 b 压到 D[x],解析 e2,再弹出,输出
Let(b,R(e1),R(e2)) - 函数:新建形参 ID b,压入 D[x] 后解析函数体,再弹出,输出
Fun(b,R(e)) - 其他节点:按固定遍历顺序解析各子节点,不改变 D;错误则终止本次解析
分配 ID 与使 ID 进入作用域是两件事。这里先编号声明再处理其右侧,只为了得到稳定的先序编号;右侧绝不能因此看见新声明。弹出要在异常退出路径也执行,或直接丢弃本次解析器状态;不能在一次失败后复用污染的 D 解析另一个文件。
核心不变量是:进入任意节点前,D[x] 恰按从外到内顺序保存该节点处生效的 x 声明。变量规则选择栈顶,正好实现最近绑定;let 在右侧不压栈符合非递归作用域;函数和 let 的体内压栈、退出弹栈使外层恢复。因此对 AST 结构归纳,每个成功解析的使用指向词法规则指定的唯一声明。
直觉
源码中的 x 是可重复使用的标签,声明 ID 则是这一次声明的身份。解析以后,编译器可以把 x#1 放进闭包环境,把 x#9 放在当前局部槽,而不再担心文本相同造成混淆。ID 没有携带数值,也不是堆地址。
字典里按名字叠起的栈,把“从里向外找最近声明”提前组织好。进入同名内层作用域只是压入一个新 ID,离开就弹回旧 ID。resolved AST 保存的是选中的 ID,不保存后来还会变化的字典查询,以免闭包转换时重新按调用点名字解释自由变量。
例子与边界
遮蔽、右侧和闭包体分别看谁
let x = 4 in
let f = fun y -> x + y in
let x = x + 1 in
f(x)
按声明先序编号为 x#1、f#2、y#3、x#4。解析外层右侧常量 4 时 D 为空;解析 f 的函数体时 D[x]=[1]、D[y]=[3],所以加法为 Add(Var(1),Var(3))。处理第二个 x 的右侧时还没有压入 4,因此它是 Add(Var(1),1),不是读取尚未初始化的 x#4。
压入第二个 x 后,调用节点为 Call(Var(2),Var(4))。resolved AST 可以完整写成:
Let(1, 4,
Let(2, Fun(3, Add(Var(1), Var(3))),
Let(4, Add(Var(1), 1),
Call(Var(2), Var(4)))))
运行时 x#4 得到 5,f 的代码仍读 x#1=4,所以结果为 9。如果把 f 中的 x 留成“运行时去当前名字表查询”,就会读到 5,得到 10。静态解析只决定 f 使用哪个声明;函数返回之后如何保留那个声明的值,由闭包转换承担。
另看 (let x=1 in x, x)。解析第一分量后必须恢复外层字典;若初始环境没有 x,第二分量的 x 应报未绑定。忘记弹栈会错误接受该程序。let x=x in x 在空环境下也必须报错,因为非递归右侧的 x 没有声明;不能以目标语言碰巧把未初始化槽清零为语义。
从身份算捕获集合
对上例函数体取旧绑定页的自由变量规则,得到 {1},不是无区分的文本集合 {x}。形参 3 被移除,调用点新增的 4 从未进入该函数的捕获集合。若函数体另含 let x=8 in x+y,其中新的 x 身份只在内层有效,不能误把它加到外部捕获字段。
对一份成功解析的闭程序,所有使用 ID 都应存在于声明表,而且其声明在该使用的词法祖先作用域中。单独检查“ID 存在”仍不够:把使用错误改到同文件另一个作用域的声明,存在性会通过,作用域不变量却失败。
推论与应用
本算法使用字典和每名字栈。假定标识符已驻留、哈希查找期望常数,n 个 AST 节点的工作期望为
为诊断保存每次完整 D 的快照不是算法所需;若这样做,嵌套 n 层时记录总量可能为
迁移任务:把第二个 x 改名 z,同时只把其绑定的最后调用实参改成 z。答案仍是 9,resolved AST 除诊断标签外完全相同。若把 f 函数体里的 x 也改成 z,它在定义点未绑定,必须失败。再把 let 改成受限 let rec f=fun y->...:应在解析函数右侧前先压入 f,但还需限定递归初始化;这不是把本页所有 let 一律提前压栈的理由。
名称解析的符号表与链接器符号表处在不同层。前者连接语法出现与声明身份,后者连接不同对象文件的导出定义并计算地址。一个局部绑定不必成为导出的链接符号,同一个源程序也可能经消除优化而不再保留对应机器槽。
参考资料
[1] Andrew Myers,Cornell CS 4120,Spring 2023,Semantic Analysis and Symbol Tables,节 “Typing contexts” 与 “Formalizing typing contexts”,并参见其符号表实现讨论。本文复用进入/退出作用域的接口思想;唯一身份算法、错误轨迹和成本账本为本页明确模型下的展开。
[2] Robert Harper,Practical Foundations for Programming Languages,2nd ed.,2016,Chapter 1 “Abstract Syntax”、Chapter 2 “Inductive Definitions”;绑定概念和自由变量定义在本站原变量绑定页保留,本页不另定义一套作用域语义。