Skip to content

算法Algorithm

词法名字解析与声明身份

Lexical name resolution · Resolved AST · 静态名称解析

用声明唯一身份、按名字的栈和显式作用域恢复,把源名字解析为稳定绑定,并区分编译期符号与运行时值。

形式陈述 ​

变量绑定已经规定哪些出现属于哪个声明。本页实现这一关系:输入是带源位置的AST,输出把每个声明和变量使用都标成唯一声明 ID 的 resolved AST,或给出未绑定名称错误。它不执行程序,也不替代类型检查。

固定词法作用域、单一变量命名空间、不可变绑定、非递归 let x=e1 in e2、fun x -> e,以及不引入绑定的常量、引用、读写、加法、调用和二元组。let 的 x 只在 e2 中有效;函数形参只在函数体内有效。递归、模块导入、重载、宏卫生另需规则,本算法不靠“通常如此”代替它们。

维护字典 D,每个文本名字映到一个声明 ID 栈;空栈等价于没有条目。新鲜 ID 由单调计数器产生,整个程序不重复。处理节点时执行:

  1. 变量 x:若 D[x] 非空,输出 Var(top(D[x])),同时保留原名与 span 作诊断;否则在这个使用位置失败
  2. 非递归 let:先为声明分配新 ID b,但不压入 D;在原 D 中解析 e1;随后把 b 压到 D[x],解析 e2,再弹出,输出 Let(b,R(e1),R(e2))
  3. 函数:新建形参 ID b,压入 D[x] 后解析函数体,再弹出,输出 Fun(b,R(e))
  4. 其他节点:按固定遍历顺序解析各子节点,不改变 D;错误则终止本次解析

分配 ID 与使 ID 进入作用域是两件事。这里先编号声明再处理其右侧,只为了得到稳定的先序编号;右侧绝不能因此看见新声明。弹出要在异常退出路径也执行,或直接丢弃本次解析器状态;不能在一次失败后复用污染的 D 解析另一个文件。

核心不变量是:进入任意节点前,D[x] 恰按从外到内顺序保存该节点处生效的 x 声明。变量规则选择栈顶,正好实现最近绑定;let 在右侧不压栈符合非递归作用域;函数和 let 的体内压栈、退出弹栈使外层恢复。因此对 AST 结构归纳,每个成功解析的使用指向词法规则指定的唯一声明。

直觉

源码中的 x 是可重复使用的标签,声明 ID 则是这一次声明的身份。解析以后,编译器可以把 x#1 放进闭包环境,把 x#9 放在当前局部槽,而不再担心文本相同造成混淆。ID 没有携带数值,也不是堆地址。

字典里按名字叠起的栈,把“从里向外找最近声明”提前组织好。进入同名内层作用域只是压入一个新 ID,离开就弹回旧 ID。resolved AST 保存的是选中的 ID,不保存后来还会变化的字典查询,以免闭包转换时重新按调用点名字解释自由变量。

例子与边界

遮蔽、右侧和闭包体分别看谁 ​

text
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 可以完整写成:

text
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 节点的工作期望为 O(n),名字栈空间为当前活动声明数;递归遍历还需 O(h) 调用栈,h 为 AST 高度,深层不含绑定的表达式也会增加 h;另有输出树 O(n)。每个名字字符串首次读取、驻留和哈希需按字符总数另计,不能因它最终变为一个整数 ID 就免费。改用平衡树,查找和更新为 O(log⁡b),b 为活动不同名字数;按作用域组织“哈希表的栈”则一次查找可能经过深度 d 个表。

为诊断保存每次完整 D 的快照不是算法所需;若这样做,嵌套 n 层时记录总量可能为 Θ(n2)。终点 checker 只记录绑定决策与事件序列,若开启完整 AST/状态打印,会单列序列化输出大小。

迁移任务:把第二个 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”;绑定概念和自由变量定义在本站原变量绑定页保留,本页不另定义一套作用域语义。

关系图谱12 个相邻概念 · 2 类关系

拖动节点调整位置。

显示关系

显示:依赖

  1. 前置三跳
  2. 前置二跳
  3. 前置一跳
  4. 当前条目
  5. 后续一跳
  6. 后续二跳
  7. 后续三跳
文字版关系按与当前条目的最短距离分组
类型化关系