编译原理第三章:编译器是怎么“认字“的?——词法分析全解析

编译原理第三章:编译器是怎么"认字"的?------词法分析全解析

上一章我们聊了文法和语言的形式化理论,知道了怎么用有限的规则描述无限的语言。但理论归理论,编译器到底是怎么把这些理论变成代码、真正"扫描"你写的程序的呢?答案就在词法分析------编译的第一道关卡。今天我们就来深入拆解它的核心武器:正则表达式和有穷自动机。


一、正则表达式------用一行公式描述一种"单词"

在上一章里,我们用正则文法(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

这个定义描述了所有合法的无符号数:22.152.15E+32.15E-32.15E32E-3 等等。注意 optionalFractionoptionalExponent 都允许为空(ε),所以纯整数也满足这个定义。

如果你尝试直接把这些定义"内联"展开成一个纯正则表达式,会得到一个极其恐怖的式子。正则定义的价值就在于模块化和可读性------每个部分各司其职,维护起来也方便。

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)* 的模块和 abb 的模块依次连接起来。

Thompson 构造法的优点是机械、系统------给定任何 RE,都能按部就班地构造出对应的 NFA。缺点是会引入大量的 ε 边和冗余状态,构造出的 NFA 比较"臃肿"。

第二步:NFA → DFA(子集构造法)

子集构造法(Subset Construction)是把 NFA 确定化为 DFA 的标准算法。它的核心思想是:DFA 的每个状态对应 NFA 的一个状态子集。

为什么?

因为 NFA 在某个时刻可能"同时处于多个状态"(分身术),而 DFA 需要用单个状态来"记住"NFA 当前所有可能的状态。所以 DFA 的一个状态 = NFA 的一组状态。

算法过程:

  1. 初始状态是 ε-closure(s₀)------从 NFA 的开始状态 s₀ 出发,只通过 ε 转换能到达的所有状态的集合。

  2. 对于每个未处理的 DFA 状态 T(即 NFA 的状态子集),对每个输入符号 a:

  • 计算 move(T, a):从 T 中的某个状态出发,通过标记为 a 的转换能到达的 NFA 状态集合。

  • 计算 ε-closure(move(T, a)):再从这些状态出发,通过 ε 转换能到达的所有状态。

  • 如果这个集合还没出现过,就加入为新的 DFA 状态。

  1. 重复步骤 2,直到没有新状态产生。

  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 的错误检测逻辑很简单:如果当前状态和当前输入符号在转换表中对应的项为空(即没有可用的转换),且当前状态又不是终止状态,那就说明出了问题------调用错误处理程序。

错误恢复策略

检测到错误后怎么办?一种常见的策略是回溯到最后一个合法终止点

  1. 查找已扫描字符串中最后一个对应于某终止状态的字符。

  2. 如果找到了,把该字符及其前面的字符识别为一个单词,然后将输入指针退回到该字符位置,扫描器重新回到初始状态,继续识别下一个单词。

  3. 如果没找到,说明完全无法识别当前这段输入,进入错误恢复。

最简单的错误恢复策略是"恐慌模式"(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,你只需要为 abε 各建一个小模块,然后按 |*、连接的规则用 ε 边连接起来------整个过程就像搭积木,不需要动脑。

如果直接从 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 + +,语法分析器会报"语法错误"。

问题二:关键字被误判为标识符。 whilereturnclass 等关键字也满足标识符的词法规则(以字母开头、后跟字母数字)。如果不做最长匹配,词法分析器可能只匹配 whi 就停下来(因为 whi 也是一个合法的标识符前缀),把 le 当成另一个 Token。即使不做这么短的匹配,如果存在某个语言的关键字是另一个关键字的前缀(比如 Pascal 中 totoarray),不采用最长匹配就会把 toarray 错误地拆成关键字 to + 标识符 array

问题三:数值解析出错。 浮点数 3.14 如果不做最长匹配,可能被识别为整数 3、运算符 .、整数 14 三个 Token。科学计数法 2.5E+3 更惨,可能被拆成 2``.``5``E``+``3 六个Token。后续语法分析器看到这种支离破碎的 Token 序列,要么报语法错误,要么构造出完全错误的语法树。

问题四:注释处理失败。 C 语言的块注释 /* ... */ 需要识别从 /* 到最近 */ 之间的所有内容。如果不用最长匹配,/ 可能被识别为除法运算符,* 被识别为乘法运算符,注释体中的代码全部暴露给编译器,造成大面积语法错误。

正因如此,几乎所有实际的词法分析器(包括 Lex/Flex 生成的分析器和手写分析器)都严格遵循最长子串匹配原则。这是保证词法分析正确性的"第一道防线"。

相关推荐
zyf1044162 小时前
暑期实践日志 Day47:复盘第七章内容,规整对应成果
学习·计算机网络·剪辑·暑期实践·课题任务
传奇开心果编程2 小时前
【Rust入门知识点学与练】第10课:Option 和 Result(错误处理入门)
开发语言·学习·rust
喵的前端路2 小时前
《EDVAC》学习记录(第二部分)
学习
lee_curry2 小时前
AI Agent 工程师完整学习路线(面向生产级项目与面试)
人工智能·学习·ai·面试·agent
小毛驴8502 小时前
前端开发学习路线可以分为几个层次
学习
star learning white3 小时前
STM32入门学习3
stm32·单片机·学习
浔溺3 小时前
al+大数据每日学习笔记31
大数据·笔记·学习
!!!!8133 小时前
AI Coding学习
学习
山岚的运维笔记3 小时前
mysql 专业笔记 -- 第 8 章:使用变量
运维·数据库·笔记·后端·学习·mysql·dba