编译原理第三章:编译器是怎么"认字"的?------词法分析全解析
❝
上一章我们聊了文法和语言的形式化理论,知道了怎么用有限的规则描述无限的语言。但理论归理论,编译器到底是怎么把这些理论变成代码、真正"扫描"你写的程序的呢?答案就在词法分析------编译的第一道关卡。今天我们就来深入拆解它的核心武器:正则表达式和有穷自动机。
一、正则表达式------用一行公式描述一种"单词"
在上一章里,我们用正则文法(3型文法)来描述各种Token的结构,比如标识符、整数常量等。但正则文法的写法有点啰嗦------为了描述"标识符是以字母开头、后跟任意个字母或数字",你需要写一堆产生式。有没有更紧凑的写法?
有,那就是正则表达式(Regular Expression, RE)。
正则表达式是一种用来描述正则语言的更紧凑的表示方法。你可以把它理解为"正则文法的压缩包"------同样的语言,用 RE 表达起来简洁得多。
正则表达式的定义
正则表达式是递归定义的,从最基本的构件开始,逐步组合出复杂的模式:
基础构件:
-
ε 是一个 RE,它表示的语言是 L(ε) = {ε}(只有空串)。
-
如果 a ∈ Σ,则 a 是一个 RE,L(a) = {a}(只有一个符号 a 的串)。
组合规则(假设 r 和 s 都是 RE,分别表示语言 L(r) 和 L(s)):
-
选择(Alternation):r | s 是一个 RE,L(r|s) = L(r) ∪ L(s)。读作"r 或 s"。
-
连接(Concatenation):rs 是一个 RE,L(rs) = L(r)·L(s)。两个语言做连接运算。
-
克林闭包(Kleene Star):r* 是一个 RE,L(r*) = (L(r))*。零次或多次重复。
-
括号:(r) 是一个 RE,L((r)) = L(r)。改变运算优先级。
运算符优先级(从高到低):* > 连接 > |
这就像数学里的运算优先级一样:先算乘方(*),再算乘法(连接),最后算加法(|)。所以 a|b*c 的含义是 a | (b*·c),而不是 (a|b)*·c。
几个例子
设字母表 Σ = {a, b}:
L(a|b) = {a} ∪ {b} = {a, b}------要么 a,要么 b。
L((a|b)(a|b)) = {a, b}·{a, b} = {aa, ab, ba, bb}------所有长度为 2 的 a、b 串。
L(a*) = {a}* = {ε, a, aa, aaa, ...}------任意个 a(包括零个)。
L((a|b)*) = {a, b}* = {ε, a, b, aa, ab, ba, bb, aaa, ...}------任意长度的 a、b 串(包括空串)。
L(a|a*b) = {a, b, ab, aab, aaab, ...}------一个 a,或者若干个 a 后面跟一个 b。
编程中的实际应用
来看几个直接跟写代码相关的 RE:
C语言十进制无符号整数 :(1|...|9)(0|...|9)* | 0
这个 RE 的含义是:要么是一个 1-9 开头的数字后面跟任意个 0-9 的数字,要么就是单独的一个 0。为什么要区分?因为 007 在 C 语言里不是十进制------它是八进制!所以十进制整数不能以 0 开头(除非它就是 0 本身)。
C语言八进制整数 :0(0|1|2|3|4|5|6|7)(0|1|2|3|4|5|6|7)*
以 0 开头,后面跟八进制数字。所以 0777 是合法的八进制数,但 089 不是。
C语言十六进制整数 :0x(0|...|9|a|...|f|A|...|F)(0|...|9|a|...|f|A|...|F)*
以 0x 开头,后面跟十六进制数字。
你会发现,正则表达式虽然紧凑,但写长了也挺难读的。这就引出了"正则定义"这个改进方案。
二、正则定义------给正则表达式起个名字
正则表达式虽然比正则文法紧凑,但在实际使用中,我们往往需要描述非常复杂的模式(比如一个完整的浮点数),直接写 RE 会又长又难读。
正则定义(Regular Definition) 的解决办法很简单:给子表达式取个名字,然后在后面的表达式中直接引用这些名字。形式化地说,正则定义是一个命名序列:
d₁ → r₁
d₂ → r₂
...
dₙ → rₙ
其中每个 dᵢ 是一个新符号(不在字母表 Σ 中,且互不相同),每个 rᵢ 是 Σ ∪ {d₁, d₂, ..., dᵢ₋₁} 上的正则表达式。也就是说,后面的定义可以引用前面已经定义过的名字。
实例:C语言标识符
digit → 0|1|2|...|9
letter_ → A|B|...|Z|a|b|...|z|_
id → letter_(letter_|digit)*
先定义 digit 表示单个数字,letter_ 表示字母或下划线,然后用它们组合出 id(标识符)的定义。一目了然,比直接写一个超长的 RE 可读性好太多。
实例:无符号数(整型和浮点型)
digit → 0|1|2|...|9
digits → digit digit*
optionalFraction → .digits | ε
optionalExponent → (E(+|-|ε)digits) | ε
number → digits optionalFraction optionalExponent
这个定义描述了所有合法的无符号数:2、2.15、2.15E+3、2.15E-3、2.15E3、2E-3 等等。注意 optionalFraction 和 optionalExponent 都允许为空(ε),所以纯整数也满足这个定义。
如果你尝试直接把这些定义"内联"展开成一个纯正则表达式,会得到一个极其恐怖的式子。正则定义的价值就在于模块化和可读性------每个部分各司其职,维护起来也方便。
RE的代数定律
正则表达式满足一系列代数定律,类似于数学中加减乘除的交换律、结合律、分配律。掌握这些定律不仅有助于理解 RE 的本质,还能帮助你化简复杂的表达式:
交换律:r|s = s|r(选择可以交换)。
结合律:r|(s|t) = (r|s)|t,r(st) = (rs)t(选择和连接分别满足结合律)。
分配律:r(s|t) = rs|rt,(s|t)r = sr|tr(连接对选择满足分配律)。
单位元:εr = rε = r(空串是连接的单位元,就像 1 是乘法的单位元)。
闭包含ε:r* = (r|ε)*(闭包运算本身就包含零次重复,即空串)。
幂等性:r** = r*(闭包的闭包等于闭包本身------重复"重复零次或多次"还是"零次或多次")。
这些定律在实际中有什么用?
举个例子:假设你想化简 (a|b)* 和 (a*|b*)*,利用代数定律可以证明它们表示的语言相同------后者看起来复杂得多,但本质上就是前者的另一种写法。
三、正则文法与正则表达式的等价性
到了这里,我们有了三种描述正则语言的工具:正则文法、正则表达式、以及即将登场的有穷自动机。它们之间有什么关系?
一个重要定理是:正则文法和正则表达式是等价的。具体来说:
-
对任何正则文法 G,存在一个正则表达式 r,使得 L(r) = L(G)。
-
对任何正则表达式 r,存在一个正则文法 G,使得 L(G) = L(r)。
这意味着它们描述的是同一类语言------正则语言,只是表达方式不同。正则文法像是"生产配方"(怎么一步步构造出合法的串),正则表达式像是"产品说明书"(直接描述合法串的长相),而后面要讲的有穷自动机则是"质检机器"(给一个串,判定它合不合法)。
这三种工具之间的等价关系,构成了词法分析的理论基础:用正则表达式定义词法规则 → 转换为有穷自动机实现识别。
四、有穷自动机------编译器的"识字机器"
从理论到实践
正则表达式告诉我们"什么样的串是合法的",但编译器需要的是一个算法------给定一串字符,怎么一步步判断它是不是某种Token?
这就需要有穷自动机(Finite Automaton, FA)。
有穷自动机的概念最早由两位神经物理学家 McCuloch 和 Pitts 在 1948 年提出,最初是用来对人类神经系统的建模。后来人们发现,这个模型恰好能完美地实现正则语言的识别。
FA的工作模型
一个FA由三个部分组成:
输入带(Input Tape):存放待识别的输入符号串,可以看作一条纸带,上面依次写着要识别的字符。
读头(Read Head) :从左到右逐个读取输入符号。注意:读头是只读 的(不能修改输入),而且是单向的(只能从左往右,不能回头)。
有穷控制器(Finite Control):具有有限个状态,根据当前状态和当前输入符号来决定转入下一个状态。
你可以把 FA 想象成一台自动售货机:你依次投入硬币(输入符号),机器根据当前已投入的金额(内部状态)和你投入的硬币面值(当前输入)来决定是继续等待、还是出货、还是退钱(状态转换)。售货机不需要"记住"你投币的完整历史,只需要知道当前的总金额就够了。
FA的表示------转换图
FA 通常用**转换图(Transition Graph)**来表示:
-
节点代表状态。
-
开始状态只有一个,用一条 "start" 箭头指向它。
-
终止状态(接收状态)可以有多个,用双圈表示。
-
有向边代表状态转换,边上标记触发该转换的输入符号。

