news 2026/10/31 11:21:21

从正则到Token流:TEST语言词法分析实验与Python扫描器避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从正则到Token流:TEST语言词法分析实验与Python扫描器避坑

简介:这份西南科技大学编译原理实验报告聚焦实验一“设计词法分析程序”,面向计算机专业学生、编译原理初学者及需要完成课程实验的读者,用于梳理词法分析从正则表达式到可运行程序的完整链路。压缩包内共1个doc文件,约444KB,内容涵盖实验目的、实验设计、实验过程与程序实现,具体包括TEST语言各类词法规则的正则表达式、NFA构造与合并、确定化及最小DFA化简、单词分类与输出方案,并给出基于Python状态转移表的词法分析程序框架。读者可据此对照理解标识符、保留字、无符号整数、分界符、运算符和注释符的识别方法,参考状态机设计、字符分类函数与输出格式组织实验报告,也能从错误处理、缓冲区管理和回溯机制等提示中延伸排错思路。目前已有479人学习,适合作为课程实验参考与期末复习材料。

1. 一份能直接跑的词法分析实验报告:从正则推到 Token 流

拿到一份编译原理实验报告,最容易出现的情况是自动机图看着都对,代码一跑就崩:除号认不出来、分界符全被标成运算符、单字符 token 被后面的字符吞掉。这份《编译系统综合设计》实验一的文档,主线是完整的:正则表达式 → NFA → 合并 → 确定化 → 最小化 DFA → Python 扫描器 → 错误定位 → 最长匹配讨论,还带一段真实的调试记录。它适合两类人:正在赶词法分析实验、需要一套能交也能跑的东西;以及想把状态机驱动扫描器真正写对的从业者。下面按「规则怎么写、自动机怎么推、代码怎么改、坑在哪」拆开讲。

2. 把 TEST 语言规则写成正则:六类词法的边界与合并顺序

正则表达式是整个词法分析的前置工作,写错一条,后面构造出来的 NFA 再漂亮也没用。我的习惯是先把词法规则落成一张表,把「类别、正则、边界情况」三列填满,确认没有二义性再动自动机。大部分翻车都发生在这一步:比如无符号整数的正则没排除前导零,或者/同时出现在运算符和注释符规则里,导致 DFA 里同一个字符有两个出口。

2.1 六类词法规则的正则写法与优先级

TEST 语言的规则数量和类别不算多,但边界条件不少。按实验正文整理成表,顺带把每条的坑标出来:

类别正则表达式边界与注意点
标识符 ID(a|…|z|A|…|Z)(0|…|9|a|…|z|A|…|Z)*首字符必须是字母,数字不能开头;不含下划线
保留字if|else|for|while|do|int|write|read词形与 ID 完全相同,靠规则优先级区分
无符号整数 NUM((1|…|9)(0|…|9)*)|0该写法明确排除了前导零,012345属于非法
分界符 DELIM(|)|;|{|}圆括号、花括号、分号,不含冒号
运算符 OP+|-|*|/|=|<||>|>=|<=|!=|==/与注释符冲突,>=与>冲突
注释符 COMMENT//开头至行尾原文排版丢失成 `/

两条优先级必须钉死。第一条是关键字优先于标识符:int既满足保留字正则也满足标识符正则,按最长匹配两者长度相同,此时只能靠规则顺序裁决,先匹配保留字表,命中就归 KEYWORD,否则才归 ID。第二条是长运算符优先于短运算符:读到>不能立刻收尾,要再看一个字符,是=就输出>=,否则才输出>。这两条如果不提前定好,后面手写状态转移表时一定会在某个状态上犹豫。

还有一个细节:运算符集合里包含了/,注释符也以/开头。这不是笔误,而是语言设计上的真实二义,必须靠在自动机里做两字符前瞻来解决——读到第一个/时先不落状态,看第二个字符是不是/,是就进注释分支,不是就退回单字符除号。实验文档后面「调试记录」里提到的第一个问题就是这件事。

