☰
编译原理词法分析器:最长匹配、DFA子集构造与手写扫描器实践
2026/10/28 9:10:17 网站建设 项目流程

简介:面向北京邮电大学编译原理课程实验学习者,这份词法分析器实验包围绕编译器前端的关键环节展开,帮助理解从字符流到词素序列的识别过程,并可作为课程实验实现与复盘参考。压缩包共4个文件,约10KB,以2个txt说明与测试数据、1个h头文件和1个cpp实现为主,分别承担实验说明、样例输入、符号/记号定义及扫描器核心逻辑,结构精简,便于快速定位词法规则定义、输入处理、分词与错误处理等模块。已有1000余人学习,适合需要对照课程要求梳理词法分析流程的读者。通过阅读与调试其中的源码,可观察正则式或词法规则如何映射为标识符、关键字、常量与运算符等词素,并理解符号栈管理与异常情况下的恢复思路,为后续语法分析实验打基础。

1. 词法分析器:编译器前端里最容易被低估的一环

编译原理课程实验里,词法分析器通常是第一个要交的东西,很多人觉得它没什么技术含量——不就是把字符流切成一个个单词吗?反直觉的地方在于:错误提示的质量、语法分析器的状态数、甚至整个前端跑多快,在词法分析这一层就已经基本定型了。一个把<=切成<和=的扫描器,会让语法分析器凭空多出一堆冲突;一个不记录行号列号的扫描器,后面所有报错都只能给你一句「语法错误」,形同黑匣子。词法分析器是整个编译前端里最小、最完整、能一晚上跑通的真实自动机,搞懂它,等于拿到了理解后续所有环节的第一把钥匙。

2. 从字符流到 Token 流:最长匹配和回溯是怎么省掉的

词法分析器的输入是一串字符,输出是一串 Token,中间这段路看起来平淡,但几乎所有实现层面的分歧都集中在两个问题上:指针怎么推进,以及「多看一个字符」这件事怎么表达。这一章先把扫描循环的最小骨架搭出来,再解释最长匹配规则为什么会让「偷看」变成必需品。

2.1 两个指针和一个哨兵:扫描循环的最小骨架

不管后面用 DFA 还是手写 switch,扫描器内部基本都靠两个下标来支撑:一个指向当前单词的起点,一个指向「还没确定归属」的下一个字符。学院派教材里把它们叫lexemeBegin和forward,实际代码里怎么命名无所谓,关键是两者的语义必须清晰——forward之前的字符已经确定属于当前 Token,forward之后的字符一概没看过。

/* 扫描器的最小骨架:lexeme_begin 是当前单词起点,forward_idx 是"下一个待读"位置 */ static int forward_idx; /* 指向尚未消费的字符 */ static int lexeme_begin; /* 指向当前正在识别的单词的起始位置 */ /* 统一的字符读取入口,所有推进都必须走这里 */ int next_char(Scanner *s) { int c = s->input[forward_idx]; if (c == '\0') { return EOF_CHAR; /* 用 '\0' 当哨兵,省掉每次读字符时的越界判断 */ } forward_idx++; return c; } /* 只回退一个字符,调用方负责保证不会退过头 */ void step_back(Scanner *s) { if (forward_idx > lexeme_begin) { forward_idx--; } } /* 偷看:不消费,只返回下一个字符 */ int peek_char(Scanner *s) { int c = s->input[forward_idx]; return (c == '\0') ? EOF_CHAR : c; }

这段代码里最值得说的是那个'\0'哨兵。把输入串的结尾补一个不可能出现在正常源码里的字符,读取函数就永远不需要判断forward_idx < len,每次读字符省掉一次比较和一次分支预测失败的可能。输入几十万字符时,这点差距是能测出来的。常见的实现思路是:文件读进内存后手动追加一个'\0',或者用 mmap 映射时确认文件尾部之后的那一页是零页。

另一个容易忽略的细节是返回值类型。next_char返回int而不是char,因为EOF_CHAR需要一个char表达不了的值(通常是-1)。在 x86 上char默认有符号,如果函数返回char,读到字节0xFF时会被符号扩展成-1,和 EOF 撞车,扫描器就会在文件中间莫名其妙地认为输入结束了。这个坑后面第 5 章还会展开说。