FA接收的语言
给定输入串 x,如果存在一条从初始状态到某个终止状态的转换路径,路径上所有边的标记依次拼出 x,那么称 x 被该 FA 接收。由 FA M 接收的所有串构成的集合就是 M 定义的语言,记为 L(M)。
比如一个识别"以 abb 结尾的 {a,b} 上的串"的 FA,它接收 abbaabb,但不接收 abab。
最长子串匹配原则
词法分析中有一条非常重要的规则:最长子串匹配原则(Longest Substring Matching Principle)------当输入串的多个前缀与一个或多个模式匹配时,总是选择最长的前缀进行匹配。
举个例子:

假设源程序中有 <= 这个字符序列。它可以被匹配为两个独立的Token(< 和 =),也可以被匹配为一个Token(<=)。按照最长匹配原则,编译器会选择后者------把 <= 识别为一个"大于等于"运算符,而不是两个独立的符号。
又比如 while 既可以被匹配为关键字 while,也可以被匹配为标识符 while(因为它也满足标识符的规则)。这时候编译器会优先匹配关键字------这通常通过在词法分析器中给关键字更高的优先级来实现。
FA 在实现最长匹配时的策略是:到达某个终止状态后,并不立即"收工",而是继续前进,尽可能匹配更长的串。只有当走不下去了(没有可用的转换),才回溯到最近一个终止状态,输出那个最长的匹配。
五、FA的分类------DFA与NFA
有穷自动机分为两种:确定的(DFA)和非确定的(NFA)。它们的核心区别在于:给定一个状态和一个输入符号,下一步去哪?
确定的有穷自动机(DFA)
DFA 的形式化定义是一个五元组:M = (S, Σ, δ, s₀, F)
-
S:有穷状态集
-
Σ:输入字母表(ε 不是 Σ 的元素)
-
δ :转换函数,S × Σ → S。即对于每个状态 s 和每个输入符号 a,恰好有一个确定的下一个状态
-
s₀:开始状态,s₀ ∈ S
-
F:接收状态集合,F ⊆ S
DFA 的关键词是"确定"------在任何状态下,面对任何输入符号,下一步去哪是唯一确定的。不存在"我既可以走这条路也可以走那条路"的情况。
DFA 可以用转换表来直观表示。比如一个识别"以 abb 结尾的串"的 DFA:

