跳到内容

第 11 卷 / 第 5 章 / 第 1 课

5.1 Token 契约与源位置:字符流怎样交给解析器 ​

前面的理论给出了边界:正则语言适合描述许多 token 规则,上下文无关文法适合表达嵌套语法,图灵机与不可判定性则提醒我们分析能力并非无限。这一章放下纸笔,从这些边界出发实现一个能报告位置与错误的词法分析器。

c
int count = 42;

可以切成:

text
KW_INT  IDENT(count)  ASSIGN  INT(42)  SEMICOLON  EOF

本课目标 ​

  • 定义 lexer 与 parser 之间的 token 契约;
  • 区分 token kind、lexeme 和解析后的值;
  • 正确设计字节偏移、行列位置和跨度;
  • 判断空白、注释、关键字和词法错误应怎样处理。

1. Token 的类型、位置与可选值 ​

一个实用 token 通常包含:

text
kind    类型,如 IDENT、INTEGER、PLUS
span    在源文件中的起止位置
value   可选的规范化或解析结果

lexeme 是源文件中的原始切片。例如在允许前导零十进制数的教学语言中,0012 的 kind 是 INTEGER,lexeme 是 0012,解析后的值是 12。不要套到所有语言:C 的前导零整数字面量会按八进制解释。保留原始切片有助于报错、格式化和重构;只保存数值会丢失写法。

不要把 kind 和 lexeme 混为一谈:两个不同标识符都属于 IDENT,同一个 + 在不同语言模式下也可能有不同语义。

2. Span 使用半开区间 ​

常见表示为:

[start,end),

包含 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,再查关键字表:

text
if   → 扫描为标识符形状 → 关键字表命中 → KW_IF
ifx  → 扫描为标识符形状 → 未命中      → IDENT

ifx 不会回退成 if 加 x,因为整个 ifx 是更长且合法的标识符。最长匹配先决定 token 边界,同长度规则的优先级再决定 kind。

上下文关键字如某些语言中的 async,可能在 lexer 中先保持为 IDENT,由 parser 根据位置解释;也可能由 lexer mode 辅助分类。过早把它固定成关键字会损害向后兼容性。

6. 字面量有原文、值和错误状态 ​

在允许十六进制和下划线数字分隔符的语言中,整数 0xff、255 和 2_55 可能得到相同数值,却具有不同 lexeme。扫描字面量至少分两步:

  1. 确定字符边界和基本形式;
  2. 去分隔符、判断进制、检测溢出并转换值。

是否在 lexer 解析数值取决于架构。无论在哪一层处理,都要避免静默溢出,并保留源跨度。

字符串更复杂:

  • 原始 lexeme 包含引号和转义;
  • 解码值把 \n 变为换行;
  • 无效转义应有精确位置;
  • 插值字符串可能在文本与表达式 mode 间切换。

7. 错误 token 让工具继续工作 ​

遇到未知字符时,lexer 可以:

  • 立即停止并返回错误;
  • 发射 ERROR token 后继续;
  • 记录诊断并跳过一个可控范围。

批处理编译器可能首错即停,IDE 更需要恢复并继续提供补全。恢复必须保证前进:至少消费一个输入单位,否则同一错误位置会无限循环。

EOF 也应作为明确 token,带零长度 span。parser 可据此区分“期待右括号但文件结束”和普通不匹配字符。

8. Token 契约示例 ​

该 C99 以上版本声明仅保存 kind 与 span;值解析留给后续阶段:

c
#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 缩进、预处理行连接和格式化工具都是反例。
  • 遇错不消费输入也能继续:这会让恢复循环卡在同一位置。

练习 ​

  1. 为 int count = 42; 标出每个 token 的半开字节区间。
  2. 设计一个同时支持编译器和格式化器的 trivia 存储方案。
  3. 说明 if 与 ifx 应怎样经过“最长匹配 + 关键字表”。
  4. 为未闭合字符串设计 ERROR token 的 span 和恢复位置。

小结 ​

lexer 的产物不是一串被剪开的文本,而是一份稳定接口:kind 告诉 parser 看到了什么,span 让诊断回到源文件,lexeme 与 value 保留写法和解释之间的区别。

下一课实现这份接口,并检查缓冲区边界、EOF、ctype 输入范围和双字符运算符。坐标的单位先说清楚,报错时才不会把箭头指向无辜的字符。

Built with VitePress | Software Systems Atlas