2.2 从正则到 NFA:Thompson 构造的四个基本件

正则到 NFA 用 Thompson 构造,思路是把每条正则拆成「连接、选择、闭包」三种组合,每种组合对应一小块固定形状的子图,最后拼起来。落地时不要一上来就画图,先写四个构造函数,让代码生成图,比手画准确得多。

from dataclasses import dataclass, field @dataclass class NFA: start: int accept: int trans: dict = field(default_factory=dict) # (state, char) -> set(state) eps: dict = field(default_factory=dict) # state -> set(state) _counter = [0] def new_state(): _counter[0] += 1 return _counter[0] def build_char(ch): """单个字符:两个状态一条边""" s, a = new_state(), new_state() return NFA(s, a, {(s, ch): {a}}, {}) def build_concat(x, y): """连接:x 的接受态用 ε 边接到 y 的起始态""" return NFA(x.start, y.accept, {**x.trans, **y.trans}, {**x.eps, **y.eps, x.accept: {y.start}}) def build_union(x, y): """选择:新起点 ε 分叉到两个子图,两个接受态 ε 汇到新终点""" s, a = new_state(), new_state() eps = {**x.eps, **y.eps, s: {x.start, y.start}, x.accept: {a}, y.accept: {a}} return NFA(s, a, {**x.trans, **y.trans}, eps) def build_star(x): """闭包:新起点可跳过子图,子图接受态可回跳起点""" s, a = new_state(), new_state() eps = {**x.eps, s: {x.start, a}, x.accept: {x.start, a}} return NFA(s, a, dict(x.trans), eps)

四个函数的共同点是:每次构造都新建起止状态,用 ε 边与子图粘连,子图内部结构完全不动。这样写的好处是可组合——标识符的正则可以写成char(letter) + star(char(letter|digit)),代码里就是build_concat(build_char_in_set(LETTER), build_star(...)),不需要手工算状态编号。

参数上有两处容易写错。第一处是build_concat里eps字典的合并顺序,x.accept: {y.start}必须放在最后,否则如果 x 和 y 恰好复用同一批状态编号,会被覆盖。第二处是build_star里x.accept的出边要指向{x.start, a}两个状态,只指回起点会导致闭包结束后无法退出,整个自动机永远接受不了任何串。

2.3 合并 NFA:一个新起点加 ε 边,别改子图

六条规则各自构造完 NFA 后要合并成一台机器,做法是新建一个全局起点,用 ε 边指向六个子图的起始状态。这样合并出来的 NFA 天然带有「六选一」的语义,后面子集构造时第一步的 ε-闭包就会把这六个起点全部收进来。

合并时有个顺序问题值得说清楚:子图的排列顺序决定了子集构造后状态编号的稳定性。如果今天按「标识符、整数、运算符……」排,明天按字母序排,那最小化出来的 DFA 状态表每次都不一样,调试时对不上号。我的做法是固定一个枚举顺序,写进常量列表,构机、确定化、最小化全部按这个顺序遍历,产出的状态表可以稳定复现。这一点在实验报告里不会写,但如果你要把状态表贴进文档或者交给别人复现,顺序稳定能省掉大量「我这边跑出来怎么跟你不一样」的沟通。

合并完先别急着确定化,做一次自检:统计除 ε 边以外的所有转移字符,看是否和预期字符集一致。TEST 语言这边应该是 26 个大写、26 个小写、10 个数字,加上+ - * / = < > ! ( ) { } ;共 15 个符号。少一个说明某条正则漏字符,多一个说明哪个构造函数的字符集参数写串了。这一步五分钟,能省掉后面半小时的排查。

3. NFA 确定化与最小化:子集构造手算一遍,再落到表上