| 状态 | 输入 a | 输入 b |
|---|---|---|
| 0 | 1 | 0 |
| 1 | 1 | 2 |
| 2 | 1 | 3 |
| 3(终) | 1 | 0 |
从状态 0 出发,读入 a 到状态 1("已匹配 a"),再读 b 到状态 2("已匹配 ab"),再读 b 到状态 3("已匹配 abb",终止状态)。在状态 3 如果继续读 a 就回到状态 1(可能开启新一轮 abb 匹配),读 b 就回到状态 0。
非确定的有穷自动机(NFA)
NFA 和 DFA 的区别在于转换函数:δ 映射到的是状态的集合(2^S),而不是单个状态。也就是说,对于同一个状态和同一个输入符号,NFA 可以有多个"候选"的下一个状态。

比如一个 NFA,在状态 0 读入 a 后,可以同时到达状态 0 和 状态 1。NFA "选择"哪条路呢?答案是:它不需要选择。只要存在某条路径能到达终止状态,这个串就被接收。你可以把 NFA 想象成一个拥有"分身术"的探测器------遇到岔路时,它同时派出分身走每条路,只要有一个分身到达了终点,就算成功。

此外还有带 ε 边的 NFA,允许在不消耗输入符号的情况下进行状态转换(ε 转换)。这就像在自动售货机里有个"免费通行"按钮------不投币也能换个状态。ε 边使得 NFA 的构造更加灵活,特别是在从正则表达式转换时非常有用。
DFA和NFA的等价性
虽然 NFA 看起来比 DFA "强大"(因为它能"分身"),但它们在识别能力上是完全等价的:
-
对任何 NFA N,存在一个 DFA D,使得 L(D) = L(N)。
-
对任何 DFA D,存在一个 NFA N,使得 L(N) = L(D)。

