简介:成都电子科技大学(UESTC)编译原理课程设计资源,面向高校编译原理课程学习者,以词法分析器与语法分析器两个核心模块为主线,演示如何将源代码切分为记号流,再按上下文无关文法构建抽象语法树。压缩包共八个文件,大小约4KB,包含两个Python源码文件,分别实现词法分析与语法分析;其余为文法定义、符号表变量信息、分词输出样例、分析结果及错误报告等辅助文件,适合对照调试、梳理编译前端流程,也可作为课程设计报告的代码附件。已有176人浏览学习,说明该案例对课程作业、期末复习和自学编译器入门有一定参考价值。通过阅读代码与运行结果,可直观理解正则表达式匹配token、LL(1)/LR(1)解析策略、错误处理与符号表组织等关键概念;同时能借助完整的样例输入输出快速定位问题,为后续设计简单编译器或开展相关课设提供可直接修改的基础框架。
1. 这是一份能跑通实验报告的课设代码,但直接交作业前请先搞懂它
拿到UESTC-编译原理-词法分析器-语法分析器.zip的瞬间,多数人第一反应是解压、打开、按下运行,看到控制台吐出 token 流和一个语法树就长舒一口气。但编译原理课设的残酷之处在于:代码能跑只是及格线,答辩时导师问的永远是“这个 DFA 的 dead state 你怎么处理的”“LR(1) 的展望符是怎么传递的”这类让你后背发凉的问题。
这个 zip 里的东西,本质是编译前端的两大核心部件的完整实现:词法分析器负责把源代码字符串切成 token 序列,语法分析器负责按文法规则把这些 token 组装成抽象语法树。中间还夹着符号表管理、错误恢复、表达式优先级处理等一堆「看着简单、做起来全是细节」的活。适合正在做编译原理实验、需要一份可对照的参考实现的学生,也适合想快速搭一个前端原型、验证文法设计思路的从业者。
我拿到这类课设代码的标准流程是三步:先看 token 定义和文法产生式是否完整,再跑一遍自带测试用例确认没有隐藏的运行时崩溃,最后重点审计正则表达式的边界情况和递归下降里的左递归处理——修补完这三处,这份代码才真正算你的。
2. 词法与语法分析的核心设计:从正则到语法树的工程化拆解
2.1 词法分析为什么用“正则 + DFA”而不是逐字符判断
词法分析器的输入是源代码字符串,输出是(token_type, lexeme, line)三元组序列。常见实现思路有两派:直观的逐字符 if-else 判断和基于正则表达式的有限自动机。课设代码里看到的通常是后者,即用正则表达式描述单词模式,再转换为 DFA 进行匹配,原因在于单词集合的规则性——保留字、标识符、无符号数、运算符、界符这些类别天然适合用正则描述。
举个例子,标识符的约束可以写成[A-Za-z_][A-Za-z0-9_]*,无符号整数写成[0-9]+,实数写成([0-9]+"."[0-9]*)|([0-9]*"."[0-9]+)。每个类别一个正则,把全部正则合并后用子集构造法转 DFA。这种做法的工程优势很明显:修改单词的边界规则只动正则表达式,不需要改整整段状态机逻辑。
关键实现在于「最长匹配」原则。编译器要求词法分析器每次取最长的合法单词,而不是遇到第一个可接受状态就停下。int在扫描到i时已经能匹配标识符的中间状态,要继续看n和t,直到读到空白符才确认这是一个完整的标识符;同理>=不能拆成>和=两个 token。很多课设代码在这里偷懒,做成了「贪心 + 回溯」,遇到intx这种输入会先输出int再输出x,这是必须修补的隐患。
代码里通常还会维护一个保留字表,标识符匹配完成后查表,命中就改写 token 类型。这个查表动作要在最长匹配之后做,否则integer会错误地匹配保留字int再加标识符eger。
2.2 语法分析器两种路线的选型:递归下降还是 LR
语法分析器是在 token 流上做结构组合。课设里最常见的是自顶向下的递归下降分析器和自底向上的 LR 分析器。递归下降的优点是手写代码直观、出错位置精确、容易在 parse 函数里插入自定义错误信息;缺点是文法必须消除左递归和提取左因子。LR 系列则更适合处理表达式文法,可以用 yacc/bison 这类工具自动生成分析表,但对学生的要求就变成了「理解状态栈和展望符」,答辩风险更高。
我见过的大部分电子科大课设代码里,两者都会出现:表达式部分用递归下降,因为加减乘除和括号的优先级处理写起来最顺手;碰到复杂的语句块则用 LR 风格的表驱动分析。选择哪种取决于文法设计,E -> E + T | T这种左递归文法,直接写递归下降会造成无限递归栈溢出,必须先改写成E -> T E'、E' -> + T E' | ε这样的右递归等价形式。
关于优先级与结合性的处理,递归下降里靠「层级嵌套」实现:表达式层调用项层,项层调用因子层,每下降一层优先级升一级。2 + 3 * 4会先由表达式层把2交给项层,遇到+之后递归调用项层去读3 * 4,乘法的绑定自然更强。括号则让因子层在遇到(时重新调用表达式层,形成递归闭环。
2.3 把 zip 里的代码在本地跑通:最小复现步骤
拿到代码包后,先看目录结构再动手运行,避免一上来就缺少依赖文件导致报错。常见目录分层如下:
Lexer/ lexer.py # 词法分析器主逻辑 token.py # token 类型定义与保留字表 dfagen.py # 正则表达式转 NFA/DFA 的工具模块 Parser/ parser.py # 语法分析器 ast_nodes.py # 语法树节点类定义 grammar.txt # 文法产生式说明 test/ test_simple.c # 简单变量声明与赋值 test_expr.c # 含嵌套括号和负数表达式 test_error.c # 故意包含语法错误的用例 build.sh # 一键编译运行脚本建议按lexer.py -> parser.py -> test/的顺序阅读,而不是倒着从测试用例猜行为。词法分析器是最独立的模块,看清 token 类型定义就能推断后面的语法分析器怎么消费这些 token。运行核心命令如下:
# 先跑词法分析器,确认能产出 token 流 python3 Lexer/lexer.py test/test_simple.c # 再跑完整编译前端,输出语法树结构 python3 Parser/parser.py test/test_expr.c --dump-ast # 查看事件追踪调试信息 python3 Parser/parser.py test/test_expr.c --trace三个命令分别验证词法层、语法层和调试追踪。--trace会在每个产生式规约时打印所处的状态栈顶内容,对应着 LR 分析器内部动作,是定位「shift/reduce 冲突有没有实际触发」的关键选项。如果 lexer 输出乱码或 parser 直接崩溃,先检查测试文件是不是用了 UTF-8 的 BOM 头,见后面踩坑章节。
运行时最值得关注的是符号表,默认实现往往是逐层作用域链:函数参数表、局部变量表、全局变量表各是一层,查找时自内向外逐层回溯。我一般会用类似的命令验证作用域遮蔽:
python3 Parser/parser.py test/test_scope.c --dump-symbols--dump-symbols输出每个作用域的变量名和类型映射表。这样做的好处是答辩时能直接把输出截图贴进实验报告,比贴千行源码更有说服力。
3. 词法分析器的完整实现:token 定义、DFA 转换与状态管理
3.1 token 类型映射表:从语言规范到枚举定义的一一对应
写词法分析器第一步是定义枚举,把源语言里的所有单词类别映射成整型或字符串常量。常见映射表如下:
| 类别 | 匹配模式正则 | 枚举名 | 额外说明 |
|---|---|---|---|
| 保留字 | int/float/char/if/else/while/return | KW_INT / KW_FLOAT / ... | 查表命中后覆盖标识符类型 |
| 标识符 | [A-Za-z_][A-Za-z0-9_]* | IDENTIFIER | 最长匹配且查表靠后 |
| 无符号整数 | [0-9]+ | INT_CONST | 十进制,前导零允许 |
| 浮点数 | [0-9]*"."[0-9]+ | FLOAT_CONST | 要求小数点至少一侧有数字 |
| 字符常量 | '[^']' | CHAR_CONST | 无转义处理 |
| 字符串 | `"(\. | [^"\])*"` | STRING_CONST |
| 运算符 | +-*/%==!=<><=>== | OP_ADD / OP_SUB / ... | 区分单字符与双字符 |
| 分隔符 | ,;(){} | SEP_COMMA / SEP_SEMI / ... | 无歧义 |
代码里 token 定义通常用枚举类:
from enum import Enum, auto class TokenType(Enum): IDENTIFIER = auto() INT_CONST = auto() FLOAT_CONST = auto() CHAR_CONST = auto() STRING_CONST = auto() KW_INT = auto() KW_FLOAT = auto() KW_CHAR = auto() KW_IF = auto() KW_ELSE = auto() KW_WHILE = auto() KW_RETURN = auto() OP_ADD = auto() OP_SUB = auto() OP_MUL = auto() OP_DIV = auto() OP_MOD = auto() OP_ASSIGN = auto() OP_EQ = auto() OP_NE = auto() OP_LT = auto() OP_GT = auto() OP_LE = auto() OP_GE = auto() SEP_COMMA = auto() SEP_SEMI = auto() SEP_LPAREN = auto() SEP_RPAREN = auto() SEP_LBRACE = auto() SEP_RBRACE = auto() EOF = auto()枚举用auto()生成序号即可,不需要手工指定数值。真正要留意的是保留字表的实现位置——它属于词法分析器内部状态,不应该暴露给语法分析器。
3.2 用 Python 实现一个极简词法 scanner:核心循环与回溯策略
下面这个 scanner 是课设代码最常见的实现形态:基于正则库逐条匹配并加最长匹配保护。它不追求 DFA 的极致性能,但胜在逻辑清晰、答辩好讲。
import re class SimpleLexer: def __init__(self, source: str): self.source = source self.pos = 0 self.line = 1 self.tokens = [] # 匹配规则按“双字符运算符优先于单字符”的顺序排列 self.rules = [ ("KW_INT", r'int\b'), ("KW_FLOAT", r'float\b'), ("KW_CHAR", r'char\b'), ("KW_IF", r'if\b'), ("KW_ELSE", r'else\b'), ("KW_WHILE", r'while\b'), ("KW_RETURN", r'return\b'), ("IDENTIFIER", r'[A-Za-z_][A-Za-z0-9_]*'), ("INT_CONST", r'[0-9]+'), ("FLOAT_CONST", r'[0-9]+\.[0-9]+'), ("OP_GE", r'>='), ("OP_LE", r'<='), ("OP_EQ", r'=='), ("OP_NE", r'!='), ("OP_ASSIGN", r'='), ("OP_ADD", r'\+'), ("OP_SUB", r'-'), ("OP_MUL", r'\*'), ("OP_DIV", r'/'), ("OP_MOD", r'%'), ("OP_LT", r'<'), ("OP_GT", r'>'), ("SEP_COMMA", r','), ("SEP_SEMI", r';'), ("SEP_LPAREN", r'\('), ("SEP_RPAREN", r'\)'), ("SEP_LBRACE", r'\{'), ("SEP_RBRACE", r'\}'), ] # 把规则编译成正则对象,匹配开头使用 self.compiled = [(token_type, re.compile(pattern)) for token_type, pattern in self.rules] def tokenize(self): while self.pos < len(self.source): char = self.source[self.pos] if char in ' \t\r': self.pos += 1 continue if char == '\n': self.line += 1 self.pos += 1 continue matched = False for token_type, pattern in self.compiled: match = pattern.match(self.source, self.pos) if match: lexeme = match.group(0) # 保留字在匹配完成后统一变成 KW_ 系列类型 final_type = token_type self.tokens.append((final_type, lexeme, self.line)) self.pos += len(lexeme) matched = True break if not matched: raise SyntaxError(f"unexpected character {char!r} at line {self.line}") self.tokens.append(("EOF", "", self.line)) return self.tokens逻辑说明:循环内先吞掉空白符和换行符,换行时递增行号;随后尝试每一个已编译的正则规则,谁先匹配谁生效。pattern.match(source, pos)只匹配指定位置的开头,天然避免了扫描中间乱入的问题。每匹配成功一个 lexeme,推进 pos 并记录行号,供后续语法分析的错误定位使用。
参数说明:规则列表的排列顺序直接影响输出结果,KW_IF必须排在IDENTIFIER前面,否则if会被当成标识符。而FLOAT_CONST排在INT_CONST前面是必需的,否则3.14会被吞成3然后剩下.14匹配失败。\b用在保留字末尾是为了防止intx被误判为int,这个边界匹配是最大坑之一,后面详述。
这段代码牺牲了一点性能换取可解释性:每条规则都调用一次正则匹配,token 数量多时有明显的重复扫描,但对于课设规模的源码完全够用。若追求效率,应改为把所有 token 模式合并为一个大正则并用命名分组区分类型,再配合|分支,让正则引擎内部做最长匹配。修改方式是将 rules 里的 pattern 拼成(?P<IDENTIFIER>[A-Za-z_][A-Za-z0-9_]*)|(?P<INT_CONST>[0-9]+)|...,然后用lastindex判断命中了哪个分组。
3.3 保留字表为什么要后置:一处细节决定能不能识别intx
保留字表的处理顺序是我在课设答辩里见过被追问最多的问题。如果匹配规则写成「先查保留字表,命中再走标识符逻辑」,输入intx时会因为int是保留字而在int处截断,剩下x被识别成另一个标识符,但源语言里intx明明应该是一个合法的普通标识符。
正确做法是:词法规则只包含IDENTIFIER一种模式,匹配完成后把 lexeme 拿去查保留字字典,查到了就把 token 类型替换为对应的保留字类型。代码里体现为:
# 词法分析器内维护的保留字映射 KEYWORDS = { 'int': 'KW_INT', 'float': 'KW_FLOAT', 'char': 'KW_CHAR', 'if': 'KW_IF', 'else': 'KW_ELSE', 'while': 'KW_WHILE', 'return': 'KW_RETURN', } # 在 tokenize 循环里,IDENTIFIER 匹配成功后执行转换 if final_type == "IDENTIFIER" and lexeme in KEYWORDS: final_type = KEYWORDS[lexeme]这样intx作为一个整体 lexeme,查表时找不到intx,保留IDENTIFIER类型;单独的int查表命中才变成KW_INT。查表位置必须在正则匹配完成之后,这是词法分析正确性的第一道关卡。
3.4 行号追踪与源码位置:语法分析器报错靠它定位
语法分析器在报告「第 5 行第 12 列附近缺少分号」这种错误时,用到的是词法分析器随 token 一起记录的行列信息。实现上可以在 lexeme 吞进时额外记录起始列:
# 在 tokenize 循环体里增加列号计算 line_start_pos = 0 # 每次换行时更新为 self.pos + 1 col = self.pos - line_start_pos + 1 self.tokens.append((final_type, lexeme, self.line, col)) self.pos += len(lexeme)line_start_pos在遇到\n时重置为当前 pos 加 1,列号从 1 开始。这种位置信息对后面语法分析器做错误恢复意义巨大:当分析器在某个 token 处发现非法前瞻时,能直接用这个位置作为错误标记点。我接手过的代码包里有不少是只存行号不存列号,一旦出错只能定位到「行」无法定位到「列」,实际操作中在 if-else 嵌套的测试用例里排查起来要人老命。
4. 语法分析器从零到跑通:递归下降实现表达式与语句解析
4.1 文法设计:消除左递归与提取左因子的标准做法
手写递归下降前先定义文法。以简化 C 子集为例:
program -> { declaration | assignment } declaration-> type IDENTIFIER [ '=' expr ] ';' type -> 'int' | 'float' | 'char' assignment -> IDENTIFIER '=' expr ';' expr -> term { ('+' | '-') term } term -> factor { ('*' | '/') factor } factor -> IDENTIFIER | INT_CONST | FLOAT_CONST | '(' expr ')'这里expr写成term { ('+' | '-') term }而不是expr -> expr '+' term | term,后者是标准左递归,直接手写会无限递归。花括号形式表示「零个或多个」,代码里等价于 while 循环。该文法同时消除了左因子——assignment和factor里都有IDENTIFIER开头的情况,但它们在语法层级上距离足够远,不会产生 FIRST 集合冲突。
如果要处理负号,把factor扩展为'-' factor | primary,这样-3作为因子处理,2 - -3合法;而不把负号放回expr层,是为了避免2 - 3被解释成2 (-3)的二元运算与一元运算符歧义。
4.2 递归下降代码主体:parse 函数的职责划分与 token 消费方式
下面给出一个可运行的递归下降语法分析器核心代码,针对上面文法。它消费词法分析器产出的 token 列表,输出一棵以嵌套字典表示的语法树。
class RecursiveDescentParser: def __init__(self, tokens): self.tokens = tokens self.idx = 0 def peek(self): # 返回当前 token,不消耗它 return self.tokens[self.idx] def advance(self): # 消耗当前 token,返回消耗掉的那个 tok = self.tokens[self.idx] self.idx += 1 return tok def expect(self, token_type): tok = self.advance() if tok[0] != token_type: raise SyntaxError( f"expect {token_type}, got {tok[0]} at line {tok[2]}, col {tok[3]}") return tok def parse_program(self): stmts = [] while self.peek()[0] != "EOF": # 根据当前 token 前瞻决定走声明还是赋值 if self.peek()[0] in ("KW_INT", "KW_FLOAT", "KW_CHAR"): stmts.append(self.parse_declaration()) else: stmts.append(self.parse_assignment()) return {"type": "Program", "body": stmts} def parse_declaration(self): type_tok = self.advance() name_tok = self.expect("IDENTIFIER") node = {"type": "Declaration", "var_type": type_tok[1], "name": name_tok[1]} if self.peek()[0] == "OP_ASSIGN": # 带初始化 self.advance() node["init"] = self.parse_expr() self.expect("SEP_SEMI") return node def parse_assignment(self): name_tok = self.expect("IDENTIFIER") self.expect("OP_ASSIGN") value = self.parse_expr() self.expect("SEP_SEMI") return {"type": "Assignment", "name": name_tok[1], "value": value} def parse_expr(self): # 表达式层:处理加减 left = self.parse_term() while self.peek()[0] in ("OP_ADD", "OP_SUB"): op = self.advance()[1] right = self.parse_term() left = {"type": "BinaryOp", "op": op, "left": left, "right": right} return left def parse_term(self): # 项层:处理乘除 left = self.parse_factor() while self.peek()[0] in ("OP_MUL", "OP_DIV"): op = self.advance()[1] right = self.parse_factor() left = {"type": "BinaryOp", "op": op, "left": left, "right": right} return left def parse_factor(self): tok = self.peek() if tok[0] in ("IDENTIFIER", "INT_CONST", "FLOAT_CONST"): self.advance() return {"type": "Literal", "value": tok[1]} if tok[0] == "SEP_LPAREN": self.advance() inner = self.parse_expr() self.expect("SEP_RPAREN") return inner raise SyntaxError(f"unexpected token {tok[1]} at line {tok[2]}")逻辑说明:parse_expr先把左侧的操作数通过parse_term压到最高优先级层级,然后循环消费+或-。循环内每次读一个新 term 并与当前左值构造二叉节点,天然实现左结合——1 - 2 - 3会被构造成(1 - 2) - 3而不是1 - (2 - 3)。parse_term同理处理乘除。parse_factor遇到括号就递归调回parse_expr,完成优先级反转。
参数说明:expect方法是递归下降的「安全气囊」,一旦 token 类型不匹配就抛出带行号和列号的异常。sep_semi缺失、)缺失、变量名前出现数字字面量这几种高频错误都能在这里精准暴露。peek与advance分离的好处是前瞻时不消耗 token,某些需「看两步」的文法结构可以自由组合。更复杂的情形(比如判定IDENTIFIER后面跟的是(,从而区分函数调用与变量引用)就需要再增加peek2()方法:
def peek2(self): # 返回后一个 token,供需要两步前瞻的场景使用 return self.tokens[self.idx + 1] if self.idx + 1 < len(self.tokens) else ("EOF", "", 0, 0)4.3 表达式优先级与括号嵌套:怎么保证2 + 3 * 4树形正确
上文代码里,2 + 3 * 4的处理过程是:parse_expr调parse_term读2,看见+,再调parse_term读3 * 4——这个过程里3 * 4的乘除层先完成结合,再交给外层构造加法节点。最终树结构为BinaryOp(+),左子树为字面量 2,右子树为BinaryOp(*),左右子树分别含 3 和 4。这就是「乘除先于加减」在递归下降里的物理实现。
括号的作用体现在parse_factor遇到(时递归进入parse_expr,此时整个括号内容被当做一个因子参与外层运算。(2 + 3) * 4会先完成 2+3 的加法子树,再作为因子进入项层与 4 构造乘法节点。调试时为了确认树形结构,我会在 parser 里加一个 dump 工具:
def dump_ast(node, indent=0): prefix = " " * indent if node["type"] in ("BinaryOp", "Assignment", "Declaration"): print(f"{prefix}{node['type']}: {node.get('op', node.get('name', ''))}") for key in node: if key not in ("type", "op", "name"): dump_ast(node[key], indent + 1) else: print(f"{prefix}Literal: {node['value']}")输出里每个缩进层级对应语法树深度。答辩时拿出这个结构的打印结果,比空口讲「递归下降基于产生式匹配」可靠得多。
5. 避坑:课设代码移植到本地时的六个高频翻车点
5.1 编码问题:UTF-8 BOM 头让 token 流第一项变成空白字符
现象:词法分析器运行后,第一个 token 总是异常,正确输出应是IDENTIFIER int,实际却多出一个空 lexeme 或报unexpected character '\ufeff'。
原因:Windows 下编辑器保存测试源码时自动添加了 UTF-8 BOM(字节顺序标记\ufeff),词法分析器未做处理,把这个不可见字符当成了普通输入。正则里的空白符匹配\s不包括\ufeff,于是直接触发未匹配分支。
解决:读取源码时显式去掉 BOM,在 Lexer 构造函数里做一次预处理:
def strip_bom(source: str) -> str: if source.startswith('\ufeff'): return source[1:] return source也可以用字节读取后decode('utf-8-sig'),Python 会把开头的 BOM 自动过滤掉。我一般在读取文件时就处理:
with open(testfile, 'r', encoding='utf-8-sig') as f: src = f.read()5.2 最长匹配缺失:关键字与标识符的边界被错误截断
现象:源文件里定义了标识符intx,词法分析器输出为KW_INT+IDENTIFIER x,导致语法分析器在int处期望标识符却拿到整型声明起始标记,报出莫名其妙的语法错误。
原因:正则用int直接匹配而非int\b,当int后紧跟字母时\b边界断言未生效,匹配在int处提前结束。
解决:在保留字的正则末尾统一加\b。正则引擎把\b定义为「单词字符与非单词字符的边界」,intx里int与x之间是单词字符到单词字符,不构成边界,因此int\b不会在int处截断;而空格、分号、括号这些位置满足边界条件,int能正常匹配。注意\b依赖正则引擎对单词字符的默认定义,即字母数字下划线。
5.3 左递归未消除:递归下降直接爆栈
现象:parser 在解析a = b + c时进入无限递归,最终抛出RecursionError: maximum recursion depth exceeded。
原因:文法使用了expr -> expr '+' term | term这种未消除左递归的形式。手工递归下降遇到这种产生式时,parse_expr的第一件事是再次调用parse_expr,循环往复永不消费 token。
解决:将文法改写为右递归或迭代表达式形式,即前文里parse_expr的 while 循环写法。核心改写规律是E -> E α | β等价于E -> β {α},α是运算符加操作数的组合,β是通向更低优先级的入口。写出文法后先用纸面推导验证若干句子,再落代码。
5.4 单字符与双字符运算符的顺序:==被拆成两个=
现象:输入a == b,输出 token 序列是IDENTIFIER a、OP_ASSIGN、OP_ASSIGN、IDENTIFIER b,语法分析器在第二个赋值号处窒息。
原因:正则规则列表里OP_ASSIGN排在OP_EQ前面,a == b扫描到第一个=时匹配了单字符模式并推进 pos,剩下的=再次匹配单字符。正则库不会自动尝试最长全局匹配,它只从当前 pos 找第一条能匹配的规则。
解决:把双字符运算符的规则全部排在单字符运算符之前。规则列表的排列顺序就是词法分析器的优先级顺序,这是最容易修但最容易被忽略的一处。修完注意连同>=、<=、!=一起检查。
5.5 错误恢复缺失:一个分号缺失导致整个文件停止解析
现象:测试文件里int a = 1(漏分号),parser 抛出异常后直接终止,后续所有语句的语法树都丢了。实验要求通常是要「尽可能报出多个错误」。
原因:递归下降实现里没有错误恢复机制,expect遇错即抛。真实编译器要求在抛错后能「跳过若干 token 重新同步」,继续解析后面的语句。
解决:在expect的异常处理外层加同步逻辑。常见做法是定义synchronize_tokens集合,在捕获异常后循环消费 token 直到遇到分号、右大括号或 EOF:
def synchronize(self): sync_tokens = {"SEP_SEMI", "SEP_RBRACE", "EOF"} while self.peek()[0] not in sync_tokens: self.advance()然后在 parse_program 的 while 循环里包裹 try-except,捕获 SyntaxError 后调用 synchronize 再继续。这样单个语句错误不会中断整个文件,代价是可能产生级联的虚假报错,但课设演示时「一个文件报三处错」比「第一处就闪退」评分高得多。
5.6 测试用例单一:只测合法输入,错误路径从未执行
现象:代码包附带测试用例全是语法正确的文件,错误恢复的 except 分支在提交前从未运行过。答辩现场导师输入一个错例,parser 崩溃或输出不可读。
原因:课设代码的测试边界覆盖不足,只验证了 happy path,没验证错误路径。
解决:至少准备三类测试:合法输入验证树结构、非法输入验证错误定位能力、边界输入(空文件、只有注释的源文件、超长标识符、连续运算符)验证健壮性。我一般会在 test 目录里放一个test_tricky.c,专门写a = 1 + 2 * (3 - 1);;(双分号)、float x = 1.2.3;(非法浮点)、int 2x;(数字开头标识符)这类边角用例。跑过这些还不崩,代码才算真正立住了。
6. 给这套分析器加可观测性:可视化语法树与语义检查扩展
词法和语法分析器跑通只是编译前端的第一步,真正让课设代码能拿高分、或者让你在日后的编译工具链开发里复用,靠的是「可观测性」和「语义扩展」。我会在遗传的代码包基础上,做三个方向的小手术。
第一个方向是可视化语法树。递归下降代码生成的嵌套字典结构肉眼难读,调试中等复杂度的表达式时我经常看花眼。标准做法是生成 DOT 语言描述,交给 Graphviz 渲染成图。实现方式是遍历 AST 节点,给每个节点分配编号,再用编号建立父子关系:
def ast_to_dot(node): lines = ["digraph AST {"] counter = 0 def walk(n): nonlocal counter my_id = counter counter += 1 label = n["type"].replace("_", "\\n") if "value" in n: label += f"\\n{n['value']}" if "op" in n: label += f"\\n{n['op']}" lines.append(f' n{my_id} [label="{label}"];') for key in n: if isinstance(n[key], dict): child_id = walk(n[key]) lines.append(f" n{my_id} -> n{child_id};") return my_id walk(node) lines.append("}") return "\n".join(lines)把这段输出存成.dot文件后用dot -Tpng ast.dot -o ast.png渲染。这个技巧对调试优先级相关的 bug 特别有效,一眼就能看出2 + 3 * 4的乘法节点是否长在加法右子树里。
第二个方向是语义检查的增量实现。词法语法层完成后,很自然的下一步是类型检查:声明时把变量名和类型登记进符号表,赋值时比对类型一致性。我给符号表加一个简单的作用域栈:
class SymbolTable: def __init__(self): self.scopes = [{}] def push_scope(self): self.scopes.append({}) def pop_scope(self): self.scopes.pop() def declare(self, name, var_type): if name in self.scopes[-1]: raise TypeError(f"duplicate variable {name}") self.scopes[-1][name] = var_type def lookup(self, name): for scope in reversed(self.scopes): if name in scope: return scope[name] return None类型检查器遍历 AST,遇到 Declaration 时 declare,遇到 Assignment 时查找左值的类型再与右值字面量类型比对,不一致就报错。这类扩展的工作量不大,但能覆盖编译原理课程后半段「语义分析」的考点,答辩时讲「我在语法树之上实现了类型检查器」比只讲词法和语法深了一层。
第三个方向是错误报告的格式统一。把pylint风格的行列定位输出整合进 parser 的错误处理层,让所有警告与错误都长成line:col: message的格式。后续无论接 IDE 插件、在网页演示还是写进实验报告,都能直接被自动化工具消费。
这些年我改过的课设代码包里,真正值得留下的不是某一版 token 定义或某棵语法树,而是那套能让人快速定位问题的方法论:先看词法层的最长匹配,再看文法是否有左递归,接着给 parser 加错误恢复,最后用可视化输出验证树结构。这套东西在以后做解释器、DSL 设计、代码格式化工具时全都复用得上。希望帮到你。
本文还有配套的精品资源,点击获取