扫描器:把字符切成记号
流水线的第一站。它拿到的是一坨字符 —— "var x = 10;" 对它来说就是 v a r 空格 x …… 十来个孤零零的字符。它要交出去的,是一串有类型的词:一个「关键字 var」、一个「标识符 x」、一个「等号」、一个「数字 10」。这一章,我们把这台切词机造出来。它比你想的简单:一个大循环,一次看一个字符。
先定义清楚:记号是什么
扫描器的产物叫记号(token)。一个记号不只是「一段文字」,它是一小包信息:
// 一个记号长这样
{
type: "NUM", // 类型:它是哪一类词(给解析器看的标签)
lexeme: "10", // 词素:它在原文里的那几个字符
literal: 10, // 字面量:如果是数字/字符串,它的真实值
line: 3 // 在第几行(报错时要用)
}
为什么要分 type 和 lexeme 两样?因为下一站(解析器)只关心类型,不关心长相。解析器判断「这里能不能放一个加号」时,它问的是「下一个记号的 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() 吃掉并返回当前字符,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):能凑成更长的合法记号,就凑长的。看到 <= 你绝不会把它当成「小于」加「等于」两个记号 —— 扫描器总是贪心地吃到不能再吃为止。这条原则,下一章处理数字和标识符时还会反复用到。
亲手切一切
下面这台扫描器是真的 —— 就是本书引擎里那台。改左边的输入,右边的记号立刻重切。试试这些:把 >= 拆开写成 > =(看它变成两个记号)、故意加一串空格(看记号数不变)、写个注释(看它整段消失):
玩的时候注意三件事,每一件都是一个知识点:
- 空格和注释没有变成记号。它们是「分隔符」和「给人看的」,扫描器直接吃掉不吐。
>=是一个记号,不是两个。最长匹配在起作用。var和avg都被切出来了,但类型不同 —— 一个是关键字VAR,一个是标识符IDENT。它俩长得一样(都是字母组成的词),扫描器是怎么分辨的?那正是下一章的第一个话题。
回流
「把字符流切成有类型的片段」是编程里一个到处都是的模式,它有个统称:词法分析 / tokenizing。
语法高亮:你现在正在看的这本书,代码块的配色就是一个迷你扫描器干的 —— 它把 var 认成关键字染成紫色、把 "字符串" 认出来染成绿色。任何编辑器的高亮,底层都是一台扫描器。
JSON / CSV / 日志解析:凡是「读一段文本、按规则切成字段」的活,第一步都是词法扫描。你手写过的那个 split + 状态判断的解析函数,其实就是一台简陋的扫描器 —— 只是没起这个名字。
正则:很多扫描器内部干脆用正则来识别每类记号(\d+ 配数字、[a-z]\w* 配标识符)。反过来,正则引擎自己在运行前,也要先「扫描」你写的那个正则表达式。切词这件事,是递归地无处不在的。
这一章的一句话
扫描器是一台状态机:一个大循环、一次看一个字符,把扁平的字符流切成一串有类型的记号,顺手扔掉空白和注释。
下一章,我们处理切词里最麻烦的几样:数字、字符串、以及怎么把关键字从一堆标识符里认出来。