简介:该资源是西南科技大学《编译系统综合设计》课程实验一的完整实验报告,面向计算机相关专业正在学习编译原理、需要完成词法分析实验的学生。报告围绕TEST语言词法分析程序的设计展开,完整梳理了从正则表达式描述词法规则、构造NFA、合并确定化并化简为最小DFA,到确定单词分类与输出方案的流程,涵盖标识符、保留字、无符号整数、分界符、运算符与注释符的规则设计,并附有基于状态转移表的Python词法分析程序框架及关键字、标识符、整数等类别的输出示例。资源包共1个doc文件,约444KB,直接对应实验报告撰写与程序实现两个环节。目前已有477人学习,适合作为实验过程参照与DFA设计思路的复习材料。
1. 一份词法分析程序实验,真正卡人的往往不是写代码
编译原理实验的第一道坎,是设计词法分析程序。很多人以为把正则表达式翻译成一串 if-else 就完事,真跑 TEST 语言样例才发现://注释被切成两个除号,>=被拆成>和=,123@报错的行号指到了上一行。问题不在编码能力,而在于从正则到 NFA、从 NFA 到最小 DFA、再从 DFA 到扫描器这条链路上,每一步都有工程取舍没做。
这套方案要解决的就是把这条链路完整走通:七类单词的正则规则怎么定、六个 NFA 怎么合并、确定化和最小化怎么落成一张能写进代码的转移表、扫描器怎么保证最长匹配和错误定位、最后怎么用几个测试片段自证它是对的。适合正在做编译原理实验、课程设计,或者要给编译器前端搭第一个可运行模块的人。
2. 词法规则建模:七类单词的正则表达式与 NFA 构造
词法分析的上游是规则定义。规则定得含糊,后面 NFA 再多状态也是白搭。这一章先把 TEST 语言的单词边界钉死,再谈自动机怎么拼。
2.1 七类单词的边界划分与正则定义
TEST 语言的单词分成关键字、标识符、无符号整数、分界符、运算符、注释符六类实体,报告里把「保留字」单列成第七类,实际落地时它和关键字是同一张表。先把每条规则写成正则:
| 类别 | 正则表达式 | 样例 | 落地注意点 |
|---|---|---|---|
| 标识符 | (a|…|z|A|…|Z)(a|…|z|A|…|Z|0|…|9)* | abc123、x | 首字符不能是数字 |
| 保留字 | if|else|for|while|do|int|write|read | int、while | 与标识符同构,靠查表区分 |
| 无符号整数 | ((1|…|9)(0|…|9)*)|0 | 0、123 | 前导零在词法层放行,语义层再管 |
| 分界符 | (|)|;|{|} | ;、{ | 单字符,无需前瞻 |
| 运算符 | +|-|*|/|=|<|>|>=|<=|!=|== | >=、!= | 双字符运算符要最长匹配 |
| 注释符 | //起到行尾 | // note | 不产生 token,整行丢弃 |
有一处必须先说清楚:保留字不要单独建自动机。给if、else、while各画一台 NFA,合并后状态数会翻好几倍,而收益是零——它们的字符结构和标识符完全一致。常规做法是让标识符自动机先把int吃下来,再拿这个词去关键字表里查一次,命中就改判成 KEYWORD。一行字典查找换掉几十个状态,这笔账怎么算都划算。
注释符也一样。它不进 token 流,属于「扫描过程中要跳过的东西」,所以自动机层面只需要一条支路识别它并丢弃,不需要给它分配输出类型。很多实验报告在这一步把注释也列进单词分类,最后输出里冒出一堆COMMENT: //xxx,反而不符合词法分析器的实际职责。
还有!这个符号。TEST 语言里它只能出现在!=中,单独一个!是非法单词。正则里写成!=\|==\|>=\|<=\|<\|>\|=\|+\|-\|*\|/,看似把!排除掉了,实际上 Thompson 构造出来的自动机仍会在读到!时进入一个前缀状态。这个状态不是接受态,所以最终不会产出 token,但要在代码里显式报错,否则!会被静默吞掉。
2.2 Thompson 构造:三条规则拼出六个 NFA
正则到 NFA 的转换有标准配方,核心就三条,记住这三条后面全是套用:
- 单字符:两个状态,一条标着该字符的边,起点到终点;
- 连接 RS:R 的接受态用 ε 边连到 S 的起始态;
- 选择 R|S:新建一个起始态,两条 ε 边分别指向 R 和 S 的起始态;R、S 的接受态再用 ε 边汇到一个新建的接受态;
- 闭包 R*:新建起始态 ε 到 R 的起始态,R 的接受态 ε 回 R 的起始态,同时 ε 到新建接受态。
拿标识符举例。设字母集为 L、数字集为 D,正则L(L|D)*的 NFA 是三个状态:状态 0 读一个字母到状态 1,状态 1 读字母或数字都回到状态 1,状态 1 是接受态。这台机器只有 2 条转移规则,却覆盖了任意长度的标识符。
无符号整数更简单:状态 0 读数字到状态 1,状态 1 读数字回状态 1,状态 1 接受。运算符稍微麻烦,因为>=、<=、!=、==是双字符。用 Thompson 构造>=\|>会得到一台带 ε 边的机器:起始态 ε 分出两路,一路读>直接到接受态,另一路读>再读=到另一个接受态。这台机器天然支持最长匹配——读到>时已经在接受态了,但还能继续读=,读完=进入第二个接受态,匹配更长的那条路。
手工画 NFA 时有个血泪经验:给状态编号,别用字母。合并六个自动机之后状态数会上到二十几个,用 A、B、C 命名到后面自己都认不出哪个是哪个。按「0 号总初态,1–2 标识符,3–4 整数,5–6 分界符,7–10 运算符,11–12 注释」这样分段编号,合并时一眼能看出边是从哪台机器出来的。
2.3 合并 NFA:一个总初态加 ε 边,别急着分别确定化
六台 NFA 造好之后,有两种走法:分别确定化成六个 DFA 再拼,或者先合并成一台大 NFA 再统一确定化。前一种看起来简单,实际上是给自己挖坑。
分别确定化的问题在于,六个 DFA 各自独立运行,遇到一个字符要同时喂给六台机器,然后看谁接受。这不但慢,更要命的是「最长匹配」没法全局保证——>在运算符 DFA 里被接受,=在另一个 DFA 里也被接受,两个结果同时返回,谁是最终的 token?还得再加一层仲裁逻辑。而合并成一台 NFA 之后做一次确定化,得到的 DFA 每个状态唯一,最长匹配就退化成「沿着一条路尽量往前走」,逻辑干净得多。
合并的操作很机械:新建一个状态 0 作为总初态,从它引出六条 ε 边,分别指向六台子 NFA 的起始态。所有子 NFA 的接受态保留各自的标记(标识符、整数、分界符……),因为确定化之后需要知道落在哪个接受态上才能判定单词类型。
合并完的 NFA 状态数大概二十出头,看起来吓人,但确定化会把它压回去。这一步的关键数据结构是 ε-闭包:给定一个状态集合,反复沿着 ε 边扩展,直到集合不再变大。计算时用一个栈做深度优先就够了,代码不到十行:
def epsilon_closure(states, eps): """states: 初始状态集合; eps: {状态: [ε可达状态]}""" stack = list(states) closure = set(states) while stack: s = stack.pop() for t in eps.get(s, ()): # 沿着 ε 边扩展 if t not in closure: closure.add(t) stack.append(t) return frozenset(closure)eps用字典存邻接表,键是状态编号,值是该状态通过 ε 边能直接到达的状态列表。返回frozenset是为了后面能当字典的键用——子集构造法里每个 DFA 状态本质上就是一个 NFA 状态集合,必须可哈希。
提示:手工推导时容易漏掉「合并后的总初态 ε-闭包包含全部六个子自动机的起始态」这一点。算出来的第一个 DFA 状态应该是一个六元素以上的集合,如果只有两三个元素,说明 ε 边漏画了。
3. 确定化与最小化:把 NFA 压成九个状态的 DFA 表
NFA 到 DFA 是这套方案里唯一需要动笔算的环节,也是最容易在报告里写不清楚的部分。很多人直接贴一张最终转移表,阅卷的人看不出中间过程。这一章把子集构造和最小化两步拆开讲,每一步都给可复现的操作。
3.1 子集构造:从 ε-闭包到 DFA 状态
子集构造法的规则一句话概括:DFA 的每个状态是 NFA 状态的一个集合,DFA 读入字符 c 后的下一状态,等于当前集合里所有状态读 c 能到达的状态集合的 ε-闭包。
手工推导时按下面的节奏走,不容易乱:
- 算起始状态
A = ε-closure({0}),0 是合并后的总初态; - 对 A 里每个状态,按字符类别分组,算出读字母、读数字、读
/、读比较符、读+ - *、读分界符、读其他分别能到哪些状态; - 对每个结果集再求 ε-闭包,得到一个集合,如果这个集合没出现过,就给它分配一个新名字(B、C、D……);
- 重复第 2 步,直到没有新集合产生。
按这个流程跑下来,能得到几个关键的 DFA 状态:A(初态)、B(读完一个字母后进入,接受态,标记为标识符)、C(读完一个数字后进入,接受态,标记为整数)、D(读到一个/后进入,接受态,标记为运算符)、E(读到比较符后进入,接受态,标记为运算符)、F(读到+ - *后进入,接受态)、G(读到一个分界符后进入,接受态)。而D再读一个/会进入一个全新的集合,那就是注释状态。
代码实现时,子集构造的骨架是这样的:
def subset_construction(start_set, trans, accept_map): """start_set: 初态的 ε-闭包; trans: {(状态, 字符类别): [目标状态]}; accept_map: {NFA状态: 单词类型}""" dfa = {} # {dfa状态名: {字符类别: dfa状态名}} work = {start_set: 'A'} # 待处理集合 -> DFA 状态名 names = {start_set: 'A'} accept = {} # DFA 状态名 -> 单词类型 queue = [start_set] idx = 1 while queue: cur = queue.pop(0) cur_name = names[cur] row = {} for cls in CLS_ORDER: # 遍历所有字符类别 targets = set() for s in cur: targets |= set(trans.get((s, cls), ())) if not targets: continue nxt = epsilon_closure(targets, EPS) if nxt not in names: names[nxt] = chr(ord('A') + idx) idx += 1 queue.append(nxt) row[cls] = names[nxt] dfa[cur_name] = row # 一个 DFA 状态可能包含多个 NFA 接受态,取优先级最高的那个 kinds = {accept_map[s] for s in cur if s in accept_map} if kinds: accept[cur_name] = pick_priority(kinds) return dfa, acceptpick_priority处理的是「同一个 DFA 状态里同时包含标识符接受态和整数接受态」这种冲突,实际上正常构造不会出现(字母和数字的转移路径是分开的),但留一个优先级函数更稳妥。trans的键是(NFA状态, 字符类别)二元组,值是一个目标状态列表,这种平铺结构比嵌套字典好写也好调。
推进过程中有一个细节值得单独提:字符类别要在确定化之前就压缩好。如果按 26 个大写字母 + 26 个小写字母 + 10 个数字分别做转移,子集构造的循环次数会翻几十倍,而结果完全一样。先把字符映射到ALPHA / DIGIT / SLASH / CMP / OP1 / DELIM / OTHER / WS八个类别,转移表的规模立刻降到可手工验证的量级。
3.2 最小化:初始划分与逐步分裂
确定化出来的 DFA 往往有多余状态。最小化的 Hopcroft 划分法分三步:
- 初始划分:把所有状态分成两组,接受态一组、非接受态一组。接受态内部如果标记不同(标识符 vs 整数),还要按标记再细分。
- 分裂:对每个组,检查组内所有状态在每一个字符类别下的转移目标是否落在同一个组里。只要有一个字符类别让两个状态跑到了不同的组,就把这个组拆开。
- 收敛:反复执行第 2 步,直到没有组能被拆开为止。每个组最终压缩成最小 DFA 的一个状态。
拿这套方案的实际结果说,确定化后大概有 11 到 13 个状态,最小化后剩 8 个有效状态加 1 个错误陷阱,具体是:
START:初态;ID:标识符 / 关键字的接受态;NUM:无符号整数的接受态;SLASH:单个/的接受态,同时是注释的前缀状态;CMP:< > ! =的接受态,同时是双字符运算符的前缀状态;OP:+ - *的接受态;DELIM:分界符接受态;COMMENT:注释的接受态,整行丢弃;ERR:错误陷阱。
被合并掉的那些状态,基本上都是「读完一个字符后没有任何出边」的死胡同——它们和同类接受态行为一致,压缩是安全的。
注意:最小化不能跨接受态和非接受态合并,也不能跨不同单词类型合并。
ID和NUM后续转移行为看起来相似(都能接自己那类字符),但输出类型不同,合并了就没法区分单词。
这一步在实验报告里最容易写崩。建议的做法是画一张划分表,每个格子写上「第 n 次划分后的分组」,让阅卷的人看到分组是怎么从 2 组裂到 8 组的。表格比大段文字描述有效得多。
3.3 状态命名的工程取舍:九个状态够用
最小化后的 DFA 写成转移表,长这样:
| 状态 | ALPHA | DIGIT | SLASH | CMP | OP1 | DELIM | OTHER |
|---|---|---|---|---|---|---|---|
| START | ID | NUM | SLASH | CMP | OP | DELIM | ERR |
| ID | ID | ID | – | – | – | – | – |
| NUM | – | NUM | – | – | – | – | – |
| SLASH | – | – | COMMENT | – | – | – | – |
| CMP | – | – | – | CMP | – | – | – |
| OP | – | – | – | – | – | – | – |
| DELIM | – | – | – | – | – | – | – |
| COMMENT | C | C | C | C | C | C | C |
| ERR | – | – | – | – | – | – | – |
表里的–表示无转移,扫描到这类字符时当前匹配结束。COMMENT那行的C表示「保持注释态」,实际实现时遇到换行才终止。
这张表有三个设计点值得说明。
第一,CMP状态的自环只对=生效。表里写成了CMP → CMP,但代码里要加if ch == '='的判断,否则><这种组合会被误判成一个双字符运算符。这是表格表达不出来、必须在代码里补的一层约束。
第二,SLASH是接受态,同时又是注释的前缀。这意味着读到/时已经可以产出一个除法运算符 token,但如果下一个字符还是/,就要推翻重来,改成注释。最长匹配的回溯机制天然支持这种「先接受再反悔」。
第三,ERR是个陷阱态,进去就出不来。它的作用不是恢复,而是让扫描器在遇到未定义字符时有一个明确的落点,方便统计错误数量。真正的前进由外层循环负责,不会卡死。
用 9 个状态覆盖七类单词,代价是转移表里大量空位。有人会觉得浪费,实际上查表时一次dict.get就返回None,比写一堆 if-else 判断快也更不容易错。这就是从自动机理论到工程实现之间最舒服的一步——理论给你正确性,表格给你可维护性。
4. 扫描器实现:最长匹配、行列号与 lex.txt 落盘
前面三章把自动机推导完了,这一章把它变成能跑的代码。核心难点只有两个:最长匹配怎么用回溯实现,错误定位的行列号怎么算才不错位。
4.1 字符类别压缩与转移表的代码形态
先把上一章那张表翻译成 Python 字典。字符分类函数只做一件事:把任意字符映射到八个类别之一。
KEYWORDS = {'int', 'if', 'else', 'for', 'while', 'do', 'read', 'write'} ACCEPT = {'ID', 'NUM', 'SLASH', 'CMP', 'OP', 'DELIM', 'COMMENT'} def char_class(ch): """把字符压成有限类别,避免为 62 个字母数字各写一条边""" if ch.isalpha(): return 'ALPHA' if ch.isdigit(): return 'DIGIT' if ch == '/': return 'SLASH' if ch in '<>!=': return 'CMP' # 可能是单字符,也可能是双字符的前缀 if ch in '+-*': return 'OP1' if ch in '(){};:,': return 'DELIM' if ch in ' \t\r\n': return 'WS' return 'OTHER' TRANS = { 'START': {'ALPHA': 'ID', 'DIGIT': 'NUM', 'SLASH': 'SLASH', 'CMP': 'CMP', 'OP1': 'OP', 'DELIM': 'DELIM', 'OTHER': 'ERR'}, 'ID': {'ALPHA': 'ID', 'DIGIT': 'ID'}, 'NUM': {'DIGIT': 'NUM'}, 'SLASH': {'SLASH': 'COMMENT'}, 'CMP': {}, # 只有 '=' 能续,交给 step 单独判断 'OP': {}, 'DELIM': {}, 'COMMENT': {}, }char_class里<>!=归成同一类是有意为之。这四个字符在 DFA 里共享同一个前缀状态CMP,具体是<还是<=,取决于下一个字符是不是=。把它们放在一个类别里,转移表就只需要一条边。
TRANS里CMP、OP、DELIM、COMMENT四个状态的值是空字典,不代表没有转移,而是转移规则太特殊,放进查表逻辑反而绕。统一用一个step函数包住:
def step(state, ch): """单步转移:返回下一状态,None 表示无法继续""" if state == 'COMMENT': return None if ch == '\n' else 'COMMENT' # 注释吃到行尾为止 if state == 'CMP': return 'CMP' if ch == '=' else None # 只有 '=' 能延长运算符 return TRANS.get(state, {}).get(char_class(ch))step是整份代码里唯一需要理解自动机语义的地方,其余全是簿记。这种把「规则」和「流程」分开的写法,调试时非常省事——出问题只需要打印step的入参和返回值,不用在几十行嵌套 if 里找。
4.2 最长匹配主循环:last_accept 回溯
最长匹配的实现思路是「能走多远走多远,但记住最后一次经过接受态的位置」。扫描指针一路往前推,每进入一个接受态就更新一次last_accept;等到走不动了,就退回到last_accept的位置,把那段字符切出来作为一个 token,剩下的留给下一轮。
def tokenize(src): tokens, errors = [], [] i, line, col = 0, 1, 1 n = len(src) while i < n: if src[i] in ' \t\r\n': # 空白直接跳过,但要维护行号 if src[i] == '\n': line, col = line + 1, 1 else: col += 1 i += 1 continue s_idx, s_line, s_col = i, line, col state, j, last = 'START', i, None while j < n: # 贪婪往前走 nxt = step(state, src[j]) if nxt is None: break state = nxt j += 1 if state in ACCEPT: last = (j, state) # 记住最后一次接受的位置 if last is None: # 首字符就进不了任何状态 errors.append((s_line, s_col, src[i], '非法字符')) i += 1 col += 1 continue end, kind = last # 回溯到最长接受点 text = src[s_idx:end] for c in text: # 消费这段字符,同步行列号 if c == '\n': line, col = line + 1, 1 else: col += 1 i = end yield_tokens(tokens, errors, kind, text, s_line, s_col, src, i, line, col, n) return tokens, errors关键变量是last,它是一个二元组(结束下标, 状态名)。内层while每次成功转移后都检查一次「当前状态是不是接受态」,是就更新。等step返回None跳出循环,last指向的就是这条路径上最长的一次匹配。如果last始终是None,说明这个字符连一步都走不了,直接报错并强制前进一格。
行列号的维护藏在两个地方:跳过空白时逐字符更新,消费 token 时逐字符更新。之所以要遍历text而不是简单加长度,是因为注释可能跨行(虽然 TEST 语言的注释到行尾就结束,但代码要留出扩展余地)。这段簿记如果偷懒写成col += len(text),一旦哪天注释支持块注释/* */,行号立刻全错。
yield_tokens负责分类和产出,逻辑不复杂但分支多:
def yield_tokens(tokens, errors, kind, text, ln, cl, src, i, line, col, n): if kind == 'ID': t = 'KEYWORD' if text in KEYWORDS else 'IDENT' # 查表判保留字 tokens.append((t, text, ln, cl)) elif kind == 'NUM': tokens.append(('INT', text, ln, cl)) if i < n and (src[i].isalpha() or src[i] == '_'): # 2a 这类情况前视报错 errors.append((line, col, src[i], '数字后紧跟字母,疑似非法标识符')) elif kind == 'SLASH': tokens.append(('OP', '/', ln, cl)) elif kind == 'CMP': if text in ('<=', '>=', '==', '!='): tokens.append(('OP', text, ln, cl)) elif text in ('<', '>', '='): tokens.append(('OP', text, ln, cl)) else: errors.append((ln, cl, text, '单独的 ! 不构成运算符')) elif kind in ('OP', 'DELIM'): tokens.append((kind if kind == 'OP' else 'DELIM', text, ln, cl)) # kind == 'COMMENT' 时什么都不做,整行丢弃ID分支里的关键字查表是整份代码里最短的一行,却承担了「保留字不单独建自动机」这个设计决策的全部落地。CMP分支的最后一个 else 处理单独出现的!,这个字符在 DFA 层面能走到接受态,但语言层面不合法,只能在这里拦截。
4.3 单词输出方案与 lex.txt 落盘
输出格式要同时满足两个要求:人能看懂,后续语法分析器能解析。比较稳妥的方案是固定列宽 + 类型名用中文,一份给人看,一份给机器读。
TYPE_NAME = {'KEYWORD': '关键字', 'IDENT': '标识符', 'INT': '无符号整数', 'OP': '运算符', 'DELIM': '分界符'} def dump(tokens, errors, path='lex.txt'): with open(path, 'w', encoding='utf-8') as f: f.write('行:列 类型 单词\n') f.write('-' * 40 + '\n') for t, text, ln, cl in tokens: f.write(f'{ln:>3}:{cl:<3} {TYPE_NAME[t]:<8} {text}\n') if errors: f.write('\n[词法错误]\n') for ln, cl, ch, msg in errors: f.write(f'{ln:>3}:{cl:<3} {msg}(字符 {ch!r})\n')用定宽对齐而不是逗号分隔,是因为报告里要贴输出截图,列对齐看起来直观。{ln:>3}让行号右对齐,{TYPE_NAME[t]:<8}让中文类型名左对齐,混排时不会错位。
lex.txt里合法单词和错误各占一段,中间空一行。这样语法分析阶段读文件时,只要在[词法错误]处截断就能拿到纯 token 流,不用再加解析规则。
提示:中文类型名在某些编辑器里宽度和英文不等,8 个字符的填充可能对不齐。图省事的话可以在
TYPE_NAME里直接用英文类型名,报告正文再用中文解释。
拿实验报告里那段样例源码跑一遍,输出应该是关键字 int、标识符 x、分界符 ;、关键字 if这样一条一条往下排。注意int x;里的int判定成关键字而不是标识符——这正是查表那一步在起作用。
4.4 错误定位与错误恢复
词法错误的定位精度直接决定这份报告能不能拿高分。上面代码里每条错误都记了(行, 列, 字符, 描述)四元组,其中列号是错误字符的起始列,不是 token 的起始列。
123@这个例子里,扫描器先匹配到NUM(123),产出一个合法 token,然后指针停到@上。下一轮外层循环,step('START', '@')返回ERR,last为None,报错位置就是@所在的列。这个位置是对的,但如果报告里写成「标识符 123@ 包含非法字符」,描述就错了——123是一个合法的整数 token,@才是非法字符。阅卷的人一眼能看出这个差别。
2a的情况更微妙。按 DFA 走,2是合法整数,a是合法标识符,两个 token 都能产出,扫描器不会报错。但它们在语法上是连在一起写的,显然是笔误。处理方式是在NUM接受之后做一次前视:如果下一个字符是字母或下划线,补一条错误记录。这条记录不影响 token 流,语法分析器照样能拿到INT(2)和IDENT(a),但报告里能明确指出「第 4 行第 5 列,数字后紧跟字母,疑似非法标识符」。
错误恢复的策略是「跳过当前字符,继续扫」。不搞插入、替换那套复杂恢复,词法层的错误没必要修,报出来让上游决定。唯一要守住的是指针必须前进——任何一条错误分支都要保证i增加,否则遇到$这种字符会死循环。这个坑后面还会专门讲。
5. 避坑与排查:注释符、最长匹配、错误恢复这五处最容易翻车
这一章列的五个问题,都是我在带实验和看别人代码时反复见到的。每个都按「现象 → 原因 → 解决」写,照着查基本能一次定位。
第一处://被切成两个除号。现象是输入// 这是注释,输出里冒出两个运算符 /,注释正文全被当成标识符和非法字符报错。原因很直接——把/的转移直接指向了运算符接受态,扫描到第一个/就立刻产出 token,根本没有给「再看一个字符」留机会。解决办法是加一个中间状态SLASH:它在接受态集合里(所以单独一个/仍然能作为除法运算符产出),但同时有一条读/的边指向COMMENT。最长匹配机制会自动处理这个二选一:读到第二个/时向前推进到COMMENT,last被更新;读不到就停在SLASH,输出除法运算符。
第二处:>=被拆成>和=。现象是i >= 0解析出三个 token 而不是两个,语法分析阶段报「意外的赋值运算符」。原因是扫描器写成了「读一个字符、判一次类型、立刻产出」,没有回溯。这就是最长匹配没做对。解决方式是 4.2 节那套last_accept机制:每进入一个接受态就记下位置,走不动了再回退。判断标准很简单——CMP状态读完>之后,如果下一个字符是=,状态还是CMP,last更新到第二格;只有下一个字符不是=时才回退到第一格。
第三处:错误字符让扫描器卡死。现象是程序跑到abc123$的$上就不动了,控制台没有任何输出,CPU 占满。原因出在错误分支:检测到非法字符后记录了错误,但指针i没有推进,下一轮循环还在同一个位置,step还是返回None,无限循环。解决办法是所有错误路径统一强制i += 1,并且同步更新列号。这条规则要写死在代码里,不能依赖「反正不会遇到非法字符」的侥幸——恰恰是非法字符才需要错误处理。
第四处:数字后跟字母被当成两个合法 token。现象是int 2a;顺利通过词法分析,直到语法分析才报错,报错信息还指向下一行。原因是 DFA 层面2和a各自合法,扫描器没有理由拒绝。这不是 bug,是词法和语法的职责边界。但工程上一般会在词法层加一条前视检查:NUM接受之后,如果紧跟字母或下划线,补一个警告。处理方式是「报错但不中断」,token 照常产出,错误列表里多一条记录。这样既不破坏 DFA 的正确性,又能让错误信息定位到真正的那一行。
第五处:注释吃掉换行,后续行号整体偏移。现象是注释行之后的错误行号总比实际小 1。原因是COMMENT状态的终止条件写成了「遇到非注释字符」,把\n也当成注释内容吃进去了,而外层循环的行号计数只在跳过空白时才加一,这一行就被漏掉了。解决办法是把COMMENT的终止条件明确成「遇到\n就停」,换行符交回外层空白分支处理,行号加一在那里发生。这条规则同样适用于未来扩展块注释——块注释里可能包含多个换行,每遇到一个都要让行号加一。
这五个坑有个共同点:都能通过一组固定的测试片段提前发现。a/b、// comment、i>=0、2a、abc$、多行带注释的代码,六条输入覆盖了全部五个场景。写完扫描器先跑这六条,比漫无目的地调试快得多。
6. 验收与答辩:用六个测试片段自证这份词法分析器
词法分析器不像图形界面那样能一眼看出好坏,验收得靠输入输出对照。我一般会准备一张测试表,每条用例写清楚输入片段、期望输出、判定依据,跑完对着勾。
| 用例 | 输入片段 | 期望输出 | 判定依据 |
|---|---|---|---|
| 最长匹配 | i>=0 | IDENT(i)OP(>=)INT(0) | 不是>+=两个 token |
| 除号与注释 | a/b和//x | OP(/);注释不产出 token | 两条路径不能混 |
| 关键字查表 | int和intx | KEYWORD(int);IDENT(intx) | 前缀相同但类型不同 |
| 数字后字母 | 2a | INT(2)IDENT(a)+ 一条警告 | 警告行号指向a |
| 非法字符 | abc$ | IDENT(abc)+ 错误$ | 错误列号指向$ |
| 行号定位 | 三行代码,第二行有错 | 错误行号 = 2 | 注释行不影响计数 |
跑这张表有个技巧:先跑最小用例,再跑完整样例。i>=0这种一行代码能暴露最长匹配问题,a/b能暴露除号问题,两个都过了再去跑实验报告里那段十几行的 TEST 语言源码。反过来先跑大样例,出错时满屏 token 里找那一处异常,效率低得多。
答辩环节通常会被问到两个问题。第一个是「你怎么证明满足最长匹配」,这时候不要背理论,直接指着last_accept那几行代码讲:每进入接受态就记录位置,走不动才回退,所以拿到的一定是这条路线上最长的那个匹配。第二个是「//和/怎么区分」,答案是SLASH状态既是接受态又有出边,靠「能不能再走一步」自动决定,不需要额外判断。这两个回答都能落到具体代码行上,比讲一堆自动机定义有说服力。
还有一个容易被忽略的验收点:把lex.txt打出来,用文本比对工具跑两遍,确认输出稳定。词法分析器如果有隐藏的状态残留(比如某个全局变量没重置),第二次运行结果可能和第一次不同。跑两遍比对是最便宜的回归测试。
从那以后我每次交付这类扫描器,都强制走一遍「六条用例 + 两遍比对」——不是为了应付检查,而是这六条真的把该踩的坑都踩过了。希望这套结构能帮到你,把这门实验从头到尾跑通,而不是卡在某一个状态转移上反复怀疑自己。
本文还有配套的精品资源,点击获取