注意:step_back里那个边界检查不是防御性编程,而是真的会触发。识别1..2这种畸形输入时,很容易出现把指针退到lexeme_begin之前的情况,之后整个扫描就乱了。

2.2 最长匹配:<后面那个字符为什么不能急着吃掉

词法规则里有一条默认约定,几乎所有教材都会提一句但很少讲透:最长匹配(maximal munch)。它的意思是,当多条规则都能匹配当前位置的前缀时,选匹配长度最长的那个。这条规则听起来理所当然,落地时却直接决定了扫描器能不能正确处理<=、<<=、++、->这类多字符运算符。

考虑一段关系运算符的识别逻辑,如果写成「看到<就返回 LT」,那么a <= b会被切成a、<、=、b,语法分析器收到一个孤零零的=之后只能报错,而真正的错误位置在很前面的地方,报错信息会误导使用者。正确做法是先吃掉<,再看下一个字符是不是=:

/* 识别关系运算符:< <= <> > >= = == */ Token scan_relop(Scanner *s) { int c = next_char(s); switch (c) { case '<': if (peek_char(s) == '=') { next_char(s); return make_token(s, TOK_LE); } if (peek_char(s) == '>') { next_char(s); return make_token(s, TOK_NE); } return make_token(s, TOK_LT); /* 偷看失败,一个字符都不多消费 */ case '>': if (peek_char(s) == '=') { next_char(s); return make_token(s, TOK_GE); } return make_token(s, TOK_GT); case '=': if (peek_char(s) == '=') { next_char(s); return make_token(s, TOK_EQ); } return make_token(s, TOK_ASSIGN); default: return err_token(s, "无法识别的运算符"); } }

这里的关键是peek_char和next_char的分工。peek_char只读不消费,判断成立之后才用next_char真正吃掉那个字符。如果偷看失败,指针原地不动,下一个 Token 的起点自然就落在正确的位置上。这就是「前瞻一个字符」在代码里的样子——它不需要任何回退,因为压根就没往前走。

真正的回退发生在更复杂的场景。比如某门课的实验要求支持>>=右移赋值,扫描>之后要看两个字符:先看是不是>,是的话再看是不是=。如果第二个判断失败,就得回退一个字符,把已经吃掉的>吐出来,让它成为下一个 Token 的一部分。三步判断加两次回退,代码就开始难看了,这也是为什么很多人宁愿把运算符表写成一个switch里的嵌套if。

2.3 回退的代价:为什么正则引擎会退化成指数级,而 DFA 不会

手写扫描器里回退一个字符是常数时间,代价可以忽略。但如果把「匹配失败就回退重试」这个思路推广到通用的正则匹配,问题就来了。经典例子是模式a*a*a*...a*(n 个a*)去匹配aaaa...a(n 个a后面跟一个b):每个a*都可以选择吃掉不同数量的a,失败之后回溯的组合数是指数级的。血泪经验是,很多文本编辑器早期版本在超长行上卡死,根子就在这种回溯式匹配。

词法分析器之所以不受这个问题的困扰,是因为它面对的不是任意正则表达式,而是「每个 Token 一条规则、所有规则互不重叠到需要复杂回溯」这个受限场景。更本质的原因是,正规语言(regular language)一定可以被某个 DFA 在不需要任何回退的情况下识别——DFA 的每一步都只依赖当前状态和当前字符,读到什么就转到什么状态,压根不存在「先走一步看看,不行再退回来」的概念。最长匹配在 DFA 上的实现方式是:一直走到「下一步会进入死状态」为止,然后记录最后一次经过的接受状态,把指针退回到那个位置。这次回退最多一次,且回退距离等于最后一个 Token 之后多读的字符数,在词法规则下这个数通常不超过 2。

所以两条路线的分野很清楚:手写扫描器靠人肉编码来保证「最多回退一两个字符」,DFA 靠自动机构造从数学上保证「只需要一次回退」。前者灵活、好调试、错误信息容易做;后者正确性有保证、不怕规则复杂,但生成的代码像天书。第 6 章还会回到这个取舍上。

3. 正则表达式到 DFA:子集构造法到底在构造什么