从 NFA 到 DFA 是这套实验里最容易被跳过的一步。很多人直接抄一张现成的状态表,跑通了就算完,结果一改规则就全乱。真正值钱的是子集构造那套机械流程:它不快,但每一步都可以手算验证,出问题一定能定位到具体哪个状态集合。

3.1 ε-闭包与 move:子集构造的两个原语

子集构造只有两个原语。ε-闭包是「从一组状态出发,只走 ε 边能到达的所有状态」,move 是「从一组状态出发,吃掉一个字符能到达的所有状态」。两个都是集合运算,写起来很短。

def eps_closure(nfa, states): """从 states 出发只走 ε 边能到达的闭包,迭代代替递归避免深图爆栈""" seen = set(states) stack = list(states) while stack: s = stack.pop() for t in nfa.eps.get(s, ()): if t not in seen: seen.add(t) stack.append(t) return frozenset(seen) def move(nfa, states, ch): """吃掉字符 ch 之后到达的状态集合(不含 ε 展开)""" out = set() for s in states: out |= nfa.trans.get((s, ch), set()) return frozenset(out) def determinize(nfa, alphabet): """子集构造:DFA 的每个状态是一个 frozenset""" d0 = eps_closure(nfa, {nfa.start}) index = {d0: 0} order = [d0] table = [] # table[i] = {char: j} queue = [d0] while queue: cur = queue.pop(0) row = {} for ch in alphabet: nxt = eps_closure(nfa, move(nfa, cur, ch)) if not nxt: continue # 空集不建状态,等价于死状态 if nxt not in index: index[nxt] = len(order) order.append(nxt) queue.append(nxt) row[ch] = index[nxt] table.append(row) return order, table, index

三个函数的分工很清楚:eps_closure用显式栈而不是递归,是因为合并后的 NFA 状态数虽然只有几十个,但 ε 链可能很长,递归写法在 Python 默认递归深度下不算安全。determinize里的alphabet必须传完整字符集,不能只传出现过的字符——漏传会让某些转移缺失,DFA 看起来能用,实际上遇到那个字符就直接掉进死状态。

determinize里有一行if not nxt: continue,意思是空集不建状态。这是简化做法,好处是状态表干净,坏处是「转移缺失」和「转移到死状态」在语义上要靠约定来补:查表查不到就按报错处理。如果你的实验要求画出完整 DFA(包含死状态),把这行删掉,让空集也进index就行,两种做法都对,取决于是要交图还是要跑代码。

3.2 最小化:先删不可达,再做等价类划分

确定化之后的 DFA 一定有冗余状态,最小化分两步走,顺序不能反。第一步是删不可达状态,从初始状态做一次可达性遍历,凡是走不到的统统删掉;第二步才是等价类划分。顺序反了会出现「划分过程中两个不可达状态被判定为等价并合并,合并后又恰好可达」这种诡异情况,虽然少见,但排查起来极耗时间。

def minimize(dfa_states, table, alphabet, accept_ids): # 第一步:可达性剪枝 reachable = {0} stack = [0] while stack: i = stack.pop() for ch in alphabet: j = table[i].get(ch) if j is not None and j not in reachable: reachable.add(j) stack.append(j) # 第二步:初始划分 = 接受态 / 非接受态 block = {i: (1 if i in accept_ids else 0) for i in reachable} while True: sig = {} for i in reachable: key = (block[i], tuple(block.get(table[i].get(ch), -1) for ch in alphabet)) sig.setdefault(key, []).append(i) if len(sig) == len(set(block.values())): break # 划分不再变化,收敛 block = {} for new_id, group in enumerate(sig.values()): for i in group: block[i] = new_id return block

这段代码的核心是key的构造:一个状态的「签名」由它自身所属的划分块,加上它在每个字符上转移到哪个划分块,组成一个元组。签名相同就归为一类,一轮一轮迭代到划分稳定。参数accept_ids是确定化阶段记下来的接受状态集合,如果这步传错,整个划分从第一轮就跑偏,最后得到的最小 DFA 会接受错误的串,而且很难从状态表上看出来。

