Skip to content

词法分析概览:从代码到 Token

在第一部分中,我们了解了解析器的整体架构。现在,让我们进入编译过程的第一个关键阶段——词法分析(Lexical Analysis)

首先要问一个问题:计算机是如何"阅读"代码的?

当你写下 let a = 1;,对你来说,这是一条清晰的变量声明语句。但对计算机而言,它最初看到的只是一串字符:leta=1;。计算机如何从这串字符中识别出"这是关键字 let"、"这是变量名 a"、"这是数字 1"?

这就是词法分析要解决的问题。

1. 词法分析的核心任务

词法分析器的职责是将源代码字符流转换为Token 序列

思考一下:当你阅读一篇文章时,你的大脑并不会把句子看作一长串没有分隔的字母。你会自然地识别出单词、标点和段落。词法分析器对代码做的事情本质上是一样的——它把代码"切分"成一个个有意义的单元。

这个过程也被称为分词(Tokenization)扫描(Scanning),执行这个任务的程序则被称为词法分析器(Lexer)TokenizerScanner

2. 什么是 Token?

一个 Token 是编程语言中最小的、有意义的组成部分。它就像英语中的一个单词。一个典型的 Token 包含以下几个关键信息:

  • type: Token 的类型。这就像给单词划分词性,例如是名词、动词还是形容词。在 JS 中,类型可以是 Keyword(关键字)、Identifier(标识符)、Punctuator(标点符号)、Numeric(数字)等。
  • value: Token 的具体值。如果 typeNumeric,那么 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() 是如何一步步工作的:

  1. 初始化:创建一个指针 pos,指向字符串的开头,即 pos = 0

  2. 进入循环,寻找第一个 Token

    • pos 指向 l。这是一个字母,分析器进入 “标识符读取”状态
    • 它会继续向后扫描,et,直到遇到一个不属于标识符的字符——空格。
    • 此时,它截取字符串 let。通过查询一个内置的关键字列表,它发现 let 是一个关键字。
    • 于是,它创建了一个类型为 Keyword、值为 let 的 Token。
    • 最后,更新 pos 指向 t 之后的位置。
  3. 寻找第二个 Token

    • 分析器从当前 pos 开始,首先会遇到一个空格。它知道空格是无意义的,于是进入 “空白跳过”状态,简单地将 pos 向后移动,直到遇到非空白字符 a
    • a 是一个字母,再次进入 “标识符读取”状态
    • 向后扫描,直到遇到 =
    • 截取 a,查询关键字列表,发现它不是关键字,因此它是一个普通的 Identifier(标识符)。
    • 创建 Token,并更新 pos
  4. 寻找第三个 Token

    • 跳过 = 前后的空格。pos 指向 =
    • = 是一个标点符号,分析器进入 “标点/运算符读取”状态
    • 它创建了一个类型为 Punctuator、值为 = 的 Token,并更新 pos
  5. 寻找第四个 Token

    • pos 指向 1。这是一个数字,分析器切换到 “数字读取”状态
    • 向后扫描,0,直到遇到非数字字符 ;
    • 截取 10,并将其转换为数字类型。
    • 创建类型为 Numeric、值为 10 的 Token,更新 pos
  6. 寻找第五个 Token

    • pos 指向 ;,这是一个标点符号。创建 Punctuator Token。
  7. 结束

    • 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/elseswitch 来判断当前字符,并进入不同的处理函数,就像我们上面描述的那样。

    • 优点:性能极高,因为它是高度优化的字符级别操作。灵活性强,可以完全控制分析的每一个细节,轻松处理上下文相关的复杂规则。
    • 缺点:需要编写更多的代码,实现起来相对复杂。

对于一个严肃的、生产级的解析器来说,手动编写的状态机几乎是唯一的选择。

5. 总结

在本章中,我们建立了一个关于词法分析的宏观认识。你需要记住的核心是:

  • 词法分析是编译的第一步,它将字符流转换为Token 流
  • Token 是语言中有意义的最小单元,它是一个结构化的对象,包含了类型、值和位置信息。
  • 词法分析器的核心是一个状态机,它根据当前字符决定下一步的操作。

在接下来的章节中,我们将亲手实现这个状态机,一步步构建出 mini-acornjs 的词法分析器。


课后练习

  1. 请手动对 const tax = rate * 1.2; 这行代码进行词法分析,写出你认为应该生成的 Token 序列(至少包含 typevalue)。
  2. 挑战性思考:当词法分析器读到 / 字符时,它如何才能准确地判断这是一个“除法运算符”,还是一个“正则表达式”的开始?提示:想一想在 JavaScript 语法中,这两种情况之前通常会出现什么样的 Token?
词法分析概览:从代码到 Token has loaded