如果实验要求从规则文件自动生成扫描器,那大概率绕不开 Thompson 构造 + 子集构造这条链路。很多人在这个环节是靠背步骤过关的:先给每个正则加 ε 边连成一整个 NFA,再求 ε-闭包,再对字母表做 move,最后填表。做完之后能跑,但说不清每一步到底在干什么。这一章把「子集构造」这个名字拆开看——它构造的不是状态,而是状态的集合。

3.1 Thompson 构造:把一条正则拆成一堆带 ε 边的状态

Thompson 构造的规则非常机械,每个正则算子对应一种小的状态图拼装方式:连接就是把两个片段首尾相接,选择就是加一个新的分叉起点和汇合终点,闭包就是把片段的首尾连起来并在中间加一条 ε 边跳过整个片段。拼完之后得到一个 NFA,它有明确的起点和唯一的接受状态,中间挂着一堆不消耗任何字符的 ε 转移。

ε 边的存在让 NFA 变得「不确定」:站在某个状态上,不读任何字符就能跳到另外几个状态。这种不确定性对人来说很直观(「这里可能是标识符的开头,也可能是关键字的开头」),对机器来说却很麻烦,因为要模拟它就得同时跟踪一组状态。这就是为什么需要下一步。

值得一提的是,所有词法规则拼在一起的方式:给每条规则单独构造一个 NFA 片段,然后加一个新的全局起点,用 ε 边连到每个片段的起点。这样多个 Token 的识别就变成了「在一个大 NFA 里跑,看最后停在哪条规则的接受状态上」。但这里有个陷阱——最长匹配要求记录的是最后一次经过接受状态的位置,而不是第一次。第一次进入接受状态就返回,if和ifx就分不清了。

3.2 子集构造:DFA 的每个状态其实是一组 NFA 状态

子集构造的思想一句话能说完:NFA 在读完某个前缀后可能处在哪些状态的集合,就是 DFA 的一个状态。因为 NFA 的状态数有限,所以可能的集合数也有限(最多 2^n 个),DFA 一定能建出来。

# 子集构造:从起始状态的 ε-闭包出发,BFS 把所有可达的 DFA 状态挖出来 def subset_construction(nfa, start, rules): d0 = epsilon_closure({start}) # 起始状态必须先做 ε-闭包 dfa_id = {frozenset(d0): 0} # 用 frozenset 当 key,因为它可哈希 worklist = [frozenset(d0)] trans = {} # (dfa_state, char) -> dfa_state accepts = {} # dfa_state -> 命中的规则编号 while worklist: s = worklist.pop() for ch in alphabet(nfa): # 按字符类而不是按字符遍历,见 3.3 t = epsilon_closure(move(s, ch)) # 先走一步,再闭包 if not t: # 空集相当于死状态,直接不建 continue key = frozenset(t) if key not in dfa_id: dfa_id[key] = len(dfa_id) worklist.append(key) trans[(dfa_id[s], ch)] = dfa_id[key] # 只要集合里含有某条规则的接受状态,这个 DFA 状态就是接受状态 hit = [r for r in rules if r.accept in s] if hit: accepts[dfa_id[s]] = min(hit, key=lambda r: r.priority) return dfa_id, trans, accepts

几个参数值得说清楚。epsilon_closure求的是一个状态集合在 ε 边上能到达的全部状态,实现上就是一个栈式 DFS。move(s, ch)求的是集合s里所有状态读入ch之后能到达的状态(不含 ε),然后再做一次闭包。if not t: continue那句是优化也是简化——空集意味着「从这组状态出发,读这个字符必死」,既然必死就不用建这个状态,查表时找不到对应项就当错误处理。accepts里的priority解决的是规则重叠问题:if既是关键字规则的接受串,也是标识符规则的接受串,得靠规则顺序决定谁赢。

子集构造的理论最坏复杂度是 2 的 n 次方,但这个词法分析领域几乎不会踩到。原因很实际:词法规则之间高度不重叠,绝大多数 Token 的首字符就决定了它属于哪条规则,所以实际建出来的 DFA 状态数通常只有几十个,和手写的状态机规模差不多。

3.3 状态表的落地:字符类、默认状态和表压缩

DFA 建好之后要变成代码里的数据结构。最直接的是二维数组trans[state][char],如果字母表按 128 个 ASCII 字符算、状态 60 个,那就是 7680 个表项,一个int16数组不过 15KB,完全不用优化。真要压,有三种常见做法。