也就是说,DFA 和 NFA 能识别的语言集合完全相同------都是正则语言。NFA 的优势在于构造方便 (从 RE 转换过来很自然),DFA 的优势在于运行高效(没有歧义,每一步都是确定的)。
所以实际的词法分析器通常采用这样的策略:先用 RE 描述词法规则,然后将 RE 转换为 NFA,再将 NFA 转换为 DFA,最后用 DFA 来执行实际的扫描。
正则文法、正则表达式、有穷自动机这三者之间的等价关系可以总结为:
❝
正则文法 ⟺ 正则表达式 ⟺ FA(DFA/NFA)
这三个"视角"看的是同一个东西------正则语言,只是分别从"生成规则""描述模式""识别机器"三个角度来刻画它。
六、从正则表达式到有穷自动机------三步构建词法分析器
从 RE 到 DFA 的转换分为两步:先构造 NFA,再把 NFA 确定化为 DFA。
第一步:RE → NFA(Thompson构造法)
Thompson 构造法的核心思想是:为每种 RE 运算预制一个小 NFA 模块,然后根据 RE 的结构把这些模块拼装起来。
基础模块:
-
ε 对应的 NFA:两个状态,一条 ε 边连接。
-
符号 a 对应的 NFA:两个状态,一条标记 a 的边连接。
组合模块:
-
r₁r₂(连接):把 r₁ 的终止状态和 r₂ 的开始状态用 ε 边连起来。
-
r₁|r₂(选择):新建一个开始状态,用 ε 边分别连到 r₁ 和 r₂ 的开始状态;r₁ 和 r₂ 的终止状态用 ε 边连到一个新的终止状态。
-
r*(闭包):新建开始和终止状态,用 ε 边实现"跳过"(零次)和"循环"(多次)。
举个例子,RE (a|b)*abb 对应的 NFA 就是把 (a|b)* 的模块和 a、b、b 的模块依次连接起来。

Thompson 构造法的优点是机械、系统------给定任何 RE,都能按部就班地构造出对应的 NFA。缺点是会引入大量的 ε 边和冗余状态,构造出的 NFA 比较"臃肿"。
第二步:NFA → DFA(子集构造法)
子集构造法(Subset Construction)是把 NFA 确定化为 DFA 的标准算法。它的核心思想是:DFA 的每个状态对应 NFA 的一个状态子集。
为什么?
因为 NFA 在某个时刻可能"同时处于多个状态"(分身术),而 DFA 需要用单个状态来"记住"NFA 当前所有可能的状态。所以 DFA 的一个状态 = NFA 的一组状态。
算法过程:
-
初始状态是 ε-closure(s₀)------从 NFA 的开始状态 s₀ 出发,只通过 ε 转换能到达的所有状态的集合。
-
对于每个未处理的 DFA 状态 T(即 NFA 的状态子集),对每个输入符号 a:
-
计算 move(T, a):从 T 中的某个状态出发,通过标记为 a 的转换能到达的 NFA 状态集合。
-
计算 ε-closure(move(T, a)):再从这些状态出发,通过 ε 转换能到达的所有状态。
-
如果这个集合还没出现过,就加入为新的 DFA 状态。
-
重复步骤 2,直到没有新状态产生。
-
任何包含 NFA 终止状态的 DFA 状态都是 DFA 的终止状态。
这里有两个关键操作:
move(T, a):从状态集合 T 中的某个状态出发,通过标记为 a 的边能到达的 NFA 状态集合。
ε-closure(T):从 T 中的状态出发,只通过 ε 边能到达的所有状态。ε-closure 的计算可以用栈来实现:初始把 T 中所有状态压栈,然后反复弹出栈顶元素,把所有通过 ε 边可达的、尚未加入的状态加入结果集并压栈,直到栈为空。
子集构造法最坏情况下可能产生 2ⁿ 个 DFA 状态(n 是 NFA 的状态数),但实际上大多数 NFA 转换出来的 DFA 状态数远没有这么多。
七、识别各种Token的DFA实例
理论讲完了,来看几个实际词法分析器中用到的 DFA。
识别标识符的DFA
正则定义:
digit → 0|1|2|...|9
letter_ → A|B|...|Z|a|b|...|z|_
id → letter_(letter_|digit)*
对应的 DFA 非常简洁:
letter_
start ──────────→ [状态1]
↑ │
letter_ │ │ digit
│ ↓
[状态2] ← (终止,识别标识符)
从 start 读入一个字母或下划线进入状态 1,然后不断循环读入字母、数字或下划线(状态 2),直到遇到非标识符字符为止。

识别无符号数的DFA
浮点数的 DFA 要复杂得多,因为它需要处理整数部分、小数部分和指数部分的各种组合。NFA 有 7 个状态(对应 digit、小数点、指数标记 E、正负号等),转换为 DFA 后合并了一些状态,但仍然比标识符的 DFA 复杂不少。