还有一个常见误解:最小化不一定让状态数变少很多。TEST 语言这种规则集,确定化后可能有二三十个状态,最小化后剩十几个,收益不算夸张。真正有价值的是合并那些「词形不同但后续行为完全一致」的状态——比如各条规则的收尾状态,合并后状态表可读性会好很多,手写实现时才有底气照着抄。

3.3 把最小 DFA 落成 Python 字典:两类写法的取舍

理论推完,最后要落到代码。这里有一个容易被忽略的选择:是逐状态翻译整张表,还是按词法类别拆开写。实验文档里给的代码是前者,一张大字典dfa把 START、ID、NUM、OPERATOR、DELIMITER、COMMENT、ERROR 七个状态和它们的转移全列出来。这种写法忠实于自动机,画图和代码能一一对应,交实验报告很合适。

但在真实工程里我一般选后者:按类别拆成几个小循环,标识符一个 while、数字一个 while、运算符两字符前瞻一次,主循环只负责分派。原因很实际——整表翻译的状态里,接受态和中间态混在一起,回退逻辑要另外写;拆开写之后,每类词素的扫描边界天然清晰,加一类新词法只加一个分支,不动其他分支。

两种写法并不冲突。实验要求「根据最小 DFA 编写程序」,那就按表写,把图、表、代码三者对齐;如果要拿去接语法分析器、要长期维护,那就在表的基础上重构成分类扫描器。文档里的代码骨架属于前者,第 4 章给的实现属于后者,两者的状态语义是一致的,对照着看能看清「DFA 是模型,扫描器是工程实现」这层关系。

4. Python 词法分析器实现:DFA 驱动、最长匹配与 Token 输出

到这一步,规则、自动机、状态表都有了,剩下的问题全在工程细节上:怎么保证最长匹配、怎么处理空白和注释、怎么在报错时给出能定位的行列号。实验文档里的代码骨架方向是对的,但有几处会直接导致跑不对,后面第 5 章逐条拆。这里先给一版能直接跑的完整实现,再解释每处为什么这么写。

4.1 状态机骨架:字符分类、主循环与分派

整份实现围绕一个主循环展开:每次拿当前字符判断属于哪一类词素的起点,然后按该类规则一路吃到不能吃为止,产出 token 后把游标推到下一个位置。行号和列号随着游标推进同步维护。

KEYWORDS = {'int', 'if', 'else', 'for', 'while', 'do', 'write', 'read'} TWO_CHAR_OPS = {'>=', '<=', '==', '!='} DELIMS = set('(){};') SINGLE_OPS = set('+-*/=<>') def tokenize(src): tokens, errors = [], [] i, line, col, n = 0, 1, 1, len(src) def advance(ch): nonlocal line, col if ch == '\n': line, col = line + 1, 1 else: col += 1 while i < n: ch = src[i] # 1) 空白:只推进游标,不产出 token if ch in ' \t\r\n': advance(ch); i += 1 continue sl, sc = line, col # 记录词素起点,报错和输出都要用 # 2) 注释:// 到行尾全部丢弃,注释内容不参与词法 if src.startswith('//', i): while i < n and src[i] != '\n': advance(src[i]); i += 1 continue # 3) 标识符 / 关键字:字母开头,吃字母数字 if ch.isalpha(): j = i while j < n and src[j].isalnum(): j += 1 word = src[i:j] kind = 'KEYWORD' if word in KEYWORDS else 'ID' tokens.append((kind, word, sl, sc)) for k in range(i, j): advance(src[k]) i = j continue # 4) 无符号整数:禁止前导零,禁止数字后紧跟字母 if ch.isdigit(): j = i while j < n and src[j].isdigit(): j += 1 if j < n and (src[j].isalpha() or src[j] == '_'): k = j while k < n and (src[k].isalnum() or src[k] == '_'): k += 1 errors.append((sl, sc, f'非法词素 {src[i:k]}:不能以数字开头')) for t in range(i, k): advance(src[t]) i = k continue word = src[i:j] if len(word) > 1 and word[0] == '0': errors.append((sl, sc, f'非法整数 {word}:存在前导零')) else: tokens.append(('NUM', word, sl, sc)) for t in range(i, j): advance(src[t]) i = j continue # 5) 双字符运算符:先看两位,命中就直接吃两位 if src[i:i + 2] in TWO_CHAR_OPS: tokens.append(('OP', src[i:i + 2], sl, sc)) advance(src[i]); advance(src[i + 1]) i += 2 continue # 6) 孤立的 ! 不合法,必须成对出现 if ch == '!': errors.append((sl, sc, "孤立字符 '!':只允许 '!='")) advance(ch); i += 1 continue # 7) 分界符 if ch in DELIMS: tokens.append(('DELIM', ch, sl, sc)) advance(ch); i += 1 continue # 8) 单字符运算符,包含 '/',注释已在第 2 步截走 if ch in SINGLE_OPS: tokens.append(('OP', ch, sl, sc)) advance(ch); i += 1 continue # 9) 其余一律非法 errors.append((sl, sc, f'非法字符 {ch!r}')) advance(ch); i += 1 return tokens, errors

