想象你经营一家珠宝加工厂,每天收到一条条没有分隔的珠子串:有字母珠、数字珠、符号珠。你需要一台自动分拣机,把珠子按花纹分门别类丢进不同盒子。手写分拣机又慢又容易漏,于是你把规则写进一本“分拣规则册子”,交给自动工厂,工厂就为你造出一台高速分拣机。这个故事里,规则册子就是正则表达式,工厂就是 Lexer 生成器,最后交付的分拣机就是词法分析器。Lex 和 Flex 是这类工厂中最著名的代表,它们把“怎样认花纹”的说明,自动变成一台可以稳定运转的机器。
学会 Lexer 生成器,等于掌握编译器前端的第一座自动工厂,能让你快速构建可靠的词法分析模块,为编译、代码分析乃至 AI 代码理解打下扎实工程基础。
可视化演示
Lexer生成器通过正则表达式描述词法规则,自动生成高效的词法分析器代码。本文以Lex和Flex为例讲解规则定... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Lexer构:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
生成器概述
想象你的工厂每天收到一串串字符珠,有英文、数字、符号,彼此没有天然分隔。你要的不是整串珠子,而是按花纹分好的一堆堆记号。手写一台分拣机意味着要为每一颗珠子设计判断逻辑,费时且易错。于是你决定把规则交给自动工厂,让工厂替你造分拣机——这正是 Lexer 生成器的定位。
在编译原理中,这台分拣机叫词法分析器,它读入源代码字符流,输出 Token 流。Token 由“类型 + 原文”组成,例如 (IF, "if")、(NUMBER, "42")。Lex 和 Flex 是生成器的代表:开发者用正则表达式描述规则,生成器自动生成 C/C++ 扫描代码。开发者不再手写状态机,而是像填写规则册子一样描述“什么花纹算什么记号”。
回到珠子工厂,你不再关心传送带怎么拐弯,只要把规则册子交给工厂,机器会自动装配好。这就是生成器对工程师最大的价值:把复杂的自动机设计变成声明式描述。
- 记号二元组词法器输出的是类型和原文组成的配对,类型告诉后续阶段“这是什么珠子”。
- 规则语言每一条正则规则都有一个对应的语言集合,记为 \(L(R_i)\)。
- 并集匹配所有规则语言的并集,表示“任意一条规则能接受都算匹配成功”。
- 从属关系\(s\in L\) 表示字符串 \(s\) 属于某条规则的语言集合。
正则表达式基础
规则册子不是自然语言,而是一种极简的花纹语言。它只有五种基本笔画:空笔画、单个珠、连续、二选一、圈起来重复。你写“红色珠 + 蓝色珠”,分拣机就明白要先看到红再看到蓝;你写“红珠或蓝珠”,分拣机就知道两条传送带都允许。
在生成器内部,这套花纹语言就是正则表达式。字符 \(a\) 表示匹配字符本身,· 表示连接,| 表示选择,* 表示零次或多次重复。括号用来改变优先级。Lex/Flex 支持更丰富的简写,比如 [0-9] 表示数字集合,+ 表示一次或多次,但它们的底层仍然可以拆解成这五种基本运算。
有了这五种笔画,工厂就能把任何复杂花纹拆成装配图纸。你写的每一条正则,最终都会变成自动机上的一个一个“机关”。
- 空串 \(\epsilon\)匹配一个长度为 0 的字符串,是构造中的“空转传送带”。
- 单字符 \(a\)最基本的原子匹配,只能接受字符 \(a\)。
- 连接 \(e_1 e_2\)先匹配 \(e_1\) 再接 \(e_2\),对应传送带首尾相接。
- 选择 \(e_1 \vert e_2\)两条传送带并轨,任一分支成功即可。
- 闭包 \(e^{*}\)把 \(e\) 这一段循环零次或多次,对应分拣机反复抓取同花纹。
Lex规则语法
规则册子通常分三栏:第一栏准备材料,第二栏写核心规则,第三栏放收尾代码。Lex 源文件也遵循同样的三段式结构:定义区、规则区、用户代码区。定义区可以放宏、起始状态声明和选项;规则区是 pattern { action } 的列表;用户代码区放需要原样嵌入生成文件的辅助函数。
规则区的每一条都像是分拣机上的一个传感器:pattern 描述应该抓住哪种花纹,action 是抓住之后要做的动作。动作里最常用的是 return TOKEN;,意思是“把这个花纹放进对应盒子”。Flex 还会提供 yytext 变量,里面装着当前匹配到的原文,方便你进一步处理。
册子里的每一行,都是分拣机上的一处机关。你不必亲手焊接每个机械臂,只要把“看到什么花纹、放进哪个盒子”写清楚,工厂就能完成组装。
- 规则对每一行规则都由模式 \(p_i\) 和动作 \(a_i\) 组成。
- 规则集合把所有规则对用花括号收集起来,形成规则册子。
- 生成箭头箭头左侧是“规格说明”,右侧是“成品机器”。
- 词法器成品生成器输出的 lexer 拥有统一接口,读字符流、吐 Token。
状态管理策略
当珠子进入“注释垃圾”段时,分拣机要暂时切换到另一档:此时字母、数字都不再是普通珠子,而是要吞掉的废料,直到看见结束符才切回正常档位。这种“档位”思想,对应 Flex 中的起始状态。
Flex 用 %s NAME 声明一个起始状态,用 BEGIN(NAME); 切换状态,用 <NAME> 前缀限定某条规则只在该状态下生效。比如处理 /* ... */ 注释时,进入注释状态后,普通规则不再参与竞争,只有以 <COMMENT> 开头的规则会执行,直到遇见 */ 再 BEGIN(INITIAL); 回到普通状态。
档位切换让同一台分拣机可以干多份工。遇到注释就吞料,遇到字符串就进字符串模式,遇到普通代码就正常分拣,一台机器不再需要拆成三台。
- 组合状态当前配置是 DFA 状态 \(q\) 与起始状态 \(s\) 的二元组。
- 输入字符箭头下方的 \(c\) 是当前读入的字符珠。
- 状态转移转移后得到新的 DFA 状态 \(q'\)。
- 条件切换\(s'\) 表示起始状态可能被
BEGIN改变后的新档位。
冲突与优先级
一颗珠子串同时符合红花纹和蓝花纹怎么办?分拣机有两条铁规矩:第一,永远先抓最长的一整串;第二,如果两串一样长,就按规则册子页码靠前的盒子来分。这样机器永远不会犹豫。
Flex 的默认行为正是如此:如果多条规则都能从当前位置匹配出一段字符串,它选择最长的那个词素;当最长长度相同时,选择声明顺序最早的那条规则。例如关键字 int 和标识符 [a-z]+ 同时匹配 intx 时,由于标识符能匹配完整的 intx 长度 4,比关键字长,所以最终结果是标识符 intx,而不是先切出 int 再切出 x。
这一规矩避免分拣机犹豫,也保证了行为可预测。无论规则怎么堆叠,只要依照“最长优先、同长先声明”的规则,任何输入都能得到唯一的切分结果。
- 候选匹配每条规则 \(j\) 都产生一个匹配长度 \(|s_j|\)。
- 最长长度对全体候选求最大,得到本轮匹配的天花板。
- 筛选达标者只看那些长度等于最大值的规则。
- 最小序号在达标者里取最小的规则序号 \(k\),也就是最早声明的那条。
NFA构造
如果直接把正则规则焊进机器,控制台会不知道下一步该往哪走。所以工厂先画一张带“魔法”的装配草图:一个岔路口可以同时走多条传送带,不需要精确到每一步。这张草图的数学名字叫 NFA,全称是非确定有限自动机。
Lexer 生成器通常使用 Thompson 算法把正则表达式变成 NFA。算法非常机械:每个基本字符生成一个只有两条状态的小片段,然后通过空转移把片段拼起来。所谓空转移,就是不需要读入任何珠子的传送带;它让机器能“凭空”从一个片段跳到另一个片段。正是空转移让 NFA 可以同时保留多种可能路径。
这张魔法图纸不需要立刻执行,却把正则的语义完整保存了下来。所有连接、选择和循环,都在这一步变成节点和空转移,后续的 DFA 构建才有原料可用。
- 状态集合 \(Q\)所有装配节点的集合,每个节点代表分拣过程的一个“观测位”。
- 字母表 \(\Sigma\)可能出现的字符珠全集,也就是词法规则关心的输入字符。
- 转移函数 \(\delta\)读到一个字符或空串后,可能跳到多个状态。
- 初态与终态\(q_0\) 是起点,\(F\) 是接受状态集合,代表一条珠子串能成功分拣的终点。
DFA生成优化
魔法图纸无法直接焊进机器,因为机器不能同时走两条路。于是工厂把“当前可能在哪一堆状态”打包成一个“确切状态”,用一张一眼就能查表的图替换魔法图。这个打包过程叫子集构造,得到的新机器叫 DFA,每个状态对每个字符只有唯一一条出路。
子集构造的核心操作是 ε 闭包:从一个 NFA 状态集合出发,沿着空转移把所有能到达的状态都收进来;再读入一个字符,收集所有后续状态,再做一次 ε 闭包,得到一个新的 DFA 状态。反复执行,直到不再产生新状态,DFA 转移表就成型了。之后还可以用最小化算法合并行为等价的状态,压缩表格尺寸。
把魔法图变成查表图之后,分拣机就不用再“平行宇宙式”地同时追赶所有传送带;它只需要查表,走一条确定的路。最小化再帮忙删掉重复工序,让表格尽量精简。
- 当前集合 \(T\)一个 DFA 状态其实是一组 NFA 状态的集合。
- 读入字符 \(a\)让集合中的每个 NFA 状态都尝试读入同一个字符。
- 并集合并把所有后继状态收集起来,避免遗漏任何可能路径。
- ε 闭包沿着空转移把“不用读字符也能到达”的状态补进来,得到最终集合。
实战应用
工厂交付后,保养手册上写着:输入长长一串珠子,分拣机始终用固定节奏工作,因为它只需查一张转移表。实际工程中,Lex/Flex 生成的词法分析器也具有这样的线性复杂度:每个字符最多被处理一次,每次转移通过查表完成,与规则数量无直接关系。
使用 Flex 的流程很简洁:编写 lexer.l 规则文件,运行 flex lexer.l 生成 lex.yy.c,再用 C 编译器编译并链接 -lfl 库。生成后的扫描器从 yylex() 函数开始,每次调用返回一个 Token。若遇到无法匹配的字符,默认会把它复制到输出流,但你可以在规则区补一条 . 规则并调用 yyerror 来报告错误,实现更严谨的容错。
这正是生成器流水线的价值:换订单只要改规则册子,生产线不用重新发明。无论是写编译器、做代码高亮,还是解析配置和日志,Lexer 生成器都能把“手工维护状态机”的体力活,压缩成描述花纹的声明式工作。
- 扫描复杂度输入长度为 \(n\) 时,词法分析器处理总时间正比于 \(n\)。
- 转移表大小DFA 转移表最多有 \(|Q|\) 行、\(|\Sigma|\) 列。
- 查表常数时间每次转移只需访问表中的一个格子,不随规则数增长。
- 总空间内存占用 \(M\) 与状态数和字符集大小的乘积成正比。
AI 应用场景
AI 编程助手的代码分词
阶段:数据预处理
解决的问题:训练代码大模型前,需要把各种语言的源码切分成 token。手写分词器要考虑关键字、字符串、注释、缩进等大量边界,维护成本高。用 Lexer 生成器声明语言规则,可以快速为 Python、Go 等生成统一接口的词法器,输出稳定的 token 流供模型训练。
具体案例:某代码模型团队要处理十万个 GitHub 仓库,他们为每种语言写一份 Flex 规则文件,通过生成器得到 .c 扫描器,再用脚本批量切分代码,最终把 token 序列喂给模型。
海量日志结构化解析
阶段:特征工程与数据清洗
解决的问题:运维日志通常是半结构化文本,包含时间、IP、状态码和消息。传统方式用逐行正则 split 性能差且难以覆盖变化格式。用 Lexer 生成器构造专用日志词法器,以 DFA 线性扫描日志,一次性输出结构化事件序列,后续 AI 模型可直接做异常检测。
具体案例:某可观测平台每天处理百亿行 Nginx 日志,团队用 Flex 定义了时间、IP、URL 等 token 规则,生成高性能词法器,将解析延迟降低了近一个数量级。
AI 辅助编译器前端
阶段:推理与解析流水线
解决的问题:AI 程序翻译和理解工具需要同时解析多种语言,手写词法分析器容易在语言切换时崩溃。用生成器为每种语言生成独立词法器,再用统一 Token 接口衔接语法分析器,前端稳定性大幅提升。
具体案例:一款 AI 代码翻译工具需要从 Java 翻译到 Python,团队先用 Flex 生成 Java 词法器,再配合语法分析器提取抽象语法树,最终交给翻译模型生成目标代码。
Python 演示
import re class Rule: def __init__(self, name, pattern): self.name = name self.pattern = pattern self.regex = re.compile(pattern) class LexerGenerator: def __init__(self): self.rules = [] def add(self, name, pattern): self.rules.append(Rule(name, pattern)) def build(self): def lexer(text): tokens = [] pos = 0 while pos < len(text): best_name = None best_len = 0 for rule in self.rules: m = rule.regex.match(text, pos) if m and len(m.group(0)) > best_len: best_len = len(m.group(0)) best_name = rule.name if best_name is None: raise ValueError(f"无法识别位置 {pos}: {text[pos:]!r}") tokens.append((best_name, text[pos:pos + best_len])) pos += best_len return tokens return lexer def main(): gen = LexerGenerator() gen.add("关键字", r"if|else") gen.add("数字", r"[0-9]+") gen.add("标识符", r"[a-z]+") gen.add("空白", r"\s+") lex = gen.build() tokens = lex("if x42 else") print(tokens) if __name__ == "__main__": main()
build() 动态生成一个采用最长匹配原则的词法分析函数。class LexerGenerator:相当于珠子工厂的厂房对象,负责积累规则并生成分拣机。def add(self, name, pattern):向规则册子追加一条规则,并预编译成正则对象。rule.regex = re.compile(pattern)提前编译正则,避免每次匹配都重新解析规则。def build(self):根据规则册子返回一个可调用的lexer函数,这就是“生成”动作。while pos < len(text):扫描头从字符串起点逐步前进,直到整个输入被分拣完毕。for rule in self.rules:遍历所有规则,模拟 DFA 同时考虑所有可能匹配。if m and len(m.group(0)) > best_len:用“只保留更长者”实现最长匹配;若长度相同,因为遍历顺序未变,先声明的规则自然获胜。if best_name is None:找不到任何规则匹配时抛出错误,相当于分拣机遇到未知花纹。tokens.append((best_name, text[pos:pos + best_len]))把“类别 + 原文”的 Token 对放进结果列表。
常见误区
误区Flex 规则是从左到右逐字符扫描的,先匹配到哪条就用哪条,所以把最常用的规则放前面能提高正确率和性能。
正解Flex 生成的是 DFA 扫描器,它会同时跟踪所有可能匹配,最终选择最长匹配;只有在最长长度相同的情况下,才由声明顺序决定。调整顺序不会让扫描变快,但会改变平局时的结果。
为何常见:人们习惯文本编辑器里的正则“找到即停”,以为词法分析器也是先到先得,忽略了“最长词素”对划分 token 的关键作用。
误区DFA 比 NFA 更“简单”,所以 DFA 的状态数一定比原来的 NFA 少,构造 DFA 是纯优化。
正解DFA 的状态来自 NFA 状态集合的“幂集”,最坏情况下会呈指数级增长。工程中常用正则生成的 NFA 本身较小,再经过最小化压缩,DFA 才变得精简。
为何常见:教材上的例子状态数很少,容易让人误以为这种结论是普遍规律。
误区Lexer 能识别 `if`、`else`、数字和变量名,所以它应该能发现语法错误,比如 `int x = ;` 这种句子。
正解词法分析器只负责把字符流切成 Token,不建立语法树,也判断不了这些 Token 的排列是否合法。int x = ; 会被切成 int、x、=、; 四个 Token,但语法错误要交给后续的 parser 报告。
为何常见:看到词法器能分出关键字,就误以为它“理解”了程序,实际上它只是最强悍的模式分拣工,不具备上下文判断能力。
误区`.` 在正则里代表任意字符,所以 Flex 规则中的 `.` 应该也能匹配换行符。
正解Flex 默认把输入按行处理,. 不匹配换行符。要处理跨行注释或字符串,应该使用起始状态,并自定义结束条件,而不是依赖 . 覆盖换行。
为何常见:很多语言的正则教程会强调“点匹配除换行外的任意字符”,但新手仍容易把它绝对化成“任意字符”。
误区写的正则越短,生成的词法分析器运行就越快;正则越长只会让生成代码变多,不会影响速度。
正解DFA 扫描每个字符的代价基本恒定,但更长的正则、更多的规则会增大构建期和转移表体积。运行期仍然线性,但“构建成本”和“运行成本”是两回事。
为何常见:大家对字符串匹配的直觉是“匹配文本越短越省事”,忽略了生成器先构建自动机再扫描的两阶段流程。
面试题
Q1. Flex 源文件三个段落的正确排列顺序是什么?
- A.规则区、定义区、用户代码区
- B.定义区、规则区、用户代码区← 正确答案
- C.用户代码区、定义区、规则区
- D.规则区、用户代码区、定义区
答案:B。Flex 文件的标准结构是定义区、规则区、用户代码区,用两个 %% 分隔。定义区准备宏和状态,规则区写 pattern { action },用户代码区放辅助函数,顺序颠倒会导致生成器无法正确解析。
Q2. 在 Flex 规则 ^int 中,^ 表示什么?
- A.取反
- B.行首← 正确答案
- C.匹配一个空格
- D.贪婪匹配
答案:B。Flex 中的 ^ 是行首锚点,表示该规则只在行首位置匹配。它并不是取反符号。类似地,$ 表示行尾锚点,这两者只影响匹配位置,不改变字符内容。
Q3. Lexer 生成器把规则说明自动转换成的核心数据结构是什么?
- A.语法树
- B.符号表
- C.DFA 状态转移表← 正确答案
- D.哈希表
答案:C。词法规则先被转换为 NFA,再通过子集构造变成 DFA,最终以状态转移表驱动扫描器。语法树是语法分析阶段的结构,符号表用于语义分析,都与词法生成器核心无关。
Q4. 输入为 intx,规则 1 是关键字 int,规则 2 是标识符 [a-z]+。Flex 会生成什么?
- A.关键字
int - B.标识符
intx← 正确答案 - C.先
int再x - D.报错
答案:B。Flex 采用最长匹配,规则 2 能匹配完整的 intx,长度 4;规则 1 只能匹配 int,长度 3,所以最终结果是标识符 intx。长度相等时才会使用声明顺序决定胜负。
Q5. NFA 转 DFA 时,DFA 的一个状态对应什么?
- A.NFA 的一个输入字符
- B.NFA 的一个状态
- C.NFA 状态集合← 正确答案
- D.NFA 的一条边
答案:C。子集构造的核心思想是“NFA 当前可能处在若干状态”,所以 DFA 的每个状态都是这些可能状态组成的一个集合,而不是单个 NFA 状态或字符。
Q6. 关于 Flex 起始状态,下列说法正确的是?
- A.
%s COMMENT可以声明一个名为 COMMENT 的起始状态← 正确答案 - B.
BEGIN(COMMENT);可以切换到该状态← 正确答案 - C.
<COMMENT>前缀可以让规则只在当前状态下生效← 正确答案 - D.起始状态只能在规则区结尾声明
答案:ABC。起始状态必须在定义区用 %s 或 %x 声明,而不是在规则区结尾。BEGIN 负责切换,带 <状态名> 前缀的规则只在对应状态下参与匹配,所以 D 错误。
Q7. 子集构造中,ε-closure(T) 表示什么?
- A.从 T 中状态出发,仅通过 ε 转移能到达的状态集合← 正确答案
- B.T 中所有状态的出边集合
- C.与 T 等价的所有正则表达式
- D.T 的接受状态集合
答案:A。ε 闭包是一个“补全”操作:从一个状态集合出发,把不需要读入任何字符、只沿空转移就能到达的所有状态都收进来。它是子集构造中保证不漏路径的关键步骤。
Q8. DFA 最小化时,Hopcroft 算法的初始划分通常是什么?
- A.每个状态单独一组
- B.所有状态一组
- C.接受状态与不接受状态各一组← 正确答案
- D.按入边字符分组
答案:C。接受状态和非接受状态行为已经明显不同,所以先把它们分成两组。接下来不断细分组,直到组内状态对每个字符都跳转到同一组,才算完成最小化。
Q9. 关于 Flex 规则冲突,下列说法正确的是?
- A.相同匹配长度下,先声明的规则优先← 正确答案
- B.最长匹配优先于声明顺序← 正确答案
- C.如果两条规则匹配相同长度,后声明的规则胜出
- D.规则动作可以调用
yytext查看匹配文本← 正确答案
答案:ABD。Flex 的规则冲突首先看匹配长度,长度更长者胜出;长度相同则先声明者胜出,所以 C 错误。动作中 yytext 保存当前匹配到的原文,是开发者最常使用的内置变量。
Q10. 关于 NFA 与 DFA 的工程权衡,正确的是?
- A.NFA 状态数通常比对应 DFA 少← 正确答案
- B.DFA 每个状态对每个字符只有一条确定转移← 正确答案
- C.DFA 最坏可能指数膨胀,因此实际词法器总是用 NFA 直接执行
- D.最小化可以压缩 DFA 状态数← 正确答案
答案:ABD。NFA 因为允许空转移和多路径,通常更紧凑;DFA 确定但可能膨胀。实际 Lexer 生成器仍然生成 DFA 以获得线性扫描性能,同时用最小化控制状态数,而不是直接用 NFA 执行,所以 C 错误。