Skip to content

第二章:编译原理速成:文法、词法与语法

在上一章,我们了解了解析器的基本工作流程。现在,你可能会有一个疑问:我们不能直接开始写代码吗?为什么还要了解枯燥的编译原理?

答案是:理论是实践的地图。不了解基本概念,我们的实现将是盲目的、脆弱的。本章将为你提供一套构建解析器所需的最小理论集合,它们将成为我们后续实践的“内功心法”,帮助你理解 Acorn 以及我们的 mini-acorn “为什么”要这么设计。

1. 编译器 vs. 解释器

首先,我们来厘清两个基本概念:

  • 编译器 (Compiler):像一位翻译家,它将你写的高级语言代码(如 JavaScript)一次性地通篇阅读,然后翻译成计算机能直接执行的低级语言(如机器码)。这个翻译过程的产物是一个独立的可执行文件。整个过程是“先编译,后执行”。
  • 解释器 (Interpreter):像一位同声传译,它逐行地读取你的高级语言代码,然后立即执行该行代码对应的操作。它不产生独立的目标文件,整个过程是“边解释,边执行”。

早期的 JavaScript 引擎主要是解释器,但为了追求更高的性能,现代的 JavaScript 引擎(如 V8)采用了更复杂的 JIT (Just-In-Time) 即时编译技术,它结合了编译器和解释器的优点,在代码执行前或执行中进行编译,以达到接近编译型语言的性能。

我们的解析器,正是这个复杂编译流程中的第一个,也是最关键的一个环节。

2. 编译的核心阶段

一个完整的编译过程通常非常复杂,但其“前端”部分主要包含三个核心阶段:

  1. 词法分析 (Lexical Analysis):我们在上一章已经见过,它负责将代码字符串分解为 Token 序列。
  2. 语法分析 (Syntactic Analysis):它负责将 Token 序列构建成 AST。
  3. 语义分析 (Semantic Analysis):在得到 AST 后,编译器会对其进行检查,以确保代码符合语言的语义规则。例如,它会检查你是否给一个 const 声明的常量重新赋值,或者检查函数调用的参数数量是否正确。这个阶段通常会涉及到类型检查和作用域分析。

本书的核心,将聚焦于前两个阶段:词法分析和语法分析。

3. 语言的“法律”:文法 (Grammar)

解析器是如何知道该如何将 Token 组装成 AST 的呢?答案是依据一套规则,这套规则就是文法(Grammar)

文法是一套形式化的、无歧义的规则,它精确地定义了一门语言中,什么样的句子是合法的,什么样的句子是非法的。它就是一门编程语言的“法律”。

为了描述这套“法律”,计算机科学家们发明了一种标准化的表示法——巴科斯-诺尔范式(Backus-Naur Form, BNF)

使用 BNF 定义四则运算

让我们来看一个具体的例子。假设我们要定义一个只包含加减乘除和括号的四则运算语言。它的 BNF 文法可以这样描述:

bnf
// 规则 1: 一个“表达式”由一个或多个“项”通过加法或减法连接而成。
Expression ::= Term (('+' | '-') Term)*

// 规则 2: 一个“项”由一个或多个“因子”通过乘法或除法连接而成。
Term ::= Factor (('*' | '/') Factor)*

// 规则 3: 一个“因子”可以是一个数字,或者是一个被括号包裹的“表达式”。
Factor ::= NUMBER | '(' Expression ')'

让我们来解读一下这些符号:

  • ::= 意思是“被定义为”。
  • | 意思是“或”。
  • ' ' 引号里的内容表示代码中实际出现的字符,它们被称为终结符(Terminal),因为它们不能再被分解,就像词法分析产出的 Token。
  • 没有引号的内容,如 ExpressionTerm,代表语法中的一个抽象概念,它们被称为非终结符(Non-terminal),因为它们可以被其他规则进一步展开。
  • (...)* 是一种扩展的 BNF(EBNF)写法,表示括号内的内容可以出现零次或多次。

这三条简单的规则,蕴含了深刻的设计:

  • 运算符优先级:通过将文法分为 ExpressionTerm 两个层次,我们巧妙地定义了乘除(在 Term 规则中)的优先级高于加减(在 Expression 规则中)。因为一个 Expression 是由 Term 组成的,所以解析器必须先满足 Term 的规则(完成所有乘除),才能继续处理 Expression 的规则(进行加减)。
  • 递归定义Factor 的定义中包含了 Expression,这形成了一个递归。正是这种递归,使得文法能够描述像 3 * (4 + 2) 这样具有嵌套结构的句子。

当我们写一个解析器时,本质上就是在用代码实现这些 BNF 规则。

4. 手写解析器 vs. 解析器生成器

既然文法是标准化的,那我们是否可以写一个程序,让它读取 BNF 文件,然后自动生成解析器代码呢?

当然可以,这类工具被称为解析器生成器(Parser Generator),例如 ANTLR、Bison。它们的优点是开发速度快,但缺点是生成的代码通常难以阅读和调试,并且很难实现高质量、人性化的错误提示。

与之相对的,就是手写解析器(Hand-written Parser),这也是 Acorn 和我们将要构建的 mini-acorn 所采用的方式。我们将在后续章节中学习的**递归下降解析(Recursive Descent Parsing)**就是一种常见的手写解析技术。它的优点是开发者对代码有完全的控制力,可以轻松实现精细的性能优化和友好的错误恢复机制。

5. 总结与练习

在本章,我们快速学习了构建解析器所需的最小理论集合。我们知道了:

  • 编译器和解释器的区别,以及现代 JS 引擎的工作模式。
  • 编译流程的前端三阶段:词法分析、语法分析、语义分析。
  • 文法是定义语言规则的“法律”,而 BNF 是描述文法的标准化语言。
  • 通过分层和递归,BNF 可以优雅地定义运算符优先级和嵌套结构。
  • 手写解析器相比生成器,能更好地控制性能和错误处理。

这些理论将成为我们理解后续所有实践章节的“内功心法”。

练习

  1. 扩展文法:尝试修改本章的 BNF 示例,为其增加对“一元负号”(例如,表达式 -5)的支持。你应该如何修改 Factor 规则?
  2. BNF 阅读:在网上搜索 “ECMAScript specification if statement”,找到官方规范中关于 IfStatement 的文法定义。尝试阅读并理解它(看不懂也没关系,感受一下真实世界规范的复杂度)。
  3. 概念辨析:用你自己的话,重新解释词法分析和语法分析的区别与联系。
第二章:编译原理速成:文法、词法与语法 has loaded