简介:一份面向编译原理课程的词法分析器设计实验报告,适合正在学习词法分析原理、需要完成类似实验作业的高校学生。报告完整呈现了实验目的、内容与要求,并围绕给定的C语言源程序,详细阐述了标识符、数字、运算符、分隔符、保留字等标记的识别逻辑,覆盖从符号种别码定义、取字符与跳过空白、到单词输出与错误处理的全过程。资源包内含1个doc文档,容量约74KB,文档中给出了两个头文件及一个源文件的核心代码片段,并附有调试过程、运行结果和实验收获体会,且对“如何识别以下划线开头的标识符”等思考题给出了改造方向,兼具实验代码参考与课程报告模板价值。目前已有490人学习,适合在编写词法分析程序、整理实验报告或复习编译原理知识点时参考。
1. 词法分析器实验:从一段源码到一串 Token,难的不是识别而是边界
词法分析器是编译原理课里第一个真正动手的模块,也是很多同学第一份“看起来跑通了、换个用例就崩”的代码。它的任务一句话就能说清:把源码字符串拆成 Token(关键字、标识符、数字、字符串、运算符),但真正动笔时你会发现,教材上的一页状态转换图远不够用,字符串里的转义字符、数字里的指数标记、&&能不能被正确合并,这些边角才是决定实验报告能不能拿高分的地方。这篇笔记打算按我实际做这个实验的顺序来写:先把 Token 规则设计讲透,再给一份能直接跑的 Python 实现,最后把最容易翻车的几个边界场景列出来。适合正在写词法分析器实验报告的人,也适合要快速搭一个词法扫描器做项目预研的开发者。
2. Token 规则设计:先定分类表,再画状态转换图
2.1 先把 Token 分类表定下来:粒度不同,后面代码完全不同
写词法分析器之前,脑子里必须有张表:你这个语言里到底有哪几类 Token,每类的合法字符范围是什么。很多人一上来就写代码,结果到运算符那一步才发现>=和>的处理逻辑搅在一起,返工成本很高。
我一般会在实验报告开头放一张 Token 分类表,同时也作为编码时的规格说明:
| Token 类型 | 匹配规则示例 | 要不要报告里写明 |
|---|---|---|
| 关键字 | if、else、while、return、int、float | 是,说明“先按标识符识别再查表” |
| 标识符 | 字母或下划线开头,后接字母、数字、下划线 | 是,给出正则表达式 |
| 整数常量 | 十进制数字串 | 是,说明不支持八进制/十六进制时要写明 |
| 浮点常量 | 整数部分 + 小数点 + 小数部分,可带指数 | 是,1.和.5是否合法必须定义清楚 |
| 字符串常量 | 双引号括起,内部支持转义 | 是,说明转义字符如何处理 |
| 运算符 | 单字符和双字符组合 | 是,列出全部两字符运算符 |
| 界符 | 分号、逗号、括号等 | 可以并入运算符,也可以单独分类 |
这张表的作用不只是给人看,它直接决定了状态转换图里你要画几个终止状态。比如我把逗号和分号归入界符,那在代码里就要单独留一个分支;如果把它们和运算符混在一起,判断逻辑能省一点,但实验报告里会被追问“为什么界符和运算符不区分”。我的建议是:界符单独分一类,理由写“为语法分析阶段提供明确分隔信息”,既好写报告又清晰。
关键字和标识符的区分是这里最常见的错误设计。很多初学者会先列出所有关键字,然后逐个去看当前位置的字符串是不是if、else。这看起来直接,但会导致一个经典问题:输入ifx时,如果先匹配到if就直接返回 KEYWORD,剩下的x会被当成下一个标识符。正确做法永远是把这一串字符按标识符规则完整读完,再查关键字表。这样ifx一定是 IDENT,不会拆出个假的 KEYWORD。
2.2 从正则到状态机:标识符、数字、字符串三条识别路径
确定分类表之后,下一步是把每个规则翻译成状态转换图。这一步看起来像在“画图”,其实是把正则表达式逐字符展开,确保代码里每个分支都有明确的进入和退出条件。
以标识符为例,正则表达式是[A-Za-z_][A-Za-z0-9_]*。对应状态机只有两个状态:起始状态读第一个字符,只要它是字母或下划线就进入“标识符中”状态;后续每读一个字符,如果是字母、数字或下划线就继续留在该状态,否则就回退并输出这个标识符。等等,这里有个“回退”概念:当前字符不合法时,它并不属于这个 Token,得留给下一次识别。所以就需要预读和退回机制,这也是后面实现里最重要的设计点。
数字的状态转换图比标识符更繁琐。我的规格是:先读连续数字作为整数部分;如果下一位是小写或大写e,再处理指数部分,指数里允许一个正负号;如果遇到小数点且下一位是数字,则消费小数点并继续读小数部分。这里每个判断都要考虑“下一位是不是合法字符”,因为123abc里的a不属于这个数字 Token,它得被后续当作标识符开头来处理。
字符串常量的状态转换也不难,但比数字更考验耐心:进入字符串状态后一直读,直到遇到双引号为止;如果遇到反斜杠,则跳过下一个字符继续读。这样"a\"b"中间的转义双引号不会提前结束字符串。画状态图时这一个“跳过下一字符”的自环很不起眼,但代码里漏掉它,字符串识别必挂。
2.3 表驱动还是直接编码:选型看规则量,不看代码风格
词法分析器的实现方式主要有两种:一种是把状态转换图编码成二维表,行是当前状态,列是输入字符类型,表项是下一个状态,典型代表是 flex 生成的代码;另一种是直接用 if / switch 写扫描循环,每个状态对应一个分支或一个方法。
我个人的经验:如果实验要求实现的 Token 类型只有十种以内,直接编码远好于表驱动。原因是实验报告的核心得分点在“你能否清楚解释每个分支为什么这样写”,直接编码时代码和状态转换图一一对应,老师问起来你能指着一行代码说“这是数字状态的小数点转移”。表驱动的优势在处理几百种 Token 的大型语言时才能体现出来,那个场景下你会愿意维护一张 50 行 50 列的状态表,但为了一个课程实验去写表驱动,生成表的脚本比词法分析器本身还长,纯属给自己加戏。
如果用的是类似 flex 的工具生成,那又是另一套思路:写正则描述规则,工具生成 DFA。但实验报告的题目是“词法分析器设计”,我倾向于建议至少手写一遍核心扫描逻辑,哪怕写完再拿 flex 对照。原因很简单:手写过一遍,你才知道报错信息里的“不可识别的字符”是在哪个分支产生的,也才能在报告里写出“非法字符处理策略”这一小节。
3. 手写一个词法分析器:完整可跑的 Python 实现
3.1 核心数据结构:Token 类型、关键字表和行列号追踪
下面这份实现是我按最小可用原则写的,没有用任何第三方库,Python 3.6+ 直接跑。先定义 Token 类型和 Token 数据结构:
from dataclasses import dataclass from enum import Enum, auto from typing import List, Tuple class TokenType(Enum): KEYWORD = auto() # 关键字:if、else、while 等 IDENT = auto() # 标识符:变量名、函数名 NUMBER = auto() # 数字常量:支持整数和浮点 STRING = auto() # 字符串常量:支持转义字符 OP = auto() # 运算符:=、==、!=、&& 等 SEP = auto() # 界符:; , ( ) { } EOF = auto() # 结束标记 ERROR = auto() # 非法字符或未闭合字符串 @dataclass class Token: type: TokenType value: str line: int col: intTokenType 枚举里我单独列了 ERROR,因为实验报告里需要处理非法输入,不能只返回一个“失败”就结束。Token 上记录行列号而不是只记录字符串,原因是语法分析阶段报错时需要精确指出位置,你在这里省掉,后面拿到别的同学的测试用例做对比时会非常痛苦。
然后定义 Lexer 类和基础方法:
class Lexer: KEYWORDS = {"if", "else", "while", "return", "int", "float", "char", "void"} # 两字符运算符必须在单字符前判断,否则 == 会被拆成两个 = MULTI_OPS = ("==", "!=", "<=", ">=", "&&", "||") def __init__(self, source: str): self.source = source self.pos = 0 # 当前读取位置 self.line = 1 # 当前行号 self.col = 1 # 当前列号 self.errors = [] # 收集错误而不是立即抛出 def peek(self, offset: int = 0) -> str: """查看当前字符,offset=1 时预读后一个字符;越界返回空串""" idx = self.pos + offset if idx >= len(self.source): return "" return self.source[idx] def advance(self) -> str: """消费当前字符并更新行/列号""" ch = self.source[self.pos] self.pos += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch def skip_whitespace(self) -> None: """跳过空白字符,包括换行、制表符、回车和空格""" while self.peek() and self.peek() in " \t\r\n": self.advance()peek 和 advance 是整个实现的基础。peek 负责预读,advance 负责消费并维护行列号。这里有个关键参数 offset:识别双字符运算符时,需要先看self.peek() + self.peek(1)这两个字符组成的字符串是否在两字符运算符表里,如果没有,才退回单字符处理。
3.2 扫描循环:按首字符分发到不同的识别方法
主扫描方法next_token()的逻辑是:先跳空白,再看当前位置的字符属于哪一类,分发给对应的私有方法。这样主流程非常短,每个识别方法只负责一种 Token:
def next_token(self) -> Token: self.skip_whitespace() if self.pos >= len(self.source): return Token(TokenType.EOF, "", self.line, self.col) start_line, start_col = self.line, self.col ch = self.peek() if ch.isalpha() or ch == "_": return self._scan_identifier(start_line, start_col) if ch.isdigit(): return self._scan_number(start_line, start_col) if ch == '"': return self._scan_string(start_line, start_col) return self._scan_operator(start_line, start_col)注意最后一个 return 前没有检查 ch 是否合法,这个合法性判断放到了_scan_operator里。这种做法看起来有点“宽松”,但好处是让主分发逻辑保持简单,非法字符统一在运算符分支里处理。
下面是三个识别方法。先看标识符和数字:
def _scan_identifier(self, line: int, col: int) -> Token: chars = [] while self.peek() and (self.peek().isalnum() or self.peek() == "_"): chars.append(self.advance()) value = "".join(chars) if value in self.KEYWORDS: return Token(TokenType.KEYWORD, value, line, col) return Token(TokenType.IDENT, value, line, col) def _scan_number(self, line: int, col: int) -> Token: chars = [] # 整数部分 while self.peek().isdigit(): chars.append(self.advance()) # 小数点:必须后面跟数字才消费,否则 123.abc 里的点属于下一个 Token if self.peek() == "." and self.peek(1).isdigit(): chars.append(self.advance()) while self.peek().isdigit(): chars.append(self.advance()) # 指数部分 if self.peek() in ("e", "E"): chars.append(self.advance()) if self.peek() in ("+", "-"): chars.append(self.advance()) while self.peek().isdigit(): chars.append(self.advance()) return Token(TokenType.NUMBER, "".join(chars), line, col)标识符这儿的“先读完整再查表”就是前面说的关键决策。数字里面有个细节值得写进实验报告:为什么self.peek() == "." and self.peek(1).isdigit()两个条件缺一不可?因为输入123.45时点号必须属于这个数字 Token,但输入1..2时第一个点后面是另一个点,它就不能属于前一个数字。如果不加这个判断,1..2会被识别成“数字1.加另一个小数.2”,这种模糊性会在语法分析阶段变成极难排查的错误。
再看字符串和运算符:
def _scan_string(self, line: int, col: int) -> Token: self.advance() # 消费开头的双引号 chars = ['"'] while True: ch = self.peek() if ch == "": self.errors.append((line, col, "未闭合的字符串常量")) return Token(TokenType.ERROR, "".join(chars), line, col) chars.append(self.advance()) if ch == "\\": # 转义字符:反斜杠后的任何字符都直接并入字符串 chars.append(self.advance()) continue if ch == '"': break return Token(TokenType.STRING, "".join(chars), line, col) def _scan_operator(self, line: int, col: int) -> Token: two = self.peek() + self.peek(1) if two in self.MULTI_OPS: self.advance() self.advance() return Token(TokenType.OP, two, line, col) ch = self.advance() if ch in "+-*/%=<>!&|;:.,()[]{}": return Token(TokenType.OP, ch, line, col) self.errors.append((line, col, f"非法字符 {ch!r}")) return Token(TokenType.ERROR, ch, line, col)字符串扫描里处理转义时,if ch == "\\"后面直接再 advance 一次,这个动作把反斜杠后面那个字符“吞掉”了。比如\"会被整体并入字符串,内部的"就不会触发结束条件。但注意这个写法有个边界:如果字符串以反斜杠结尾,比如"abc\,循环里反斜杠分支会再 advance 一次,此时已经到文件末尾,peek()返回空串并写入空字符串,最终会因下次循环ch == ""判定为未闭合。这个行为是对的,但实验报告里最好写明白“反斜杠不能出现在字符串末尾”。
最后补上完整遍历方法,让外部直接调用:
def tokenize(self) -> Tuple[List[Token], List[Tuple[int, int, str]]]: tokens = [] while True: tok = self.next_token() tokens.append(tok) if tok.type == TokenType.EOF: break return tokens, self.errors3.3 跑一个最小例子:一行输入验证全流程
拿一段最简单的源码int x = 123;来测,只要调用:
lexer = Lexer('int x = 123;') tokens, errors = lexer.tokenize() for tok in tokens: print(tok)输出顺序应该是:KEYWORD int、IDENT x、OP =、NUMBER 123、SEP ;。这里有个细节容易漏:;在我定义的_scan_operator里会被识别成 OP 而不是 SEP,因为我没有单独处理界符。前面 Token 分类表里定了 SEP 类型,但代码没直接用,说明分类表和实现还没完全对齐——这种不一致在实验报告里会被老师一眼看出。
解决方法有两个:要么在_scan_operator里把; , ( ) { }单独抽出来返回 SEP,要么删掉 SEP 类型,全部归入 OP。我实际做的时候选择保留 SEP,因为语法分析阶段分号和括号经常需要特殊处理。修改方式是把_scan_operator里的判断拆开:
if ch in ";,(){}": return Token(TokenType.SEP, ch, line, col)这个问题也提醒一件事:实验报告里的表格和代码必须一一对应,不能表格里写一套、代码里做另一套。
4. 词法分析器避坑:五个最容易翻车的边界场景
4.1 字符串里的转义字符让分析器提前“收工”
现象:输入"say \"hello\"",词法分析器在第一个转义双引号处就把字符串结束了,剩下的hello""被当成标识符或其他 Token,报出一串莫名其妙的错误。
原因:字符串识别逻辑只判断“遇到双引号就结束”,没有处理反斜杠转义。转义字符本质上是一种“吞掉下一个字符”的机制,反斜杠本身并不属于字符串内容,它只是告诉扫描器下一个字符不要按字面语义处理。
解决:在扫描字符串的循环里加一个判断,遇到反斜杠时跳过下一个字符。我上面代码里已经写了这个分支,但验证时必须专门测四组:"abc"、"a\"b"、"a\\b"、"abc\。前三个都应该正常结束,最后一个应该报未闭合错误。很多人只测了第一组,转义分支的代码根本没有被执行过。
4.2 行号列号统计错位:换行符被吞导致定位失效
现象:报错时提示的line: 5, col: 3,但源码里那个位置明明是对不上的,语法分析阶段按错误行号去查源码,找到的是另一个表达式。
原因:advance 方法里更新行列号的逻辑没有和“跳过空白”的操作协同。比如跳过空白时如果同时跳过了换行,就应该把列号重置为 1,但如果代码在跳过空白时逐字符调用 advance,逻辑是对的;问题往往出在有人为了“效率”直接按行切分源码,split("\n")后每行单独扫描,结果跨行的字符串常量直接被截断,行号自然对不上。
解决:不用按行切分,整个源码字符串从头扫到尾,行列号的更新只集中在 advance 方法里。按行切分的做法在词法分析里是典型的反模式,因为字符串常量、注释都可以跨行,切行会强制你写一堆跨行判断来绕过自己制造的问题。我踩过一次这个坑后,就再也没按行切过源码。
4.3 最长匹配没做对:&&被拆成两个&之后表达式判断全乱
现象:输入a && b,输出 Token 序列是a & & b,语法分析器在这里直接语法报错,而肉眼检查源代码根本看不出问题。
原因:运算符识别时先匹配了单字符&,没有在返回前检查下一位是否还能组成更长的运算符。这在词法分析里叫“最长匹配原则”:只要后面还能继续组成合法 Token,就必须继续读,不能提前结束。&和&&是两个完全不同的 Token,语义差别就跟&和短路逻辑的关系一样大。
解决:先查两字符运算符表,确认当前位置后两字符不在表里,才做单字符处理。我的代码里_scan_operator开头两行就是干这个的。另外要注意,+和+=、-和--也是同样的陷阱,测试用例里要覆盖每一个两字符运算符,不能只测一个。
提示:上一节实现里的
MULTI_OPS只列了六组,实际做实验时按语言定义全部列全,比如+=、-=、*=、/=等。
4.4 错误恢复太粗暴:一个坏字符引发连锁误报
现象:源码里只有一个非法字符@,但输出错误列表里有七八条,后面的合法 Token 也被识别成了 ERROR。
原因:词法分析器遇到非法字符时直接advance()跳过它,本来没问题。但如果跳过时没有继续把后续内容重新扫一遍,而是简单地把当前位置标记为错误状态并返回,主循环里可能就一直卡在同一位置,或者把后面一大段字符串当成非法 Token 整体返回。
解决:错误恢复策略按“吃掉坏字符、继续扫描”做。我的代码里遇到非法字符时 advance 掉并返回一个 ERROR Token,主循环继续走向下一个位置。这样一条坏输入只会产生一个错误,后续 Token 不受影响。实验报告里把这个策略写清楚,老师会认为你有实际工程意识,不只是课本照搬。
4.5 性能误判:逐字符读取文件让大文件扫描慢到怀疑人生
现象:源代码文件就几百 KB,词法分析跑了十几秒,你以为是算法太复杂,实际是 IO 在拖后腿。
原因:如果在扫描过程中每次需要新字符时都从磁盘读一个字符,系统调用开销会把性能彻底拖垮。逐字符 IO 在小文件上感觉不到,文件一大就暴露。
解决:一次性把整个源码读进内存,然后全部在字符串上进行操作。Python 里open(path).read()读一个几 MB 的文件毫无压力,词法分析器的内存占用也不会因此明显增高。再进一步,可以使用io.StringIO包装字符串以统一接口,但对这个实验来说纯字符串操作就够了。我自己的项目里处理几十 MB 的词法分析任务时,瓶颈根本不在词法扫描,而在后面语法分析,这从侧面说明字符串预读方案完全够用。
5. 让词法分析器有说服力:Token 流输出、回归测试与可视化校验
5.1 把 Token 流输出成 JSON,报告和调试都好用
实验报告里贴一堆Token(type=IDENT, value='x')很难看,判读也费劲。我一般会让 tokenize 方法返回既定的结构,再写一个简单的序列化函数,把 Token 列表转成 JSON 数组:
import json def tokens_to_json(tokens): return json.dumps( [ {"type": t.type.name, "value": t.value, "line": t.line, "col": t.col} for t in tokens ], indent=2, ensure_ascii=False, ) print(tokens_to_json(tokens))输出效果是每行一个 Token 对象,字段名清楚,测试时可以直接和期望文件做 diff。实际项目里这个词法分析器如果还要对接语法分析器,JSON 格式做中间交换格式也很好用,调起来比直接处理 Python 对象更直观。注意 JSON 里的 line 和 col 是 Token 的起始位置,不是结束位置,这个约定要在报告里写明。
5.2 做一个 golden 回归测试:改规则不再靠肉眼扫
我最后会养成的习惯是:每次改动词法规则后,不手动看结果,而是把一组测试输入和对应期望输出固化下来,跑一个全量比对。这个做法叫 golden test,实现成本很低:
def test_lexer(snippet: str, expected: list) -> None: lexer = Lexer(snippet) tokens, errors = lexer.tokenize() actual = [(t.type.name, t.value) for t in tokens if t.type != TokenType.EOF] assert actual == expected, f"\n期望: {expected}\n实际: {actual}" assert not errors, f"存在词法错误: {errors}"把前面提到的边界用例全部写成断言:字符串转义、浮点数指数、双字符运算符、非法字符、未闭合字符串。之后每次改代码,跑一遍测试文件,过不了就说明新改动引入了回归问题。这比每次手动敲几行输入去验证强得多,尤其是实验后期你要调整关键字表或运算符表的时候。
5.3 校验状态转换图与实际代码的一致性
很多人写完词法分析器就不管状态转换图了,但实验报告里这张图是灵魂。我的习惯是把每个 Token 类型的识别路径在代码注释里标注出来,比如_scan_number方法顶部写“状态 S1 -> S2 -> 接受”,这样代码和图天然形成对照。
做完这些,词法分析器实验就算真正闭环了:分类表定规格,状态转换图定路径,代码实现跟上,边界用例一遍遍回归。这么多年做编译相关的东西,我最深的体会是词法分析器的难度不来自算法,而来自对边界的一点点较真。希望这份笔记能让你少走些弯路,把力气花在真正有用的地方。
本文还有配套的精品资源,点击获取