卷 II · 词CH 04深度 04/20

扫描器:把字符切成记号

流水线的第一站。它拿到的是一坨字符 —— "var x = 10;" 对它来说就是 v a r 空格 x …… 十来个孤零零的字符。它要交出去的,是一串有类型的词:一个「关键字 var」、一个「标识符 x」、一个「等号」、一个「数字 10」。这一章,我们把这台切词机造出来。它比你想的简单:一个大循环,一次看一个字符。

扫描器记号状态机最长匹配

先定义清楚:记号是什么

扫描器的产物叫记号(token)。一个记号不只是「一段文字」,它是一小包信息:

// 一个记号长这样
{
  type:    "NUM",     // 类型:它是哪一类词(给解析器看的标签)
  lexeme:  "10",      // 词素:它在原文里的那几个字符
  literal: 10,        // 字面量:如果是数字/字符串,它的真实值
  line:    3          // 在第几行(报错时要用)
}

为什么要分 typelexeme 两样?因为下一站(解析器)只关心类型,不关心长相。解析器判断「这里能不能放一个加号」时,它问的是「下一个记号的 type 是不是 PLUS」,而不去管那个加号在原文第几列。把「是什么类」和「长什么样」分开,是流水线能一段段解耦的前提。

Lox 的记号类型不多,扳手指能数完:括号花括号这些单字符符号、!= == <= >= 这些双字符符号、数字 / 字符串 / 标识符这三类字面量、还有十来个关键字(var fun if …)。加起来三四十种。

核心:一个大循环,一次一个字符

扫描器的骨架,是一个「只要没读完,就继续切下一个词」的循环:

function scan(source) {
  const tokens = [];
  let start = 0, current = 0, line = 1;

  while (current < source.length) {
    start = current;          // 记住这个词从哪开始
    scanToken();              // 切一个词出来
  }
  tokens.push({ type: "EOF", line });   // 结尾放个哨兵
  return tokens;
}

每一轮循环切出一个记号。scanToken 看一眼当前字符,就知道该切出什么:

function scanToken() {
  const c = advance();        // 吃掉一个字符,返回它
  switch (c) {
    case '(': addToken("LPAREN"); break;
    case ')': addToken("RPAREN"); break;
    case '+': addToken("PLUS");   break;
    case '-': addToken("MINUS");  break;
    case ';': addToken("SEMI");   break;
    case ' ':
    case '\t': break;            // 空白:什么都不做,直接跳过
    case '\n': line++; break;    // 换行:行号 +1,也跳过
    // ……
  }
}

看清楚这里发生了什么:这就是一台状态机。「当前读到哪个字符」是它唯一的状态,每读一个字符,它就根据这个字符决定切出什么、然后前进。空格和换行被直接跳过(它们不产生记号,只是分隔)—— 这就是为什么你代码里多打几个空格、换不换行,对程序毫无影响:它们在第一站就被扔掉了。

◆ 记住这个词:advance

扫描器(还有后面的解析器)反复在做同一个基础动作:「往前走一步,看看手里是什么」advance() 吃掉并返回当前字符,peek() 只看不吃。整台机器就是靠这两个动作,加一个「当前位置」的指针,一路啃到底。

这种「一个指针 + peek / advance」的扫描模式,你之后会在解析器里原样再见到一次(只不过啃的是记号而不是字符)。认准它 —— 它是这本书里出现频率最高的一个套路。

关键的小机关:!= 还是 !

有个问题:读到一个 !,它是「逻辑非」这个单字符记号,还是「!= 不等于」的开头?光看这一个字符,决定不了。得再往前偷看一眼:

case '!':
  addToken(match('=') ? "BANG_EQ" : "BANG");
  break;
case '=':
  addToken(match('=') ? "EQ_EQ" : "EQ");
  break;
case '<':
  addToken(match('=') ? "LE" : "LT");
  break;

match('=') 的意思是:「如果下一个字符是 =,就把它也吃掉、返回真;否则别动、返回假」。于是 != 被切成一个 BANG_EQ 记号,而单独的 ! 被切成 BANG

这背后是一条铁律,叫最长匹配(maximal munch):能凑成更长的合法记号,就凑长的。看到 <= 你绝不会把它当成「小于」加「等于」两个记号 —— 扫描器总是贪心地吃到不能再吃为止。这条原则,下一章处理数字和标识符时还会反复用到。

亲手切一切

下面这台扫描器是真的 —— 就是本书引擎里那台。改左边的输入,右边的记号立刻重切。试试这些:把 >= 拆开写成 > =(看它变成两个记号)、故意加一串空格(看记号数不变)、写个注释(看它整段消失):

玩的时候注意三件事,每一件都是一个知识点:

  • 空格和注释没有变成记号。它们是「分隔符」和「给人看的」,扫描器直接吃掉不吐。
  • >= 是一个记号,不是两个。最长匹配在起作用。
  • varavg 都被切出来了,但类型不同 —— 一个是关键字 VAR,一个是标识符 IDENT。它俩长得一样(都是字母组成的词),扫描器是怎么分辨的?那正是下一章的第一个话题。

回流

⟲ 回流 · 你写过的每个 tokenizer,都是它

「把字符流切成有类型的片段」是编程里一个到处都是的模式,它有个统称:词法分析 / tokenizing。

语法高亮:你现在正在看的这本书,代码块的配色就是一个迷你扫描器干的 —— 它把 var 认成关键字染成紫色、把 "字符串" 认出来染成绿色。任何编辑器的高亮,底层都是一台扫描器。

JSON / CSV / 日志解析:凡是「读一段文本、按规则切成字段」的活,第一步都是词法扫描。你手写过的那个 split + 状态判断的解析函数,其实就是一台简陋的扫描器 —— 只是没起这个名字。

正则:很多扫描器内部干脆用正则来识别每类记号(\d+ 配数字、[a-z]\w* 配标识符)。反过来,正则引擎自己在运行前,也要先「扫描」你写的那个正则表达式。切词这件事,是递归地无处不在的。

这一章的一句话

扫描器是一台状态机:一个大循环、一次看一个字符,把扁平的字符流切成一串有类型的记号,顺手扔掉空白和注释。

下一章,我们处理切词里最麻烦的几样:数字、字符串、以及怎么把关键字从一堆标识符里认出来。