主循环的九条分支就是九个「词素起点判定」,顺序本身就是优先级:注释判断排在单字符运算符之前,所以/先被注释截走,剩下的才是除号;双字符运算符判断排在单字符之前,所以>=不会被拆成>和=。这两个顺序如果对调,整个扫描器行为就变了,而且不一定报错,只是 token 流悄悄不对。

advance里用nonlocal line, col修改外层变量,是为了让行号列号在任何分支下都只更新一次。有个坑要提前说:字符串切片src[i:j]不会推进游标,必须显式对range(i, j)逐个advance,否则行号列号会和实际位置脱节。这个错误很难在短输入上发现,一旦输入多了几百行,报错位置就完全对不上。

4.2 最长匹配与回退:>=、!=、//怎么一次读对

最长匹配这件事,文档在讨论部分说代码「满足最长匹配原则」,理由是按状态逐个匹配,读到>时再看下一位是不是=。这个说法方向正确,但要落到实现上有两种做法,取舍不同。

第一种是状态表 + 回退:DFA 进入「已识别>但尚未确定」的中间态,再看下一个字符,如果是=就进接受态输出>=;如果不是,就回退一个字符,输出>。回退意味着输入流要支持seek,或者游标要能减一,同时行号列号也要回滚,实现成本明显更高。

第二种是两字符前瞻:直接看src[i:i+2]在不在双字符集合里,命中就吃两位,否则吃一位。上面代码第 5、6、7、8 条分支就是这种写法。它不需要回退,游标只前进不后退,行号列号只增不减,实现简单很多。代价是前瞻长度固定为 2,如果哪天语言加了>>=、<<=这类三字符运算符,就得改成先查三位再查两位。

我一般选第二种,因为词法规则里运算符长度通常很短,写死一个最大长度反而更清晰。但有个例外:注释和除号的区分不能靠前瞻长度解决,必须靠判断顺序。/和//都是一到两位,正好卡在前瞻的边界上,所以代码里把注释判断放在最前面,用startswith('//', i)单独截走。如果只写src[i:i+2] in TWO_CHAR_OPS然后指望它处理注释,就得把//塞进集合里,再在命中后另外写一段「跳过到行尾」的逻辑——能做,但分支会比现在散。

验证最长匹配是否真的生效,别只看正常输入。拿这几个串各跑一遍:a>=b、a>b、a!=b、a!b、a/b、a//b。期望输出分别是>=、>、!=、报错、/、注释丢弃。六个都对了,说明前瞻和判断顺序都没问题。

