简介:一份词法分析器设计实验的完整实验报告,面向编译原理课程学习者及需要完成类似实验的本科学生。报告以C语言词法分析器为实例,涵盖实验目的、实验内容、程序清单、调试运行结果及思考题,系统展示了如何将源代码分解为标识符、保留字、运算符等词法单元,并给出SYMBOL.H、BASEDATA.H与Symbol.c等关键模块代码,便于理解词法分析程序的实现思路与调试方法。资源为1个doc文档,压缩包大小74KB,已有489人学习下载。报告来自湖北汽车工业学院实验教学场景,内容完整、格式规范,既可作为实验报告的写作模板,也能帮助读者对照检查自身词法分析器设计方案,尤其适合正在学习编译原理、需要快速把握词法分析器设计要点的学生参考借鉴。
1. 词法分析器设计实验中真正难的不是“识别”,而是“边界”
很多人做词法分析器设计实验,以为核心是把源码里的单词“认出来”,拿到键盘上敲代码时才发现:问题全出在“这一个字符到底属于谁”的边界判定上。比如ifx到底该按关键字if多出一个x报错,还是按普通标识符整体收下;数字后面紧跟字母算一个非法 token 还是两个合法 token;字符串里出现换行该不该结束;注释到什么位置才算完整。这些判断如果顺序写错,语法分析器拿到的 token 流就会“看起来对、接不上”。
这一篇顺着“实验一:词法分析器设计”该走的完整路线来:先立 Token 切分的原理和选型理由,再给一份能直接跑的 Python 表驱动实现,接着用最小样例和回归测试把行为钉死,最后收在几个只有跑大量用例才能发现的问题上。适合正在做编译原理实验的学生,也适合需要用脚本解析配置文件的工程师。
2. 词法分析器的边界在哪:Token 切分粒度、正则到自动机、三种实现选型
2.1 Token 粒度的两个常见分歧:关键字、复合运算符
词法分析器的输出必须是一个有类型、有值、有位置信息的 token 序列,而不仅仅是“把字符串切开”。第一个分歧在关键字:int应该被单独识别为关键字,还是当成一个标识符交给语法层判断?常见做法是把它当成独立 token 类型,因为后续语法规则的写法会简单很多。另一个分歧是复合运算符,比如>=、==、&&,如果把它们拆成两个独立字符,语法分析器就得自己做“两个符号拼成一个操作符”的合并,这种职责下放会造成大量重复代码。
我一般建议实验报告里明确写出 Token 的粒度划分规则:
- 关键字:完整匹配,且必须要求后面是字母、数字或下划线的终结符,否则
ifx会被误切。 - 运算符与界符:按最长匹配原则处理,
==优先于=,>=优先于>。 - 字面量:整数、浮点数、字符串分别建模,字符串要考虑转义序列。
- 注释:不产生 token,但必须被正确跳过,而且不能把注释吃掉的内容误报成错误。
这个粒度表应该在实验报告第 1 节就出现,它是后面状态表设计的直接依据。
2.2 从正则到确定有限自动机:手算状态表的三种提笔方式
词法分析器的经典构造路线是“正则表达式 → NFA → 子集构造法 → DFA → 最小化”,但实验报告里真正要写清楚的是“怎么把规则变成能编码的状态表”。常见做法有三种,不同人习惯不同:
第一种是直接画状态转换图,每个状态代表“已经读入的字符集合的某种特征”。比如数字识别会分成起始态、整数态、小数点后态、科学计数法指数态。这种方式直观,但状态一多容易漏边。
第二种是先把所有 Token 的正则表达式写出来,然后为每个正则单独画子图,最后用一个“总入口状态”合并。合并时要处理冲突,比如id和keyword的正则都匹配同一段输入,这时一般用优先级解决:先判断关键字,再做标识符识别,只是实现上统一在识别完标识符后查一次关键字表。
第三种是直接从正则构造 NFA,再计算机器可识别的转移矩阵。对实验报告来说,这种方式步骤最完整,但工作量最大。若只需要交一个可运行的实验,我会用第二种;若课程要求体现“编译原理”过程,建议交出 NFA 到 DFA 的完整推导。
下面给一个最小状态表的构造示意。假设只识别=、==和数字,状态可以按下表组织:
| 状态编号 | 含义 | 读入= | 读入数字 | 其他字符 |
|---|---|---|---|---|
| S0 | 起始态 | S1(暂存,可能是=或==) | S2(整数态) | 错误 |
| S1 | 读过= | 终态A(输出==) | 终态B(输出=回退数字) | 终态B(输出=回退该字符) |
| S2 | 整数态 | 终态C(输出整数,回退=) | S2 | 终态C(输出整数,回退该字符) |
注意 S1 遇到数字和普通字符时,不仅要输出=,还要把当前字符“退回”输入流,因为那个字符不属于这个 token。这个“回退”动作是词法分析器最容易漏掉的设计点,后面实战章还会专门讲。
2.3 表驱动、手写递归、正则库:实验场景怎么选
实现词法分析器有这三条常见路线,选型直接影响报告篇幅和后续扩展:
| 路线 | 典型工具/写法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 手写状态机 | Python/Java 循环 + 状态变量 | 可控性强、无外依赖、状态清晰 | 状态一多代码冗长 | 实验报告首选 |
| 表驱动 | 状态转移表(字典/二维数组)+ 通用循环 | 逻辑和表分离,新增 token 只加表 | 表设计初期费时 | 本次实验推荐 |
| 正则库 | Pythonre、JavaPattern | 写起来最快 | 最长匹配和歧义顺序不直观 | 实验对比、生产环境脚本 |
实验类任务多数选表驱动,因为“状态表”本身就是报告里最好展示的成果物。但要提醒一点:不要用一棵巨大的if-else树写完所有识别逻辑,那样既难测试,也没有体现出自动机思想。
2.4 状态表交互与常见误用
交互上有个高频误区:状态表只写“读到合法字符怎么办”,不写“读到不该读的字符怎么办”。一个完整的词法分析器必须为每个状态定义兜底转移,否则非法输入会直接导致数组越界或死循环。此外,终态并不一定在读到字符边界时立即返回,必须结合“最长匹配”原则选择最后进入的终态。比如===应当先输出==,再把第三个=留作下一个 token。
使用状态表时,还要区分“进入终态就返回”和“读满后再回退”。数字123abc的正确处理是先接受123,然后回退abc继续解析,而不是在a处直接报“非法字符”并丢弃整个串。这个细节实验报告里值得用一个小例子专门说明。
3. 用 Python 写一个表驱动的词法分析器:状态表、循环、错误回退
3.1 先定义 Token 结构与关键字表
动手写代码的第一步不是写识别逻辑,而是定义“一种 token 长什么样”。统一结构能减少后续语法分析器对接时的麻烦。下面这份定义包括类型、文本值、行号、列号:
from dataclasses import dataclass from enum import Enum, auto class TokenType(Enum): KEYWORD = auto() IDENTIFIER = auto() INT_CONST = auto() FLOAT_CONST = auto() STRING = auto() OP = auto() DELIMITER = auto() EOF = auto() ERROR = auto() @dataclass class Token: type: TokenType lexeme: str line: int col: int def __repr__(self): return f"{self.line}:{self.col}\t{self.type.name:<14}\t{self.lexeme}"关键字表单独成字典,便于扩展。要注意关键字匹配发生在“已经完整读出一个标识符串”之后,而不是在读到第一个字母时就判断,否则intx会在中途被打断。正确顺序是:读完整串 → 查表 → 决定是关键字还是标识符。
KEYWORDS = {"if", "else", "while", "int", "float", "return", "void"}3.2 状态表怎么组织:字典加默认值
表驱动并不要求使用二维数组。Python 里用字典嵌套最直观,外层键是当前状态,内层键是字符类别,值是一个二元组(新状态, 动作)。这里把字符归类成几个类别,而不是存储每个字符,这样表尺寸会小得多。
# 字符类别:依次为 字母/下划线、数字、=、!、<、>、/、引号、空白、其他 CATEGORY_MAP = { "letter": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_", "digit": "0123456789", "eq": "=", "noteq": "!", "lt": "<", "gt": ">", "slash": "/", "quote": '"', "space": " \t\r", }有了CATEGORY_MAP,读入一个c就通过查表转换成类别名,再把“类别名”作为状态表的第二维键。这样新增一种运算符不需要改识别循环,只需要在状态表里多插一行。
接下来定义状态和动作。状态命名上用S0、S1、S_ID、S_NUM这种可读性强的字符串,报错时也能直接打印状态名帮助定位问题。这里给出一个简化版状态表,只覆盖标识符、数字、==、=、!、!=和<、<=、>、>=:
TRANSITIONS = { ("START", "letter"): ("ID", "accumulate"), ("START", "digit"): ("NUM", "accumulate"), ("START", "eq"): ("EQ", "accumulate"), ("START", "noteq"): ("NEQ", "accumulate"), ("START", "lt"): ("LT", "accumulate"), ("START", "gt"): ("GT", "accumulate"), ("START", "quote"): ("STR", "accumulate"), ("START", "space"): ("START", "skip"), ("ID", "letter"): ("ID", "accumulate"), ("ID", "digit"): ("ID", "accumulate"), ("NUM", "digit"): ("NUM", "accumulate"), ("NUM", "letter"): ("ERROR", "error"), ("EQ", "eq"): ("START", "emit_eq_eq"), ("NEQ", "eq"): ("START", "emit_neq"), ("LT", "eq"): ("START", "emit_lte"), ("GT", "eq"): ("START", "emit_gte"), }这里每个动作都对应一个函数或一段逻辑。状态表的优势体现得很清楚:识别规则变成数据,识别循环变成对数据的解释器。后续若要增加&&、||,只需补充状态和转移,不需要动主循环。
3.3 主循环与最长匹配
核心循环按下面几条规则执行:
- 每次读一个字符,按
CATEGORY_MAP确认类别。 - 查找
(当前状态, 类别)对应的转移项。如果查不到,进入回退处理。 - 回退处理需要维护一个
last_final_state变量,记录最近一个能输出 token 的终态,以及当时读到的字符位置。 - 到达
START且需要保存返回值时,输出一个 token,继续循环;到达终态后再遇到无法转移的字符,则按最近的终态位置回退。
下面这段是主循环的大致骨架:
def tokenize(self, text: str): tokens = [] i = 0 state = "START" lexeme_start = 0 last_final_state = None last_final_pos = -1 n = len(text) while i < n: c = text[i] cat = self._categorize(c) key = (state, cat) if key in self.transitions: next_state, _ = self.transitions[key] state = next_state if state in self.final_states: last_final_state = state last_final_pos = i i += 1 else: if last_final_state is not None: lexeme = text[lexeme_start:last_final_pos + 1] token = self._make_token(last_final_state, lexeme, line, col) tokens.append(token) i = last_final_pos + 1 state = "START" lexeme_start = i last_final_state = None else: # 无终态可回退,报错 raise SyntaxError(f"line {line}: 非法字符 {c}") return tokens这段逻辑的关键在last_final_pos的更新频率:只在进入终态时记录,不到终态不更新。这样在处理==和=这类冲突时,能保证拿到最长的那个匹配。_make_token里负责查关键字表、区分整数和浮点、决定操作符类型等后续工作。注意last_final_pos保存的是“最后一个仍在终态内的字符下标”,所以截取字符串要用闭区间加一。
3.4 错误处理的三个层次
错误处理至少要有三个层次,否则实验报告会被扣掉不少分。
第一层是“非法字符”。在START状态遇到完全无法归类的字符,直接抛出带行列号的异常。这一层最简单,但能挡住大多数意外输入。
第二层是“中途死亡”。例如在标识符识别过程中进入了一个没有被定义为终态的状态,这时需要报“无效标识符”或“无效数字”,而不仅仅是“不认识的字符”。常见的例子是12abc:NUM状态遇到字母,按前面状态表定义应进入ERROR动作,此时优先输出已成功的数字 token,再把abc单独识别,而不是把整段12abc直接当作错误。
第三层是“字符串非法”。字符串内的转义序列错误(比如"\q")和未闭合的字符串,词法分析器应当给出针对性提示。未闭合字符串的常见处理是读到行尾或文件尾时报错,并返回一个ERRORtoken,避免整个文件解析中断。
4. 把实验跑起来:最小样例、行列号输出、回归测试与 3 个常见翻车点
4.1 一个覆盖全部 Token 类别的最小样例
实验报告要写清楚“这个分析器到底认得了什么”,最好的办法是给一个刻意构造的样例文件。它不需要很长,但必须覆盖所有 token 类型、运算符优先级、注释跳过和错误路径。下面是我一般会用来做冒烟测试的最小样例:
int main() { int count = 42; float ratio = 3.14; if (count >= 42 && flag != true) { count = count + 1; } return 0; } // end把这串代码喂给分析器,预期输出是:关键字、标识符、整数常量、浮点常量、括号/分号界符、=/>=/!=/&&操作符分别被识别。注释// end应当被完全跳过,不输出任何 token。这个样例能同时验证标识符与关键字之间的冲突处理,因为int和main相邻出现。
4.2 输出格式带行列号,先于语法分析解决定位问题
很多实验只输出(type, value),遇到多行代码后调试极其痛苦。词法分析器的输出应当至少包含三列:行列号、类型、原文。后续语法分析器报错时,直接引用这些行列号,能省掉大量“这一步错在哪”的猜测。
下面的_make_token里体现这个设计:
def _make_token(self, state, lexeme, line, col): if state == "ID": if lexeme in KEYWORDS: return Token(TokenType.KEYWORD, lexeme, line, col) return Token(TokenType.IDENTIFIER, lexeme, line, col) if state == "NUM": if "." in lexeme or "e" in lexeme or "E" in lexeme: return Token(TokenType.FLOAT_CONST, lexeme, line, col) return Token(TokenType.INT_CONST, lexeme, line, col) # 其余按运算符和界符处理行列号要在词法分析器里维护,而不能等到语法分析阶段再回溯源码重新数行。维护方法很简单:读入字符时遇到\n行号加一,列号归零;其他字符列号加一。这样做对单行字符串字面量也有效,因为列号总是指向当前读到的物理位置。
4.3 三个常见翻车点
翻车点一:最长匹配没实现。有些实验在状态机进入第一个终态时就立即返回,导致>=被切成>和=。修复方法就是前面写的last_final_state记录法,要让状态机“再多看一个字符”再决定回退与否。
翻车点二:EOF 时最后 token 没落盘。输入循环结束后,缓冲区里可能还残留一个完整的 token,比如文件末尾没有换行符的return 0;后面的0。主循环结束时要单独调用一次flush()逻辑,处理last_final_state或者START非空的情况。
翻车点三:回退实现成了“按字符数回退”。如果代码里用“上次读到的位置减一”来回退,遇到多字符回退(比如12abc要回退 3 个字符)就会错乱。正确做法是保存严格的下标位置,回退时直接赋值i = last_final_pos + 1,而不是按回退个数循环递减。
4.4 用断言把行为固定下来
实验验收时老师会拿不同输入进来测,如果只靠“跑一次看不出明显问题”来验收,很快会翻车。更可靠的做法是把样例输入和预期 token 序列写进断言,用 Python 的unittest或pytest跑回归。下面是一个最小断言示例:
def test_minimal_c_code(): lexer = Lexer() tokens = lexer.tokenize("int main() { return 42; }") types = [t.type for t in tokens] assert TokenType.KEYWORD in types assert TokenType.IDENTIFIER in types assert TokenType.INT_CONST in types assert all(t.line >= 1 for t in tokens)这类断言要按“类型序列完全匹配”来写更严格,但初期可以先按包含来测,等 token 类更稳定后建议改成精确序列对比。精确对比的断言会让实验报告更有说服力,因为在表格里可以直接列出“输入 → 期望 token 序列 → 实际输出”的对照,一眼能看出覆盖是否完整。
5. 词法分析器的验收技巧:用“黄金样例 + 对比”替代肉眼看输出
5.1 黄金文件与逐行 diff
一个很实用的技巧是把若干典型源码片段整理成一个“黄金文件”,提前运行一次确认输出完全正确,然后把这次输出作为标准答案保存下来。之后再修改代码,只要跑一次 diff,就能知道哪些改动影响到了现有行为。对实验报告来说,这个方法能证明“改动只影响新增特性,没有破坏已有功能”。
具体操作分两步。第一步,准备好golden.c输入文件和golden.txt期望输出文件;第二步,跑命令生成实际结果并对比:
python lexer.py golden.c > actual.txt diff -u golden.txt actual.txt如果 diff 没有任何输出,说明结果与预期一致。golden 文件里要故意放几个边界输入,比如连续多行空行、注释末尾没有换行、字符串里包含转义引号。把这些边界输入和正常代码放在同一个文件里,能一次性验证多类行为。
5.2 构造边界输入的思路
构造边界输入时,抓住四个方向基本就能覆盖大多数隐藏 bug。第一是“空输入”,源代码是空字符串,这时只能输出一个 EOF token,不能崩溃。第二是“只有注释”,例如一行// nothing,分析完应该没有任何实际 token。第三是“相邻运算符连续出现”,比如a=b==c! =d,这里! =中间有空格时应按两个 token 处理,! =紧连则按!=处理。第四是“长 token 跨行”,比如字符串字面量包含转义换行,或注释一直延伸到文件末尾,这最容易暴露 EOF 处理的不完整。
5.3 每次改动后的最小验收清单
改动任何状态表之后,建议按下面这个小清单快速自查:关键字后直接跟数字或字母的行为是否仍正确,==、>=、&&这类复合运算符是否保持最长匹配,非法字符报错时行列号是否指向实际出错位置,文件末尾无换行时最后一个 token 是否仍然输出。这个清单可以写进实验报告的“测试”一节,代替大段的文字描述,也方便验收老师快速理解你的测试覆盖策略。
词法分析器这类实验写到最后,拼的不是识别了多少种 token,而是边界行为有没有约束住。状态表能扩展能力,黄金样例能守住已有行为,把这两样配合好,一份实验报告的质量上限会比单纯堆代码高不少。
本文还有配套的精品资源,点击获取