Appearance
第二章:编译原理速成:文法、词法与语法
在上一章,我们了解了解析器的基本工作流程。现在,你可能会有一个疑问:我们不能直接开始写代码吗?为什么还要了解枯燥的编译原理?
答案是:理论是实践的地图。不了解基本概念,我们的实现将是盲目的、脆弱的。本章将为你提供一套构建解析器所需的最小理论集合,它们将成为我们后续实践的“内功心法”,帮助你理解 Acorn 以及我们的 mini-acorn “为什么”要这么设计。
1. 编译器 vs. 解释器
首先,我们来厘清两个基本概念:
- 编译器 (Compiler):像一位翻译家,它将你写的高级语言代码(如 JavaScript)一次性地通篇阅读,然后翻译成计算机能直接执行的低级语言(如机器码)。这个翻译过程的产物是一个独立的可执行文件。整个过程是“先编译,后执行”。
- 解释器 (Interpreter):像一位同声传译,它逐行地读取你的高级语言代码,然后立即执行该行代码对应的操作。它不产生独立的目标文件,整个过程是“边解释,边执行”。
早期的 JavaScript 引擎主要是解释器,但为了追求更高的性能,现代的 JavaScript 引擎(如 V8)采用了更复杂的 JIT (Just-In-Time) 即时编译技术,它结合了编译器和解释器的优点,在代码执行前或执行中进行编译,以达到接近编译型语言的性能。
我们的解析器,正是这个复杂编译流程中的第一个,也是最关键的一个环节。
2. 编译的核心阶段
一个完整的编译过程通常非常复杂,但其“前端”部分主要包含三个核心阶段:
- 词法分析 (Lexical Analysis):我们在上一章已经见过,它负责将代码字符串分解为 Token 序列。
- 语法分析 (Syntactic Analysis):它负责将 Token 序列构建成 AST。
- 语义分析 (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。- 没有引号的内容,如
Expression、Term,代表语法中的一个抽象概念,它们被称为非终结符(Non-terminal),因为它们可以被其他规则进一步展开。 (...)*是一种扩展的 BNF(EBNF)写法,表示括号内的内容可以出现零次或多次。
这三条简单的规则,蕴含了深刻的设计:
- 运算符优先级:通过将文法分为
Expression和Term两个层次,我们巧妙地定义了乘除(在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 可以优雅地定义运算符优先级和嵌套结构。
- 手写解析器相比生成器,能更好地控制性能和错误处理。
这些理论将成为我们理解后续所有实践章节的“内功心法”。
练习
- 扩展文法:尝试修改本章的 BNF 示例,为其增加对“一元负号”(例如,表达式
-5)的支持。你应该如何修改Factor规则? - BNF 阅读:在网上搜索 “ECMAScript specification if statement”,找到官方规范中关于
IfStatement的文法定义。尝试阅读并理解它(看不懂也没关系,感受一下真实世界规范的复杂度)。 - 概念辨析:用你自己的话,重新解释词法分析和语法分析的区别与联系。