4.3 输出 lex.txt 与错误位置:行列号怎么维护

实验要求把合法单词写进lex.txt并报告错误位置。输出格式没有硬性规定,但有两个要素建议一定有:token 类型和行列号。类型是为了给语法分析用,行列号是为了报错能定位。

src = """{ //This a test program. int abc123$; int 123@; int 2a; abc = 012345; for (i = 1; i <= n; i = I + 1) { if(i != n) abc = abc * i else abc = abc / i; } }""" tokens, errors = tokenize(src) with open('lex.txt', 'w', encoding='utf-8') as f: f.write('# kind\ttext\tline:col\n') for kind, text, ln, cl in tokens: f.write(f'{kind}\t{text}\t{ln}:{cl}\n') with open('lex_err.txt', 'w', encoding='utf-8') as f: for ln, cl, msg in errors: f.write(f'[{ln}:{cl}] {msg}\n') print(f'tokens={len(tokens)}, errors={len(errors)}')

用制表符分隔而不是空格,是为了让lex.txt能直接被后续脚本按列切开。列名行用#开头,读取时跳过即可。行列号放在第三列,格式行:列,比单独两列更紧凑,人眼看也方便。

跑完这段,lex_err.txt里应该出现四条记录:abc123$后面的$非法字符、123@后面的@非法字符、2a整体非法词素、012345前导零。注意第一条和第二条,实验文档的描述是「标识符 123@ 中包含非法字符@」,但按最长匹配的正确行为,123是合法整数,@才是非法字符,两者应该拆开。这个差异不是谁对谁错,而是文档在手工分析时把123@当成一个整体看了,代码要按自动机的真实行为走。

行号从 1 开始、列号从 1 开始,这是编辑器和编译器的通用约定。如果列号从 0 开始,报错信息贴到编辑器里会整体偏移一格,用户点过去定位会差一位。这类约定不写清楚,后面接语法分析器时两边对不上,又要回来查。

5. 避坑:这份报告代码里五处会当场翻车的地方

文档里的代码骨架思路没问题,但直接复制粘贴运行,结果和预期差得很远。下面五处是我照着敲一遍之后逐条改出来的,每一条都按「现象、原因、解决」写,方便对照自己的版本查。

5.1 状态表层面的三处硬伤

第一处:除号永远识别不出来。

现象是输入a/b,输出的 token 流里没有/,反而整段被当成注释或错误。原因在状态转移表里,START状态对/的转移直接写成了COMMENT,也就是说读到第一个斜杠就无条件进注释态,根本没机会判断第二个字符。而注释态的转移表里只有SLASH -> COMMENT,没有回 START 的路,所以后续字符全被吞。

解决是把「斜杠」拆成两个阶段:先进入一个中间态,看下一个字符是不是/,是则进注释并跳到行尾,不是则退回来输出单字符/。用前瞻写法就是 4.2 里那句src.startswith('//', i)优先判断,判断不中再落到单字符运算符分支。

第二处:DELIMITER是个死状态。

现象是{、}、(、)、;全部被标成OPERATOR,类型统计里分界符数量为 0。原因在START状态的转移:'OPERATOR_DELIMITER': 'OPERATOR',把运算符和分界符合并到了一个状态,而DELIMITER这个状态在整张表里没有任何一条边指向它,从头到尾不可达。

解决有两个方向。要么在字符分类函数里把分界符单独拆成一类(DELIM_CHAR),START对它转移到DELIMITER;要么在输出阶段按词素内容二次分类,字符属于(){};就改标成DELIM。前者更符合自动机语义,后者改动小。我选前者,因为类型判断发生在扫描阶段比事后修补更不容易漏。

第三处:单字符 token 被下一个字符一起吞掉。

现象是x+1里的+消失,输出里出现一个ERROR类型的+1。原因是状态机在OPERATOR态读到下一个字符1(类别DIGIT)时,转移表给的是ERROR,于是两个字符一起进了错误态,+也没单独输出。

