ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

编译原理语义分析:符号表管理、类型检查与 goto 标签处理

编译原理语义分析:符号表管理、类型检查与 goto 标签处理 1. 语义分析到底在检查什么从语法树到带注解的树编译器前端跑完词法分析和语法分析手里拿到的是一棵语法树它只保证这些记号按语法规则能够拼起来至于这个拼法有没有意义语法分析器一概不管。语义分析semantic analysis就是接着往下走的这一层它要回答的问题是a b c;这行里b和c有没有被声明过它们的类型能不能相加相加的结果能不能赋给a如果a是个数组名这行还合法吗我当年第一次写课程实验的时候天真地以为语义分析就是把语法分析的结果再过一遍。结果写出来的东西只能检查变量是否声明一旦遇到类型不匹配、函数调用参数个数不对、goto跳到不存在的标签这些问题就开始靠打补丁硬怼最后代码里全是if套if改一处崩三处。后来我才想明白语义分析不是再查一遍语法它是一个有明确输出的独立阶段关键是要先把要检查哪些约束、在哪里检查、检查结果放在哪这三件事定下来。1.1 语义分析的三类约束对象把语义检查归纳一下落到实际实现里大概就是三类东西而且这三类互相之间是需要共享信息的。约束类别典型检查内容依赖的信息类型约束赋值兼容性、运算符操作数类型、函数返回值类型、参数个数与类型类型表达式、类型等价规则作用域约束名字是否已声明、是否重复声明、是否在可见范围内使用符号表、作用域链控制流约束break/continue是否在循环内、goto标签是否存在、能否跳入块内部标签表、块嵌套深度很多人做实验时把这三类混在一个大函数里写前期能跑后期加需求就爆炸。比较合理的做法是按访问者模式visitor组织语法树的每一类节点有独立的检查函数函数里只关心这一类节点的语义需要查符号表就调符号表接口需要报错就调统一的报错接口。这样加一种新检查只要新增一个访问逻辑不用动已有的代码。1.2 语法制导定义把检查挂到产生式上教科书里讲语义分析绕不开语法制导定义Syntax-Directed Definition, SDD和语法制导翻译Syntax-Directed Translation, SDT。这两个词听起来唬人说到底就是一句话给每条文法产生式配上一小段语义动作语法分析走一步就顺手算一步。SDD 里有两类属性。综合属性synthesized attribute是自下而上的子节点的值汇总成父节点的值比如表达式的类型就是典型的综合属性——E → E1 E2E.type由E1.type和E2.type推出来。继承属性inherited attribute是自上而下或者从左往右传的父节点把信息给了子节点比如符号表当前所在的表项指针就是一路往下传的继承属性。我建议实际动手时不要把 SDD 当成纯理论去背而是理解成每个 AST 节点都有一个可以放结果的槽位。以表达式为例class BinaryOp(Node): def check(self, env): left_type self.left.check(env) # 综合属性自下而上 right_type self.right.check(env) if not is_assignable(left_type, right_type): env.error(self.line, f运算符 {self.op} 不支持 {left_type} 和 {right_type}) return self.op_result_type(left_type, right_type)这里的env就是继承属性把当前作用域、当前所在函数、当前循环深度这些上下文一路带下去。这比用全局变量存当前作用域靠谱得多尤其是后面要做嵌套函数或者 lambda 的时候全局变量方案基本没法改。1.3 类型等价判断名字等价还是结构等价类型检查里最容易翻车的点是我在面试和实验答辩里问过无数次的两个类型什么时候算相等。这个问题有两大流派。名字等价name equivalence每个类型声明产生一个新的、独一无二的类型两个变量类型相同当且仅当它们引用同一个类型声明。C 语言的结构体基本走这条路struct A和struct B哪怕成员完全一样也不能互相赋值。结构等价structural equivalence递归地比较两个类型的构造方式成员类型都一样就算相同。早期的 Pascal 和一些教学语言偏这条路。还有一个常被忽略的第三点类型别名typedef的处理。typedef int Length;之后Length和int算不算同一类型绝大多数实用语言选择算因为别名只是给同一个类型起了个新名字。实现上就是让符号表里Length的表项直接指向int的类型对象而不是复制一份。注意类型等价判定是递归的如果类型定义里出现自引用比如链表节点必须做环检测或者缓存判定结果否则会栈溢出。1.4 报错信息的设计比检查本身更花时间写完检查逻辑你会发现真正耗时的是报错信息。一个合格的语义错误信息至少要包含三样东西出错位置行号、列号或字符偏移、出了什么错期望什么、实际是什么、可能的修正方向。对比一下这两条信息的体验差距error: semantic errorline 12: variable count used before declaration line 12: count total 1; ^^^^^ hint: did you mean counter declared at line 5?后面这种当然要多写代码但它的价值在做实验答辩的时候体现得特别明显。我的做法是把报错统一收敛到一个ErrorReporter里支持错误取前三处就够的策略——很多编译器在遇到第一个错误后会产生级联错误比如变量未声明导致它的类型是error type然后类型不匹配的错误又报一条。对error type做特殊处理让它和任何类型都兼容且不再触发新错误能大幅减少噪声。2. 符号表管理编译器里最容易被低估的数据结构如果只让我挑一个实验中看起来最简单、实际最难写对的模块我会选符号表。它表面上就是个名字到信息的映射可一旦加上作用域、块嵌套、同名遮蔽、名字空间隔离复杂度立刻上来了。我见过太多人的符号表是这么写的全局一个dict进入函数就把参数塞进去出函数就清空——然后遇到嵌套块、循环里定义的变量、结构体成员全线崩溃。2.1 一个符号条目应该存什么先想清楚表里放什么比用什么结构放重要得多。一个够用的符号条目字段大致如下。字段含义示例name标识符名字totalkind实体种类变量 / 常量 / 函数 / 类型 / 标签type类型表达式int、array of int[10]scope_level声明所在作用域层级0 全局1 函数2 块offset相对栈帧或全局段的偏移由后续阶段填写line / col声明位置用于报错和 IDE 跳转extra按种类的附加信息函数参数表、常量值kind这个字段非常重要它是区分名字空间的关键。C 语言里有一个经典现象结构体标签和普通变量可以同名。struct node { int v; }; node *p;在 C 里是合法的struct node的node在标签名字空间node作为类型名通常还需要 typedef 才可见。如果你的符号表只有一张就必须靠kind区分如果分多张就要在查找时按上下文决定查哪张。2.2 实现选型线性表、哈希表还是搜索树这是标准的选择题考点但实际写代码时的取舍比题目里复杂。方案查找复杂度优点缺点适用场景线性表O(n)实现最简单能保留声明顺序大程序慢教学实验、几十个符号哈希表平均 O(1)查找快工程主流需要处理冲突遍历无序任何真实编译器二叉搜索树O(log n)有序遍历方便需要平衡实现更重需要按名字排序输出我的实际建议是先写线性表把逻辑跑通再换哈希表。原因是符号表最难的地方不是查找速度而是作用域的进出管理。等作用域逻辑完全正确了把list换成dict只是改几行。反过来先上哈希表一旦作用域出错你要在冲突链、桶数组和栈帧之间来回找调试成本高得离谱。哈希函数的话简单的多项式滚动哈希就够了def hash_name(s, table_size): h 0 for ch in s: h (h * 31 ord(ch)) % table_size return h表大小取素数比如 211、1021能有效减少聚集。冲突用链地址法每个桶挂一个列表比开放地址法好写因为符号表会频繁插入删除开放地址法的墓碑标记处理起来很烦。2.3 作用域链的三种实现方案作用域是符号表的灵魂落地方式主要有三种各有取舍。方案一单表 作用域栈。只有一张哈希表表项里带scope_level。进入作用域就记一个当前层级退出时把该层级的所有表项删掉。查找时从当前层级往上找返回第一个匹配的。这个方案实现最省事缺点有两个删除需要遍历以及同名遮蔽需要靠层级比较来区分查找时必须带层级判断。方案二嵌套符号表 栈。每个作用域一张独立的表外面套一个栈。进入块就push一张新表退出就pop。查找时从栈顶往下逐张表找。这个方案的好处是插入删除都是 O(1)同名遮蔽天然由就近原则处理代码非常干净。class ScopeStack: def __init__(self): self.stack [{}] # 全局作用域打底 def enter(self): self.stack.append({}) def leave(self): self.stack.pop() def declare(self, symbol): table self.stack[-1] if symbol.name in table: raise SemanticError(f重复声明: {symbol.name}) table[symbol.name] symbol def lookup(self, name): for table in reversed(self.stack): if name in table: return table[name] return None方案三树形作用域。作用域组成一棵树支持跨兄弟作用域查找。一般用在有复杂模块系统的语言里教学实验用不上。我做了几次实验之后基本固定在方案二因为它的进入/退出是配对的和语法树的块结构天然对齐。你只要保证语法树遍历时遇到块节点enter()、离开leave()作用域就不可能错。2.4 块结构进出与遮蔽的实操细节方案二有两个坑我踩过不止一次。第一个坑是参数和函数体的作用域关系。函数参数属于哪个作用域如果参数表和函数体的块表分开那么函数体里定义一个和参数同名的局部变量算不算重复声明不同语言处理不同C 允许内层变量遮蔽参数但很多教学语言选择报错。我的做法是把参数声明在函数的外层作用域函数体作为内层块这样遮蔽规则由统一的就近查找天然处理不用特判。第二个坑是退出作用域时不能简单地pop掉整张表。如果后面还要做类型检查的第二遍遍历或者要生成调试信息你可能需要保留已经退出的作用域信息。这时候可以给每个Symbol记一个作用域 ID退出时不删表只在查找时用当前作用域 ID 是否在祖先链上来判断可见性。代价是查找变慢但换来了可回溯性。提示如果做 IDE 相关的插件或者代码补全需要在某个位置能看见哪些名字那么保留作用域历史和 ID 的做法的价值就体现出来了。2.5 标签也需要符号表但规则不一样大多数教材讲符号表默认只讲变量和函数等到写goto的实现时才发现标签也需要一张表而且它的规则和变量表不一样。最直观的区别是变量必须先声明后使用而标签可以先用后声明。这直接决定了标签表必须支持先登记、后回填。我通常的做法是在作用域对象里单独开一个labels字典和symbols并列避免两种实体混在一起导致查找时误命中。代码骨架大致是这样class Scope: def __init__(self): self.symbols {} self.labels {} self.pending_gotos [] # 尚未解析的 goto后一节会详细展开pending_gotos的用法这是goto语义处理的核心。3. goto 语句的语义处理标签作用域与向前跳转goto在所有控制流语句里都算是个异类。if、while、for的语义相对封闭检查完表达式类型基本就没别的事了而goto天生是非结构化的它能把控制流从一个位置扯到另一个位置于是问题来了能跳到哪儿标签在多大范围内有效跳进一个块内部合不合法很多人的实验做到这一步是直接放弃的——语法上认了goto语义上完全不检查等生成中间代码或者产生运行时错误再说。但恰恰是这几条规则把符号表和控制流两个主题串在了一起也是考试里最爱出题的地方。3.1 goto 与 label 的语法形状先明确一下要处理的东西。典型的文法产生式长这样stmt - goto identifier ; stmt - identifier : stmt第二条产生式有个麻烦identifier :这个前缀和普通表达式语句identifier ...在语法上有一定歧义需要靠向前看符号LL 风格或者回溯LR 风格区分。如果手写递归下降一般在statement()里先看第一个记号是不是标识符再看下一个记号是不是冒号是就按带标签的语句处理。标签在语法树上通常表现为一个给后面的语句加注解的节点也叫LabeledStmt。它本身不产生任何运行时代码只在编译期起作用。3.2 标签的作用域为什么不能跨函数跳这是goto检查里最核心的一条规则标签的作用域通常限定在它所在的那个函数体内。也就是说在函数f里goto L而L定义在函数g里这是编译错误必须报出来。为什么这么规定因为标签的语义是跳转到一个代码位置而这个位置对应的是某个栈帧里的指令地址。跨函数跳转意味着你要从一个栈帧跳到另一个栈帧的中间位置调用约定、栈指针、寄存器保存状态全乱套根本没法生成正确的代码。所以主流语言都禁止只有极少数作为扩展特性支持受限的跨函数跳转。实现上这意味着标签表必须挂在函数这个层级上而不是挂在任意块上。你可以这样设计符号表的作用域栈里遇到函数节点时额外维护一个current_function引用标签就登记在current_function.labels里goto查找时只在这个字典里找。检查项规则违规示例标签是否存在必须在当前函数内能找到goto L;但函数内无L是否跨函数不允许跨函数f中 goto 到g的L是否重复定义同一函数内标签唯一同一函数内两个L:是否跳入受限块视语言规定通常禁止跳入循环/分支内部goto L;而L在while体内3.3 向前跳转延迟绑定的实现方式真正的难点在于向前跳转forward jump也就是goto出现在标签定义之前。goto end; x 1; end: y 2;处理到goto end;的时候end这个标签还没有被声明按找不到就报错的逻辑这里会误报。所以必须引入延迟绑定遇到无法解析的goto先记进一个待解析列表等函数体遍历完再统一把列表里剩下的、仍然找不到标签的条目报错。def visit_goto(self, node): label node.label fn self.current_function if label in fn.labels: fn.labels[label].jumpers.append(node) # 向后跳转直接绑定 else: fn.pending_gotos.append(node) # 向前跳转先挂起 def visit_label(self, node): fn self.current_function if node.name in fn.labels: raise SemanticError(f第 {node.line} 行: 标签 {node.name} 重复定义) entry LabelEntry(namenode.name, linenode.line) # 回填所有等待这个标签的 goto for g in fn.pending_gotos: if g.label node.name: entry.jumpers.append(g) fn.pending_gotos [g for g in fn.pending_gotos if g.label ! node.name] fn.labels[node.name] entry def finish_function(self): fn self.current_function for g in fn.pending_gotos: self.error(g.line, f未定义的标签: {g.label})这段代码里有几个细节值得说。第一pending_gotos用列表而不是集合是为了在报错时按出现顺序输出出错位置更可读。第二回填时从pending_gotos里剔除已经解析的条目最后剩下的就是真正的未定义标签。第三把跳转者jumpers记录在标签条目上对后续阶段中间代码生成、基本块划分非常有用——你可以直接知道这个标签被哪些位置引用做局部优化时能判断跳转是否冗余。3.4 跳入块内部为什么它比跨函数更微妙跨函数跳转是被明令禁止的但跳进一个块内部在很多语言里是合法但危险的。考虑这段代码goto inside; while (cond) { inside: do_something(); }跳进循环体内部会发生什么循环变量没初始化、循环条件没判断直接执行循环体。这在语义上是能跑的但几乎肯定是写错了。C 语言允许这种写法甚至允许用goto实现块内跳出Java 则直接规定goto是保留字但不使用转而提供带标签的break/continue。如果你要在自己的语言里做严格检查做法是给每个作用域和每个标签都记一个块路径或者块嵌套深度跳转时判断目标标签所在块是否是当前块的祖先或者自身。实现上可以在进入块时给块分配一个递增 ID并记录父块 ID形成一个块树跳转时沿着父链往上找目标块跳转类型块关系判定结果块内跳转目标块 当前块允许跳出到外层目标块是当前块的祖先允许常见做法跳入嵌套块目标块是当前块的后代视语言一般禁止或警告同层兄弟跳转目标块与当前块无祖先关系禁止提示如果实在不想实现块树一个低成本替代方案是给每个标签记录声明时的块嵌套深度跳转时比较深度差。深度差为负表示往外跳一般允许深度差为正表示往里跳报错。这个近似方案对多数实验足够但对同层兄弟块互相跳的情况会误判。3.5 goto 的存在会波及后面的阶段goto不是语义分析查完就完事的它会影响后面几乎所有阶段。最直接的是基本块划分标签所在位置天然是一个基本块入口goto所在位置天然是一个基本块出口。做控制流图CFG时这些边界不划对后面的数据流分析结果全错。另外如果语言里有goto做变量未初始化检查时就需要更保守。对于顺序执行的代码你可以做简单的定义-使用到达分析但goto可以构造出任意复杂的控制流导致某些变量在某些路径上确实没被赋值。所以很多编译器对含goto的函数直接降级分析精度只报最明显的错误。4. 手写一个能跑的迷你语义分析器讲了这么多规则落到代码上其实并不长。这一节把符号表、语义检查和goto检查串成一个最小可运行的原型你可以直接拿去改。为了不让代码被语法分析部分淹没我假设前面已经产出了一棵 AST节点类型包括Block、Assign、BinaryOp、Identifier、Num、Goto、LabeledStmt、FuncDef。4.1 整体结构整个分析器只有三个部分作用域栈、符号/标签记录、遍历检查逻辑。数据流是单向的——遍历一遍 AST边走边填符号表边检查约束。class SemanticError(Exception): def __init__(self, line, msg): super().__init__(f第 {line} 行: {msg}) self.line line4.2 符号表与标签表的实现class Symbol: def __init__(self, name, kind, type_, line): self.name name self.kind kind # var | const | func | type self.type type_ self.line line class LabelEntry: def __init__(self, name, line, depth): self.name name self.line line self.depth depth self.jumpers [] class FunctionScope: def __init__(self, name): self.name name self.labels {} self.pending_gotos [] self.block_stack [0] # 块深度函数体为第 1 层 class ScopeStack: def __init__(self): self.tables [{}] self.func None def enter_block(self): self.tables.append({}) def leave_block(self): self.tables.pop() def declare(self, sym): top self.tables[-1] if sym.name in top: raise SemanticError(sym.line, f重复声明: {sym.name}) top[sym.name] sym def lookup(self, name): for table in reversed(self.tables): if name in table: return table[name] return None4.3 遍历检查的主干class Analyzer: def __init__(self): self.scope ScopeStack() def visit(self, node): method getattr(self, visit_ type(node).__name__, self.generic) return method(node) def generic(self, node): for child in getattr(node, children, []): self.visit(child) def visit_FuncDef(self, node): if self.scope.lookup(node.name): raise SemanticError(node.line, f函数 {node.name} 重复定义) self.scope.declare(Symbol(node.name, func, node.ret_type, node.line)) self.scope.func FunctionScope(node.name) self.scope.enter_block() for p in node.params: self.scope.declare(Symbol(p.name, var, p.type, p.line)) self.visit(node.body) # 函数体遍历完毕清算未解析的 goto for g in self.scope.func.pending_gotos: raise SemanticError(g.line, f未定义的标签: {g.label}) self.scope.leave_block() self.scope.func None def visit_Block(self, node): self.scope.enter_block() for stmt in node.stmts: self.visit(stmt) self.scope.leave_block() def visit_Assign(self, node): lhs self.scope.lookup(node.name) if lhs is None: raise SemanticError(node.line, f变量 {node.name} 未声明) rhs_type self.visit(node.value) if not compatible(lhs.type, rhs_type): raise SemanticError(node.line, f类型不匹配: {lhs.type} {rhs_type}) def visit_Goto(self, node): fn self.scope.func if fn is None: raise SemanticError(node.line, goto 出现在函数之外) depth len(self.scope.tables) if node.label in fn.labels: fn.labels[node.label].jumpers.append(node) else: fn.pending_gotos.append(node) def visit_LabeledStmt(self, node): fn self.scope.func depth len(self.scope.tables) if node.name in fn.labels: raise SemanticError(node.line, f标签 {node.name} 重复定义) entry LabelEntry(node.name, node.line, depth) still_pending [] for g in fn.pending_gotos: if g.label node.name: entry.jumpers.append(g) else: still_pending.append(g) fn.pending_gotos still_pending fn.labels[node.name] entry self.visit(node.stmt)4.4 测试用例与预期输出准备三段测试输入覆盖合法、未定义标签、跨函数跳转三种情况。用例代码特征期望结果case1函数内 goto 向前跳转标签存在通过无错误case2goto 引用了不存在的标签报未定义的标签case3两个函数一个 goto 另一个的标签报未定义的标签case2 的错误之所以能在函数结束时才报出来正是延迟绑定的结果。如果你把pending_gotos的清算放在visit_Goto里立即做case1 就会被误报成错误——这也是我第一版实现里最典型的 bug下面还会细说。4.5 类型兼容性判断的最小实现为了代码能跑起来compatible只需要覆盖最基本的规则def compatible(dst, src): if dst src: return True if dst error or src error: return True # 抑制级联错误 return False那个error类型的短路判断看着不起眼但它决定了报错体验。比如一个变量因为未声明它的类型被推断成error那么在后续的类型检查里凡是跟它相关的地方都不应该再刷屏报错。这是我在改了三四版报错输出之后才意识到的技巧。5. 踩坑实录那些让实验卡到半夜的问题前面讲的是应该怎么做这一节讲我实际做的时候是怎么错的。这些坑的共同点是代码语法完全正确跑起来也不崩但结果就是不对而且不看中间数据根本查不出来。5.1 忘记配对调用 leave_block报出一堆假重复声明这是最高频的一个。作用域栈是手动的进入块enter_block()、离开块leave_block()必须成对出现。我第一版在visit_Block里进入块之后遇到return或break就直接return了没走leave_block()。结果后面同名的变量一声明就报重复声明因为上一层的表根本没弹出去。排查方式很简单在enter_block和leave_block里各打一行日志把当前栈深度打出来。如果深度只增不减就是漏了配对。def enter_block(self): self.tables.append({}) # print([scope] enter, depth , len(self.tables)) def leave_block(self): assert len(self.tables) 1, 作用域栈下溢 self.tables.pop()加断言是个好习惯它把静默出错变成立刻崩溃定位速度快很多。5.2 标签表挂错了层级跨函数误判为合法第二个坑和goto的延迟绑定有关。我最开始把labels字典挂在Analyzer上而不是挂在FunctionScope上。结果是函数f里定义的标签L在函数g里的goto L居然能通过检查。原因很简单两个函数共用一张标签表。修复方法就是把标签表和待解析列表都下沉到函数层级函数开始时新建函数结束时清算。这个改动只有几行但它对应了标签作用域是函数这条语义规则。这也说明一件事数据结构挂在哪一层直接决定了你实现了哪条语义规则。想清楚规则再设计结构比反过来有效得多。5.3 报错顺序不对输出全是噪声第三个坑是报错顺序。如果先做类型检查再做声明检查那么一个未声明的变量会先触发类型错误如果先做控制流检查再做声明检查goto的错误可能排在一堆类型错误后面翻半天才找到。我的处理方式是给错误分优先级并且在同一个节点上最多报一条错误。优先级排下来大概是声明与作用域 类型 控制流细节。另外在遍历顺序上尽量保证先声明的先检查这样第一条错误往往就是根因。错误类别优先级处理时机重复声明 / 未声明高进入节点时立即检查类型不匹配中表达式求值后检查未定义标签低函数遍历结束时统一清算跳入嵌套块低标签绑定时检查5.4 名字比较的坑大小写和编码这个问题看起来低级但真有人栽。符号表的哈希函数如果用ord()处理字符串遇到非 ASCII 标识符要小心如果语言规定大小写敏感那就别在插入前统一lower()。我见过一个同学为了减少重复在declare里对名字做了strip().lower()结果调试了两小时才发现是两个只差大小写的变量被他合并了。另一个相关细节是关键字与标识符的区分。关键字应该由词法分析阶段就归成独立记号类别不要在符号表里塞关键字否则查找时你会频繁误命中。5.5 把符号表 dump 出来是最有效的调试手段我后来养成一个习惯在语义分析结束时把最终的作用域栈、每个作用域的符号、每个函数的标签和跳转关系全部打印成结构化文本。这一步不需要任何调试器光看输出就能发现八成的逻辑错误。[function main] depth1 symbols: x : var int (line 3) y : var int (line 4) labels: end (line 9) - jumped from line 7 pending: (none)如果pending里还残留东西说明标签没定义如果某个符号出现在错误的作用域里说明进出栈有问题如果- jumped from是空的说明向前跳转的回填逻辑没走通。这三行信息几乎覆盖了goto检查的全部问题。6. 考试与面试里的高频考点整理这一节把散落的点收拢一下给准备考试或者面试的朋友一个对照表。我把常见问法分成三类每一类都标出容易答错在哪。6.1 概念辨析题的常见陷阱问法关键答案常见错误回答语义分析在哪个阶段语法分析之后、中间代码生成之前说成和语法分析合并综合属性与继承属性的区别综合自下而上、继承自上而下或从左到右只背定义不讲来源方向名字等价 vs 结构等价前者看声明来源后者递归比较构造直接说名字相同就等价标签的作用域范围通常限定在所在函数内说成整个程序或整个块语义分析阶段的定位特别值得强调它不是编译的一个可选优化而是必须的一个阶段。因为语法分析器无法处理变量是否声明类型是否匹配这类需要跨节点信息的问题。理解了这一点很多关于编译阶段划分的选择题就不会错。6.2 从 goto 延伸到控制流图面试里如果被问到goto多半是要往控制流方向引。可以准备的延伸话题包括基本块的定义一段顺序执行、只有一个入口和多个出口的指令序列、控制流图的构造块之间用有向边连接、以及goto对到达定值分析的影响。一个自然的追问是如果语言里有goto编译器还能做哪些优化比较稳的回答是常量传播和公共子表达式消除仍然可以做因为它们不依赖控制流结构但循环优化如循环不变量外提在含goto的函数里会大打折扣因为循环结构可能被goto打散编译器识别不出循环。6.3 可以继续深挖的几个方向如果你已经把语义分析和符号表写通了往下可以有这样几条路第一条是属性文法的自动化。手写访问者模式在语法树节点类型多的时候很繁琐可以了解一下用注解或者 DSL 自动生成遍历代码的思路很多编译器框架就是这么做的。第二条是作用域信息的持久化。把符号表输出成结构化格式就能支持编辑器的跳转定义、查找引用、重命名等功能。这个过程其实就是把编译器的前端能力暴露给工具链。第三条是类型系统的扩展。从最简单的int/float出发加上数组、指针、结构体、函数类型之后类型等价的判定会迅速变复杂尤其是带泛型或者类型推导的时候。这条路走下去你会发现自己从写实验变成了读论文但那种原来当年那个 bug 是因为这个理论没搞清楚的感觉还挺值得的。顺带分享一个我做实验时的小习惯每加一条检查规则就写两个测试用例一个应该通过一个必须报错。攒到后面就是一份回归测试集。改代码的时候跑一遍心里踏实。比起在几百行 AST 里靠肉眼找哪里漏了检查这个习惯省下来的时间不止一点。还有一个小发现goto的检查逻辑其实和异常处理的跳转路径检查高度相似——都是把控制流从一个位置引到另一个位置都需要判断跳转目标是否可见都要维护一个待解析列表。把goto这套写明白了后面做异常处理的语义检查会轻松很多很多代码思路是直接复用的。
返回列表