识别各进制整数的DFA
C 语言的整数有三种进制:十进制(如 123)、八进制(如 0777,以 0 开头)、十六进制(如 0xFF,以 0x 开头)。识别它们的 DFA 需要在读入第一个字符时就做出"分支判断":
-
第一个字符是 1-9 → 走十进制路径
-
第一个字符是 0,第二个字符是 x → 走十六进制路径
-
第一个字符是 0,第二个字符是 0-7 → 走八进制路径
-
单独的 0 → 十进制的零
每个路径的终止状态携带不同的"种别码":(DEC, value)、(OCT, value)、(HEX, value),以便后续的语义分析阶段进行不同的数值转换。

识别注释的DFA
C 语言的块注释 /* ... */ 也有对应的 DFA:从 /* 进入"注释模式",然后忽略所有字符直到遇到 */ 为止。这个 DFA 的巧妙之处在于"其它"字符的自循环------在注释体内,任何不是 */ 的字符都让自动机留在当前状态,完美实现了"跳过一切"的效果。

识别多种Token的统一DFA
在实际的词法分析器中,所有类型的Token(标识符、数字、运算符、界限符等)会被合并到一个统一的 DFA 中。从 start 出发,根据第一个字符的不同走向不同的分支:字母走标识符分支,数字走常量分支,+ 走加法运算符分支(还要区分 + 和 ++),等等。每个分支到达终止状态时,输出对应的 Token 类型和属性值。

