简介:一份北京邮电大学计算机科学与技术专业大三上学期编译原理课内作业,完整收录词法分析、语法分析两大核心模块的设计与实现。内含可直接运行的源代码、配套文档说明、实验报告以及PPT/PDF演示材料,作业得分97分,代码均经过测试验证,适合计算机相关专业学生作为课程设计参考或答辩展示范本。压缩包体积约2.7MB,以源代码、实验报告文档、PPT演示文稿及PDF说明为主,其中源代码覆盖词法/语法分析关键流程,报告与PPT可辅助理解整体架构与设计思路。资源目前已有123人学习下载,若运行遇到问题,可私聊咨询并支持远程教学。整体来看,这份资料不仅提供可运行代码,还包含从需求分析到测试结果的完整文档链,便于读者对照学习,快速迁移到自己的编译原理项目中。
1. 编译原理课内作业做到97分:词法和语法分析这条主链要一次打通
那门课的要求表发下来时,很多同学以为“编译原理实验”就是写个能跑通老师给的三个用例的小程序。真动手才发现,词法分析、语法分析、源代码组织、文档说明、实验报告和PPT是六件事,前两件事决定你的程序能不能跑,后四件事决定你的分数上限。这篇笔记讲的是一套能直接照做的方案:先确定选型,再把词法分析和语法分析逐步落地,最后把踩过的坑和汇报材料的写法一起讲清楚。目标读者是正在做编译原理作业、被词法语法分析卡住的学生,以及想把手写编译器前端的思路迁移到别处的开发者。
2. 动手前先定方案:手写还是生成器,Python还是C
2.1 词法分析和语法分析在课内作业里的真实分工
课内作业不像工程里的完整编译器,不需要中间代码生成和优化,核心就是“把源代码变成一棵能自圆其说的树”。这条链上一前一后只有两个角色:词法分析把字符流切成Token序列,语法分析把Token序列变成语法树。很多同学把两者混在一起写,结果词法里做了语法的活,语法里又去处理字符,出了错根本不知道是哪个模块的问题。
我的建议是先画出模块边界:词法分析只输出“Token种类、值、行号、列号”,不做括号配对,也不管语句结构;语法分析只消费Token流,遇到无法消费的Token就抛带位置的语法错误。这样分工清晰,报告也好写。词法分析大约占三成工作量,但决定程序能不能跑通;语法分析占大头,语法文法和错误恢复是拿高分的关键。
2.2 手写还是Flex/Bison,用一张表做选型
常见做法有两种:手写词法状态机加递归下降分析器,或者用Flex/Bison、JFlex/CUP这类生成器。选哪个,先回答三个问题:课程是否允许你用生成器?答辩时老师会不会追问生成出来的表是什么含义?你的语法规模是几十条规则还是几百条?
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 手写词法 + 递归下降 | 逻辑全在自己手里,答辩能讲透 | 需要自己处理错误恢复和文法改造 | 课程强调原理、要求答辩 |
| Flex/Bison 生成 | LR分析表自动生成,支持大语法 | 黑匣子,表冲突时很难排查 | 语法规模大,课程允许工具链 |
| JFlex + CUP | Java生态,符号表好接 | 环境配置成本高 | 课程使用Java、要求LR分析 |
我一般倾向手写,因为课内评分里“现场提问”这一项很要命。老师问“你这个非终结符的递归调用链画一下”,如果程序是生成器生成的,你只能解释生成规则,解释不了实现细节。手写虽然慢,但一份几百行的递归下降分析器足以覆盖课内语言。
2.3 Python还是Java还是C,三个问题决定语言选型
语言选型直接影响调试效率。用C写词法分析器,每次跑都要编译,字符串处理还容易越界;用Java写,Token类和AST节点类写起来很规整,但文件结构重;用Python写,正则和列表解析让词法部分异常简洁,递归下降的代码行数也能压到最少。我的标准是:课程没限定语言时,优先选自己调试最快的那个。
如果你的语法树节点很多,Java的强类型能减少运行时错误;如果用Python,建议给Token和AST节点都用__slots__,省内存也防止手滑写错属性。至于C,除非课程强制,否则不推荐——编译原理作业的核心是算法思路,不是指针练习。行业内“java+编译原理”和“python+源代码”这两种搜索组合都很常见,说明这两门语言是写实验的主流选择。
2.4 作业目录:源代码、文档说明、实验报告、PPT各放什么
这个标题里“源代码+文档说明+实验报告+ppt+pdf”一共五类交付物,最容易翻车的是把报告写成代码的流水账。建议按下面这个结构组织,让每个文件服务一个目的:
compiler-lab/ ├── lexer/ │ ├── token.py # Token定义,含行号和列号 │ └── lexer.py # 词法分析器主逻辑 ├── parser/ │ ├── parser.py # 递归下降语法分析器 │ └── ast.py # AST节点定义 ├── tests/ │ ├── case_wrong1.txt # 语法错误用例 │ └── case_right1.txt # 正确用例 ├── docs/ │ ├── 文档说明.md # 设计文档,画模块图和调用链 │ └── 实验报告.md # 实验过程、结果、分析 └── slides/ └── 答辩.pptx # 一页架构图 + 关键代码 + 测试结果源代码放lexer和parser两个目录,测试用例独立放,文档说明和实验报告放docs。PPT单独放,内容不是代码的复制,而是“从源码到语法树”的一张主线图。很多同学把报告写了一万多字,源代码只有两百行,老师一眼就看出注水,分数反而不高。
2.5 预先定一条“可砍不可丢”的验收线
动手前先给自己的作业定验收线,这能避免做过头或做不够。我常用的一条线是:至少二十个正确用例能通过,三类错误(非法字符、语法错误、不完整代码)能被准确定位,每个模块能独立命令行运行,答辩时能推演一个Token从字符到语法树的完整旅程。这条线里没有“跑得特别快”也没有“支持几十种类型”,因为课内作业评分看重的是完整度和理解深度,不是语言特性数量。
3. 词法分析先定Token体系,再写扫描循环
3.1 Token体系与优先级:关键字先匹配还是标识符先匹配
词法分析的第一步不是写正则,而是列Token种类表。课内作业常见的Token有:关键字、标识符、整数/浮点常量、字符串常量、运算符、界符、注释。先把这个表定下来,再考虑匹配优先级。
最容易翻车的是关键字和标识符的关系。if、else、while从词法形态上和count、index完全一样,都是字母开头加字母数字。常见做法是让ID先匹配,再查关键字表决定是否改判为关键字,这样表里不用写十几条关键字正则,也不会出现“if被当成普通标识符”这种低级问题。
| Token种类 | 匹配规则 | 示例 | 优先级 |
|---|---|---|---|
| 关键字 | 先ID再查表 | if, else, while | 在ID之后判断 |
| 标识符 | 字母/下划线开头 | _count, a1 | 中 |
| 数字常量 | 整数或浮点 | 123, 3.14 | 中 |
| 运算符 | 单符号或多符号 | +, ==, <= | 中 |
| 界符 | 括号、分号等 | (, ), ; | 中 |
| 注释 | 单行或多行 | //, /* */ | 最先跳过 |
3.2 用正则表驱动的词法分析主循环,Python可直接改
下面这段代码是词法分析器的核心骨架,用正则表按顺序匹配。每个正则对应一种Token,匹配成功后推进位置、更新行号和列号:
import re class Token: __slots__ = ('kind', 'value', 'line', 'col') def __init__(self, kind, value, line, col): self.kind = kind self.value = value self.line = line self.col = col def __repr__(self): return f"{self.kind}({self.value!r}) @{self.line}:{self.col}" class Lexer: # token_spec 的顺序就是匹配优先级,MISMATCH 必须放最后 token_spec = [ ('NUMBER', r'\d+(\.\d+)?([eE][+-]?\d+)?'), ('ID', r'[A-Za-z_][A-Za-z0-9_]*'), ('ASSIGN', r'='), ('EQ', r'=='), ('NEQ', r'!='), ('PLUS', r'\+'), ('MINUS', r'-'), ('STAR', r'\*'), ('SLASH', r'/'), ('LPAREN', r'\('), ('RPAREN', r'\)'), ('SEMI', r';'), ('SKIP', r'[ \t]+'), ('NEWLINE', r'\n'), ('MISMATCH', r'.'), ] keywords = {'if', 'else', 'while', 'return', 'int'} def __init__(self, text): self.text = text self.pos = 0 self.line = 1 self.col = 1 def next_token(self): while self.pos < len(self.text): for kind, pattern in self.token_spec: m = re.match(pattern, self.text[self.pos:]) if not m: continue value = m.group(0) start_col = self.col if kind == 'NEWLINE': self.line += 1 self.col = 1 else: self.col += len(value) self.pos += len(value) if kind == 'SKIP' or kind == 'NEWLINE': break if kind == 'MISMATCH': raise SyntaxError( f"非法字符 {value!r} 第{self.line}行第{start_col}列") if kind == 'ID' and value in self.keywords: kind = value.upper() return Token(kind, value, self.line, start_col) return Token('EOF', '', self.line, self.col)逻辑说明:next_token每次从当前位置尝试所有正则,第一个匹配成功的结果胜出。SKIP和NEWLINE被单独处理,前者只跳过空格和TAB,后者负责更新行号。MISMATCH在最后兜底,匹配任意一个无法识别的字符并抛异常,异常信息带行号和列号。ID匹配后查关键字表,命中则把Token种类改成IF、ELSE这种专用类型。
这里有一个容易踩坑的细节:正则的顺序不是随意的。EQ(==)必须放在ASSIGN(=)之前,否则==会被拆成两个=。同理,数字正则如果写成\d+(\.\d*)?,遇到3.这种残缺浮点数也会被吞进去,语法分析阶段会收到一个不完整的Token。具体边界问题在避坑章节细讲。
3.3 如果课程要求手写状态机,正则只是第一步
有些课程明确要求不能使用re库,必须手写DFA。这时候思路要换:把每个正则表达式改写成一个状态转移表,或者把整个词法规则合并成一个大DFA。关键是把状态分成几类:标识符状态、整数状态、运算符状态、空白状态。
# 识别标识符的DFA转移表:状态0是起始,状态1是接受 # 表中每一项是 (当前状态, 输入字符类型) -> 下一状态 transitions = { (0, 'letter'): 1, (1, 'letter'): 1, (1, 'digit'): 1, (1, '_'): 1, }这段代码展示的是DFA表的结构:只有到达接受状态(这里是状态1)时,才把累积的字符组成一个Token。实现时要注意,接受状态有“最长匹配”的含义,不能读到下一个字符不属于该状态就立刻输出,还要看当前状态是否接受。比如标识符后面跟一个.,应该输出整个标识符,再把.留给下一轮,而不是只输出abc的前两个字符。这个细节在报告和答辩里都是加分项。
3.4 词法错误要带行号和列号,不要只给一句“syntax error”
词法阶段的错误报告,信息量直接决定调试效率。至少包含三样:错误类型、错误位置、错误内容。上面代码里MISMATCH分支抛出SyntaxError,信息是“非法字符 '#' 第3行第5列”,这在实验报告里可以直接截图作为测试证据。更讲究一点的做法是给错误分级别:词法错误和语法错误分开,因为评分标准通常分别考察。
4. 语法分析从文法改造到递归下降:LL(1)和错误恢复不能少
4.1 文法改造:消除左递归、提取公共因子
递归下降要求每个非终结符对应一个函数,而函数不能无限递归到自身开头。所以原始文法里的左递归必须先改掉。比如这个经典表达式文法:
expr -> expr + term | term term -> term * factor | factor factor -> number | id | ( expr )一个读代码的同学一眼就能看出问题:expr的第一个产生式又回到expr,直接递归下降会栈溢出。改写后的LL(1)文法需要把左递归变成右递归,并引入新的非终结符:
expr -> term expr_tail expr_tail -> '+' term expr_tail | '-' term expr_tail | ε term -> factor term_tail term_tail -> '*' factor term_tail | '/' factor term_tail | ε factor -> number | id | '(' expr ')'这里expr_tail和term_tail的出现是为了处理+和*的结合性。不过要注意,实际代码里不一定真用右递归,很多实现用while循环直接实现T + T - T的链式结构,这在语义上是等价的。报告里写右递归文法,代码里用循环,要主动在文档里注明“循环是右递归的迭代实现”,否则答辩时容易被追问。
4.2 First集和Follow集:手算一遍,错误恢复要用
即使代码不建分析表,First和Follow集也值得手算一遍。它们决定两件事:预测时该选哪个产生式,错误恢复时该跳过哪些Token。上面这个文法的部分集合如下:
FIRST(expr_tail) = { '+', '-', ε } FIRST(term_tail) = { '*', '/', ε } FOLLOW(expr) = { ')', $ } FOLLOW(expr_tail) = FOLLOW(expr)手算Follow集有一点容易错:空串产生式不会真的产生输入,但会在预测中占用LOOKAHEAD。比如expr_tail遇到)时,因为ε存在于它的First集,所以要选择空串分支并退出。这个判断在代码里就是一个if条件,写错了就会在右括号处误报语法错误。
4.3 递归下降Parser骨架:每个非终结符一个函数
下面是Parser的核心结构。每个非终结符对应一个方法,lookahead指向当前Token,match消费并前进一个Token:
from lexer import Lexer, Token class Parser: # 同步token:语法错误后跳到这些位置继续分析 sync_tokens = {'SEMI', 'RPAREN', 'EOF'} def __init__(self, text): self.lexer = Lexer(text) self.lookahead = self.lexer.next_token() def advance(self): self.lookahead = self.lexer.next_token() def match(self, kind): if self.lookahead.kind != kind: raise SyntaxError( f"期望 {kind},实际 {self.lookahead.kind} @" f"{self.lookahead.line}:{self.lookahead.col}") self.advance() # expr -> term (('+' | '-') term)* def parse_expr(self): self.parse_term() while self.lookahead.kind in ('PLUS', 'MINUS'): self.advance() self.parse_term() # term -> factor (('*' | '/') factor)* def parse_term(self): self.parse_factor() while self.lookahead.kind in ('STAR', 'SLASH'): self.advance() self.parse_factor() # factor -> NUMBER | ID | '(' expr ')' def parse_factor(self): if self.lookahead.kind in ('NUMBER', 'ID'): self.advance() elif self.lookahead.kind == 'LPAREN': self.advance() self.parse_expr() self.match('RPAREN') else: raise SyntaxError( f"无法解析的Token {self.lookahead.kind} @" f"{self.lookahead.line}:{self.lookahead.col}")参数说明:这里的while循环对应文法里的*闭包,parse_expr里的循环处理连续加减,parse_term处理连续乘除。match在Token不匹配时抛错,异常信息里带着当前Token的种类、行号和列号。没有构建AST节点,因为课内作业如果只要求“判断语法是否正确”,遍历到这里就够了;要构建语法树,只需要在每次advance之前把当前Token包装成节点并挂在返回值上。
4.4 错误恢复:panic mode与同步Token集合
课内作业的一个隐藏评分点是“一次报告多个错误”。如果程序遇到第一个语法错误就退出,只能算及格水平。常见做法是panic mode:捕获语法错误后,跳过一段输入,直到遇到可信的同步位置,再继续分析。
def synchronize(self): while self.lookahead.kind not in self.sync_tokens: self.advance() if self.lookahead.kind != 'EOF': self.advance()这里第一行循环跳过所有非同步Token,第二行判断是为了避免在EOF处反复报错。同步Token集合不能定得太大,否则错误恢复会吞掉太多有效代码;也不能太小,不然恢复不成。一般取语句结束符;、右括号)和EOF,这三类Token后面通常能开始一条新语句或一个新表达式。
4.5 语法树要不要建,看评分标准再定
有些作业要求输出语法树或打印分析过程,有些只要判断接受还是拒绝。如果需要建AST,递归下降的代码改动不大:每个parse方法返回一个节点对象,parse_expr返回二元运算节点,parse_factor返回数字节点或标识符节点。注意优先级已经在嵌套的expr/term/factor结构里体现,不需要额外处理。如果课程不要求,建了反而增加答辩被追问的深度,按需取用就好。
5. 编译原理作业最容易翻车的五个坑:现象、原因、解决
5.1 关键字被当成普通标识符
现象:语法分析器报错说“期望SEMI,实际IF”,但源代码里if写得很规范。原因:词法表的正则顺序里ID排在关键字正则之前,或匹配后没有查关键字表。解决:按前面做法,先匹配ID再查keywords集合,把if、else这类词改判为专用Token种类。注意关键字集合记得全小写,如果语言支持IF大写,需要先统一转小写再查表。
5.2 行号错位,错误总是指向文件末尾
现象:有一个非法字符在第三行,但报错信息说在第50行。原因:SKIP和NEWLINE没有分开处理,空格和换行走同一个分支,导致换行符没有被计入行号。解决:把\n单独设为一个Token种类,匹配到就line += 1并重置col。这是词法分析器里最常见的低级错误,但即便写错了也不影响正确Token的输出,所以很隐蔽。
5.3 数字后直接跟小数点,Token被切成两半
现象:源代码3.是残缺的浮点字面量,词法分析器把它识别成NUMBER(3)和一个独立的小数点Token,然后语法分析器报出难以理解的错误。原因:数字正则写成\d+(\.\d*)?,点号后面零位数字也能匹配。解决:浮点数要求小数点后面至少要有一位数字,即\d+(\.\d+)?([eE][+-]?\d+)?,这样3.会在词法阶段直接报非法字符,错误位置反而准确。这个正则看起来只差一个*和+的区别,实际效果差别很大。
5.4 错误恢复死循环,界面刷出几千行同样的错误
现象:程序卡死或同一行错误重复输出几百次。原因:synchronize()跳过了所有Token,但没有保证至少前进一步。如果当前lookahead恰好是同步Token,while循环一次不执行,然后重复调用recover,永远不推进。解决:在同步函数末尾加一次判断——如果当前位置不是EOF就主动advance()一次。这个经验是血泪换来的,跑测试的时候一度以为是死循环,实际上是同步逻辑没有强制推进。
5.5 报告写的文法和代码实现不一致
现象:实验报告里写的右递归文法,代码里却是while循环,答辩时被老师指出。原因:写报告时把教科书上的文法直接抄上去,没有对照自己的代码。解决:报告里在文法旁边加一句说明,“本实现将expr_tail的右递归用迭代循环等价改写”,并给出两个关键函数的调用链。这不算扣分点,但主动说明能避免“代码和报告对不上”的差评。
6. 把97分说清楚:验收脚本、调试开关和文档分工
验收脚本是让老师三分钟相信你代码能跑的最好方式。我不会直接在命令行一条条敲用例,而是写一个run_tests.py,遍历tests目录下的所有用例文件,统计通过数和失败数,并把失败用例的错误信息汇总输出。脚本判断结果不能只看退出码,还要让错误信息里有行号,这样报告截图才有说服力。
调试开关是答辩现场演示的隐藏加分项。建议给主程序加一个命令行参数--tokens,只跑词法分析并打印Token流;另一个参数--verbose,打印每个语法分析步骤的栈变化。演示的时候先跑--tokens展示词法正确性,再跑--verbose展示语法分析的分层过程,比直接输出“OK”两个字母直观得多。哪怕代码量加二十行,这个投入非常值得。
文档说明、实验报告和PPT三者分工要清晰:文档说明写模块划分和函数调用链;实验报告写测试用例、错误恢复效果和与预期结果的对比;PPT放一页架构图、一段关键代码和两个测试截图。答辩时先讲整体流程,再说一个具体Token从字符到语法树的旅程,比如a + 3 * (b - 1),这样从头到尾串一遍,远比堆砌术语有说服力。我养成的习惯是,每次交作业前都找一个没写过代码的同学来读文档,他读不懂的地方,就是评分老师会怀疑的地方。这招每学期都帮我避掉了很多扣分。希望帮到你。
本文还有配套的精品资源,点击获取