符号表与作用域

本页目录

名字解析把源码中的名字使用关联到声明或候选声明。符号表保存这些身份及相关信息,后续类型检查、捕获分析和代码生成再依赖它。复杂语言里这些阶段可能交错,并非所有名字都能在完全没有类型信息时一次解析完。

最重要的区别是拼写与身份:两个都叫 x 的变量可以是不同符号,同一个符号也可能通过别名或限定路径访问。

作用域决定名字可见性

词法作用域依据程序结构确定查找环境。假设语言允许内层遮蔽外层,进入内层后优先找到内层声明;离开后恢复外层环境。

相同拼写,不同声明身份:查找从当前作用域向外 函数作用域 x → Symbol 17 内层块作用域 x → Symbol 23 内层使用 x:绑定到 Symbol 23 parent 离开内层块后,后续使用重新按外层环境解析;已解析引用保留各自的符号身份。

声明何时开始可见由语言规定。函数是否允许前向引用、局部变量是否在自身初始化器中可见、同块重声明是否错误,都不能用“遇到定义就放表里”替代规范。

例如 let x = x + 1,某些语言允许右侧指向前一个 x,另一些规则可能把新声明置于尚未初始化的状态。实现应区分声明、初始化和可读取状态。

一个简洁的词法环境实现

每个作用域保存名字到 SymbolId 的映射和 parent。insert 只检查当前作用域的重复规则;lookup 从当前环境沿父链查找;lookup_current 用于需要只查本层的场景。

哈希表每层查询通常是期望 O(1),查父链则与深度相关。也可以用每个名字的绑定栈、撤销日志或持久映射实现,不存在“必须采用作用域栈才正确”的唯一数据结构。

分析结束后如果 AST、IDE 索引还引用 SymbolId,符号记录必须继续存在。弹出当前活动作用域不等于删除所有被后续阶段引用的声明数据。把临时遍历栈与长期符号存储分开,可以避免悬空索引。

活动环境可以退出,已解析引用不能跟着消失

下面定义一个教学语言的规则:同层同名声明报错,内层允许遮蔽;只处理值名字,不支持导入或重载。符号记录存入本次分析的 arena,作用域只保存到记录的索引。这个整数 ID 在当前分析中稳定,不是跨文件编辑后天然稳定的全局 ID。

from dataclasses import dataclass

@dataclass(frozen=True)
class Symbol:
    name: str
    declaration: str

class Scope:
    def __init__(self, parent=None):
        self.parent = parent
        self.names = {}

    def declare(self, arena, name, location):
        if name in self.names:
            raise ValueError(f"duplicate {name}: {location}")
        identity = len(arena)
        arena.append(Symbol(name, location))
        self.names[name] = identity
        return identity

    def lookup(self, name):
        current = self
        while current is not None:
            if name in current.names:
                return current.names[name]
            current = current.parent
        raise NameError(name)

arena = []
outer = Scope()
a = outer.declare(arena, "x", "line 1")
inner = Scope(outer)
assert inner.lookup("x") == a  # 内层声明出现前
b = inner.declare(arena, "x", "line 3")
resolved_use = inner.lookup("x")
assert resolved_use == b and a != b
try:
    inner.declare(arena, "x", "line 4")
except ValueError:
    pass
else:
    raise AssertionError("duplicate accepted")
assert len(arena) == 2  # 失败插入没有留下幽灵记录
active = outer
assert active.lookup("x") == a
assert arena[resolved_use].declaration == "line 3"
try:
    active.lookup("missing")
except NameError:
    pass
else:
    raise AssertionError("unresolved name accepted")

最后一次读取 arena 说明:退出内层以后,已解析的使用点仍指向内层声明。代码生成或“跳转定义”不能重新从当前外层环境 lookup("x"),否则会把内层引用错误地改绑到外层。

时刻当前查找结果已记录的内层使用
尚未声明内层 x外层 a尚无
声明内层 x 后内层 b保存 b
退出内层外层 a仍为 b

要支持同块连续 shadowing,可在每次声明后建立新的可见性层或调整绑定栈规则;不能一边采用本例的“同层重复报错”,一边声称它已经完整实现 Rust 的 let 规则。rustc 的作用域层

查找环境描述现在可见什么;解析结果记录当时绑定到谁 活动环境 outer x → a 已解析使用节点 SymbolId = b 长期符号记录 b → line 3 内层声明 离开 inner 后,不重新用 outer 的 x 覆盖已解析的 b。 增量编辑时还需检查记录所属版本,当前分析中的稳定 ID 不等于永久有效。

离开作用域,旧引用会改绑吗?