根因是接受态没有及时收尾。单字符运算符读到就该立刻产出 token 并回到START,而不是继续留在OPERATOR态等下一个字符。修复方式是在每次状态转移前判断当前状态是否为接受态,是就先 flush 一次 token,再处理新字符。分类扫描器写法天然没这个问题,因为每类词素的边界是显式的。

5.2 词素与位置层面的两处

第四处:标识符会把后面的运算符吃进去。

现象是abc* i被识别成一个 IDabc*,或者abc123$后面的内容整段异常。原因在ID状态的转移表:'OPERATOR_DELIMITER': 'ID',意思是标识符态遇到运算符还能继续留在标识符态。这条转移违反了标识符的正则定义——标识符只允许字母和数字,遇到任何运算符都必须收尾。

解决是把ID态对OPERATOR_DELIMITER的转移改成「触发收尾,回到START,再用当前字符重新分派」。这也是所有手写状态机都要遵守的一条规则:接受态的出口必须回到初始态重新判断,不能原地续命。文档里NUM态的处理是对的(遇到非数字一律ERROR),但ID态写反了,两处对照着看就能发现不一致。

第五处:错误只报内容不报位置,且错误分支会丢词素。

现象是跑完只看到「有非法字符」,不知道在第几行第几列,输入长一点就没法定位。原因有两层:扫描函数只返回 token 列表,没有维护行列号;ERROR态收尾时被显式排除在输出之外(if current_state not in {...'ERROR'}),错误词素既不进 token 也不进错误列表,直接蒸发。

解决是给扫描器加两个游标变量,随每次字符推进同步更新,产 token 和记错误时都把当前位置带上。错误词素不进lex.txt,但要单独写一份lex_err.txt,格式[行:列] 描述。另外文档的单词分类表把int同时列在「关键字」和「保留字」两栏,这种内部矛盾在交给别人复现前最好统一掉,否则读的人会以为两者有区别,实际在词法阶段是一回事。

6. 进阶:把状态表当数据,做自动验证与后续扩展

词法分析器写完之后,真正拉开差距的是怎么证明它对。靠人眼看几个例子不够,我的习惯是把测试用例写成表,跑一遍断言,输入输出全部落成数据。下面这张表覆盖了六类词法的主要边界,可以直接拿去当回归用例:

输入片段期望 token 序列考察点
int x;KEYWORD(int) ID(x) DELIM(;)关键字优先于标识符
x>=1ID OP(>=) NUM双字符运算符最长匹配
x>1ID OP(>) NUM单字符运算符不吃多
a//bID(a)注释直接丢弃,不产出 token
a/bID OP(/) ID除号不被注释截走
012345错误:前导零整数正则的零边界
2a错误:数字开头标识符首字符约束
abc$ID(abc) + 错误:非法字符$错误不吞合法词素

把这张表写成列表,每个用例断言tokens的类型序列和errors的条数,跑一次几毫秒。每次改状态表、改字符分类、改判断顺序,先跑一遍,全绿再往下接语法分析。这套东西看起来笨,但能挡住绝大部分「改 A 崩 B」的回归问题。

再往前一步,可以把状态表从代码里抽出来,变成外部数据。转移表写成 JSON 或者 Python 字典常量,扫描器只负责查表,这样加一条新词法规则不需要动主循环,改数据就行。抽表的时候有个细节要注意:接受态要单独标出来,不能靠状态名里有没有ACCEPT字样判断,否则重命名一次状态就全乱。我的做法是表里加一列accept: true/false,扫描器只认这一列。

还有一个衔接点值得提前想好:词法分析器的输出要喂给语法分析器,token 的类型名最好和语法分析里的终结符一一对应。上面代码用的是KEYWORD、ID、NUM、OP、DELIM五类,关键字的具体词形放在 token 文本里。如果语法分析那边需要把int、if当成不同终结符,就在词法阶段把KEYWORD再细分成KW_INT、KW_IF,别等到语法分析阶段再按文本二次判断——那样等于把词法的工作推迟了,后面递归下降写起来会很别扭。

