Appearance
词法分析概览:从代码到 Token
在第一部分中,我们了解了解析器的整体架构。现在,让我们进入编译过程的第一个关键阶段——词法分析(Lexical Analysis)。
首先要问一个问题:计算机是如何"阅读"代码的?
当你写下 let a = 1;,对你来说,这是一条清晰的变量声明语句。但对计算机而言,它最初看到的只是一串字符:l、e、t、、a、、=、、1、;。计算机如何从这串字符中识别出"这是关键字 let"、"这是变量名 a"、"这是数字 1"?
这就是词法分析要解决的问题。
1. 词法分析的核心任务
词法分析器的职责是将源代码字符流转换为Token 序列。
思考一下:当你阅读一篇文章时,你的大脑并不会把句子看作一长串没有分隔的字母。你会自然地识别出单词、标点和段落。词法分析器对代码做的事情本质上是一样的——它把代码"切分"成一个个有意义的单元。
这个过程也被称为分词(Tokenization)或扫描(Scanning),执行这个任务的程序则被称为词法分析器(Lexer)、Tokenizer 或 Scanner。
2. 什么是 Token?
一个 Token 是编程语言中最小的、有意义的组成部分。它就像英语中的一个单词。一个典型的 Token 包含以下几个关键信息:
type: Token 的类型。这就像给单词划分词性,例如是名词、动词还是形容词。在 JS 中,类型可以是Keyword(关键字)、Identifier(标识符)、Punctuator(标点符号)、Numeric(数字)等。value: Token 的具体值。如果type是Numeric,那么value可能就是10。start,end: Token 在源代码字符串中的起始和结束位置(索引)。loc: 一个包含起始和结束行列号的对象。这在后续的错误提示中至关重要,它可以精确地告诉开发者“哪一行哪一列出错了”。
例如,let 这个单词,经过词法分析后,可能会变成这样一个结构化的对象:
json
{
"type": "Keyword",
"value": "let",
"start": 0,
"end": 3,
"loc": {
"start": { "line": 1, "column": 0 },
"end": { "line": 1, "column": 3 }
}
}词法分析的最终目标,就是将整个源代码文件,转换成一个由这样一个个 Token 组成的序列(数组),同时聪明地忽略掉那些对程序逻辑无用的信息,比如空格、换行和注释。
3. 工作流程:一个状态机的视角
词法分析器最核心的方法通常是 nextToken(),它的任务就是从当前位置读取并返回下一个 Token。这个过程的本质,可以看作一个状态机。分析器在不同的“状态”之间切换,以决定如何处理接下来的字符。
让我们以 let a = 10; 为例,看看 nextToken() 是如何一步步工作的:
初始化:创建一个指针
pos,指向字符串的开头,即pos = 0。进入循环,寻找第一个 Token:
pos指向l。这是一个字母,分析器进入 “标识符读取”状态。- 它会继续向后扫描,
e、t,直到遇到一个不属于标识符的字符——空格。 - 此时,它截取字符串
let。通过查询一个内置的关键字列表,它发现let是一个关键字。 - 于是,它创建了一个类型为
Keyword、值为let的 Token。 - 最后,更新
pos指向t之后的位置。
寻找第二个 Token:
- 分析器从当前
pos开始,首先会遇到一个空格。它知道空格是无意义的,于是进入 “空白跳过”状态,简单地将pos向后移动,直到遇到非空白字符a。 a是一个字母,再次进入 “标识符读取”状态。- 向后扫描,直到遇到
=。 - 截取
a,查询关键字列表,发现它不是关键字,因此它是一个普通的Identifier(标识符)。 - 创建 Token,并更新
pos。
- 分析器从当前
寻找第三个 Token:
- 跳过
=前后的空格。pos指向=。 =是一个标点符号,分析器进入 “标点/运算符读取”状态。- 它创建了一个类型为
Punctuator、值为=的 Token,并更新pos。
- 跳过
寻找第四个 Token:
pos指向1。这是一个数字,分析器切换到 “数字读取”状态。- 向后扫描,
0,直到遇到非数字字符;。 - 截取
10,并将其转换为数字类型。 - 创建类型为
Numeric、值为10的 Token,更新pos。
寻找第五个 Token:
pos指向;,这是一个标点符号。创建PunctuatorToken。
结束:
- 当
pos到达字符串的末尾,nextToken()会返回一个特殊的EOF(End of File)Token,标志着词法分析过程的结束。
- 当
这个过程可以用下面的伪代码来概括:
plaintext
function nextToken():
skipWhitespace() // 跳过空白和注释
char = peek() // 查看当前字符,但不移动指针
if isLetter(char):
return readIdentifierOrKeyword() // 读取标识符或关键字
if isDigit(char):
return readNumber() // 读取数字
if isQuote(char):
return readString() // 读取字符串
if isPunctuator(char):
return readPunctuator() // 读取标点或运算符
if isEOF():
return createToken(EOF) // 文件结束
// 如果遇到不认识的字符
throwError("Unexpected character")4. 手动状态机 vs. 正则表达式
实现词法分析器主要有两种方案:
正则表达式:为每种 Token 类型编写一个正则表达式,然后依次尝试匹配。
- 优点:对于简单的语言,实现起来非常快速和简洁。
- 缺点:性能通常较差。更重要的是,它很难处理像 JavaScript 这样具有上下文相关的词法规则。例如,字符
/既可能是除法运算符,也可能是一个正则表达式的开始。用正则来区分这两种情况会变得异常复杂。
手动状态机(我们采用的方案):通过
if/else或switch来判断当前字符,并进入不同的处理函数,就像我们上面描述的那样。- 优点:性能极高,因为它是高度优化的字符级别操作。灵活性强,可以完全控制分析的每一个细节,轻松处理上下文相关的复杂规则。
- 缺点:需要编写更多的代码,实现起来相对复杂。
对于一个严肃的、生产级的解析器来说,手动编写的状态机几乎是唯一的选择。
5. 总结
在本章中,我们建立了一个关于词法分析的宏观认识。你需要记住的核心是:
- 词法分析是编译的第一步,它将字符流转换为Token 流。
- Token 是语言中有意义的最小单元,它是一个结构化的对象,包含了类型、值和位置信息。
- 词法分析器的核心是一个状态机,它根据当前字符决定下一步的操作。
在接下来的章节中,我们将亲手实现这个状态机,一步步构建出 mini-acornjs 的词法分析器。
课后练习
- 请手动对
const tax = rate * 1.2;这行代码进行词法分析,写出你认为应该生成的 Token 序列(至少包含type和value)。 - 挑战性思考:当词法分析器读到
/字符时,它如何才能准确地判断这是一个“除法运算符”,还是一个“正则表达式”的开始?提示:想一想在 JavaScript 语法中,这两种情况之前通常会出现什么样的 Token?