麻烦的细节:数字、字符串、关键字、注释
上一章那些单字符记号,一眼就能切。真正麻烦的是那些「事先不知道有多长」的东西:一个数字可能是 3 也可能是 3.14159,一个字符串要一直读到下一个引号,一个标识符得一路吃到不是字母为止。这一章把这几样做扎实 —— 顺便回答上一章留下的悬念:关键字和变量名长得一模一样,扫描器凭什么分得开?
数字:吃到不是数字为止
读到一个数字字符,扫描器不知道这个数字有多长,所以它一直吃,吃到下一个不是数字的字符停下:
function number() {
while (isDigit(peek())) advance(); // 吃掉整数部分
// 小数点后面得紧跟数字,才算小数点
if (peek() === '.' && isDigit(peekNext())) {
advance(); // 吃掉那个点
while (isDigit(peek())) advance(); // 吃掉小数部分
}
addToken("NUM", parseFloat(lexeme()));
}
又是最长匹配:314 会被当成一个数字,不会切成 3 1 4。有意思的是那个小数点判断 —— 为什么要偷看点后面的那个字符?
因为 Lox 里 123.method() 这种写法,那个点是「方法调用」的意思,不是小数点。所以规则是:点后面必须紧跟数字,才算小数点。3.14 里的点是小数点,3.abs 里的点不是。这就是为什么代码里要 peekNext() 多看一格 —— 切词有时候需要往前多探一步,才能消除歧义。
真实语言的数字词法可以很复杂:0xFF(十六进制)、1_000_000(下划线分隔)、1e10(科学计数)、1.5f(类型后缀)…… 每一样都是往这个 number() 函数里加几行。Lox 只支持最朴素的十进制 —— 不是因为别的难,是因为它们只是「更多的 if」,不带来新的原理。想加,你随时能加,这正是自己造语言的爽处。
字符串:读到下一个引号
字符串更直接:看到开头的 ",就一路吃到下一个 ":
function string() {
while (peek() !== '"' && !atEnd()) {
if (peek() === '\n') line++; // 允许字符串跨行,顺便数行号
advance();
}
if (atEnd()) { error("字符串没有收口:缺一个 \""); return; } // 读到底都没找到
advance(); // 吃掉收尾的那个 "
const value = source.slice(start + 1, current - 1); // 去掉两头的引号
addToken("STR", value);
}
注意两件事。其一,literal 存的是去掉引号的内容(hello),而 lexeme 是带引号的原文("hello")—— 引号是给扫描器定界用的,值本身不含引号。其二,那个 if (atEnd()) 分支 —— 如果一路读到源码结尾都没等到收口的引号,这就是个错误。字符串没收口,是最常见的词法错误,下一章我们专门讲怎么把这类错误报好。
悬念揭晓:关键字和标识符怎么分
上一章的问题:var(关键字)和 avg(变量名)都是字母组成的词,扫描器怎么知道谁是谁?
答案出乎意料地朴素,而且藏着一个漂亮的思路。扫描器先不管三七二十一,把这一整个「字母词」全吃下来,当成标识符;吃完之后,再回头查一张表,看它是不是某个关键字:
function identifier() {
while (isAlphaNumeric(peek())) advance(); // 先一路吃到底
const text = lexeme(); // 吃到的这个词
const type = KEYWORDS[text] || "IDENT"; // 查表:是关键字?否则就是普通标识符
addToken(type);
}
const KEYWORDS = {
"and": "AND", "or": "OR", "if": "IF", "else": "ELSE",
"true": "TRUE", "false": "FALSE", "fun": "FUN", "for": "FOR",
"while": "WHILE", "nil": "NIL", "print": "PRINT",
"return": "RETURN", "var": "VAR"
};
这就是全部机关:一张查找表。var 在表里,所以它是 VAR;avg 不在表里,所以它是 IDENT。
为什么不反过来 —— 一看到 v 就赶紧判断「是不是 var 的开头」?因为那样会掉进最长匹配的陷阱:variable 这个变量名,前三个字母正好是 var,你会误把它当关键字。
正确姿势是:先按最宽松的规则(标识符)把整个词吃全,再回头查它是不是恰好等于某个关键字。这样 var 命中关键字表、variable 不命中 —— 因为整个词是 variable,不等于 var。这个「先吃全,再分类」的顺序,是最长匹配原则最典型的一次应用。记住它。
这也解释了一个你可能没深想过的现象:关键字为什么是「保留字」。因为 if 在关键字表里,你就没法拿 if 当变量名 —— 扫描器永远会把它切成 IF 关键字,而不是标识符。哪些词被保留,就取决于这张表里放了什么。
注释和空白:产生「什么都不产生」
注释是被彻底丢弃的。看到 //,就一路吃到行尾,但不产生任何记号:
case '/':
if (match('/')) {
while (peek() !== '\n' && !atEnd()) advance(); // 吃到行尾,什么都不吐
} else {
addToken("SLASH"); // 单个 / 是除法
}
break;
这就是为什么注释对程序的行为零影响:它在流水线的第一站就蒸发了,后面的解析器、求值器从没见过它。空白也一样。「给人看的」和「给机器看的」,在扫描这一步就彻底分了家 —— 这是一个比看上去更深刻的分界。
回流
「保留字就是一张查找表里的词」这个认识,能解释你在真实语言里遇到的一些怪事。
为什么有的语言能新增关键字而不搞坏旧代码?因为直接往关键字表里加词,会让所有用这个词当变量名的老代码报错。于是现代语言发明了「上下文关键字」(soft keyword):这个词只在特定位置才当关键字,别处仍是普通标识符。Kotlin 的 suspend、data、value,JS 的 await、yield、async,都是这样 —— 它们没进那张「硬」表,扫描器仍把它们切成标识符,是解析器在特定语境下才把它们当关键字看。这也顺带说明了:扫描器和解析器的分工,有时候会为了向后兼容而重新划线。
你 IDE 里的自动补全、语法高亮,为什么对关键字反应那么快?因为识别关键字只是一次哈希表查找,是整条流水线里最便宜的操作之一。
这一章的一句话
不定长的记号靠「吃到不能再吃」来切;关键字不过是标识符里恰好命中一张查找表的那些词 —— 先吃全,再分类。
扫描器基本齐活了。下一章我们收个尾:当输入是错的,扫描器该怎么优雅地喊出来 —— 好的报错,是一门语言的脸面。