5.1 Token 契约与源位置:字符流怎样交给解析器
前面的理论给出了边界:正则语言适合描述许多 token 规则,上下文无关文法适合表达嵌套语法,图灵机与不可判定性则提醒我们分析能力并非无限。这一章放下纸笔,从这些边界出发实现一个能报告位置与错误的词法分析器。
int count = 42;可以切成:
KW_INT IDENT(count) ASSIGN INT(42) SEMICOLON EOF本课目标
- 定义 lexer 与 parser 之间的 token 契约;
- 区分 token kind、lexeme 和解析后的值;
- 正确设计字节偏移、行列位置和跨度;
- 判断空白、注释、关键字和词法错误应怎样处理。
1. Token 的类型、位置与可选值
一个实用 token 通常包含:
kind 类型,如 IDENT、INTEGER、PLUS
span 在源文件中的起止位置
value 可选的规范化或解析结果lexeme 是源文件中的原始切片。例如在允许前导零十进制数的教学语言中,0012 的 kind 是 INTEGER,lexeme 是 0012,解析后的值是 12。不要套到所有语言:C 的前导零整数字面量会按八进制解释。保留原始切片有助于报错、格式化和重构;只保存数值会丢失写法。
不要把 kind 和 lexeme 混为一谈:两个不同标识符都属于 IDENT,同一个 + 在不同语言模式下也可能有不同语义。
2. Span 使用半开区间
常见表示为:
包含 start,不包含 end。长度就是 end - start,相邻 token 的边界可直接拼接。
位置可以同时保存:
- 字节偏移:切片和与文件 API 对接最可靠;
- 行、列:面向用户展示;
- 文件 ID:支持多文件、宏展开和 include;
- 原点链:生成代码或宏 token 对应到原始来源。
偏移必须和被切片的缓冲区使用同一单位:如果 lexer 在 Python 字符串上计数,得到的是码点偏移,不能直接拿去切 UTF-8 原始字节。比如 中a 中 a 的 UTF-8 字节偏移为 3,码点偏移为 1。
“列”必须规定按字节、Unicode code point、grapheme cluster 还是显示宽度计算。Tab 和全角字符会让显示列与字节列不同。编译器内部通常以字节偏移作为权威位置,诊断渲染时再计算显示位置。
3. 输入解码是词法分析之前的契约
源文件首先是字节。lexer 要么接收已经解码的字符流,要么自己负责解码,并明确:
- 接受哪种编码;
- 非法字节序列怎样报错;
- 换行是否统一
LF、CRLF和CR; - 标识符是否允许 Unicode;
- 是否执行 Unicode 规范化。
若解码时做换行或 Unicode 规范化,还要保留到原始文件的映射;处理后的字符串偏移不一定等于原始字节偏移。
不同 Unicode 字符可能视觉相似,规范化又可能改变标识符相等关系。语言规范若允许 Unicode 标识符,必须明确采用哪些字符属性和版本;不能简单把库函数 isalpha 当作完整语言规则。
4. 空白与注释是否成为 token
编译器 parser 通常不需要普通空白和注释,lexer 可以放进 trivia 通道或跳过。但格式化器、IDE 和源码变换工具需要保留它们,至少要能关联到相邻 token。
三种常见策略:
- 完全丢弃 trivia:编译器最简单;
- 作为独立 token 输出:保真,但 parser 需过滤;
- 附着在语法 token 前后:便于构建 lossless syntax tree。
选择取决于下游消费者。若项目既要编译又要格式化,不应让编译路径的最小 token 结构限制全部工具。
5. 标识符与关键字常共享扫描规则
先按标识符规则读完最长 lexeme,再查关键字表:
if → 扫描为标识符形状 → 关键字表命中 → KW_IF
ifx → 扫描为标识符形状 → 未命中 → IDENTifx 不会回退成 if 加 x,因为整个 ifx 是更长且合法的标识符。最长匹配先决定 token 边界,同长度规则的优先级再决定 kind。
上下文关键字如某些语言中的 async,可能在 lexer 中先保持为 IDENT,由 parser 根据位置解释;也可能由 lexer mode 辅助分类。过早把它固定成关键字会损害向后兼容性。
6. 字面量有原文、值和错误状态
在允许十六进制和下划线数字分隔符的语言中,整数 0xff、255 和 2_55 可能得到相同数值,却具有不同 lexeme。扫描字面量至少分两步:
- 确定字符边界和基本形式;
- 去分隔符、判断进制、检测溢出并转换值。
是否在 lexer 解析数值取决于架构。无论在哪一层处理,都要避免静默溢出,并保留源跨度。
字符串更复杂:
- 原始 lexeme 包含引号和转义;
- 解码值把
\n变为换行; - 无效转义应有精确位置;
- 插值字符串可能在文本与表达式 mode 间切换。
7. 错误 token 让工具继续工作
遇到未知字符时,lexer 可以:
- 立即停止并返回错误;
- 发射
ERRORtoken 后继续; - 记录诊断并跳过一个可控范围。
批处理编译器可能首错即停,IDE 更需要恢复并继续提供补全。恢复必须保证前进:至少消费一个输入单位,否则同一错误位置会无限循环。
EOF 也应作为明确 token,带零长度 span。parser 可据此区分“期待右括号但文件结束”和普通不匹配字符。
8. Token 契约示例
该 C99 以上版本声明仅保存 kind 与 span;值解析留给后续阶段:
#include <stddef.h>
typedef enum {
TOK_EOF,
TOK_ERROR,
TOK_IDENTIFIER,
TOK_INTEGER,
TOK_KW_INT,
TOK_ASSIGN,
TOK_SEMICOLON
} TokenKind;
typedef struct {
size_t start; // inclusive byte offset
size_t end; // exclusive byte offset
size_t line; // 1-based line number
size_t column; // contract must define its unit
} SourceSpan;
typedef struct {
TokenKind kind;
SourceSpan span;
} Token;这里不复制 lexeme;调用方可用 [start,end) 从不可变源缓冲区切片。源缓冲区必须至少活到所有引用它的 token 用完;可由统一源码存储器持有,或让 token 拥有必要文本。IDE 编辑后还需绑定文件版本,否则旧 token 可能拿着正确偏移切到新文件里的错误内容。
常见误区
- token 只需要类型:诊断、IDE 与重构至少需要 span。
- 字符位置就是字节位置:UTF-8 中一个字符可占多个字节。
- 空白没有语义所以都能丢:Python 缩进、预处理行连接和格式化工具都是反例。
- 遇错不消费输入也能继续:这会让恢复循环卡在同一位置。
练习
- 为
int count = 42;标出每个 token 的半开字节区间。 - 设计一个同时支持编译器和格式化器的 trivia 存储方案。
- 说明
if与ifx应怎样经过“最长匹配 + 关键字表”。 - 为未闭合字符串设计
ERRORtoken 的 span 和恢复位置。
小结
lexer 的产物不是一串被剪开的文本,而是一份稳定接口:kind 告诉 parser 看到了什么,span 让诊断回到源文件,lexeme 与 value 保留写法和解释之间的区别。
下一课实现这份接口,并检查缓冲区边界、EOF、ctype 输入范围和双字符运算符。坐标的单位先说清楚,报错时才不会把箭头指向无辜的字符。