跳到内容

5.3 最长匹配、Lexer Mode 与生成器:多条规则怎样协作

当多条 token 规则都能匹配当前位置时,高塔的扫描器必须用一套固定规则裁决竞争。

单条 token 规则很容易。困难来自规则竞争:=== 都能从同一位置开始,if 同时具有标识符形状,/ 可能是除法,也可能开启注释。lexer 必须给这种竞争一套确定规则。

本课目标

  • 准确执行 maximal munch 与同长优先级;
  • 理解组合自动机为什么要记录最后接受状态;
  • 使用 lexer mode 处理字符串、注释和插值;
  • 判断手写扫描器与生成器的真实取舍。

1. 最长匹配不是“先命中的规则”

从当前位置开始,lexer 通常选择能匹配的最长前缀,也叫 maximal munch。若多条规则匹配同样长的前缀,再按规则顺序或显式优先级决定 token kind。

设规则:

text
KW_IF       "if"
IDENT       [A-Za-z_][A-Za-z0-9_]*
EQUAL       "=="
ASSIGN      "="

结果:

输入前缀结果原因
if KW_IF两条规则同长,关键字优先
ifxIDENT(ifx)标识符匹配更长
==EQUAL两字符比一字符长
=ASSIGN只有一字符规则接受

2. DFA 扫描器要记住最后一次接受

组合所有规则后,一个 DFA 状态可能标记一个或多个 token kind。扫描流程:

  1. 记录 token 起点;
  2. 沿 DFA 尽可能前进;
  3. 每到接受状态,保存当前位置与最高优先级 kind;
  4. 遇到无转移字符时,回到最后接受位置;
  5. 若从未到达接受状态,发射错误并消费最小安全单位。

若规则包含 1.1..2 之类前缀竞争,机器可能在接受 1 后继续尝试,最终失败再回到最后接受点。ifx 则不会回到 if,因为标识符状态一路保持接受且位置更远。

3. flex 规则与动作

一个简化 flex 文件:

text
%option noyywrap nodefault yylineno

%{
#include "tokens.h"
%}

IDENT_START    [A-Za-z_]
IDENT_CONT     [A-Za-z0-9_]

%%
"if"                            return TOK_KW_IF;
{IDENT_START}{IDENT_CONT}*       return TOK_IDENTIFIER;
[0-9]+                          return TOK_INTEGER;
"=="                            return TOK_EQUAL;
"="                             return TOK_ASSIGN;
[ \t\r\n]+                     /* skip trivia */
.                               return TOK_ERROR;
%%

flex 采用最长匹配;同样长度时,较早规则优先。因此关键字规则放在标识符之前。最后的 . 提供可见错误路径,nodefault 避免未匹配字符被默认回显。

生成器可以把规则编译成 NFA/DFA 与表驱动 C 代码,但不会替你决定:Unicode 规范、token value、源位置、诊断、mode、内存所有权和 parser 接口。

4. Mode 把有限上下文纳入状态

字符串插值示例:

text
"hello ${user.name}"

至少涉及:

text
DEFAULT → 读到开引号 → STRING
STRING  → 读到 ${     → INTERPOLATION
INTERPOLATION → 匹配对应 } → STRING
STRING  → 读到闭引号 → DEFAULT

mode 仍是有限状态控制。插值表达式内部若允许嵌套花括号,还需计数或由 parser 协作,不能只靠一个布尔 mode。

flex start conditions、ANTLR lexer modes 等机制可以限制某组规则只在特定 mode 生效。手写扫描器则通常用枚举和分支表达。

5. C 预处理不能概括成“词法之前全部完成”

C 翻译有预处理 token、宏展开、头文件包含和后续 token 转换等多个阶段。预处理器本身也需要识别预处理 token;#include 不是简单地在 lexer 启动前凭空消失。

实现教学语言时可以采用“先预处理成字符,再普通词法分析”的简化管线,但应标明这是架构选择,不是完整 C 标准模型。

宏展开还会让 token 的物理位置和逻辑来源不同。高质量诊断需要 expansion span 与 spelling span,才能同时指出宏调用位置和定义来源。

6. 手写还是生成

维度手写扫描器生成器
规则表达控制流直接、可定制声明式、集中
复杂状态mode 与特殊字面量易定制依赖工具机制与动作代码
自动机正确性自己负责工具负责正则部分
构建依赖需固定工具版本与生成流程
调试可逐行跟踪要理解规则、生成代码和运行时
性能可针对工作负载优化表示与选项决定,不保证天然更快

“生成器一定比手写快一两个数量级”没有普遍依据。分支预测、字符分类、表压缩、缓存、token 动作和输入方式都会影响结果。应使用真实源代码语料基准测试吞吐、延迟和内存。

7. Lexer 与 parser 的接口

常见拉取式接口:

text
parser 调用 next_token()
lexer 返回 kind + span + value
parser 需要时请求下一个

优点是内存小、控制简单。IDE 可能需要缓存 token,以便增量重词法分析;并行工具也可能先生成 token buffer。

mode 有时由 parser 反馈。例如 / 在某些语言里可能开启正则字面量,也可能是除法。反馈会增加耦合,应把状态转换和恢复规则写进接口契约。

8. 资源与安全边界

面对不受信任源码,lexer 应限制:

  • 文件总大小;
  • 单个 token 长度;
  • 注释或插值嵌套深度;
  • 数值字面量位数;
  • 诊断数量;
  • mode 堆栈深度。

即使自动机扫描是线性的,构造一个含数亿位的整数值也可能消耗巨大 CPU 和内存。token 边界识别与值转换可分开,并在两处各设预算。

常见误区

  • 最长匹配会优先关键字:先比较长度,同长才比较规则优先级。
  • 生成器自动解决全部 lexer 问题:它主要生成匹配核心,接口与诊断仍需设计。
  • mode 可以处理任意嵌套:有限 mode 不等于无界栈。
  • 线性扫描不会被拒绝服务:超长 token、值转换和诊断洪泛仍有成本。

练习

  1. > >= >> >>= 写规则并列出每个输入的最长匹配结果。
  2. 用 flex 或其他生成器实现关键字与标识符同长优先级测试。
  3. 设计字符串插值的 mode 状态图,并说明嵌套花括号交给谁处理。
  4. 为 lexer 制定五项资源上限及触发后的诊断策略。

小结

多规则 lexer 的核心是可重复的决策顺序:最长前缀优先,同长时按规则优先级。mode 处理有限上下文,生成器负责自动机骨架,源位置、错误恢复与资源限制仍是编译器作者的工作。

下一章接过 token 流,把线性序列恢复成树,并让错误输入也能产出足够结构供后续诊断使用。

Built with VitePress | Software Systems Atlas