八、DFA的算法实现
DFA 的模拟运行非常简单,本质上就是一个 while 循环:
s = s₀; // 从初始状态开始
c = nextChar(); // 读入第一个字符
while (c != eof) {
s = move(s, c); // 根据当前状态和输入符号转入下一状态
c = nextChar(); // 读入下一个字符
}
if (s 在 F 中) return "yes"; // 到达终止状态,接受
else return "no"; // 未到达终止状态,拒绝
nextChar() 返回输入的下一个字符,move(s, c) 查转换表得到下一个状态。整个过程的时间复杂度是 O(n),其中 n 是输入串的长度------每读一个字符只需要做一次状态转换(常数时间操作)。这就是 DFA 的优势:运行极其高效,且时间与输入长度成线性关系。
九、词法分析阶段的错误处理
词法分析器也会遇到错误,但它能检测的错误类型比较有限:
单词拼写错误 :比如 0x3G(十六进制数里出现了不合法的 G),或者 1.05e(科学计数法的指数部分不完整)。
非法字符 :比如源程序中出现了 ~@ 等语言定义中不存在的符号。
错误检测机制
DFA 的错误检测逻辑很简单:如果当前状态和当前输入符号在转换表中对应的项为空(即没有可用的转换),且当前状态又不是终止状态,那就说明出了问题------调用错误处理程序。
错误恢复策略
检测到错误后怎么办?一种常见的策略是回溯到最后一个合法终止点:
-
查找已扫描字符串中最后一个对应于某终止状态的字符。
-
如果找到了,把该字符及其前面的字符识别为一个单词,然后将输入指针退回到该字符位置,扫描器重新回到初始状态,继续识别下一个单词。
-
如果没找到,说明完全无法识别当前这段输入,进入错误恢复。
最简单的错误恢复策略是"恐慌模式"(Panic Mode):从剩余输入中不断删除字符,直到词法分析器能在剩余输入的开头发现一个正确的字符为止。简单粗暴,但至少不会让编译器直接崩溃。
更复杂的错误恢复策略可能会尝试插入、删除或替换字符来"修复"输入,但这在词法分析阶段通常不值得------更高级的错误(比如语法结构错误)留给语法分析阶段去处理更合适。
十、全章小结
词法分析是编译的第一道关卡,它的任务是扫描源程序的字符流,识别出一个个Token。整个过程的理论链条是:
❝
正则表达式(描述词法规则) → NFA(灵活但不确定) → DFA(确定且高效) → 实际扫描
正则表达式提供了简洁的模式描述能力,正则定义让复杂的模式可以模块化。Thompson 构造法将 RE 系统地转化为 NFA,子集构造法将 NFA 确定化为 DFA。最终的 DFA 用 O(n) 的线性时间完成扫描,高效而可靠。
这套"RE → NFA → DFA"的流水线不仅是编译原理的经典内容,也深刻影响了现代软件工具------grep、sed、awk、各种编程语言的 re 模块,底层都依赖于有穷自动机。下次你用正则表达式匹配邮箱地址或者验证手机号格式时,不妨想想:你正在使用的,正是编译原理中最先被自动化的那套技术。
思考题
思考题一:为什么从正则表达式到词法分析器的转换要经过"RE→NFA→DFA"两步,而不是直接从RE构造DFA?
参考答案:
从理论上说,确实存在从 RE 直接构造 DFA 的方法(比如 Brzozowski 导数法),但在实践中"RE → NFA → DFA"两步走的策略是主流选择,原因在于工程上的简洁性 和可维护性。
第一步(RE → NFA)之所以容易,是因为 NFA 的结构天然对应 RE 的递归定义。 Thompson 构造法的核心是"组合":RE 的每种运算(选择、连接、闭包)都有一个预制的小 NFA 模块,复杂 RE 只需把模块按结构拼装即可。这个过程完全是机械的、局部的,不需要做全局分析。比如 (a|b)*abb,你只需要为 a、b、ε 各建一个小模块,然后按 |、*、连接的规则用 ε 边连接起来------整个过程就像搭积木,不需要动脑。
如果直接从 RE 构造 DFA,就必须在每一步处理确定性问题。 选择运算(r|s)在 DFA 中意味着:对于同一个输入符号,可能需要同时考虑 r 和 s 两条路径,然后合并成一个确定的状态------这本质上就是子集构造在做的事情。闭包运算(r*)更加复杂,因为它涉及"循环",需要预计算所有可能的循环展开路径。直接在 RE 层面处理这些问题,代码逻辑会非常复杂且难以调试。
分两步走的好处是职责分离: Thompson 构造只负责"忠实翻译"RE 的结构为 NFA(不管确不确定),子集构造只负责"消除不确定性"(不管 NFA 是怎么来的)。每个步骤独立、简洁、容易验证正确性。这也是软件工程中"单一职责原则"的体现------把复杂问题拆成两个独立的简单问题,比直接攻击整个问题更容易维护和理解。
此外,NFA 作为中间表示还有一个额外好处:在某些场景下(比如正则表达式的动态匹配),直接使用 NFA 反而比 DFA 更灵活。NFA 可以在不重新确定化的情况下轻松添加新规则(只需加几条 ε 边),而 DFA 每次修改规则后都需要重新运行子集构造。
思考题二:在词法分析中,如果不遵循"最长子串匹配原则",会出现什么问题?请结合具体例子说明。
参考答案:
最长子串匹配原则是词法分析正确性的关键保障。如果不遵循这条原则,词法分析器会频繁产生错误的 Token 划分,导致后续的语法分析和语义分析全部出错。
问题一:多字符运算符被拆散。 大多数编程语言都有多字符运算符,比如 >=、<=、==、!=、&&、||、++、--、+=、->、:: 等。如果不遵循最长匹配,>= 会被识别为两个独立的 Token(> 和 =),导致 if (x >= 5) 被解析为"如果 x 大于(某表达式),等于 5"------完全不是原意。类似地,++ 会被拆成两个 +,a++ 变成了 a + +,语法分析器会报"语法错误"。
问题二:关键字被误判为标识符。 while、return、class 等关键字也满足标识符的词法规则(以字母开头、后跟字母数字)。如果不做最长匹配,词法分析器可能只匹配 whi 就停下来(因为 whi 也是一个合法的标识符前缀),把 le 当成另一个 Token。即使不做这么短的匹配,如果存在某个语言的关键字是另一个关键字的前缀(比如 Pascal 中 to 和 toarray),不采用最长匹配就会把 toarray 错误地拆成关键字 to + 标识符 array。
问题三:数值解析出错。 浮点数 3.14 如果不做最长匹配,可能被识别为整数 3、运算符 .、整数 14 三个 Token。科学计数法 2.5E+3 更惨,可能被拆成 2``.``5``E``+``3 六个Token。后续语法分析器看到这种支离破碎的 Token 序列,要么报语法错误,要么构造出完全错误的语法树。
问题四:注释处理失败。 C 语言的块注释 /* ... */ 需要识别从 /* 到最近 */ 之间的所有内容。如果不用最长匹配,/ 可能被识别为除法运算符,* 被识别为乘法运算符,注释体中的代码全部暴露给编译器,造成大面积语法错误。
正因如此,几乎所有实际的词法分析器(包括 Lex/Flex 生成的分析器和手写分析器)都严格遵循最长子串匹配原则。这是保证词法分析正确性的"第一道防线"。