简介:编译原理课程设计学习包面向高校计算机专业学生及需要完成编译原理实验、课程设计的学习者,围绕词法分析、语法分析与小型编译器实现提供一套可参考的完整材料。内容覆盖词法分析器设计、LL(1)方法分析简单语句(如 i+i*i)、LR(0)与SLR(1)语法分析、四元式生成及汇编代码生成,并附小型编译器课程设计报告,便于对照理解从源程序到中间代码、目标代码的基本流程。压缩包共14个文件,以cpp、c、h源码为主体,配套txt说明与doc实验报告,整体约557KB,结构紧凑,适合在本地编译调试和二次整理。已有2350人学习下载,读者可借此复现词法、语法分析实验,梳理LL(1)与SLR(1)文法的处理思路,并参考报告组织课设文档与实现步骤。
1. 期末两周做完一个小型编译器:这份课设资源能给你什么
期末周最难受的不是不会写,是明知道要交什么却拼不起来——编译原理课程设计要求词法分析、语法分析、中间代码、实验报告一条龙,单拎哪块都能讲两句,合到一起就跑不动。这份资源给的是一套能直接运行的小型编译器:读入一段类 C 源码,切成 Token、建语法树、生成四元式,附实验报告,从需求分析到测试用例都有。适合两类人,一类是离答辩只剩两周的,一类是想亲手摸一遍编译器前端到底长什么的。它不是糊弄人的伪代码,词法用手写状态机,语法用递归下降,中间代码带回填,留足了二次开发的下手位置。
2. 五段流水线与目录结构:从 .src 文件到四元式列表
先把整体架子摆清楚,后面看代码才不迷路。很多课设翻车不是算法写错,是文件职责没分干净,词法分析里顺手做了类型判断,语法分析里又去查符号表,最后改一处崩三处。
2.1 五段流水线:每一段只认上一段的输出
一份合格的课程设计编译器,前端分四步走,加一层驱动:
| 阶段 | 输入 | 输出 | 职责边界 |
|---|---|---|---|
| 词法分析 | 源码字符串 | Token 列表 | 只认字符,不认语法 |
| 语法分析 | Token 列表 | AST | 只认结构,不查类型 |
| 语义分析 | AST | 带类型的 AST | 查符号表、报重定义 |
| 中间代码生成 | 带类型 AST | 四元式列表 | 只做翻译,不做优化 |
| 驱动 main | 文件路径 | 各阶段 dump | 串流程、打印中间结果 |
这条线的好处是可切分验证。词法写完先拿一堆 Token 对,语法写完先打印 AST 看括号挂对没,中间代码写完再拿四元式手推一遍结果。出问题时你能一眼看出是哪一段的输出不对,而不是对着最终报错猜。
我一般会在main.py里留一个--dump参数,把每个阶段的中间结果打出来。答辩现场老师随口说"你把 Token 流打出来看看",你能立刻打出来,这分就稳了。
2.2 目录结构与文件职责
典型的课设目录不长这样才怪:
mini-compiler/ ├── main.py # 驱动:读文件、串流水线、按 --dump 打印 ├── lexer.py # 词法分析:字符流 -> Token ├── parser.py # 语法分析:Token -> AST ├── ast_nodes.py # AST 节点定义,只放数据结构 ├── semantic.py # 符号表、重定义与未定义检查 ├── codegen.py # AST -> 四元式,含回填 ├── errors.py # 统一异常:带行号列号的 CompileError ├── examples/ # 用例:demo1.src 起手,error_*.src 是反例 └── report/ # 实验报告、文法、测试记录ast_nodes.py单独拆出来是个小习惯,但很值。解析器和代码生成器都 import 它,谁也不用 import 谁,避免循环依赖——这个坑在 Python 里特别容易踩,一踩就是 ImportError 加一堆看不懂的栈。
errors.py里只放一个异常类,构造函数收msg、line、col,__str__拼成第 3 行第 12 列: 期望 ) 实际 ;。全流程只抛这一种异常,main.py里一个except CompileError就能统一收口,报错格式整齐,实验报告里也好截图。
2.3 环境与运行:三条命令跑通第一个用例
不需要任何第三方包,Python 3.8 以上,标准库够用。如果课设指定 C/C++,把同样的结构照搬过去,只是字符串处理和内存管理要自己上心,尤其 Token 的 text 记得 free。
# 1. 看版本,避免环境里只有 python2 python3 -V # 2. 不加参数时,走默认用例并打印四元式 python3 main.py examples/demo1.src # 3. 需要中间结果时,指定要 dump 的阶段 python3 main.py examples/demo1.src --dump tokens,ast,quadsdemo1.src里塞一段十几行的代码就够验证了:
int a; int b; a = 3; b = 4; if (a < b) { print a + b * 2; } else { print b - a; }跑出来的四元式大致长这样,_表示该字段不用:
000: = 3 _ a 001: = 4 _ b 002: < a b t1 003: jf t1 _ 006 004: * b 2 t2 005: + a t2 t3 006: print t3 _ _ ...对着这段手推一遍,能立刻发现优先级有没有错:b * 2排在a + t2前面,说明乘法的递归层比加法深一层。这种"看输出反推代码"的验证方式,比写十个断言都快。
2.4 数据契约:Token 与 AST 的字段定死之后
流水线能串起来的前提是相邻两段的数据结构约定死,中间不许私下加字段。Token 就三个半字段,多一个都嫌重:
from dataclasses import dataclass @dataclass class Token: kind: str # 记号类别,如 NUM / ID / KW_IF / OP_PLUS / EOF text: str # 原文,报错和实验报告里都要用 line: int # 起始行,从 1 开始 col: int # 起始列,从 1 开始kind用字符串而不用枚举,是权衡后的选择:课设规模下字符串可读性更重要,--dump tokens打出来直接能看,调试时省一半时间。真要做大再换成IntEnum,改起来也就几十行。
AST 节点同理,每个节点只放子节点引用和必要字段,不放"父节点指针"这种方便但不必要的东西——加了就要维护,维护就要出错:
class Node: pass @dataclass class Num(Node): value: int @dataclass class Var(Node): name: str @dataclass class BinOp(Node): op: str left: Node right: Node @dataclass class Assign(Node): name: str expr: Node @dataclass class If(Node): cond: Node then: list other: list @dataclass class While(Node): cond: Node body: list @dataclass class Print(Node): expr: Nodethen和other用 list 存语句序列,天然支持语句块,也省掉了单独的 Block 节点。代价是遍历时多一层循环,但这点开销换来的结构简单,值。
3. 词法分析模块:手写 DFA 把字符流切成带行号的 Token
词法分析是整个流水线里最容易写、也最容易写歪的一段。歪的地方通常集中在两处:最长匹配没做,和行列号没记对。前面那个会让你把==切成两个=,后面那个会让你的报错信息在答辩时变成笑话。
3.1 状态设计:四种状态够用了
手写 DFA 不需要真的画状态转移表,用 if-else 分支表达等价的状态迁移就行,关键是心里要有那张图:
| 状态 | 吃进什么 | 迁移到 | 产出 |
|---|---|---|---|
| 起始 | 空白 | 起始 | 无 |
| 起始 | 数字 | 数字态 | 无 |
| 起始 | 字母或下划线 | 标识符态 | 无 |
| 数字态 | 数字 | 数字态 | 无 |
| 数字态 | 非数字 | 起始 | NUM |
| 标识符态 | 字母数字下划线 | 标识符态 | 无 |
| 标识符态 | 其他 | 起始 | ID 或关键字 |
这张表就是全部逻辑。别上来就写正则,课设要的是你能讲清状态怎么迁移,re.findall一行解决战斗但答辩问一句"你这个 DFA 有几个状态"就答不上来。
3.2 扫描主循环:一个 while 加三个分发
主循环只负责"看一眼当前字符属于哪类,交给对应的子过程处理",不掺业务逻辑:
KEYWORDS = {"if", "else", "while", "int", "return", "print"} class Lexer: def __init__(self, src: str): self.src = src self.i = 0 self.line = 1 self.col = 1 self.tokens = [] def _advance(self) -> str: """吃进一个字符,同时维护行列号。所有前进都必须走这里。""" ch = self.src[self.i] self.i += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch def _peek(self, k: int = 0) -> str: """只看不吃,越界返回空串,省掉一堆边界判断。""" j = self.i + k return self.src[j] if j < len(self.src) else "" def tokenize(self) -> list: while self.i < len(self.src): ch = self._peek() if ch in " \t\r\n": self._advance() # 空白直接吞掉,不产 Token elif ch.isdigit(): self.tokens.append(self._number()) elif ch.isalpha() or ch == "_": self.tokens.append(self._ident()) elif ch in "+-*/(){};,=<>!": self.tokens.append(self._operator()) else: raise CompileError(f"非法字符 {ch!r}", self.line, self.col) self.tokens.append(Token("EOF", "", self.line, self.col)) return self.tokens几个细节值得说:
_advance是所有字符前进的唯一入口,行号列号只在这里改。新手常犯的错是在_number里直接self.i += 1,结果数字后面的换行没被算进行号,报错就偏一行。
_peek越界返回空串,这样上层写while self._peek().isdigit()时不用每次判断下标,空串的isdigit()是 False,循环自然停。
末尾一定补一个EOFToken。语法分析里凡是遇到"看到文件结尾还不知道该干嘛"的情况,靠这个哨兵统一处理,比到处判self.i < len(...)干净得多。
3.3 最长匹配与关键字表:==不能切成两个=
运算符这块最容易图省事写成单字符,然后if (a == b)直接报语法错。正确做法是先看两个字符,命中双字符运算符再消费第二个:
def _operator(self) -> Token: line, col = self.line, self.col ch = self._advance() pair = ch + self._peek() if pair in ("==", "!=", "<=", ">="): self._advance() return Token("OP_" + pair, pair, line, col) return Token("OP_" + ch, ch, line, col) def _number(self) -> Token: line, col = self.line, self.col buf = [] while self._peek().isdigit(): buf.append(self._advance()) return Token("NUM", "".join(buf), line, col) def _ident(self) -> Token: line, col = self.line, self.col buf = [] while self._peek().isalnum() or self._peek() == "_": buf.append(self._advance()) text = "".join(buf) # 先按标识符扫完,再查关键字表,天然满足最长匹配 kind = "KW_" + text.upper() if text in KEYWORDS else "ID" return Token(kind, text, line, col)关键字的处理方式很值得讲一句:不要一开始就按关键字前缀去匹配,而是先把整个标识符扫出来,再查表。ifx这种标识符如果按前缀匹配就会被切成if+x,查表法天然不会错。这就是"最大匹配"原则最简单的落地形式。
_number现在只认整数。要支持小数,加一个"见过点没有"的布尔标志,在循环里允许消费一次.,再把int(text)换成float(text);要支持十六进制,在开头判断0x。改动都在十行以内,实验报告里"扩展性"那一节正好写这个。
3.4 错误定位:行号列号是给答辩老师看的
CompileError的构造函数收三个参数,__str__输出成人类能读的格式:
class CompileError(Exception): def __init__(self, msg: str, line: int, col: int): self.msg, self.line, self.col = msg, line, col def __str__(self): return f"第 {self.line} 行第 {self.col} 列: {self.msg}"main.py里统一捕获,打印后sys.exit(1),不吐栈。实验报告的"错误处理"章节可以直接贴这条报错,比贴一坨 Traceback 有说服力。
提示:列号用 1 开始而不是 0 开始。虽然内部下标是 0 起,但面向人的输出统一 1 起,别让老师数着数着发现差一位。
写到这里,词法这一段其实就完整了。跑--dump tokens,输入a = 3 + 4 * 2;,应该看到ID(a) OP_= NUM(3) OP_+ NUM(4) OP_* NUM(2) OP_; EOF,顺序和数量都对上,再往下走语法。
4. 语法分析与中间代码:递归下降建 AST,回填法出四元式
语法分析是课设的重头戏,也是实验报告里篇幅最大的一章。它的核心工作只有一句话:把线性的 Token 序列变成有层次的树。中间代码生成则是把这棵树再压平成四元式。两件事放一章讲,是因为它们共用同一套递归遍历的节奏。
4.1 文法改造:先把左递归消掉再动手
课本上的表达式文法通常长这样:
E -> E + T | T T -> T * F | F F -> ( E ) | id | num这是左递归,递归下降直接照着写会无限递归——parse_E()一进来就调parse_E(),栈直接爆。必须改写成右递归形式:
E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id | num但改写之后有个副作用:结合性反了。1 - 2 - 3按右递归解析会变成1 - (2 - 3),算出来是 2 而不是 -4。这是课设里最经典的翻车点之一,答辩时老师随口给个减法是左结合的例子就能问住。
我一般不真的按 E' 写,而是用循环表达左递归:
E -> T { (+|-) T } T -> F { (*|/) F } F -> ( E ) | id | num循环天然左结合,每绕一圈就把左边的结果当成新的左操作数。代码也更短。
4.2 表达式解析:三层递归,一层管一个优先级
按上面的文法,表达式解析拆成三个方法,逐层下降:
class Parser: def __init__(self, tokens): self.toks = tokens self.p = 0 def peek(self, k: int = 0) -> Token: return self.toks[min(self.p + k, len(self.toks) - 1)] def eat(self, kind: str = None) -> Token: tok = self.peek() if kind and tok.kind != kind: raise CompileError(f"期望 {kind},实际 {tok.kind}({tok.text})", tok.line, tok.col) self.p += 1 return tok # expr := add def parse_expr(self) -> Node: return self.parse_add() # add := mul { (+|-) mul } def parse_add(self) -> Node: node = self.parse_mul() while self.peek().kind in ("OP_+", "OP_-"): op = self.eat().text node = BinOp(op, node, self.parse_mul()) # 左结合:新节点挂左边 return node # mul := atom { (*|/) atom } def parse_mul(self) -> Node: node = self.parse_atom() while self.peek().kind in ("OP_*", "OP_/"): op = self.eat().text node = BinOp(op, node, self.parse_atom()) return node # atom := num | id | ( expr ) def parse_atom(self) -> Node: tok = self.peek() if tok.kind == "NUM": self.eat() return Num(int(tok.text)) if tok.kind == "ID": self.eat() return Var(tok.text) if tok.kind == "OP_(": self.eat() node = self.parse_expr() self.eat("OP_)") return node raise CompileError(f"表达式里出现意外的 {tok.kind}({tok.text})", tok.line, tok.col)三个方法的调用关系就是优先级顺序:parse_add里的操作数来自parse_mul,所以乘法永远比加法先结合成子树。node = BinOp(op, node, ...)这行把已有子树放左边,是左结合的实现关键;写成BinOp(op, ..., node)就变右结合了,一个字的位置差别。
eat(kind)带默认参数,不传就"随便吃一个",用在已知当前 Token 类型、只想前进的场景。传了就做强校验,不匹配直接抛带位置的异常。这种"校验前移"的写法,能让报错停在最接近出错点的地方,而不是等三层递归返回后才报一个笼统的错。
4.3 语句解析:用首 Token 做分发
语句层用最朴素的预测分析——看一眼当前 Token 是什么类型,就知道该走哪个分支,因为每条语句的首记号都不同:
# program := stmt* def parse_program(self) -> list: stmts = [] while self.peek().kind != "EOF": stmts.append(self.parse_stmt()) return stmts def parse_stmt(self) -> Node: tok = self.peek() if tok.kind == "KW_INT": # int a = 3; self.eat() name = self.eat("ID").text self.eat("OP_=") return Assign(name, self.parse_expr()) if tok.kind == "KW_IF": # if (c) {...} else {...} self.eat() self.eat("OP_(") cond = self.parse_expr() self.eat("OP_)") then = self.parse_block() other = [] if self.peek().kind == "KW_ELSE": self.eat() other = self.parse_block() return If(cond, then, other) if tok.kind == "KW_WHILE": # while (c) {...} self.eat() self.eat("OP_(") cond = self.parse_expr() self.eat("OP_)") return While(cond, self.parse_block()) if tok.kind == "KW_PRINT": # print e; self.eat() expr = self.parse_expr() self.eat("OP_;") return Print(expr) if tok.kind == "ID": # a = e; name = self.eat().text self.eat("OP_=") expr = self.parse_expr() self.eat("OP_;") return Assign(name, expr) raise CompileError(f"不能作为语句开头: {tok.kind}({tok.text})", tok.line, tok.col) def parse_block(self) -> list: self.eat("OP_{") body = [] while self.peek().kind != "OP_}": body.append(self.parse_stmt()) self.eat("OP_}") return bodyparse_stmt里if tok.kind == "ID"那一支要放在最后判断,因为它最宽泛。如果把 ID 判断写到最前面,int这种关键字 Token 类型是KW_INT不会命中,倒也不会错,但逻辑上不够清晰。分发顺序按"越具体越靠前"排,是让代码自己说明意图。
parse_block里while self.peek().kind != "OP_}"这个循环没有 EOF 保护,如果源码少了个右花括号,会一直读到 EOF 然后eat("OP_}")抛错。报错位置在文件末尾,不够精准但也不会死循环。要更友好一点,循环条件里加个 EOF 判断,抛"缺少 }"。
4.4 四元式生成:回填把待定的跳转目标补上
中间代码用四元式(op, arg1, arg2, result)。表达式部分的生成是纯递归,把每个子表达式算成一个临时变量名:
class CodeGen: def __init__(self): self.quads = [] self.tmp = 0 self.label = 0 def new_tmp(self) -> str: self.tmp += 1 return f"t{self.tmp}" def emit(self, op: str, a1, a2, res) -> int: self.quads.append([op, a1, a2, res]) return len(self.quads) - 1 # 返回下标,方便回填 def gen_expr(self, node) -> str: if isinstance(node, Num): return str(node.value) if isinstance(node, Var): return node.name if isinstance(node, BinOp): l = self.gen_expr(node.left) r = self.gen_expr(node.right) t = self.new_tmp() self.emit(node.op, l, r, t) return t raise TypeError(f"未知表达式节点 {type(node)}")emit返回刚生成的那条四元式的下标,是整个回填机制的基础。有了这个下标,后面发现跳转目标还没确定时,就能回到这条指令把res补上。
条件跳转的关键在于:生成if语句时,jf(条件为假则跳转)的目标位置要等到then块全部生成完才知道。所以先发一条占位的jf,记下下标,等 then 块生成完再回头填:
def gen_stmts(self, stmts: list): for s in stmts: self.gen_stmt(s) def gen_stmt(self, node): if isinstance(node, Assign): v = self.gen_expr(node.expr) self.emit("=", v, "_", node.name) elif isinstance(node, Print): v = self.gen_expr(node.expr) self.emit("print", v, "_", "_") elif isinstance(node, If): c = self.gen_expr(node.cond) jf = self.emit("jf", c, "_", None) # 假跳,目标待回填 self.gen_stmts(node.then) if node.other: j = self.emit("j", "_", "_", None) # then 结束跳过 else self.quads[jf][3] = len(self.quads) # 回填:假跳到 else 开头 self.gen_stmts(node.other) self.quads[j][3] = len(self.quads) # 回填:跳过 else 的出口 else: self.quads[jf][3] = len(self.quads) # 无 else,直接跳到结尾 elif isinstance(node, While): start = len(self.quads) # 循环条件的位置 c = self.gen_expr(node.cond) jf = self.emit("jf", c, "_", None) self.gen_stmts(node.body) self.emit("j", "_", "_", start) # 无条件跳回条件判断 self.quads[jf][3] = len(self.quads) # 条件为假跳出循环len(self.quads)在任何时刻都等于"下一条要生成的指令下标",所以它天然就是"当前位置",拿来回填再合适不过。这种技术叫回填(backpatching),课设里能把它写对,实验报告的技术含量就上去了。
while那段先记start = len(self.quads),生成的循环末尾j直接指向start,形成回边。注意j用的是无条件跳转,条件判断已经在上面用jf做过了,这里不用重复。
入口驱动很简单:
def compile_source(src: str) -> list: tokens = Lexer(src).tokenize() ast = Parser(tokens).parse_program() cg = CodeGen() cg.gen_stmts(ast) return cg.quads拿 2.3 那个 demo 跑一遍,重点检查两处:if的jf目标是else块的第一条,else块结束的j目标是整个 if 之后的第一条。这两条对上了,控制流就对了。再把while版本的用例跑一下,看最后一条j是不是跳回了条件判断的位置。手推一遍比啥都靠谱。
5. 避坑与排查:七个把课设拖到通宵的问题
这一章是本篇里最应该先看的部分。下面这七条,每一条我都在不同人的课设里见过,代价从两小时到一整晚不等。
5.1 减法算出来的结果是反的
现象:print 10 - 3 - 2;输出 9 而不是 5,四元式里能明显看到3 - 2先算。
原因:照搬课本把左递归改成了右递归形式E -> T E',改完没管结合性。右递归天然右结合,链式减法就从左往右算变成了从右往左算。
解决:别真的写 E',用while循环表达重复部分,每轮把已有子树挂到左边。3.2 节那个parse_add就是正确写法,node = BinOp(op, node, ...)里node在前就保证了左结合。改完拿10 - 3 - 2和10 / 5 / 2各跑一次,结果分别是 5 和 1,就算过了。
5.2==被切成两个等号
现象:写的if (a == b)报"表达式里出现意外的 OP_=",或者更隐蔽地,条件恒为真。
原因:词法里运算符只看了单个字符,==第一个=就产出了 Token,第二个又产出一次,语法分析拿到两个连续的赋值号直接懵。
解决:在_operator里先拼两个字符看一下,命中双字符运算符再消费第二个。注意判断顺序要先长后短,先试<=再试<,反过来永远匹配不到双字符。词法改完,把所有运算符列一遍对着--dump tokens检查:= == < <= > >= != + - * /十个都要对。
5.3 报错的行号总是偏一行
现象:明明错在第 5 行,报错说第 4 行;或者列号永远停在 1。
原因:某些分支里直接写了self.i += 1绕过_advance,换行符没被计入行号。常见于数字扫描里图快直接加下标。
解决:全项目搜self.i +=,除了_advance内部那一处,其他全删掉改成调用_advance()。再补一个自测:故意在第 3 行末尾放个非法字符@,跑一次确认报的是第 3 行。这条自测值得写进实验报告的测试记录表。
5.4 四元式里打印出None
现象:--dump quads打出来的跳转指令目标位是None,或者运行到一半死循环。
原因:回填漏了分支。最常见的是if没有else时忘了回填jf的目标,或者while的jf忘了指向循环出口。
解决:在gen_stmt结尾加一条断言,遍历self.quads,凡是op是jf或j的,检查res不为None且落在[0, len(quads)]区间内。这条断言用不了几行,但能把回填遗漏从"运行半天发现不对"提前到"生成完立刻报错"。另外注意回填的目标允许等于len(quads),也就是"跳到末尾",这不算越界。
5.5 内层变量覆盖了外层同名变量
现象:int a; { int a; a = 1; } print a;打印出 1,而按作用域规则外层a应该还是原值(或者未定义)。
原因:符号表用了一个全局 dict,进块不压栈、出块不弹栈,内层定义直接覆盖外层同名项。
解决:符号表做成栈,enter_scope()压一个空 dict,exit_scope()弹掉。查找时从栈顶往下找,命中即返回;定义时只看栈顶那一层,有同名就报"重复定义"。这条改动会牵连语义分析的所有查表调用,建议先写两个用例把行为固定下来再动代码。
5.6 输入文件读进来第一行就报非法字符
现象:源文件第一行没写错,但词法直接抛"非法字符"。
原因:文件开头带了 BOM(\ufeff),Windows 记事本保存 UTF-8 时默认会加。词法器不认识这个字符。
解决:读文件时用encoding="utf-8-sig",Python 会自动吃掉 BOM。这条不算编译原理的内容,但确实是真实会卡住人的问题,写进实验报告的"环境说明"里不亏。
5.7 答辩演示时用例跑不出结果
现象:本地跑得好好的,换台机器现场演示就报错。
原因:examples/里用了相对路径,或者注释里写了中文但文件不是 UTF-8,或者依赖了某个只在本地装过的包。
解决:入口里所有路径基于os.path.dirname(__file__)拼绝对路径;文件读取统一指定encoding="utf-8-sig";依赖只留标准库。演示前在干净环境里完整跑一遍全部用例,包括error_*.src那些反例——反例能正常报错,比正例跑通更能说明实现完整。
6. 二次开发:在骨架上加 for 循环和数组的改法与验证
课设交完只是开始,真要拿这份东西去应付后续的课程或者面试展示,得能往上加东西。这套结构的好处是每个扩展点都落在单一文件里,改动边界清楚:
| 扩展点 | 改动文件 | 大致工作量 | 验证方式 |
|---|---|---|---|
支持for循环 | parser.py、ast_nodes.py、codegen.py | 60 行左右 | 降糖成 while 后对比四元式 |
支持+=复合赋值 | lexer.py、parser.py | 20 行 | 展开成a = a + b后比对 |
| 支持一维数组 | 全部四个文件 | 150 行左右 | 下标越界与未定义各一个反例 |
支持float | lexer.py、codegen.py | 30 行 | 混合类型运算的类型提升顺序 |
| 加常量折叠优化 | codegen.py 前插一遍 | 40 行 | 四元式条数明显变少 |
最省事的第一个扩展是for,因为它不需要改中间代码生成器——在parse_stmt里把for(i=0; i<n; i=i+1)解析出来,直接构造一个While节点返回就行,后面的代码生成器根本不知道有 for 这回事:
# for (init; cond; step) body => { init; while (cond) { body; step; } } def parse_for(self) -> list: self.eat("KW_FOR") self.eat("OP_(") init = self.parse_stmt() # 复用语句解析,注意它已经吃掉了分号 cond = self.parse_expr() self.eat("OP_;") step = self.parse_stmt() # 同理,i = i + 1; self.eat("OP_)") body = self.parse_block() return [init, While(cond, body + [step])]这里有个前提:parse_stmt里赋值语句那一支已经吃掉了结尾的分号,所以parse_for里不用再eat("OP_;")。这种"复用带副作用的解析函数"省代码,但要求你对每个函数的消费边界心里有数,写之前先确认一遍。
验证这类语法糖最有效的方法是对比四元式:把for版本和手写的等价while版本各编译一次,两份四元式列表应该完全一致。不一致就说明降糖过程中语句顺序或跳转目标错了。这个方法我后来用在很多地方——凡是"语法糖展开"的改动,都拿原语写法的输出做黄金基准,比肉眼检查可靠得多。
数组的改动大一些,核心是两件事:词法里加[],语法上区分"变量"和"下标访问",代码生成里用一条[]=或=[]四元式表达。符号表要记录每个标识符的类型和长度,语义分析时检查下标访问的对象确实是数组。
至于常量折叠,在gen_expr碰到BinOp且左右都是Num时直接算完返回,不发射四元式就行。四行代码,但实验报告的"优化"章节就有了实打实的内容可以写。
从我自己的教训说一句:每次改动前,先把当前所有用例的输出存成一份基线,改完逐行 diff。我吃过一次亏,为了加个+=顺手调了_operator里的匹配顺序,结果<和<=全反了,等到答辩前两小时才发现。从那以后我每次动词法或文法,都强制先把全部用例跑一遍存基线再改,不管改动看起来多小。希望帮到你。
本文还有配套的精品资源,点击获取