把使用点连接到声明 ID,比只给 x 涂上同一种颜色更准确。进入内层后新查找可能得到 S1,退出后又得到 S0;但先前记录的引用不会被重新解析成当前环境中的 x。闭包的使用点也有自己的绑定依据。下面的开关重建一份新的源码分析,避免把删改声明误解成运行时改绑变量。

正在呈现知识画面
离开作用域,旧引用会改绑吗?

同名不等于同一符号。退出内层只改变后续 lookup,已经记录的引用仍指向原声明;闭包引用哪个绑定与捕获方式也是两个问题。

符号记录应该保存什么

字段用途
稳定身份、声明种类区分同名变量、类型、函数等
声明位置与所属环境诊断、跳转、可见性检查
类型或待求类型引用后续约束生成和类型检查
可变性、访问权限等属性赋值、导入和调用限制
定义与引用关系IDE 引用查询与重构,可单独存索引

命名空间也由语言规定。C 的 tag 名字与普通标识符分属不同名字空间;标签有其作用域。把所有拼写放到同一张不分类的表里,会误报重复或找错定义。

模块导入:候选来源与访问权限

模块路径、显式导入、通配导入、别名和内置名字之间的优先级各不相同。符号表应保留候选来源,方便报告“两个导入都提供这个名字”的歧义,而不是任意选最后插入者。

找得到一个声明与允许从当前位置访问它是两个问题。可见性检查还可能依赖模块层级、包或其他语言规则,不只是 public/private 两个值。

模块循环也不一定非法。有些语言允许先收集声明,再解析函数体或处理强连通分量;另一些禁止某类依赖环。遇到递归导入应防止无限解析,但不能对所有循环无条件报错。Rust 的名字解析还与宏展开和不同名字空间配合。rustc 名字解析

重载:查找候选与选择目标可以分步

有重载的语言可能先按名字与作用域得到候选集合,再结合参数个数、类型、隐式转换和泛型约束选择可调用目标。成员查找还会依赖接收者类型。

因此“lookup 返回一个符号”只是简单语言的接口。复杂实现可以返回候选、延迟解析项或错误占位,并保留来源信息供诊断。

闭包捕获:名字解析只完成第一步

闭包引用外层局部变量时,解析器需要知道它指向哪个外部绑定;捕获方式、环境布局和逃逸则需要分析闭包如何使用该值以及语言的所有权规则。

fn main() {
    let values = vec![2, 4, 6];
    let length = || values.len();
    assert_eq!(length(), 3);
    assert_eq!(values[0], 2);
}

这里读取 Vec 的长度只需共享借用,不因为 Vec 不是 Copy 就自动移动所有权。move 捕获也不代表闭包一定只能调用一次;Fn、FnMut、FnOnce 取决于闭包调用时如何使用捕获值。Rust 闭包说明

被捕获也不等于一定堆分配。未逃逸闭包可能使用栈环境,按值捕获可以把数据放进闭包对象,优化还可能消除环境。真正要求是捕获值在所需期间有效;借用、复制、移动、装箱或 upvalue 是不同实现手段。

闭包也不能在调用时重新按同名变量查找

词法绑定确定后,后续同名声明不会把已有闭包改接到新声明。下面的 Rust 例子中,闭包始终引用第一个 value;第二个 value 只是遮蔽了后续直接使用的名字。两个绑定都在所需期间有效。

fn main() {
    let value = 10;
    let read_old = || value;
    let value = 20;
    assert_eq!(read_old(), 10);
    assert_eq!(value, 20);
    assert_eq!(read_old(), 10);
}

这与“捕获按值还是按引用”是两个维度:先确定引用的是哪一个绑定,再决定以何种方式把该绑定提供给闭包。若语言允许修改被引用的同一绑定,后续读取可能观察其新值;但换成另一个同名绑定,不等于修改了原绑定。

泛型实例:源符号与后端实例分开管理

一个泛型声明有稳定身份,具体类型参数组成实例化上下文。后端可以用“声明身份 + 替换参数”等键缓存实例,不必每次使用都复制一套源语言作用域符号。

代码生成是否单态化、共享实现或使用字典传递是后续策略。不同类型参数也不保证最终一定保留不同机器码,优化、内联和链接去重都会影响产物。

从批量编译到 IDE

批量遍历可以使用活动环境栈,交互系统还需处理修改后的符号身份、依赖失效和旧位置映射。一个函数体编辑不应无理由让全项目所有符号身份改变;公共声明变化则可能需要重查依赖。

诊断最好同时指出使用位置、候选声明和具体失败规则,例如“该名字来自私有模块”或“两个通配导入产生歧义”,而不是全部变成“找不到符号”。

参考

Keywords: symbol table, SymbolId, lexical scope, shadowing, namespace, visibility, overload, closure capture, lifetime