跳到内容

第 11 卷 / 第 7 章 / 第 1 课

7.1 名称解析与作用域:同一个名字究竟指向谁 ​

这一章从名称绑定出发,为语法树补上类型与控制流信息,再转成适合分析的中间表示。

AST 已经确认 x + 1 具有合法语法形状,却还不知道 x 是局部变量、函数参数、模块成员,还是一个根本没有声明的名字。名称解析把普通变量使用绑定到声明;重载函数可能先绑定到候选集,等类型信息齐全后再选择具体目标。

本课目标 ​

  • 区分声明、定义与名称使用;
  • 实现词法作用域中的定义与查找;
  • 处理遮蔽、重复声明和前向引用;
  • 为后续类型检查分配稳定的 symbol identity。

1. 名称不是字符串相等 ​

text
let x = 1;
{
    let x = 2;
    print(x);
}
print(x);

两个 print 中都写着字符串 x,却绑定到不同声明。编译器需要把 AST 名称节点改写或标注为稳定符号 ID:

text
outer x → Symbol#17
inner x → Symbol#23

后续阶段使用 ID,而不是反复按字符串猜作用域。ID 至少在本次编译及对象生命周期内稳定;跨编辑、跨缓存复用的身份还需要专门设计,递增整数不会自动获得这种能力。优化或重命名改变显示名称时,绑定关系仍应保留。

2. 作用域树而不只是哈希表栈 ​

简单单遍语言可维护作用域栈:进入块时 push,离开时 pop,查找从内向外。完整编译器常把作用域保留为树,支持:

  • IDE 在任意位置查询可见符号;
  • 函数体延迟分析;
  • 模块导入和增量编译;
  • 闭包捕获与逃逸分析;
  • 诊断同名声明的来源。
text
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:

c
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 示例只验证块级定义与父链查找,允许内层遮蔽,拒绝本层重复声明。父引用必须构成无环词法作用域树,前向引用与可见区间尚未实现:

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,以便错误指出新旧两处:

text
line 9:5: `count` 在当前作用域中重复声明
line 3:5: 前一次声明位于这里

4. 声明可见时机属于语言规范 ​

考虑:

text
let x = x + 1;

右侧 x 指向外层变量、当前正在初始化的变量,还是非法?不同语言选择不同。

分析声明时可能采用:

  1. 先分析初始化式中的名称,再把名字加入当前 scope;
  2. 先加入 declared-but-uninitialized 状态,再分析初始化式;
  3. 先收集整个作用域声明,再分析主体,并按规范检查初始化依赖。

第三种便于实现函数互相递归和前向引用,但收集声明不等于执行初始化式,也不自动允许读取尚未初始化的值。重复声明、初始化顺序与 IDE 可见性仍由语言规则决定。不要让遍历顺序偶然定义语言语义。

5. 名称空间可能不止一个 ​

某些语言把类型名、值名、标签和宏放在不同名称空间:同一拼写在不同类别中可同时存在。另一些语言使用统一名称空间。

符号表键可能是:

text
(name, namespace) → SymbolId

成员查找还依赖接收者类型:point.x 的 x 不应从当前词法 scope 查找。限定名 package.module.name 要逐段解析,并处理可见性和导入别名。

6. 遮蔽、捕获与闭包 ​

遮蔽是内层声明暂时隐藏外层同名声明。它可能合法、警告或禁止,取决于语言和符号类别。

内层函数引用外层局部变量时发生捕获:

text
fn outer() {
    let count = 0;
    fn next() { count = count + 1; }
}

名称解析先把 count 绑定到外层 Symbol;捕获是按值还是按引用、多个闭包是否共享可变变量,先由语言语义决定。闭包转换再选择保持这些语义的存储表示,逃逸分析可能决定是否需要堆分配。名称绑定正确是后续布局决策的前提。

7. 模块与导入带来图结构 ​

模块依赖可能成环。常见策略:

  • 第一遍收集导出接口,第二遍分析实现;
  • 区分允许循环的声明依赖与禁止循环的初始化依赖;
  • 给导入建立显式优先级,检测星号导入歧义;
  • 缓存模块接口摘要,支持增量构建。

仅用递归“先分析所有依赖”会在环上无限递归。应维护 unseen / visiting / done 状态来检测环。若语言允许循环,再用接口预收集或强连通分量协调分析;仅仅把 visiting 节点跳过,不能保证其未完成接口已经可用。

8. 错误恢复使用占位符号 ​

未解析名称若直接返回空指针,类型检查会反复报错或崩溃。可以创建 error symbol:

text
unknownName → ErrorSymbol#...

它携带错误类型,后续运算传播但抑制同一次名称使用引出的派生诊断。独立位置的未知名称仍可以分别报告。一次根因错误不应变成几十条“无法推断类型”。

常见误区 ​

  • 符号表就是 name → type:还需 symbol identity、声明位置、种类、可见性与状态。
  • 从内向外查找可解决所有名称:成员、导入、重载和继承有独立规则。
  • 遍历到声明时加入表就够了:前向引用和递归需要显式可见时机。
  • 未解析名称立即停止最好:IDE 与批量诊断通常需要 error symbol 继续分析。

练习 ​

  1. 为嵌套块实现 define_current 与 lookup_visible,测试合法遮蔽和重复声明。
  2. 给函数互递归设计两遍名称收集流程。
  3. 为 object.member 区分词法名称查找与成员查找。
  4. 设计 error symbol,确保未知变量只报告一次根因。

小结 ​

名称解析把源码拼写绑定到稳定声明身份。作用域、可见时机、名称空间、导入和捕获都属于语言语义,不能由一次 AST 遍历的顺序偶然决定。

下一课在绑定关系上检查类型:每个操作接受哪些输入、何时允许转换、错误怎样继续传播。

Built with VitePress | Software Systems Atlas