第一种是字符等价类。扫描一遍所有状态的出边,把「在所有状态下的转移目标都相同」的字符合并成一类。词法分析器里这个压缩比很夸张:26 个字母加下划线、10 个数字,往往能合并成几个类,128 列直接掉到十几列。第二种是默认状态,把大部分状态里转移目标都是「错误」的那些项省掉,查表时先查一个稀疏表,查不到就跳到默认状态。第三种是行位移压缩,把二维表压成一维,用base[state] + char去查,适用于状态多、每行非空项少的情况。

提示:如果实验要求不高,别急着做表压缩。一个 128×60 的二维int数组既好调试又能直接打印出来看,压缩之后出问题反而难查。压缩是性能优化,不是正确性需求。

另一个容易被忽略的设计点是:关键字通常不进 DFA。把if、while这些串单独建成 NFA 分支,会让 DFA 的状态数暴涨,而且每加一个关键字就要重新生成一遍表。更常见的做法是让 DFA 只识别id,识别完之后拿这个词去关键字表里查一次,查到就改 Token 类型,查不到就是普通标识符。这个「先当标识符、再查表」的两段式,是词法分析里最经典的设计取舍之一。

4. 手写扫描器的源码结构:一个 while + switch 能扛住什么

真实工程里,纯手写的扫描器比自动生成的多得多。原因不复杂:手写的好调试、错误信息能写到具体字符、加特例(比如预处理指令、原始字符串字面量)时不用回头改语法文件。这一章按一个完整的手写扫描器的结构走一遍,从主循环到关键字查表再到数字字面量。

4.1 主循环:空白、注释、错误三条支路

主循环的职责就一个:返回下一个有意义的 Token。所以它得先把所有「不产生 Token 的东西」吃掉——空格、制表符、换行、两种注释——然后才进入真正的识别逻辑。

/* 扫描主循环:跳空白和注释,然后分派到具体的识别函数 */ Token *next_token(Scanner *s) { for (;;) { skip_whitespace(s); /* 空格 \t \r \n,顺带维护 line / column */ if (s->input[s->pos] == '/' && s->input[s->pos + 1] == '/') { skip_line_comment(s); /* 一直吃到 \n 或 EOF,注意 EOF 要单独处理 */ continue; } if (s->input[s->pos] == '/' && s->input[s->pos + 1] == '*') { skip_block_comment(s); /* 内部自己处理未闭合的情况 */ continue; } break; /* 剩下的就是 Token 起点了 */ } lexeme_begin = s->pos; s->tok_line = s->line; s->tok_col = s->column; /* 记下起点位置,报错时要用 */ if (s->pos >= s->len) return make_token(s, TOK_EOF); int c = peek_char(s); if (is_alpha(c) || c == '_') return scan_ident_or_keyword(s); if (is_digit(c)) return scan_number(s); if (c == '"') return scan_string_literal(s); if (c == '\'') return scan_char_literal(s); return scan_operator_or_punct(s); }

for (;;)加continue的结构比while (has_token)之类的写法好在哪?好在它把「跳过无关内容」和「识别 Token」这两件事在控制流上分开了。跳过阶段可能连续遇到空白、行注释、块注释、再空白、再注释,continue让这个循环自然成立,不需要在外层再套一层。而break之后的部分只在确定「当前位置确实是一个 Token 的开头」时才执行。

