ADCakeyuan's blog

手写一门编程语言(二):词法分析器,以及最难的十行模板字符串

Favicon 800x800.png
Published on
//
8 分钟读完
/
TL;DR

词法器的本体不难,难在两种模式的互相递归:字符串里有 ${},${} 里有字符串,字符串里还能再有 ${}。第一版把插值 token 平铺进主流导致解析歧义,最终解法是把插值表达式整组挂在模板标记上——一个数据结构的选择消灭了一整类 bug。

上一章我们把图纸画完了:文法表、拒绝清单、五个语义决定。这一章开始写真正的第一行代码——词法分析器(lexer):把一串字符 "var 杯子 = [\"龙井\"]" 切成一串有类型的 token。

词法器本体并不难,难的是我这门语言选了一个「教学语言通常不碰」的特性:模板字符串插值。这一章的大半篇幅都给它。

手写,还是正则?

很多教程用一堆正则表达式做词法分析,一把梭出 token。能跑,但我不选,原因有二:

  1. 行列号追踪。报错体验的一半在词法器里——[line 3, col 12] 未知的转义序列这种信息需要逐字符扫描时顺手记录。正则方案里每个 match 都要反查位置,越写越乱。
  2. 插值需要模式切换。${} 意味着词法器要在「字符串模式」和「代码模式」之间来回横跳(后面细讲),这是状态机活儿,正则写出来就是事故现场。

所以核心循环长这样——逐字符消费,顺手记录起点:

while (!eof()) {
  if (peek() === '"') {
    advance()
    readString() // 字符串有自己的子状态机
    continue
  }
  scanAtom() // 数字、标识符、运算符、标点
}

每个 token 记录起始行列号,而不是结束位置——这个原则后面救了我好几次。

数字、转义,和第一串测试

数字要处理三种形态:整数 42、小数 1.5、科学计数法 2.5e-2。顺便立两条规矩:.5 不合法(要写 0.5),1. 也不合法(点后面必须跟数字)——严格换来的是报错信息可以写得非常具体:

1.     // 数字后的小数点必须跟数字,比如 1.5 而不是 1.
1e     // 科学计数法的 e 后面必须是数字

字符串转义支持 \n \t \r \\ \" \$,其余转义一律报错列出支持清单。同样,这里选择严格而不是宽容:宽容的词法器会把错误推迟到运行时,那时用户已经忘了哪个字符有问题。

写到这里我停下来补了第一波测试:单字符标点、双字符运算符(== 不能被拆成两个 =)、关键字与标识符的边界(ifx 是标识符不是 if)。29 个用例里最后有 20 个属于这一波——先立边界行为,再加酷炫特性,顺序不能反。

重头戏:模板字符串插值

我的语言里有这种写法:

var name = "茶";
print("你好 ${name},${1 + 2} 杯");   // 你好 茶,3 杯

${} 里面是一段完整的表达式。麻烦在于:

print("外层 ${ "内层 ${更深}" } 结束");

字符串里有插值,插值里有字符串,那个字符串里又有插值。词法器要在两种模式之间递归横跳,深度任意。

第一版:平铺进主流(失败)

直觉写法是:遇到 ${ 就开始把表达式 token 平铺进主流,扫到匹配的 } 后回到字符串模式。跑起来才发现致命问题——两种 } 分不清:

  • 插值表达式里的 map 字面量 {a: 1} 自带花括号,需要计数配对;
  • 最外层那个终止插值的 },和表达式内部的花括号长得一模一样;
  • 最要命的是嵌套模板:内层字符串的 TEMPLATE_END 和外层的收尾引号在 token 流里根本无法区分,解析器读着读着就串台了。

测试当场就炸了:TEMPLATE_START 只数出来 1 个,预期是 2 个。

最终版:插值 token 挂在标记上

推翻重来。核心洞察是:插值表达式是一个语法整体,为什么不把它的 token 整组挂在触发它的标记上?

if (ch === '$' && peekNext() === '{') {
  const at = mark()
  advance() // $
  advance() // {
  const interpTokens = readInterpolation(start)   // 整组收集,不在主流里留痕
  if (template) emit(at, 'TEMPLATE_MIDDLE', '${', cooked, interpTokens)
  else emit(at, 'TEMPLATE_START', '"${', cooked, interpTokens)
  ...
}

主流 token 流里只剩三种标记:TEMPLATE_START(首段字面量)、TEMPLATE_MIDDLE(每个后续插值前的字面量)、TEMPLATE_END(尾段)。插值表达式的 token 作为数组挂在对应标记的 .interp 字段上,嵌套插值在数组里递归出现。

效果立竿见影:"你好 ${name}!" 的主流只剩 [TEMPLATE_START, TEMPLATE_END],解析器拿到 START 后递归解析 .interp,不需要任何嵌套深度追踪,一类 bug 直接消失。数据结构的一个选择,比十个补丁都管用——这是我这个项目里学到的最贵的一课。

一个没修的歧义(故意留的)

"${1 + 2"

这段代码缺了插值的收尾 }。但词法器看到 " 时无法判断:它是字符串的收尾引号,还是一个嵌套字符串的开引号?纯词法器在这两者间原理上不可两全,当前行为是报「字符串缺少收尾的双引号」。正确的解法需要解析器协同(解析器知道表达式完没完),我在 TODO 里留了这道题——如果你有解法,仓库 issue 欢迎来砸。

两个彩蛋

嵌套块注释。/* /* 还在注释里 */ */——C 语言都不支持,但实现只要一个深度计数器。为什么想要它?因为注释掉一段包含注释的代码是人类的天性。

中文标识符。判定规则多加两个区段:

;(ch >= '\u4E00' && ch <= '\u9FFF') || // CJK 统一表意文字
  (ch >= '\u3400' && ch <= '\u4DBF') // 扩展 A 区

然后 var 总价 = 0 就合法了。一行代码的情绪价值,全项目最高。

交付物

  • scan(source):字符流 → token 流,含三段式模板标记与嵌套插值分组
  • 29 个词法测试:基础 token、字符串转义、插值(含三层嵌套)、注释、错误位置
  • 顺手为后面扫清的障碍:. 开头数字报错、行注释里不开启字符串模式

下一章是解析器:递归下降为什么「降」、赋值左值的经典难题,以及 map 和代码块的 {} 之争。

本系列代码全部开源在 cha-lang,词法器的完整实现在 src/lexer.ts,按 commit b6a1fde 可以看到这一章完成时的样子。