最后说个血泪经验。我第一次做这个实验时,状态图画得挺漂亮,代码跑起来也能出结果,交之前随手换了个更长的测试程序,输出直接乱套。查了两小时才发现是行号没跟着切片推进,报错位置全错位,而我一直在看 token 类型对不对,压根没注意位置列。从那以后我每次写完扫描器,都强制先用一个二十行以上的多行输入跑一遍,专门核对错误位置的行列号是否和编辑器里一致,再去看 token 流。这份报告里的正则表、NFA 构造、最小化状态表和测试用例都整理在一起了,照着第 4 章的骨架敲一遍,比盯着图看半小时管用。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 11:20:54

Rust迭代器原理与日志处理实战

1. Rust迭代器核心原理剖析在Rust中&#xff0c;迭代器&#xff08;Iterator&#xff09;是处理集合数据的核心抽象。与C等语言不同&#xff0c;Rust的迭代器采用惰性求值&#xff08;lazy evaluation&#xff09;设计&#xff0c;这意味着在调用消费方法前不会真正执行计算。这…

作者头像 李华
网站建设 2026/10/31 11:19:14

Kali Linux虚拟机连接安卓模拟器的配置与优化

1. 为什么需要在Kali Linux虚拟机中连接安卓模拟器 作为一名长期从事移动安全测试的从业者&#xff0c;我经常需要在Kali Linux环境中对安卓应用进行渗透测试。但直接在Kali中运行安卓模拟器会导致性能严重下降&#xff0c;特别是在进行动态分析时&#xff0c;整个系统几乎无法…

作者头像 李华
网站建设 2026/10/31 11:18:08

HTML实战源码包:静态结构、表单验证、AJAX与本地存储全链路示例

简介&#xff1a;本资源是一套面向HTML初学者的系统性编程实践示例集&#xff0c;聚焦网页结构搭建与基础语义化标记能力培养&#xff0c;适用于零基础自学、前端入门教学或课后实训巩固。压缩包共14个文件&#xff0c;含12个HTML示例页&#xff08;涵盖frame框架、表格table、…

作者头像 李华
网站建设 2026/10/31 11:17:28

SAP S/4HANA物料细分策略:解决物料号膨胀与ATP隔离的配置与落地

简介&#xff1a;面向SAP物料管理&#xff08;MM&#xff09;顾问、实施人员及企业信息化从业者的方案分享型PPT&#xff0c;聚焦S4 HANA中Segmentation细分策略&#xff0c;帮助解决物料属性细微变化导致物料号膨胀、销售需求采购库存难以按属性区分的管理难题。压缩包共1个pp…

作者头像 李华
网站建设 2026/10/31 11:16:37

Unity射击游戏工程源码解析:从运行到二次开发

简介&#xff1a;这份资源是面向Unity3D初学者与游戏开发入门者的射击小游戏完整工程包&#xff0c;基于Unity5.0构建&#xff0c;适合用来练习脚本编程、粒子特效、音频管理与场景搭建等核心技能。压缩包共28个文件&#xff0c;约33.73MB&#xff0c;包含dll运行库、config配置…

作者头像 李华
网站建设 2026/10/31 11:15:08

ThingsBoard Gateway集成OPC-UA节点映射实战指南

简介&#xff1a;本资源是一份面向工业物联网开发者与自动化工程师的实操型技术文档&#xff0c;聚焦ThingsBoard Gateway与OPC-UA协议的集成落地&#xff0c;解决异构工业设备接入云端平台的核心难题。文档以KEPServerEX6模拟OPC-UA服务端、UaExpert作为调试客户端、ThingsBoa…

作者头像 李华