7.1 名称解析与作用域:同一个名字究竟指向谁
这一章从名称绑定出发,为语法树补上类型与控制流信息,再转成适合分析的中间表示。
AST 已经确认 x + 1 具有合法语法形状,却还不知道 x 是局部变量、函数参数、模块成员,还是一个根本没有声明的名字。名称解析把普通变量使用绑定到声明;重载函数可能先绑定到候选集,等类型信息齐全后再选择具体目标。
本课目标
- 区分声明、定义与名称使用;
- 实现词法作用域中的定义与查找;
- 处理遮蔽、重复声明和前向引用;
- 为后续类型检查分配稳定的 symbol identity。
1. 名称不是字符串相等
let x = 1;
{
let x = 2;
print(x);
}
print(x);两个 print 中都写着字符串 x,却绑定到不同声明。编译器需要把 AST 名称节点改写或标注为稳定符号 ID:
outer x → Symbol#17
inner x → Symbol#23后续阶段使用 ID,而不是反复按字符串猜作用域。ID 至少在本次编译及对象生命周期内稳定;跨编辑、跨缓存复用的身份还需要专门设计,递增整数不会自动获得这种能力。优化或重命名改变显示名称时,绑定关系仍应保留。
2. 作用域树而不只是哈希表栈
简单单遍语言可维护作用域栈:进入块时 push,离开时 pop,查找从内向外。完整编译器常把作用域保留为树,支持:
- IDE 在任意位置查询可见符号;
- 函数体延迟分析;
- 模块导入和增量编译;
- 闭包捕获与逃逸分析;
- 诊断同名声明的来源。
ModuleScope
└── FunctionScope(add)
├── parameter a
├── parameter b
└── BlockScope
└── local result每个 scope 记录父 scope 和本层声明映射。若名字不是整块可见,还要保存声明的可见区间,并按查询位置过滤;只有一棵树不足以回答任意位置的 IDE 查询。查找时从当前层沿父链向上;模块、类型成员和继承层次可能采用不同查找规则,不能都硬塞进一条词法父链。
3. 定义与查找接口
声明引入名称和类型信息,定义还提供实体内容。以 C 为例,extern int count; 声明对象,int count = 0; 定义它;函数原型与带函数体的定义也不同。具体允许哪些重复声明由语言决定。下面的 C 数据模型片段需要 <stdint.h>,以及宿主定义的各种 ID、Map 与 Span:
typedef uint32_t SymbolId;
typedef struct Symbol {
SymbolId id;
StringId name;
SymbolKind kind;
AstNodeId declaration;
TypeId declared_type;
Span span;
} Symbol;
typedef struct Scope {
struct Scope *parent;
Map declarations; // StringId -> SymbolId
} Scope;define 只检查本层是否已有冲突声明;lookup 先检查本层,再沿父链查找。若用 lookup 检查重复声明,会错误禁止合法遮蔽。
下面的完整 Python 示例只验证块级定义与父链查找,允许内层遮蔽,拒绝本层重复声明。父引用必须构成无环词法作用域树,前向引用与可见区间尚未实现:
from dataclasses import dataclass
from itertools import count
_ids = count()
@dataclass(frozen=True)
class Symbol:
id: int
name: str
class Scope:
def __init__(self, parent=None):
self.parent = parent
self.declarations = {}
def define_current(self, name):
if name in self.declarations:
raise ValueError(f"duplicate declaration: {name}")
symbol = Symbol(next(_ids), name)
self.declarations[name] = symbol
return symbol
def lookup_visible(self, name):
scope = self
while scope is not None:
if name in scope.declarations:
return scope.declarations[name]
scope = scope.parent
return None
outer = Scope()
first = outer.define_current("x")
inner = Scope(outer)
second = inner.define_current("x")
assert inner.lookup_visible("x") is second
assert outer.lookup_visible("x") is first
assert first.id != second.id
assert inner.lookup_visible("missing") is None
try:
inner.define_current("x")
except ValueError as error:
print(error) # duplicate declaration: x
else:
raise AssertionError("duplicate declaration accepted")声明表应保存声明 span,以便错误指出新旧两处:
line 9:5: `count` 在当前作用域中重复声明
line 3:5: 前一次声明位于这里4. 声明可见时机属于语言规范
考虑:
let x = x + 1;右侧 x 指向外层变量、当前正在初始化的变量,还是非法?不同语言选择不同。
分析声明时可能采用:
- 先分析初始化式中的名称,再把名字加入当前 scope;
- 先加入
declared-but-uninitialized状态,再分析初始化式; - 先收集整个作用域声明,再分析主体,并按规范检查初始化依赖。
第三种便于实现函数互相递归和前向引用,但收集声明不等于执行初始化式,也不自动允许读取尚未初始化的值。重复声明、初始化顺序与 IDE 可见性仍由语言规则决定。不要让遍历顺序偶然定义语言语义。
5. 名称空间可能不止一个
某些语言把类型名、值名、标签和宏放在不同名称空间:同一拼写在不同类别中可同时存在。另一些语言使用统一名称空间。
符号表键可能是:
(name, namespace) → SymbolId成员查找还依赖接收者类型:point.x 的 x 不应从当前词法 scope 查找。限定名 package.module.name 要逐段解析,并处理可见性和导入别名。
6. 遮蔽、捕获与闭包
遮蔽是内层声明暂时隐藏外层同名声明。它可能合法、警告或禁止,取决于语言和符号类别。
内层函数引用外层局部变量时发生捕获:
fn outer() {
let count = 0;
fn next() { count = count + 1; }
}名称解析先把 count 绑定到外层 Symbol;捕获是按值还是按引用、多个闭包是否共享可变变量,先由语言语义决定。闭包转换再选择保持这些语义的存储表示,逃逸分析可能决定是否需要堆分配。名称绑定正确是后续布局决策的前提。
7. 模块与导入带来图结构
模块依赖可能成环。常见策略:
- 第一遍收集导出接口,第二遍分析实现;
- 区分允许循环的声明依赖与禁止循环的初始化依赖;
- 给导入建立显式优先级,检测星号导入歧义;
- 缓存模块接口摘要,支持增量构建。
仅用递归“先分析所有依赖”会在环上无限递归。应维护 unseen / visiting / done 状态来检测环。若语言允许循环,再用接口预收集或强连通分量协调分析;仅仅把 visiting 节点跳过,不能保证其未完成接口已经可用。
8. 错误恢复使用占位符号
未解析名称若直接返回空指针,类型检查会反复报错或崩溃。可以创建 error symbol:
unknownName → ErrorSymbol#...它携带错误类型,后续运算传播但抑制同一次名称使用引出的派生诊断。独立位置的未知名称仍可以分别报告。一次根因错误不应变成几十条“无法推断类型”。
常见误区
- 符号表就是
name → type:还需 symbol identity、声明位置、种类、可见性与状态。 - 从内向外查找可解决所有名称:成员、导入、重载和继承有独立规则。
- 遍历到声明时加入表就够了:前向引用和递归需要显式可见时机。
- 未解析名称立即停止最好:IDE 与批量诊断通常需要 error symbol 继续分析。
练习
- 为嵌套块实现
define_current与lookup_visible,测试合法遮蔽和重复声明。 - 给函数互递归设计两遍名称收集流程。
- 为
object.member区分词法名称查找与成员查找。 - 设计 error symbol,确保未知变量只报告一次根因。
小结
名称解析把源码拼写绑定到稳定声明身份。作用域、可见时机、名称空间、导入和捕获都属于语言语义,不能由一次 AST 遍历的顺序偶然决定。
下一课在绑定关系上检查类型:每个操作接受哪些输入、何时允许转换、错误怎样继续传播。