语法分析交出来的那棵树,形状是对的,意思未必对。这句话我在不少场合都说过:一个表达式符合产生式,只能说明括号配平了、运算符优先级没歧义,至于里面那个名字到底声明过没有、两边类型能不能相加、这行代码是不是写在函数体外面,上下文无关文法一个字都管不了。语义分析要干的活,就是往这棵树上补一类语法管不了的信息——上下文相关的那些约束,比如"变量必须先声明后使用""非 void 函数所有路径都要有返回""break 只能待在循环里"。这些东西的合法性跟代码所处的位置强绑定,同一个标识符在块内合法、出了块就非法,所以必须带着状态去遍历。
这篇是我自己在做编译器前端时攒下来的一份复盘,围绕"上下文相关情况怎么处理"这条主线,讲符号表怎么设计、类型检查怎么落、控制流类约束怎么变成图论问题、报错体验怎么救。读者对象大概是两类人:一类是刚学完语法分析、正准备动手写检查器的同学,另一类是想给自己写的 DSL 或者配置语言加一套静态检查的工程师。前者能拿到一张清晰的实现地图,后者能抄走几个能直接用的结构和算法。
1. 语义分析到底在补什么窟窿:整体设计思路
1.1 从"结构合法"到"意思合法":上下文相关规则的四类典型场景
写检查器最怕一上来就闷头写代码,写到一半发现规则之间互相纠缠。我的做法是先把语言规范里所有"静态语义"条款列成清单,再按依赖关系分四类,这四类基本覆盖了真实语言里九成以上的上下文相关检查。
第一类是名字解析与作用域:变量、函数、类型、标签有没有声明过,是否在同一作用域内重复声明,内层同名声明对外层的遮蔽关系对不对,私有成员是不是被外部访问,命名空间或者模块前缀有没有写错。这类检查的特点是不涉及类型,只需要回答"这个名字在这个位置指向哪条声明"。
第二类是类型一致性:赋值两边是否兼容,算术运算的操作数是不是数值,比较运算两边能不能比,函数调用的实参个数和类型对不对得上,返回值类型跟声明是否一致,条件表达式的分支类型怎么合并,下标是不是整数。这类检查是工作量最大的部分,也是类型系统设计的直接体现。
第三类是控制流约束:非 void 函数的所有执行路径上是否都有返回,break/continue 是否处于循环内部,case 是否在 switch 内部,标签是否重复、goto 的目标是否存在,return 之后的代码是否永远执行不到。这类约束表面上像"语法检查",实际上必须看整棵子树的形状,属于典型的上下文相关。
第四类是零散静态约束:常量表达式(数组长度、case 标签、枚举初值、位宽参数必须是编译期常量),变量使用前是否明确赋值,抽象成员是否被实现,override 是否真的覆盖了父类方法,同一 switch 里 case 值是否重复。
| 检查类别 | 依赖的上下文信息 | 典型数据结构 | 遍历方式 |
|---|---|---|---|
| 名字解析 | 当前可见的声明集合 | 符号表 + 作用域栈 | 随 AST 深度优先进出 |
| 类型一致性 | 子表达式类型 + 期望类型 | 类型对象、类型等价判定 | 自底向上综合 + 自顶向下传递 |
| 控制流约束 | 语句树形状 + 循环/函数嵌套 | 上下文深度栈、控制流图 | 后序遍历、可达性分析 |
| 零散静态约束 | 常量值、赋值状态 | 常量求值器、数据流集合 | 前向数据流迭代 |
把这四类分开看,好处是每一类都能用相对独立的机制去做,出问题时也能快速定位是哪一类规则漏了。我见过不少项目把这些检查揉在一个巨大的visit函数里,最后没人敢动,加一条规则要读八百行,这种代码三个月后就是负债。
1.2 两条主流技术路线:语法制导翻译配符号表,还是属性文法做依赖求值
处理上下文相关,教科书上给了两条路。一条是属性文法:给每个产生式挂上属性方程,属性之间形成依赖图,按拓扑序求值。综合属性从子节点往父节点传,继承属性从父节点或兄弟节点往下传。另一条是语法制导翻译:在递归下降或者 LR 的分析动作里直接写命令式代码,边解析边查符号表、边建类型。这两条路在理论上等价,在工程上差别很大。
属性文法的好处是声明式、可验证,规则写出来就是一份形式化文档,写编译器构造课程作业时特别舒服。但它有三个现实问题:一是依赖图需要额外存储,大程序上一棵十万节点的树要算依赖关系,内存和时间都不便宜;二是错误处理很难塞进去,属性方程求值失败以后怎么继续、怎么只报一次,这套逻辑在声明式框架里写起来非常别扭;三是调试成本高,报错栈里全是求值器的框架代码,看不出是哪个产生式的哪条方程出的问题。
所以我更偏向语法制导翻译这条路线。具体做法是把"环境"显式建模成一个可以推入推出的对象,遍历进入作用域时压栈、离开时弹栈,遇到标识符就去当前环境链上查。这实际上是把属性文法里的继承属性手工实现了,只不过用了命令式的写法。有个取舍点要提前想清楚:如果用 LR 分析器,语义动作只能看到规约时的栈内容,拿不到"未来"的信息,所以前向引用必须靠预扫描解决;如果用递归下降,倒是可以在需要的位置临时往前多看一点,但这也破坏了一次遍历的干净性。
提示:属性文法别完全丢掉。我在项目里保留了一份属性方程表,只用来做文档和测试用例的推导依据,实际实现走命令式。两者对不上时,通常是实现漏了规则,这份表就成了一份免费的规格说明。
1.3 单遍扫描与多遍扫描的取舍,以及为什么我几乎总选两遍
早期我写过一遍扫描的检查器,解析、建符号表、类型检查同时进行。它跑得快,但很快就撞墙:函数 A 调用函数 B,B 定义在后面,一遍扫描时 B 还不存在。第一反应是"语言规定必须前向声明",这其实是把实现限制写进了语言规范,非常糟糕。后来做减法,改成两遍:第一遍只做声明收集,把所有顶层符号、类型定义、函数签名登记到符号表,函数体先跳过;第二遍再做完整的表达式与语句检查,此时所有名字都可见了。
两遍的代价是要把 AST 完整保留在内存里,对小脚本引擎没问题,对几十万行的源码就有点吃紧。折中方案是分层预扫描:文件级别先扫一遍收集声明,函数体内部仍按需即时检查,因为函数体内的局部变量本来就必须先声明后使用(这一点很正常,局部前向引用会让代码难读)。这样一来,内存峰值只跟单个函数体的大小相关。
还有一种特殊情况要单独处理:某个语言允许"提升",也就是函数声明可以写在调用之后,甚至块内声明的函数会提升到块顶。这类语言没法靠简单预扫描解决,必须做作用域级的声明提升——进入块时先把块内所有函数声明登记,再处理其它语句。这跟符号表的销毁时机强相关,第 2 节会细说。
2. 符号表:上下文信息怎么记账才不出错
2.1 作用域实现的两种形态,以及各自的失效场景
符号表的核心矛盾只有一句话:同一时刻只能看见"当前作用域及其外层"的声明,离开作用域后这些声明必须立刻消失。围绕这句话有两种实现。
栈式符号表:每个作用域对应一个字典,进入作用域压入,离开弹出。查找时从栈顶往下逐层查,找到即止。插入和查找都是均摊 O(1),弹出时整层直接丢弃,非常干脆。缺点是每一层都要单独分配一个容器,函数嵌套很深、每层只声明一两个变量的场景下(比如深层 lambda),分配开销会明显上来。
带层级标记的链式符号表:只有一张全局哈希表,每条记录带上scopeLevel,插入时把同名的新记录插到该名字链表的头部。查找从链表头开始,取第一条scopeLevel <= 当前可见层级的记录。退出作用域时,把该层插入的记录从链表上摘掉。它的优势是同名项的遮蔽关系天然体现在链表里,来回切作用域不丢信息,IDE 这种需要同时展示所有声明的场景特别合适。
| 维度 | 栈式符号表 | 带层级的链式符号表 |
|---|---|---|
| 插入 | O(1) | O(1),头插 |
| 查找 | 均摊 O(1),层数越多越慢 | O(k),k 为同名遮蔽深度 |
| 退出作用域 | O(1),整体丢弃 | O(该层插入条数) 逐个摘除 |
| 内存 | 每层一个容器 | 一张表 + 链表指针 |
| 是否保留历史声明 | 否 | 是 |
| 适合场景 | 一次性编译、脚本引擎 | 增量编译、IDE、语言服务器 |
我自己的项目里用的是栈式,因为只做一次性编译。但做过一个需要在编辑时反复重新分析局部代码的小工具,那时链式结构确实更顺手——改写一个函数不需要重建整张表。这个选择没有绝对对错,看你是否需要"时间旅行"。
2.2 一条符号记录该存哪些字段,少存一个都要返工
字段设计上我踩过一次亏:第一版只存了名字、种类、类型三样,运行起来一切正常,直到开始做报错才发现,"这里重复声明了"这条信息我没法指出跟哪一处重复。后来补了declPos(声明所在的行列和文件偏移),错误信息才能写成"重复声明,上一处在第 12 行第 5 列"。这类字段看起来是锦上添花,实际上是诊断质量的地基。
我现在固定会存的字段大致是这些:
| 字段 | 用途 | 省掉的代价 |
|---|---|---|
name | 键,通常还能做字符串驻留省内存 | 无从查起 |
kind | 区分变量/函数/类型/字段/参数/标签/常量 | 无法做分类检查,比如把类型名当变量用 |
type | 类型对象引用 | 类型检查无从下手 |
declPos | 声明位置,报错与"相关声明"提示 | 只报错不给位置,用户得自己找 |
scopeLevel | 层级,用于遮蔽判断和增量维护 | 内层遮蔽后无法知道该不该恢复 |
storage | 存储类别,如局部、参数、全局、只读 | 后端生成阶段还得再查一遍源码 |
attrs | 修饰标记,如常量、静态、私有的 | 语义检查只能靠语法标记猜 |
initState | 是否已明确赋值(针对需要严格初始化的语言) | 明确赋值分析得另开一份表 |
关于类型对象怎么表示,有个决定影响全局。我的做法是基本类型用全局唯一的单例对象,指针、数组、函数、泛型实例这些复合类型按结构做驻留——类型构造的时候就查一次表,已存在就复用。这样类型判断退化为指针比较,速度极快。代价是要在设计类型构造入口时统一收口,任何绕过入口直接new一个类型对象的代码都会导致同一类型存在多份实例,判断失败,而这种 bug 极难定位。我后来在类型的构造函数里加了断言,只允许工厂方法调用。
至于类型等价,结构等价看两个类型的组成是否一致,适合表达式推导出的匿名类型;名字等价要求来源是同一个声明,适合用户显式定义的具名类型。多数语言混用:具名类型按名字等价,匿名结构按结构等价。混用的边界要在规范里写清楚,否则"两个字段完全相同的结构体能不能互赋值"这种问题会反复被用户问到。
2.3 预扫描:解决"先声明后使用"的鸡生蛋
考虑一段很普通的代码:类的成员函数里引用了同类中后面才声明的另一个成员。按"先声明后使用"的严格规则,这段代码应该报错,但几乎所有语言都允许——因为类体是一个整体,成员之间存在互相引用。函数之间互相调用、类型别名互相引用(只要不是无限递归)、模块之间循环导入,都是同一类问题。
两种解法我都试过。延迟检查法:遇到查不到的名字时不立即报错,而是把"这里需要一个类型"这个待办挂起,等整个编译单元检查完再统一回填,仍然没解决的才报"未声明的标识符"。实现简单,但错误定位会漂移,用户看到报错的位置可能是代码最后一行,跟真正的错误点隔着几百行。显式预扫描法:在进入类体、文件或者模块时先走一遍,把所有声明登记进符号表,然后再做检查。它多遍历一次树,但报错位置精确,我后来都改用这个。
具体到实现,预扫描阶段只做"登记",不做任何求值。函数只要登记签名(参数类型、返回类型),函数体跳过;变量登记名字和声明的类型,初值表达式跳过;类型定义登记名字,成员列表可以顺便登记但先不做类型解析。这样一趟下来成本很低,主要开销在遍历本身的常数上。
注意:预扫描阶段登记的类型名,可能本身还没解析完成(比如两个结构体互相包含指针)。这时符号表里存的类型对象要允许"未完成"状态,等到检查阶段再补全。我用的做法是给类型对象留一个
resolving标记,如果解析过程中又遇到它,说明出现了无限递归的类型定义(比如结构体直接包含自身而非指针),直接报"类型定义存在循环"。
3. 类型检查:上下文相关的主力战场
3.1 把类型规则写成可执行的判断式
类型检查的理论表达是判断式:在环境 Γ 下,表达式 e 的类型是 τ,写作 Γ ⊢ e : τ。落到代码里,它就是一个"给节点和环境,返回类型"的函数。真正要写全的是每种语法结构对应的规则表,我一般先按表达式种类列一张表,再逐条实现。
| 表达式种类 | 前置条件 | 结果类型 | 备注 |
|---|---|---|---|
| 整数字面量 | 数值在类型可表示范围内 | 默认整型或按后缀决定 | 超出范围要报错,别静默截断 |
| 浮点字面量 | 语法合法 | 默认浮点型 | 精度按后缀区分 |
| 字符串字面量 | 编码合法 | 字符串或字符数组 | 跟语言字符串模型相关 |
| 标识符 | 符号表可查且种类是值 | 该声明的类型 | 种类是类型名时报"类型不能作值使用" |
| 二元算术 | 两边都是数值,或支持重载 | 提升后的公共类型 | 字符串拼接单独作为重载处理 |
| 二元比较 | 两边可比较 | 布尔 | 指针比较、枚举比较规则不同 |
| 逻辑运算 | 两边都是布尔(或按真值性) | 布尔 | 支持短路时不求值右侧 |
| 赋值 | 左值可写,右侧类型可赋给左侧 | 左侧类型 | 常量、只读字段不可作左值 |
| 函数调用 | 名字是函数,实参数目和类型匹配 | 返回类型 | 变参、默认参数要单独处理 |
| 下标 | 被下标者是数组/映射,下标是整数或键类型 | 元素类型 | 越界检查通常是运行时的 |
| 成员访问 | 左侧类型含该成员,可见性允许 | 成员类型 | 私有成员在类外访问要拒 |
这张表是实现清单,也是测试用例清单。我习惯每实现一条,就补一组正例和反例,跑通再动下一条。这样推进看上去慢,实际上比后期抓漏要快得多。
有一类细节容易被忽略:左值性。语法上a.b.c和f().b.c长得一样,但前者可以出现在赋值左边,后者不行。检查器需要区分"求值"和"求地址"两种上下文,同一个表达式节点在两种上下文下的合法性不同。我在节点上加了isLValue的判断,在赋值、取地址、自增自减这几个位置显式要求左值。
3.2 综合与继承:类型信息在树上的流动方向
类型信息不是单向流动的。1 + 2的类型可以由两个子节点算出来,这是综合;但var x: float = 1里那个字面量1应该被当作浮点还是整数,答案来自父节点的期望类型,这是继承。只会综合的检查器处理不了这种场景,会先把1推成整型,然后报"整型不能赋给浮点",误报。
我的实现是双向类型检查,两个入口函数分工:
def infer(node, env): """自底向上推导:返回节点的实际类型,不依赖期望类型""" kind = node.kind if kind == "INT_LITERAL": return default_int_type(node.value) if kind == "IDENT": sym = env.lookup(node.name) if sym is None: return error_type(node, "未声明的标识符") return sym.type if kind == "BINARY": lt = infer(node.left, env) rt = infer(node.right, env) return binary_result_type(node.op, lt, rt, node.pos) # ... 其余种类 return error_type(node, "无法推导类型") def check(node, expected, env): """自顶向下检查:把期望类型往下传,返回实际类型(已做兼容性判定)""" actual = infer_with_hint(node, expected, env) if not assignable(actual, expected): report(node.pos, "类型不匹配", expected=expected, actual=actual) return actualinfer_with_hint是关键:它在处理字面量、空集合字面量、lambda 参数、null这类"自身没有确定类型"的节点时,直接用expected作为结果,避免误报。这套写法在函数式语言的编译器里叫双向类型检查,实用价值很高,尤其适合带类型推导的语言。
遍历方向上,如果属性之间只依赖"子 → 父"和"父 → 子、左兄弟 → 右兄弟",那就属于L-属性,一次深度优先、每个节点从左到右就能算完,不需要建依赖图。绝大多数语言都能设计成 L-属性,这是工程上的福音。真遇到需要"右兄弟 → 左兄弟"的规则(比如某些语言的声明顺序依赖后面的注解),就得退回依赖图加拓扑排序,那种情况要单独评估值不值得。
3.3 隐式转换插在哪一层,决定了后端有多干净
类型不匹配但允许隐式转换的时候,转换动作到底在哪一层做?三种做法我都见过。
第一种是后端临时决定:前端只管检查不做改写,生成代码时看两边类型不同就插一条转换指令。这种做法前端最省事,但后端要为每种类型组合写转换规则,一旦类型系统扩展,后端跟着改,两边的知识重复且容易不一致。
第二种是在前端改写 AST:检查到需要转换时,就地用一个CAST节点包住原子树,把目标类型记在节点上。后端看到CAST就生成对应指令,不需要知道为什么会有转换。我倾向这个方案,因为它把类型知识收敛在前端一处。
第三种是在中间表示层做规范化:AST 保持原样,生成中间代码时统一提升。这适合已经有成熟 IR 的项目。
选第二种时有几个坑。只允许一次转换,不要链式。整型提升到浮点再提升到双精度这种事情,应该一步算清楚目标类型,插一个节点,而不是插两个。否则中间表示里会出现一堆无意义的转换,优化器还得写规则去消。转换节点要保留原始位置,报错和调试信息才指得回源码。不要给编译期常量插转换,直接在常量折叠阶段算出正确类型的值即可。
def coerce(node, target, env): src = infer(node, env) if same_type(src, target): return node if is_widening(src, target): # 加宽:安全,无需告警 return Cast(node=node, target=target, pos=node.pos) if is_narrowing(src, target): # 收窄:可能丢精度,按语言规定决定是否告警 maybe_warn(node.pos, "可能丢失精度", src, target) return Cast(node=node, target=target, pos=node.pos) report(node.pos, "无法隐式转换", src, target) return ErrorNode(node.pos)加宽和收窄的边界要在规范里写死。整型到浮点在某些取值范围内并不安全(超过尾数位宽的整数转成浮点会丢精度),这条如果不写清楚,用户会在生产环境里踩到。
3.4 类型推导与约束求解的极简版本
如果语言带完整的类型推断,检查过程会变成"收集约束 + 求解"。思路不复杂:给每个未知类型创建一个类型变量,遍历表达式时生成等式约束,最后用统一算法把类型变量绑到具体类型上。
def unify(a, b): a = prune(a) # 顺着已绑定的链找到代表元 b = prune(b) if a is b: return True if isinstance(a, TypeVar): if occurs_in(a, b): # 出现检查,防止 a = list[a] raise TypeError("无限类型") bind(a, b) return True if isinstance(b, TypeVar): return unify(b, a) if isinstance(a, FuncType) and isinstance(b, FuncType): return (unify(a.param, b.param) and unify(a.ret, b.ret)) if isinstance(a, ListType) and isinstance(b, ListType): return unify(a.elem, b.elem) return False那个occurs_in检查是最容易漏的一行。漏了以后,写一个递归定义的函数(比如把自身作为参数的匿名函数),统一算法会构造出无限展开的类型,然后把栈撑爆。我第一次实现的时候就在这卡了半天,现象是编译器在某个文件上直接崩掉,没有任何报错信息。
求解顺序上,先统一显式标注的类型,再统一推导出来的类型,最后把剩下没有约束的类型变量默认成某个具体类型(或者报"无法推导")。这个"默认"策略要慎重,太激进会把错误藏起来,太保守会让用户到处写类型标注。
4. 控制流类检查:把"所有路径"翻译成图上的问题
4.1 返回路径检查:本质是一个后序遍历里的布尔函数
"非 void 函数所有路径都要有返回"这条规则,用自然语言写在规范里,看着像散文,写成代码其实很短。对每个语句定义"这条语句是否保证执行完之后不会继续往下执行",也就是是否alwaysReturns。
def always_returns(stmt): k = stmt.kind if k == "RETURN": return True if k == "BLOCK": for s in stmt.body: if always_returns(s): return True return False if k == "IF": if stmt.else_branch is None: return False return always_returns(stmt.then_branch) and always_returns(stmt.else_branch) if k == "WHILE": if not is_literal_true(stmt.cond): return False # 条件是常量真:循环不会自然结束,体内若有 break 则可能跳出 return not has_break_targeting(stmt, stmt) if k == "SWITCH": if not has_default(stmt): return False return all(always_returns(c) for c in stmt.cases) return False实现里有三个容易出错的点。一是if没有 else 时必须返回 False,因为存在一条不写 return 的路径,这条常识但特别容易在重构时丢掉。二是while(true)的处理,不能简单当作永远返回,得看循环体里有没有能跳出这个循环的 break,有的话就有路径能走到循环后面。三是带标签的 break和嵌套循环的对应关系,判断 break 属于哪个循环必须靠标签匹配,不能只按嵌套层数猜。
还有一类规则更细:某些语言要求"函数末尾必须有一个显式的 return 语句",即使最后一句是if/else全覆盖也不行。这是语法层面的约束,不该混进返回路径检查里,否则报错信息会很奇怪。
4.2 上下文栈:break、continue、return 的合法性判定
这一组检查是上下文相关最纯粹的体现——同一行break,写在循环里合法,写在循环外非法。实现方式是维护一个"上下文深度栈",进入循环时压一个循环标记,进入 switch 时压一个 switch 标记,进入函数时压一个函数标记,离开时弹出。
检查break时从栈顶往下找最近的"可被 break 的目标",循环和 switch 都算,但带标签的 break 要按标签名精确匹配。检查continue时只能找循环,不能命中 switch。检查return时找最近的函数标记,并且要用它的返回类型去校验 return 后的表达式。
这套栈还有别的用途:yield只能出现在生成器函数里,await只能出现在异步函数里,super调用只能出现在构造函数里,this不能出现在静态方法里。这些都是"当前上下文允许什么"的问题,用一个统一的上下文栈加若干布尔标志就能全部覆盖,不需要每类检查单开一套机制。
提示:为了让报错更有指向性,栈里除了标记类型,最好再存一个位置。报"break 不在循环内"时,同时提示"最近的循环从第 8 行开始",用户一眼就能看出是哪个循环没包住这段代码。
4.3 明确赋值与常量求值:两个被低估的小机制
有些语言要求变量在使用前必须被明确赋值,int x; print(x);直接报错。这条规则要做的是明确赋值分析,它是一段前向数据流分析:沿着控制流图,为每个程序点记录一组"此时一定已赋值的变量",进入分支时取交集,循环处迭代到不动点。
完整的数据流框架对小语言有点重,我的简化做法是递归地算"这句话执行完之后,哪些变量一定被赋值了"。顺序语句取并集,if/else取两个分支的交集(没有 else 就用入口集合),循环体按"可能一次都不执行"处理,循环结束后取入口集合。这样能得到保守但正确的结论,实现成本不到一百行。
常量求值器是另一块刚需。数组长度、case 标签、枚举初值、位宽、static_assert的参数,都要求编译期常量。它本质上是一个小型的折叠器:字面量是常量,const修饰的变量且初值是常量也是常量,常量之间的算术、比较、位运算结果还是常量,其它一律视为非常量。每实现一个运算符,就要在求值器里配一条规则,漏了就会出现"明明能算出来却报不是常量"的诡异现象。
求值器还要处理溢出和除零。常量除零必须报错,不能等到运行时。整数溢出要看语言规范,要么报错,要么按环绕处理,两种都得明确。我在一个项目里就因为求值器没查除零,导致一个const int n = 1 / 0;被折叠成一个极大值,然后拿去当数组长度,分配了巨大的空间。
5. 报错体验是语义分析的第二次生命
5.1 错误恢复与毒药类型:别让一个错误炸出五十条
语义分析最容易做坏的地方不是漏检查,而是错误级联。用户少写一个分号,或者拼错一个类型名,检查器一路往下推,每个引用到这个类型的节点都报一次,最后满屏红色,真正的问题被淹没。这种体验比不报错还糟。
解决办法是引入一个专门的错误类型,业内常叫毒药类型。它跟任何类型都兼容,任何用到它的表达式结果也是它,并且在产生它的地方报一次错之后,后续遇到它一律静默。实现上就是类型系统里加一个特殊单例:
def assignable(src, dst): if src is ERROR_TYPE or dst is ERROR_TYPE: return True # 静默放行,避免级联 # ... 正常判定标识符查不到的时候,不要返回None,而是返回一个类型为错误类型的假符号,这样后续所有对这个名字的检查都会自动静默。这个技巧能砍掉七八成的噪声。
还有几个配套策略。同一行只报一次:记录上一次报错的行号,同一行内的连续报错直接丢弃,因为通常用户写错一处就是一行的事。错误数量上限:超过某个阈值(比如一百条)就停止检查,只报"错误过多,已中止",避免用户滚半天。文件级隔离:一个文件出错不影响其它文件检查,尽量把所有能报的都报出来,让用户一次改完再编译。
5.2 诊断信息该带什么:位置、期望、实际、相关声明
一条好的错误信息至少包含四样东西:在哪、期望什么、实际是什么、跟谁冲突。缺一样,用户就得多花一轮排查。
拿重复声明举例。只报"重复声明"是及格线,报"第 12 行第 5 列的变量 x 重复声明,上一次声明在第 8 行第 3 列"就舒服得多。要做到这点,符号表里那条declPos字段就是必须要有的。类型不匹配同理:报"第 20 行赋值类型不匹配:期望 float,实际是 string",比"类型错误"有用十倍。
再往上一层是带修复建议。比如"未声明的标识符 count,你是不是想写 counter(第 15 行有同名声明)",这种提示靠编辑距离加符号表扫描就能实现,成本不高,体验提升明显。我现在的做法是查不到名字时,对当前可见的所有符号算一次编辑距离,距离在阈值内就附上候选。这一条加进去以后,拼写错误的平均修复时间肉眼可见地下降。
还有一个细节容易被忽略:诊断信息的顺序。按源码位置排序输出,而不是按发现顺序。按发现顺序输出的报错列表,用户读起来像随机排列,找第一个错误都要滑半天。
6. 常见问题与排查技巧实录
6.1 症状速查表:这些坑我基本都踩过一遍
语义分析的 bug 有个特点:编译能过,但检查结果不对,不会崩溃,只会静默放行或者误报。下面这张表是我这些年攒下来的高频症状,配合排查手段基本能定位。
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 块内声明的变量在块外还能用 | 退出作用域时没弹栈,或者弹错了层 | 在进入/退出作用域时打印栈深度和当前可见符号集合 |
| 内层同名变量遮蔽后,外层变量查不到了 | 链式表把外层记录覆盖掉了,或者栈式表查找顺序反了 | 构造三层嵌套、同名变量的小用例,单步看查找返回哪条记录 |
| 大量"未声明"误报,但名字明明写了 | 预扫描漏了某种声明形式(比如解构声明、导入别名) | 打印符号表全量 dump,对照源码逐条核对 |
| 报错位置指向错误的行 | 用的是父节点的位置而不是符号 token 的位置 | 检查每个报错调用传的是不是node.pos而非child.pos |
| 类型推导出"未知类型"传到顶层 | 类型变量没有被求解,或者默认化策略没覆盖到 | 在检查入口加断言,出现未绑定类型变量就中止 |
| 函数明明所有分支都有 return 却报缺返回 | if的 else 分支判空了,或者 switch 没识别 default | 单独给always_returns写单元测试,覆盖嵌套组合 |
| 一个类型错误带出几十条报错 | 没有引入毒药类型,错误到处传播 | 检查查不到符号时的返回值是不是错误类型假符号 |
| 编译某些文件时直接崩栈 | 统一算法缺出现检查,构造出无限类型 | 给递归类型的用例单独加测试 |
| 常量表达式报"不是常量" | 常量求值器缺某个运算符或某种常量来源 | 列出全部常量来源和运算符,逐条对照实现 |
这张表最实用的用法不是出事以后查,而是写测试用例时照着抄。每一条症状对应一组最小复现用例,跑一遍就基本知道检查器健不健康。
6.2 几条踩坑心得:文档里不会写的那些
第一条,先把规则清单列出来再写代码。我吃过最大的亏是边写边加规则,写到后面自己都不知道实现了哪些、漏了哪些。后来改成先把语言规范里所有静态语义条款抽成一个编号列表,每条后面标注"待实现/已实现/有测试",这个列表直接当项目看板用。规则数量通常在几十到一百多条之间,看着多,但列出来以后心里就踏实了。
第二条,给检查器做一个开关矩阵。每类检查一个开关,运行时可关。出现可疑误报时,逐个关掉二分定位,比读代码快。这个开关还能用来做性能分析,看哪类检查最耗时。
第三条,符号表和语法树一定要能 dump。文本格式就行,缩进表示嵌套。我排查过的最难的一个 bug 是"某个作用域多留了一层",靠肉眼读代码根本看不出来,dump 出来一眼就发现那层栈没弹。dump 的代码不到五十行,回报率极高。
第四条,黄金文件测试比手写断言有效得多。准备一批源码文件,每个文件里埋一到若干处故意写错的地方,把检查器的完整输出(排序后的诊断列表)存成基线文件,以后每次改动比对差异。语义分析的输出是文本,天然适合这种测试方式,而且能挡住绝大多数回归。
第五条,别在语义分析阶段做优化。我见过有人在类型检查里顺手做常量折叠,折叠完的类型和源码对不上,报错位置全乱。常量折叠应该作为独立的遍,在检查全部通过之后跑,输入是干净的树,输出是折叠后的树,职责清晰。
第六条,性能瓶颈往往不在算法上。语义分析的复杂度基本是线性的,真正拖慢速度的通常是字符串比较和哈希冲突。名字驻留(同一个名字字符串全局只存一份)这一招,在我那个项目里把整体耗时砍掉了将近三成,因为符号查找的键比较从字符串逐字符比对退化成了指针比较。这个优化实现起来不到三十行,收益却很明显,值得早点做。
我自己现在的习惯是,语义分析这块写完先不急着接后端,而是拿语言规范里每一条静态规则去反向找用例,找不到用例的规则说明要么实现漏了,要么规范写得太虚。这个反向核对的过程往往比写代码本身更能暴露问题,也是我判断一个前端"是否真的检查干净了"的主要依据。