skip_block_comment是这段代码里最容易写错的地方,因为它涉及两条错误路径:一是遇到 EOF 还没找到*/,说明注释没闭合;二是注释内部出现了/*,有些语言允许嵌套有些不允。前者必须把起始行号带进错误信息,否则使用者根本不知道去哪找;后者取决于语言规范,但无论选哪种都要写进文档,不能靠猜。

行号和列号的维护是另一个高频翻车点。skip_whitespace里遇到\n要把line加一、column归零,遇到\t要不要按 8 列算、遇到\r\n是两个字符还是一个,这些细节不写清楚,报错时给出的列号就会整体偏移。常见的实现思路是先做一次输入归一化,把\r\n和单独的\r全换成\n,之后所有逻辑只面对一种换行符,省心很多。

4.2 标识符与关键字:为什么关键字要查表而不是进 DFA

标识符的识别本身很简单:首字符是字母或下划线,后续字符是字母、数字或下划线。真正有设计空间的是「识别完之后怎么判断它是不是关键字」。

三种常见实现:哈希表、排序数组二分、字典树。哈希表最通用,但需要运行时初始化和处理冲突;字典树查得最快且前缀共享,但构建代码略多;排序数组二分是课程实验里性价比最高的选择——关键字表在编译期就固定了,排好序放进一个静态数组,运行时零初始化开销。

/* 关键字表:按字典序排好,二分查找,静态数组,零运行时初始化 */ static const char *const KEYWORDS[] = { "break", "case", "char", "const", "continue", "default", "do", "double", "else", "enum", "extern", "float", "for", "goto", "if", "int", "long", "register", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef", "union", "unsigned", "void", "volatile", "while" }; /* 只比较前 len 个字符,且要求表项长度恰好等于 len,避免 "ifx" 命中 "if" */ TokenType lookup_keyword(const char *s, int len) { int lo = 0, hi = (int)(sizeof(KEYWORDS) / sizeof(KEYWORDS[0])) - 1; while (lo <= hi) { int mid = (lo + hi) / 2; int cmp = strncmp(s, KEYWORDS[mid], (size_t)len); if (cmp == 0) { if (KEYWORDS[mid][len] == '\0') return TOK_KW_BASE + mid; /* 长度也对上 */ cmp = -1; /* 表项更长,说明当前串字典序更小 */ } if (cmp < 0) hi = mid - 1; else lo = mid + 1; } return TOK_ID; /* 表里没有,就是普通标识符 */ }

这段代码里最关键的不是二分,而是KEYWORDS[mid][len] == '\0'这个判断。只做strncmp的话,识别出if之后去查表,strncmp("ifx", "if", 3)会读越界;就算用len限定了比较长度,ifx的前两个字符也和if完全相等,会被误判成关键字。加上长度恰好相等的检查才安全。

另一种做法是把关键字和标识符统一交给符号表。符号表初始化时把关键字以「预置条目」的形式插进去,查表接口只有一个find(s),返回非空就说明是关键字,返回空就新建一个标识符条目。这种设计的好处是后面语法分析、语义分析都能复用同一个符号表接口,坏处是符号表的实现质量会直接拖累词法分析的性能——每识别一个标识符都要做一次哈希和内存分配。

注意:标识符的长度上限要定死。很多实验实现里用一个固定大小的缓冲区存当前单词,超出长度就截断,结果两个长标识符被当成同一个,报错信息还显示成截断后的样子,排查起来非常痛苦。

4.3 数字字面量:状态机写法与边界条件

数字是词法分析里边界条件最多的 Token。整数、定点小数、科学计数法、十六进制、八进制、各种后缀,每种都有自己的合法形式,还要处理一堆畸形输入。

/* 数字扫描:一次走完整个字面量,即使出错也要把整个串吃掉再报错 */ Token scan_number(Scanner *s) { int is_float = 0; if (peek_char(s) == '0' && (s->input[s->pos + 1] == 'x' || s->input[s->pos + 1] == 'X')) { advance(s); advance(s); /* 吃掉 0x */ if (!is_hex(peek_char(s))) return err_token(s, "0x 后缺少十六进制数字"); while (is_hex(peek_char(s))) advance(s); return make_token(s, TOK_INT); } while (is_digit(peek_char(s))) advance(s); if (peek_char(s) == '.') { /* 小数点必须跟数字,或前面已有数字 */ is_float = 1; advance(s); while (is_digit(peek_char(s))) advance(s); } if (peek_char(s) == 'e' || peek_char(s) == 'E') { is_float = 1; advance(s); if (peek_char(s) == '+' || peek_char(s) == '-') advance(s); if (!is_digit(peek_char(s))) return err_token(s, "指数部分缺少数字"); while (is_digit(peek_char(s))) advance(s); } return make_token(s, is_float ? TOK_FLOAT : TOK_INT); }

这段代码的写法有个统一原则:出错时也要把整个字面量消费掉。如果1e+在报完错之后指针停在e上,下一轮扫描会把e当成一个标识符,再后面可能又触发一堆莫名其妙的错误,错误信息从一条变成五条,真正的问题反而被淹没。一次性把整个畸形串吃掉,错误数量就等于问题数量,这是编译器错误恢复里最基本的策略。

还有一个容易忽略的细节:.5这种写法在 C 里是合法的(等价于0.5),但代码里scan_number的入口条件是is_digit(peek_char(s)),.开头的根本进不来。要么在分派逻辑里判断「.后面跟数字」也走数字扫描,要么明确不支持这种写法并在文档里写清楚。两种都行,但必须有意识地做决定,不能靠碰巧。

5. 避坑与排查:词法分析器最容易翻车的五个地方

前四章讲的是设计,这一章讲的是实际写代码时会撞上的东西。这五个问题有个共同点:现象看起来像别的地方出了问题,排查半天才发现根子在扫描器。

5.1 字符与缓冲区层面的三个坑

现象一:源文件最后一行没有换行符时,最后一个 Token 丢了,或者程序直接死循环。

原因通常出在循环条件上。如果跳过空白的逻辑写成while (cur != '\n') advance();,那读到文件结尾时既不会遇到\n,也不会自然退出,advance一直往后读,读到内存里随机的内容才停;另一种实现是用\n作为 Token 的分隔依据,最后一行没有\n,最后一个 Token 就一直处于「未完成」状态,函数返回时没被交出去。解决办法是彻底把 EOF 当成一种普通字符来处理:输入缓冲区末尾补'\0',next_char读到它就返回EOF_CHAR,所有循环条件统一写成while (peek_char(s) != EOF_CHAR && !is_terminator(peek_char(s)))。所有「读到某个字符为止」的逻辑都必须显式包含 EOF 分支,这是写扫描器时的第一条纪律。

现象二:在 Windows 上运行,报错行号是实际行号的两倍,或者块注释之后的所有代码都不见了。

原因是\r\n被当成两个换行。skip_whitespace里遇到\r加一次行号,遇到\n又加一次;或者行注释的结束条件写成「读到\r为止」,结果\r之后剩下的\n被当成一个空行继续留在流里,行号统计就整体偏移了。更隐蔽的一种情况出现在字符串字面量里:"abc\r\n"这种跨行字符串,扫描逻辑只认\n,\r被当成普通字符塞进字面量里,最后字符串常量的实际内容和预期差一个字节。解决办法是在读文件之后立刻做一次归一化,把所有\r\n和孤立的\r都替换成\n,并在文档里注明行号统计基于归一化之后的文本。

现象三:在某些输入上扫描结果完全正确,换一个文件就开始把标识符识别错,甚至触发 EOF。

这类问题的经典根源是char的有符号性。EOF在标准库里定义为-1,如果扫描函数签名写成char next_char(),读到字节0xFF时会被符号扩展成-1,和EOF完全一样,扫描器就会在文件中间错误地宣布输入结束。UTF-8 编码的中文字符里有大量0x80以上的字节,所以在源码里写中文注释或者中文字符串,这个 bug 迟早会暴露。解决办法是全链路用int存字符:next_char返回int,缓冲区指针用unsigned char *读,比较之前先转成unsigned char。这条经验几乎每个写过 C 语言文本处理的人都有过一次,代价通常是一整个下午。

5.2 规则与状态层面的两个坑

现象四:输入1.2.3时,编译器吐出五条错误,第二条开始全是「意外的符号.」。

原因就是 4.3 节说的那个问题:数字扫描遇到第二个.时提前返回了,指针停在第二个.上,下一轮扫描把它识别成一个独立的标点 Token,语法分析器收到INT DOT INT DOT INT这样的序列,自然一脸懵。解决办法是让数字扫描的错误路径负责「吃掉整个字面量」:从当前起点开始,只要后续字符可能是数字字面量的一部分(数字、.、字母、+/-指数符号),就继续消费,直到遇到明确的终止符为止,然后再一次性报错。这个策略在编译器前端叫 panic mode 恢复,词法层的实现比语法层简单得多,但效果很明显。

现象五:a+++b被切成了a+++b,和预期不一致;或者块注释/* ... */里再出现/*时行为诡异。

前者是最长匹配方向搞反了的典型症状。正确的切法是a+++b:从a之后的位置开始,+和++都能匹配,取长的++,剩下的+b里再取+。如果实现里写成「先试单字符再试双字符,成功就返回」,就会得到+++这个错误切分。正规做法是有序地尝试所有可能的长度,从最长往最短试,第一个成功的就采用。后者涉及注释的嵌套语义,C 系语言不允许嵌套,所以扫描到第一个*/就结束,注释内部的/*是普通内容。但如果实现里用了递归或者栈来处理注释,就可能出现嵌嵌套行为,和语言规范不符。解决办法是把注释扫描写成一个简单的线性循环,遇到*/立刻退出,中途什么都不记。

6. 设计取舍:手写还是生成,以及怎么验证一个扫描器是对的

走到这里,前面所有机制都可以归结到一个选择上:扫描器是手写,还是用规则文件生成。

维度手写扫描器规则文件 + 生成器
起步成本低,一个while加switch就能跑中,要先写规则、跑生成器、再接生成代码
加新 Token改代码,可能要动主循环改规则文件,重新生成
错误信息可以精确到字符,能写任意提示语只能报「无法匹配」,需要额外映射表
性能可控,能针对热点手写优化通常更快,表驱动,没有分支预测失败
可调试性高,设断点就能看状态低,生成出来的代码基本没法读
规则复杂时容易写成一大坨嵌套if状态数自动膨胀,但正确性有保证
非 ASCII 支持自己处理编码转换取决于生成器的字母表定义

这张表最能说明问题的一行是「错误信息」。真实语言的编译器里,用户看到的报错质量很大一部分由扫描器决定:是「第 12 行第 8 列附近有一个无法识别的字符@」,还是「lex error at offset 4213」,体验差距巨大。生成器路线要做到同样的效果,得在自动机外面再套一层「死状态 → 具体错误消息」的映射,映射本身的维护成本往往超过手写的收益。

但手写也有明显的天花板。规则一复杂,嵌套if就开始互相干扰,改一个 Token 的识别逻辑可能影响到三个不相干的地方。血泪经验是:手写扫描器一旦超过三百行,就该考虑把规则形式化,哪怕不真的跑生成器,也要先写出规则表,再照表实现。

验证方法这块,我习惯先做一件很土的事:写一个 token dump 工具,把源文件喂进去,输出(类型, 词元文本, 行, 列)四元组,然后拿几个精心构造的输入去跑。这些输入应该覆盖几类边界:只有空白和注释的文件、最后一行没换行的文件、含\r\n的文件、所有多字符运算符连写(<<=>>===!=)、数字的畸形写法(1.2.3、1e+、0x)、超长标识符、注释未闭合。每类输入的预期输出都是人肉核对的,对不上就说明扫描器有问题——这种测试比任何单元测试框架都直接,因为它输出的就是 Token 流本身,没有任何抽象层可以藏 bug。

第二个验证手段是可逆性检查。对大多数语言来说,把 token dump 里所有词元文本按顺序拼起来,应该等于原文件去掉空白和注释之后的内容。这个性质能一次性抓出一大类问题:漏掉字符、重复消费字符、回退过头。实现起来也不难,扫描器跑两遍,一遍出 Token 流,一遍出「去掉空白注释的原文件」,两个字符串比对。这个检查在字符编码出问题时特别有效,因为漏掉一个字节在单看 Token 流时根本看不出来。

第三个是差分测试。如果目标语言已经有一个成熟的开源实现,就把同一批源文件分别喂给两个扫描器,比较 Token 序列。不一致的地方有两种可能:要么自己的实现有 bug,要么那个实现对规范的解读不同,两种都值得深挖。这种做法在课程实验里可能没有现成的参考实现,但如果有,收益极高。

从那以后我每次写扫描器,都会先把 token dump 的断言写出来再写扫描逻辑:先定义清楚「这段输入应该产出哪几个 Token、每个的行列号是多少」,再去实现。这个顺序反过来做,就会变成「先写一堆if,跑一遍发现能出 Token,就认为对了」,然后在校验阶段被一个\r\n或者一个1e+打回原形。这份课程实验的资源可以当对照本来用:先看它的主循环怎么组织、空白和注释怎么跳、关键字查表用的是什么结构,再拿上面那几组边界输入去跑一遍,看它的 Token 流和你预期的是否一致——读别人的扫描器代码,比读任何教材上的自动机理论都更能建立起对词法分析的直觉。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询