免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

编译原理语义分析:符号表管理与goto语义约束

编译原理语义分析:符号表管理与goto语义约束 语义分析这一章很多人学到一半就卡住了。词法分析和语法分析好歹能看到具体产出——一个 token 流、一棵语法树做出来心里有底语义分析要做的事情却散落在编译器各个角落给变量定类型、建符号表、检查标识符有没有声明过、判断 goto 能不能跳过去、检查函数调用参数个数对不对。写起来东一榔头西一棒子调起来又不知道从哪个环节下手。而符号表管理和 goto 语句的语义约束恰恰是这一章里最容易写崩、也最能拉开水平差距的两个点。这篇内容我打算把这三块串起来讲透。语义分析负责给语法树加上含义符号表是它的核心数据结构goto 则是控制流语义里最容易被忽略、检查规则却最细的一类语句。看完你会知道语义分析器该怎么分层设计、符号表从全局作用域到嵌套作用域怎么组织、goto 的标号作用域和跳转合法性怎么校验以及那些课本上一笔带过、但实际动手必然踩到的坑。适合正在做编译原理课程实验、准备相关面试题或者想自己写一门小语言的朋友参考。1. 语义分析到底在做什么从语法树到带标注的中间表示1.1 语法正确不等于程序正确先建立一个最基本的认知语法分析只关心程序的形状对不对不关心它的意思对不对。a b 1;这几个 token 排列方式符合赋值语句的语法规则解析器就会痛快地给你一棵语法树至于a和b有没有声明过、类型能不能相加、a是不是只读常量语法分析一概不管。举个更直白的例子。你写int f() { return hello; }语法上完全合法——一个函数定义返回一个字符串字面量。但语义上这是错的返回类型是int你却返回了const char*。再比如x y 1;里y根本没声明break;出现在循环外面goto end;后面根本没有end这个标号。这些错误语法分析器一个都发现不了全得靠语义分析兜住。所以语义分析的定位很清楚在语法树的基础上检查那些语法规则表达不了的约束。它要回答的问题包括标识符是否已声明、类型是否匹配、运算符操作数是否合法、控制流跳转目标是否存在、函数调用实参与形参是否对应等等。这一层做完你得到的不再是一棵裸树而是一棵每个节点都带了类型、每个标识符都绑定到符号表条目的带标注树。1.2 语义分析的职责清单把语义分析要做的事摊开来看大致能分成几类我列个表方便对照检查类别典型检查内容报错示例声明与引用标识符是否声明、是否重复声明undeclared identifier y类型相关赋值/运算/返回类型是否兼容cannot assign char* to int控制流break/continue 是否在循环内、goto 标号是否存在label end used but not defined函数相关实参个数与类型、是否有返回值too few arguments to function f作用域相关内层是否遮蔽、是否越界访问x redeclared as different kind这几类里声明与引用、类型相关是重头戏控制流检查就是我们后面要重点聊的 goto 部分。你要注意的是这些检查不是孤立进行的——它们几乎都依赖同一份数据符号表。检查y有没有声明得查符号表检查函数返回类型对不对得先查函数的符号表条目拿到返回类型。这也是为什么我把符号表管理放在语义分析之后紧接着讲它俩是绑死的。1.3 两种落地路线语法制导翻译与独立遍历实现语义分析有两条主流路线选哪条直接决定你后面代码怎么写。第一条是语法制导翻译Syntax-Directed Translation把语义动作直接嵌进语法分析的产生式里边解析边计算属性、边填符号表。递归下降手写解析器时你可以在每个 parse 函数的合适位置插入语义检查代码。好处是不用再遍历一遍树效率高、代码紧凑坏处是解析逻辑和语义逻辑糊在一起函数越写越长改一处容易牵动全身。第二条是先建完整语法树再单独跑一遍语义分析遍历器。解析阶段只负责建树语义阶段用一个 Visitor 遍历这棵树遇到节点类型就做对应处理。好处是职责分离语法分析和语义分析各自独立、好调试、好扩展坏处是多存了一棵树多跑一趟遍历对极大规模输入有额外开销。我自己的建议是课程实验、玩具语言、以及绝大多数要看得清的场景直接上第二条路线。因为语义分析本来就容易出 bug把逻辑单独拎出来你至少能在遍历到某个节点时打个断点、看一眼当前作用域里有什么排查体验完全不一样。真实工业编译器比如 Clang也会做多个语义阶段早期就做名字绑定和类型检查这个思路是一致的。注意语法制导翻译里有个经典坑——属性求值顺序。如果语义动作依赖子节点先算完你却把孩子还没处理完就把父节点动作执行了符事情就错了。手写递归下降时语义动作一定要放在子节点都处理完之后的位置或者显式用两趟第一趟收集声明、第二趟检查引用来规避。2. 符号表管理编译器里最容易写崩的数据结构2.1 符号表里到底存什么符号表本质就是一张名字到信息的映射但信息具体存什么决定了你能做多细的检查。课本上常写得比较笼统实际动手我建议按条目类型分开设计。一个变量条目至少要存名字、种类变量/常量/参数/标号/函数、类型、作用域层级、声明位置行号列号报错时要用。函数条目还得额外存返回类型、参数列表。标号条目goto 用要存标号名和它所在的作用域。用 Python 描述一个符号条目大概长这样class Symbol: def __init__(self, name, kind, type_nameNone, level0, line0, extraNone): self.name name self.kind kind # var / const / param / func / label self.type_name type_name self.level level # 所在作用域层级用于判断可见性 self.line line # 声明行号报错定位用 self.extra extra or {} # 函数参数表、返回类型等 def __repr__(self): return f{self.kind} {self.name}:{self.type_name} L{self.level}这里level字段很关键别省。它标记条目属于第几层作用域后面做内层遮蔽外层的检查、以及 goto 跳转合法性判断时都要用到。line也一定要存否则报错只能告诉用户某处错了定位不到具体位置用户体验差一大截。实操心得报错信息里带上行号列号这件事越早做越省事。等你写完所有检查逻辑再回头补位置信息会发现很多节点根本没存行号又得爬回去改 AST 定义。我在第一个版本就吃过这个亏后来统一规定 AST 每个节点必须带line属性语义阶段直接拿来用。2.2 作用域的实现栈式结构 vs 树形结构符号表最大的难点不是存而是作用域。同一层里内层声明的变量要遮蔽外层同名变量离开作用域后内层那批条目又要失效。实现上有两种经典做法。第一种是栈式符号表维护一个符号栈进入作用域就压入一个空表或作用域标记离开就弹出。查找时从栈顶往下逐层找找到第一个匹配的就停。这种做法天然贴合作用域的嵌套和退出顺序内存占用小实现简单。第二种是作用于链Scope Chain每个作用域是一个独立的对象内部用一个字段指向上层作用域查找时沿着enclosing指针一层层往上走。这其实就是很多解释器比如 Python 语言的某些实现、以及大量教学解释器采用的方式好处是结构清晰、不依赖退出顺序便于以后做闭包、做跨作用域引用分析。class ScopedSymbolTable: def __init__(self, name, level0, enclosingNone): self.name name self.level level self.enclosing enclosing self.symbols OrderedDict() self.labels set() # 当前作用域可见的标号集合 def define(self, symbol): symbol.level self.level self.symbols[symbol.name] symbol return symbol def lookup(self, name): scope self while scope is not None: if name in scope.symbols: return scope.symbols[name] scope scope.enclosing return None def lookup_current(self, name): return self.symbols.get(name)我倾向第二种。原因很简单goto 语句的标号跨作用域检查、以及将来可能做的循环体特殊处理都需要从当前作用域往外走的能力链式结构天然支持栈式结构也能做但要多绕一层。而且链式结构在调试时能直接把整个作用域链打印出来一眼看清当前可见的所有名字排查变量找不到这类问题特别爽。2.3 数据结构选型哈希表打底链表兜底单层作用域内部符号的查找结构用什么常见选项是哈希表、有序链表、平衡树。哈希表平均查找 O(1)最常用。Python 里直接dictC 里自己开桶。注意要保持插入顺序的话Python 3.7 的 dict 天然有序其他语言要选有序哈希或额外维护顺序。有序链表实现最简单缺点是查找 O(n)。适合符号数量很少的单层作用域比如一个函数里就十来个变量链表的实际性能和哈希差不多还能省掉哈希函数的复杂度和内存开销。平衡树能兼顾有序遍历和 O(log n) 查找但实现麻烦除非你要按字典序遍历符号比如导出符号列表否则没必要。我的实际选择是全局作用域用哈希表符号多函数级作用域看情况如果函数体变量一般不超过几十个直接用有序列表反而更省心。很多教材一上来就让你手写哈希表、处理冲突作为练习可以但生产代码里没必要重复造轮子语言标准库给的就够用。2.4 重载、名字修饰与前向引用稍微进阶一点的两个话题函数重载和前向引用。函数重载意味着符号表里同一个名字可能对应多个条目靠参数列表来区分。这时不能简单用名字做 key得用名字参数签名做 key或者 key 存名字、value 存一个条目列表查找时线性比对参数类型。做课程实验一般用不到重载但面试里经常被问到重载和覆盖的区别在符号表层面怎么体现提前想清楚有好处。前向引用则是函数f里调用了定义在它后面的函数g。这要求符号表要么做两趟扫描第一趟先把所有顶层声明收集进符号表第二趟才做函数体检查要么允许在遇到未声明的标识符时先挂起等扫描完再统一确认。这个坑我在实现时踩过——一开始单趟扫描结果互相递归的两个函数全报未声明改成两趟立刻就好了。两趟扫描也是局部语义分析里非常通用的做法值得记下来。3. 类型检查与类型系统落地3.1 类型表达式与类型等价判定语义分析查得最多的就是类型。要给类型做检查先得把类型本身用数据结构表示出来。基本类型int、float、char直接用枚举或字符串标记复杂类型要递归表达——数组是元素类型 维度函数是参数类型序列 返回类型指针是指向类型。类型等价判断有两种主流标准名字等价和结构等价。名字等价要求两个类型来自同一个类型声明才算相同结构等价只要求结构形态一致即可。举个场景你有两个都表示两个 int 的数组的类型int[2]名字等价会说这俩是不同声明的类型不等价结构等价则说结构一样等价。实际实现里结构等价更容易写递归比对结构即可但要注意别掉进无限递归的坑如果类型是循环定义比如链表节点里有指向自身的指针比对时一定要有终止条件或者用集合记录已访问过的类型对。名字等价需要维护一个类型声明表长期看扩展性更好但课程实验里结构等价足够用了。3.2 类型推导与合一算法表达式类型检查的核心是自底向上算类型。一个二元运算符节点先算出左右子表达式的类型再根据运算符的类型规则决定结果类型。比如在两侧都是 int 时结果是 int有一侧是 float 就整体提升为 float。把这些规则整理成一张表遍历到运算符节点时查表即可。更麻烦的是类型推导——有些语言允许你不写类型编译器自己推。经典的算法是Hindley-Milner 类型推导核心工具叫合一unification给每个未知类型分配一个类型变量遇到约束就尝试让两个类型相等遇到类型变量则做替换。合一过程中如果出现某类型变量既等于 int 又等于 float这种冲突就报类型错误。合一算法本身不难几行递归就能写难的是错误信息。合一失败时你拿到的往往是一串中间类型变量直接抛给用户会得到unify(α3, α7) failed这种天书。解决办法是错误延迟报告记录下每个类型变量是哪个表达式引入的失败时回溯到最初的表达式来报错。这块花的时间往往比算法本身还多做产品级编译器的人应该深有体会。3.3 报错信息设计报错质量是衡量语义分析器成熟度的关键指标。同样一个类型错误类型不匹配和第 12 行函数 f 返回类型为 int但你返回了字符串字面量 hello是两个体验。我总结了几条写报错信息的原则带位置行号必须给能给列号更好。带上下文说清楚哪里和哪里冲突比如函数 f 期望 2 个参数实际传入 3 个。给建议能猜出用户意图时顺带给一句是否想用 xx。一次报多个别遇到第一个错误就退出收集起来一起报用户改起来更省事。但要小心错误级联——一个未声明变量会引发一堆后续的假错误得做错误恢复比如遇到未声明标识符时给它一个未知类型并继续。注意错误恢复会让错误数量看起来很多实际根因可能只有一个。报错时最好能标记出首次出现的错误并把后续明显连带的错误降级或折叠否则用户会被几十条报错淹没。4. goto 语句控制流语义里最扎手的部分4.1 goto 的语义约束有哪些goto 的语法很简单goto 标号;加上标号的声明标号: 语句。但它的语义约束一点都不简单。至少要检查这几条标号必须存在跳转目标在当前函数中要有定义否则报标号未定义。标号在函数内唯一一个函数里不能有两个同名标号否则跳转目标不明确。标号作用域是函数级的这是和普通变量不一样的地方——C 语言的标号在整个函数体内可见不受块作用域限制。也就是说函数里任何一层块内定义的标号函数里任何位置都能 goto 过去只要不跨越函数边界。这条规则常常和变量作用域规则搞混。不能跨函数跳转goto 只能在同一个函数体内跳不能跳到别的函数里去。第 3 条是 goto 语义分析最容易出错的地方。普通变量是词法作用域内层块里的变量出了块就没了标号却不一样它属于函数级作用域C99 标准明确规定标号有函数作用域。所以你不能用同一套词法作用域的规则去检查标号否则会出现内层块定义的标号外层 goto 过去报未定义的错误结论。4.2 标号表怎么组织基于标号是函数级作用域这个事实标号表最自然的组织方式就是每个函数维护一张标号表函数内所有块共享这一张表。进入函数时创建离开函数时销毁。class FunctionContext: def __init__(self, name): self.name name self.labels set() # 已定义的标号 self.used_labels set() # 被 goto 引用过的标号 def define_label(self, name, line): if name in self.labels: raise SemanticError(fline {line}: 标号 {name} 重复定义) self.labels.add(name) def use_label(self, name, line): self.used_labels.add((name, line)) def check_unresolved(self): errors [] for name, line in self.used_labels: if name not in self.labels: errors.append(fline {line}: 标号 {name} 未定义) return errors注意used_labels里存了行号。因为跳转引用可能在标号定义之前出现前向跳转很常见你没法在遇到 goto 时立刻判断标号是否存在必须等整个函数体扫描完再统一核对used_labels和labels的差集。这种先收集、后核对的模式在语义分析里用得非常多。4.3 goto 与作用域、变量初始化的互动标号本身好处理但 goto 一旦和变量初始化搅在一起问题就复杂了。考虑这段代码C 风格伪码goto skip; int x 10; skip: printf(%d, x); // x 被用了但初始化被跳过了这里 goto 跳过了x的初始化语句但后面又使用了x。在 C 语言里这是允许的会得到未初始化的值但很多现代语言比如 Java、C#会直接报错变量 x 可能尚未初始化。Java 对 goto 做了严格限制只保留了受限的break label/continue label且明确规定不能形成绕过局部变量初始化的跳转。从语义分析的角度看这就引出一个进阶话题确定赋值分析Definite Assignment Analysis。分析器要遍历控制流判断每个使用变量的点上该变量是否一定已经被赋值过。做这个分析需要构建控制流图处理分支、循环、goto 带来的复杂路径。课程实验里通常不做但如果你想让自己的小语言更接近工业级这块值得花时间研究——它是很多语言变量可能未初始化报错背后的真正机制。一个简化的处理策略是只检查跳转是否跳过变量声明。如果 goto 的目标标注位置上的作用域比 goto 语句所在位置的作用域更内层就报跳转进入受保护作用域如果目标位置上存在被跳过的、带初始化的变量声明就报跳转跳过变量初始化。这样做能挡住大部分明显错误又不用实现完整的控制流分析。4.4 现代语言为什么逐渐收窄 gotogoto 的危害在编程界是老话题。从语义分析的角度看它带来三个实实在在的麻烦第一控制流图变得难以分析任意跳转让数据流分析、可达性分析、确定赋值分析都复杂化第二它和结构化控制流if、while、for的语义容易冲突跳进跳出循环体时循环变量的状态怎么算很不清晰第三它破坏作用域规则的可预测性本来按块进块出就能维护的作用域被任意跳转一搞进入和退出顺序被打乱。所以现代语言的处理方式是限制而非全禁Java 取消了通用 goto只留标签版的 break/continueGo 保留了 goto但明确规定不能跳过变量声明、不能跳进内层代码块Rust 干脆只留标签版 loop/break。这种受限控制流的设计目标很明确——保留跳出的表达能力跳出多层循环确实方便砍掉跳跃进入任意位置的破坏性。这里头有个设计经验值得借鉴当你发现一个特性本身合理但破坏性强时不要一刀切禁止而是给它加约束、限定它能做什么。goto 的现代演进就是这条思路的典型案例做语言设计和 API 设计都能用得上。5. 动手实操给玩具语言加上语义分析和符号表5.1 语言定义光说不练没意思我们用一个小语言把上面的东西串起来。语法规则定得简单点program - func_def func_def - func ID ( params ) block block - { stmt* } stmt - var_decl | assign | if_stmt | goto_stmt | label_stmt | call_stmt var_decl - var ID expr ; assign - ID expr ; goto_stmt - goto ID ; label_stmt - ID : stmt if_stmt - if ( expr ) block类型只有 int 一种省得在类型系统上花太多篇幅。解析器用递归下降手写语义分析单独跑一趟遍历。我们要检查的东西变量是否声明、是否重复声明、赋值类型是否匹配、goto 标号是否存在、标号是否重复。5.2 符号表实现符号表就用前面那套作用域链结构加上标号相关的逻辑。这里把完整的核心部分贴出来class Symbol: def __init__(self, name, kind, type_nameint, level0, line0): self.name name self.kind kind self.type_name type_name self.level level self.line line class ScopedSymbolTable: def __init__(self, name, level0, enclosingNone): self.name name self.level level self.enclosing enclosing self.symbols {} self.labels set() def define(self, symbol): symbol.level self.level self.symbols[symbol.name] symbol return symbol def lookup(self, name): scope self while scope is not None: if name in scope.symbols: return scope.symbols[name] scope scope.enclosing return None注意lookup实现里有个细节它一路往上查找到第一个就返回。这正好实现了内层遮蔽外层——内层同名变量先被查到外层就被挡住了。不用额外做遮蔽检查逻辑查找顺序天然保证了正确性。5.3 语义检查遍历遍历器用递归实现每个节点类型一个方法。关键是要维护好当前作用域和错误列表class SemanticAnalyzer: def __init__(self): self.global_scope ScopedSymbolTable(global, 0) self.current_scope self.global_scope self.errors [] self.current_function None def error(self, msg, line): self.errors.append(fline {line}: {msg}) def enter_scope(self, name): self.current_scope ScopedSymbolTable( name, self.current_scope.level 1, self.current_scope) def leave_scope(self): self.current_scope self.current_scope.enclosing def visit_var_decl(self, node): # 同一层内重复声明检查 if self.current_scope.symbols.get(node.name): self.error(f变量 {node.name} 重复声明, node.line) return self.current_scope.define(Symbol(node.name, var, int, linenode.line)) def visit_assign(self, node): sym self.current_scope.lookup(node.name) if sym is None: self.error(f未声明的变量 {node.name}, node.line) elif sym.kind ! var: self.error(f{node.name} 不是变量不能赋值, node.line) def visit_identifier(self, node): if self.current_scope.lookup(node.name) is None: self.error(f未声明的标识符 {node.name}, node.line)这里有个值得说的设计visit_var_decl用的是current_scope.symbols.get而不是lookup。因为重复声明检查只看当前层外层同名是允许的也就是遮蔽用lookup会误报外层同名的情况。这个区别很微妙但写错就意味着一堆假错误一定要分清当前层定义检查和全链查找这两种语义。5.4 goto 的语义检查goto 和标号的处理单独拎出来因为它遵循函数级作用域def visit_goto(self, node): if self.current_function is None: self.error(goto 出现在函数外, node.line) return self.current_function.used_labels.add((node.name, node.line)) def visit_label(self, node): if self.current_function is None: self.error(标号定义在函数外, node.line) return if node.name in self.current_function.labels: self.error(f标号 {node.name} 重复定义, node.line) else: self.current_function.labels.add(node.name) # 标号后面的语句继续正常处理 self.visit(node.stmt) def visit_func_def(self, node): self.current_function FunctionContext(node.name) self.enter_scope(ffunc:{node.name}) # 先处理参数 for p in node.params: self.current_scope.define(Symbol(p, param, int, linenode.line)) for stmt in node.body: self.visit(stmt) # 函数体处理完统一核对标号 self.errors.extend(self.current_function.check_unresolved()) self.leave_scope() self.current_function Noneused_labels和labels的比对放在函数体处理完之后这一步顺序不能错。如果在visit_goto时就急着判断前向跳转goto 写在标号定义之前全都会误报标号未定义。实测下来几乎所有 goto 的检查 bug 都出在这个收集时机上。实操心得把收集阶段和校验阶段显式分开是语义分析里一个非常好用的模式。不仅是 goto函数调用先收集所有函数签名再检查调用也这么干。心里默念两遍先都收进来再一起算账能帮你省下大量调试时间。6. 常见问题与排查技巧实录6.1 高频问题速查表语义分析阶段的问题症状往往很迷惑因为错误现象和根因可能隔了好几层。我把踩过的典型问题整理成一张表症状可能原因排查方向所有变量都报未声明符号表查找链断了或 enter/leave 顺序反了打印当前作用域链、检查 leave_scope 调用位置内层变量遮蔽外层时误报重复声明用了 lookup 而非查当前层重复声明检查改用 symbols.get 或 lookup_current互为递归的函数报未声明单趟扫描前向引用未处理改两趟先收集所有顶层声明goto 前向跳转全部误报收集时机错误goto 时立刻校验改为函数体末尾统一 check_unresolved块级标号找不到用了词法作用域规则检查标号标号应为函数级标号表挂函数上下文报错没有行号AST 节点未保存 line 属性解析阶段统一附上行号一个错误引发几十条报错未做错误恢复给未声明标识符分配未知类型继续标记根因错误这张表里最坑的是第一个和第四个。第一个通常是一处小小的调用顺序问题但报错范围大到吓人第四个则是逻辑顺序问题而非数据结构问题光看数据结构代码看不出任何毛病。6.2 调试与定位技巧语义分析调试有个通用技巧在每个关键节点打印当前状态。具体做法是给enter_scope、leave_scope、define、lookup各加一行调试输出每次操作打印作用域层级、名字、命中位置。跑一个小例子把日志从头看一遍绝大多数作用域错乱问题当场就能看出来。另一个技巧是为符号表单独写单元测试。符号表是纯数据结构不依赖 AST完全可以在没有解析器的情况下测。我通常写这么几组用例同层定义同名、跨层定义同名、跨层查找命中、退出作用域后查找失败、标号跨块可见。这几组过一遍符号表基本就稳了后面跑到完整程序里出问题时你能快速排除掉是不是符号表本身的锅。关于错误级联的处理我用的策略是错误分等级。未声明标识符这种会导致后续连锁的报一条并返回未知类型让后续检查遇到未知类型时直接跳过不再报错。这样用户看到的报错数量大幅下降首因也更突出。这个小设计对使用体验的提升非常明显比单纯减少错误检查项要聪明得多。6.3 面试里常被追问的点编译原理相关的面试题里语义分析和符号表是高频考点几个被问得特别多的问题值得提前准备问语义分析和语法分析的区别答语法分析检查程序的形状是否符合文法语义分析检查形状之外的约束如名字绑定、类型、控制流合法性通常依赖符号表。问符号表的作用域怎么实现答栈式符号表或作用域链前者压栈弹栈后者沿父指针查找都要实现内层遮蔽外层。问标号的作用域和变量有什么不同答变量是词法作用域标号是函数级作用域函数内所有块共享同一份标号表。问goto 有哪些语义限制答不能跨函数、不能重复定义标号、现代语言还禁止跳过变量初始化或跳入内层受保护块。问什么是合算法答类型推导中用于求解类型相等约束的算法遇到类型变量时做替换冲突时报告类型错误。回答这类问题时别只背结论能说出为什么这么设计会加分很多。比如聊到标号作用域你可以补一句标号用函数级作用域是为了让跳转目标在整个函数内都可见方便从任意位置跳出多层循环这就从知道上升到理解了。7. 我在实际动手中的几点体会最后聊点经验层面的东西不讲具体技术讲怎么把这一章学明白。第一个体会是符号表要当独立模块来写别和遍历逻辑搅在一起。我见过不少人把符号表的压栈弹栈直接写在遍历函数里结果就是符号表的行为依赖遍历顺序你想单独测一下跨作用域查找都没法测。把符号表抽成一个纯类提供 define/lookup/enter/leave 这几个接口遍历器只负责调用它调试和测试都轻松很多。第二个体会是goto 这块是最容易被轻视的。词法、语法阶段大家都愿意花时间到了语义分析尤其是 goto 这种边角料很多人直接跳过或者草草实现。但恰恰是 goto 的语义约束最能训练你对作用域和控制流的理解。把标号为什么是函数级作用域、为什么不能跳过变量初始化这些问题想透你对整个语义分析的理解会上一个台阶。第三个体会是错误信息值得单独花时间打磨。这不是锦上添花而是理解质量的体现。当你能给出一条精确定位、说明冲突双方、还附带建议的报错说明你对程序内部结构的把握已经到了很细的程度。我后来做别的项目时凡是涉及解析或校验的模块都会刻意留出时间来打磨报错回报率非常高。如果后续要把这套东西继续扩展有几个方向可以试加一个简单的确定赋值分析检查变量可能未初始化把 goto 限制成只能向前跳像 Go 那样禁止跳过声明或者把符号表序列化出来做一个可视化工具看作用域结构和标号分布。这几件事做下来你对编译前端的整个语义阶段会有一个非常完整的认识。
返回列表