TOC
Open TOC
WELCOME
Introduction
这一章主要介绍了为什么要学编译原理,以及这本书的构成。
这本书重视实践,所以在理论知识会比其他的编译原理书稍微少一些,当然理论也是非常重要的,这里作者提到了一个概念 Curry – Howard correspondence,说的是计算机程序和数学证明之间的同构关系,似乎很有趣,让我想起了 Coq
。
为什么要学编译原理
- 领域特定语言 Domain-specific language 无处不在,如
Make
、Bash
、Lex
、SQL
等等 - 提高编程能力,具有挑战性
- 比较酷,就像龙书和巫师书的封面所描绘的那样
这本书的构成
- 这本书的主要部分是两次对自制语言
Lox
的实现,分别使用Java
(概念)和C
(速度)实现 - 由于是对语言的实现是逐步完成的,代码的旁边给出了 snippets,用于指示代码的位置以及在不断增长的实现中插入代码的位置(有些书籍会使用工具根据更高级别的描述自动生成实现的一些源文件,即所谓的 compiler-compilers,如
Lex
和Yacc
)
为新语言开发编译器首先是用现有语言开发的,然后用新语言重写并自行编译,这个过程叫 Bootstrapping,最终的效果便是可以用语言本身实现自己的编译器,称为 Self-hosting。
这一章节的 DESIGN NOTE 主要介绍了为编程语言命名的一些原则。
A Map of the Territory
这张图放在开头真的很震撼 🤣
将这张图分为两个部分:
- 上山(前端):词法分析、语法分析、语义分析、中间代码生成,具有机器无关性
- 下山(后端):代码优化和目标代码生成,与目标机有关
词法分析和语法分析
词法分析和语法分析看图就能理解了。
语义分析
语义分析,这里书中的小标题叫静态分析,第一件事便是绑定(标识符与实际对象),之后静态语言会进行类型检查,一系列操作后,我们会得到用户程序的语义,我们需要存储这些信息:
-
通常,它会作为属性存储在语法树本身的节点中,这些节点在解析过程中没有初始化,但稍后会填充。
-
其他时候,我们可能会将数据存储在一旁的查找表中。通常,此表的键是变量和声明的标识符名称。在这种情况下,我们称之为符号表,它与每个键关联的值告诉我们该标识符指的是什么。
-
最强大的工具是将树转换成一个全新的数据结构,更直接地表达代码的语义。这便是中间代码生成。
中间代码
中间代码作为中间人,为多种源语言和目标平台牵线搭桥(为生成中间代码的每种源语言编写一个前端,然后为每个目标体系结构提供一个后端,这就把乘法变成了加法)。这里介绍了一些中间代码的风格:
- control flow graph
- static single-assignment
- continuation-passing style
- three-address code
代码优化
代码优化,让程序保持相同的语义,同时运行的更高效,CSAPP 第五章便介绍了这方面的内容,当然,许多成功的语言很少进行编译时优化。例如,Lua
和 CPython
生成相对未优化的代码,并将大部分性能工作集中在运行时上。
代码生成
代码生成,这里的代码通常指的是 CPU 运行的类似于原始程序集的指令,而不是人类可能想要阅读的源代码。这里有两个选择,我们是为真正的 CPU 还是虚拟 CPU 生成指令:
- 如果我们生成真正的机器代码,我们就会得到一个可执行文件,操作系统可以直接加载到芯片上。本机代码的速度非常快,但生成它需要大量的工作。使用芯片的语言也意味着编译器与特定的体系结构相关联,缺乏可移植性。
- 反之,我们生成虚拟的机器代码,通常称之为字节码,因为每条指令通常只有一个字节长。这些综合指令的设计是为了更接近语言的语义,而不是与任何一种计算机体系结构的特性联系在一起。可以将其视为该语言低级操作的密集二进制编码。在生成字节码后,我们还要做翻译的工作,这里还有两个选择:
- 可以为每个目标体系结构编写一个小型编译器,将字节码转换为该机器的本机代码,可以认为我们将字节码作为了一种中间代码。
- 或者编写一个虚拟机,一个在运行时模拟支持虚拟体系结构的假想芯片的程序。在虚拟机中运行字节码比提前将其转换为本机代码要慢,因为每次执行指令时都必须在运行时对其进行模拟。作为回报,可以获得简单性和可移植性。比如说,用 C 语言实现虚拟机,就可以在任何有 C 编译器的平台上运行这种语言。这就是在本书中构建的第二个解释器的工作原理。
注:术语“虚拟机”也指一种不同的抽象。系统虚拟机在软件中模拟整个硬件平台和操作系统。本书中讨论的虚拟机类型是语言虚拟机或进程虚拟机。
运行时
运行时,无论是直接让操作系统运行可执行文件或者让虚拟机执行程序,我们通常需要语言在程序运行时提供一些服务,如垃圾回收或反射(跟踪执行期间每个对象的类型)。
一些捷径与其他路线
一些简单的编译器结合了语法分析、语义分析和代码生成,不需要分配任何语法树或其他中间代码。
这些编译器限制了语言的设计,典型的例子是 Pascal
和 C
,因为一旦你看到一些表达式,你需要知道足够的信息来正确编译它,这就是为什么在 C
中,不能调用没有被显式声明的函数。
这需要一种结构化的编译技术,称为Syntax-directed translation
有些语言编写的程序在得到语法树(和一些静态分析)后就可以被执行,为了运行该程序,解释器一次遍历语法树的一个分支和一个叶,在每个节点运行时对其进行求值。这就是在本书中构建的第一个解释器的工作原理。
它以编程语言编写的程序的源代码作为输入,并以相同或不同的编程语言生成等效的源代码。它在以大致相同的抽象级别运行的编程语言之间进行转换。
举两个例子:
-
在 UNIX 的任何地方都可以使用
C
编译器,并且可以生成高效的代码,因此以C
为目标语言是让源语言在许多体系结构上运行的好方法。 -
Web 浏览器的“机器代码”是
JavaScript
,所以现在几乎所有语言都有一个针对JavaScript
的编译器,因为这是让代码在浏览器中运行的主要方式。
总而言之,在做好前端的工作后,如果源语言和目标语言语法类似,可以直接跳过分析,直接输出目标语言的源代码。或者经过分析与优化,生成目标语言的源代码。
执行代码的最快方法是将其编译为机器代码,但可能不知道最终用户的机器支持什么体系结构。
于是,当程序从源代码加载时,或者从独立于平台的字节码加载时,我们可以将其编译为其计算机支持的体系结构的本机代码。这被称为即时编译。
这不是捷径,而是危险的攀岩。
编译器与解释器的区别
类似水果与蔬菜
举 Go
语言作为例子
- 是一个编译器(您可以将其用作编译代码的工具而不运行它)
- 是一个解释器(您可以调用它以立即从源代码运行程序)
- 有一个编译器(当您将其用作解释器时,它仍在内部编译)
The Lox Language
这里的自制语言叫 Lox
,其基本语法和 C
很像。
和一些脚本语言的关系
Lox
看起来最像JavaScript
,主要是因为大多数C
语法语言都像JavaScript
。正如我们稍后将了解到的,Lox
的作用域与Scheme
非常接近。我们将在第三部分中构建的Lox
的C
风格要归功于Lua
干净、高效的实现。Lox
是动态类型(类型检查在运行时,静态类型语言的实现需要更多的理论与实践)Lox
拥有自动内存管理
There are two main techniques for managing memory: reference counting and tracing garbage collection (usually just called garbage collection or GC).
In practice, ref counting and tracing are more ends of a continuum than opposing sides. Most ref counting systems end up doing some tracing to handle cycles, and the write barriers of a generational collector look a bit like retain calls if you squint.
For lots more on this, see A Unified Theory of Garbage Collection (PDF).
数据类型
- 布尔型
- 数字,整型和浮点型
- 字符串
nil
,即null
表达式
- 算术运算符
- 比较运算符,不同的数据类型也可以进行相等性测试(不过不会有隐式类型转换)
- 逻辑运算符,通过短路求值,可以充当控制流
- 括号,由于所有表达式有相同的优先级和结合性
语句
- 分号
;
,打印语句和表达式语句 - 花括号,影响作用域(词法作用域)
变量声明
var
关键词,若不初始化则默认为 nil
控制流
我们已经有了
and
和or
来进行分支,可以使用递归来重复代码,所以理论上已经足够了。不过,用命令式语言编写这样的程序会非常尴尬。另一方面,
Scheme
没有内置的循环。它依赖于递归进行重复。Smalltalk
没有内置的分支,并且依赖动态分派来选择性地执行代码。
if
while
for
函数
- 函数调用
- 函数定义(声明,由于是动态类型,所以不区分),
fun
关键词,若无return
,则隐式返回nil
在 Lox
中,函数是一等公民,可以在函数内部定义函数,有闭包
Peter J. Landin coined the term “closure”. Yes, he invented damn near half the terms in programming languages. Most of them came out of one incredible paper, The Next 700 Programming Languages.
类
面向对象的两种实现
Class-based programming - Wikipedia
Prototype-based programming - Wikipedia
它认为如果你降低了语言或工具的一部分的复杂性,就会有一种补偿增加了语言或工具的另一部分的复杂性。
这里从 Class-based 到 Prototype-based,语言实现的复杂性降低,用户使用的复杂度增加
在 Lox
中,类也是一等公民,使用 <
运算符表达继承,类的 init()
会被隐式继承
Lox
不是纯面向对象语言(因为内置类型不是类的实例)
标准库
内置的 print
语句
内置函数 clock()
,用于性能测试
表达式与语句
这一部分的 DESIGN NOTE 讲述了表达式与语句的关系,也曾经是我学习编程语言时遇到的一个疑惑。
Lox
既有表达式也有语句。有些语言省略了后者。相反,它们也将声明和控制流构造视为表达式,如 Lisp
、Haskell
、Ruby
等。为了做到这一点,对所有类似语句的表达式,我们需要定义它们所代表的值:
- 分支:所选择的分支的结果
- 变量声明:变量的值
- 块:最后一条表达式的结果
- 循环:🤣
另外,我们要决定这些类似语句的表达式和其他表达式结合时会发生什么:
这是 Ruby
的例子,if
表达式有显式的 end
把语句都变成表达式,将迫使你回答一些像这样一些模糊的问题。作为回报,我们消除了一些语言中的冗余性(对于 C
而言,既有 if
语句,又有 ?:
三元表达式)
不使用语句的语言通常还具有隐式返回特性,函数自动返回其主体计算结果的任何值,而不需要某些显式返回语法。对于小函数和方法,这非常方便。事实上,许多有语句的语言都添加了 =>
这样的语法,以便能够定义函数体是计算单个表达式的结果的函数。但让所有函数都以这种方式工作可能有点奇怪。如果不小心,函数将泄漏返回值,即使只是想让它产生副作用。
A TREE-WALK INTERPRETER
前几章进行词法分析、语法分析和代码求值。之后,我们一次添加一个语言特性,将一个简单的计算器发展成一个成熟的脚本语言。
对于本地的代码,在每一章之后会附有相应的完整代码。
对于代码的分析,打算采用如下两种方式:
Scanning
这项任务通常被称为 scanning 或 lexing,在早期,scanning 只是指处理从磁盘读取原始源代码字符并将其缓冲在内存中的代码片段,然后 lexing 是对字符进行有用处理的后续阶段。如今,将源文件读入内存是件小事,因此在编译器中很少有明确的阶段。因此,这两个术语基本上可以互换。
效果
输入
输出
运行方式
让我们看一看这是如何做到的,首先,运行这个程序有两种方式:
- 从文件读入源文件(命令行参数)
- 交互式输入
An interactive prompt is also called a “REPL” (pronounced like “rebel” but with a “p”). The name comes from Lisp where implementing one is as simple as wrapping a loop around a few built-in functions:
Working outwards from the most nested call, you Read a line of input, Evaluate it, Print the result, then Loop and do it all over again.
无论是哪一种方式,源文件都会以字符串的形式被传递到 lox/Lox.java/run()
,经过 Scanner
类处理后得到 tokens,并输出
错误处理
我们需要对处理过程中出现的错误进行处理,这些用于处理错误的工具实际上构成了用户界面的很大一部分。当错误出现时,我们应该向用户提供他们所需要的所有信息。
另外,将生成错误的代码与报告错误的代码分开是一种良好的工程实践。目前我们的错误报告函数位于 Lox
类中,而非在 Scanner
类中。在理想情况下,我们会有一个某种类型的 ErrorReporter
接口,传递给 scanner 和 parser,以便我们可以更改不同的报告策略。
在这里,为了实现的简单化,我们在 Lox
类中设置一个字段 hadError
,并提供相应的错误报告函数,报告错误出现所在的行。
Having said all that, for this interpreter, what we’ll build is pretty bare bones. I’d love to talk about interactive debuggers, static analyzers, and other fun stuff, but there’s only so much ink in the pen.
Lexemes and Tokens
我们需要区分这两个概念:
- lexeme:源代码的一部分,一个字符串
- token:包含 lexeme 以及其他有用的信息
于是我们需要思考 token 的数据结构,作者为 token 设计了四个字段,其中的 TokenType
是一个枚举类,定义了 token 的所有可能的类型,对于字面量,我们将其保存在字段 literal
中,字段 line
提供了错误处理时所需的行号。
从源代码的第一个字符开始,scanner 会找出该字符所属的 lexeme,并使用该字符以及该 lexeme 的后续字符。当它到达该 lexeme 的末尾时,便得到一个 token。然后从源代码中的下一个字符开始,循环并再次执行。
决定一种特定语言如何将字符分组为 lexeme 的规则称为 lexical grammar。例如,Lox
对于标识符具有与 C
相同的规则,因而我们可以使用正则表达式来匹配这个 lexeme:
Lox
与大多数编程语言一样,拥有非常简单的语法规则,可以将该语言归类为 regular language。
It pains me to gloss over the theory so much, especially when it’s as interesting as I think the Chomsky hierarchy and finite-state machines are. But the honest truth is other books cover this better than I could. Compilers: Principles, Techniques, and Tools (universally known as “the dragon book”) is the canonical reference.
对于 Python 和 Haskell,它们的 lexical grammar 并不是 regular language,其中一个原因是 scanner 需要将缩进的级别记录下来,而非只用一个有限的数字来标识它所处的状态。
如果你想的话,你可以使用正则表达式非常精确地识别 Lox
的所有不同的 lexeme,像 Lex
或 Flex
这样的工具是专门设计来让你做到这一点的。因为我们的目标是了解 scanner 是如何完成它的工作的,所以我们不会这样做。
Scanner
下面是外界调用 Scanner
类的接口 scanTokens()
在 Scanner
类中,我们需要一些字段来定位源文件中 lexeme 的位置,对某个正在被处理的 lexeme,其起始字符下标为 start
,目前扫描到的字符下标为 current
。只要还没有扫描完所有的 lexeme,我们便不断调用 scanToken()
。最后加上一个 EOF
的 token。
所以现在的核心便是 scanToken()
方法,我们需要分一下类:
- Single-character tokens.
其中的 SLASH
也就是 /
需要单独处理(除号还是注释)
- One or two character tokens.
左边一列符号是右边一列符号的前缀,所以需要区分
- Meaningless characters: newlines and whitespace.
对于换行符,需要将行数加一(这里怀疑不同平台标准不一致)
- Literals.
字符串以双引号开头,数字和标识符则在 default
分支中处理
这里需要稍微提一下,Lox
支持多行字符串,对于输入
其结果为
另外,Lox
目前不支持负数字面量,对于输入
其结果为
Lox
将所有的数字字面量存储为浮点数
对于剩下的字面量(布尔型和 nil
),视其为关键词
- Keywords.
对于标识符和关键词的处理,这里有一个原则叫 maximal munch
When two lexical grammar rules can both match a chunk of code that the scanner is looking at, whichever one matches the most characters wins.
于是,我们将所有的关键词与对应的 TokenType
建立哈希表,如果能够匹配,那么类型就是对应的 TokenType
,否则便是 IDENTIFIER
对于具体的实现,看代码。
隐式分号
将分号还是换行符视为语句终止符?
DESIGN NOTE: IMPLICIT SEMICOLONS
这里有点涉及语言设计的细节了,先略过。
习题
Add support to Lox’s scanner for C-style /* ... */
block comments. Make sure to handle newlines in them. Consider allowing them to nest.
经过观察,C 是不允许 /* ... */
嵌套的,也就是说,当遇到 /*
,只要遇到 */
,就与第一个 /*
结合,*/
后面的内容则不视为注释。
其中 nonNest()
为私有方法
若允许 /* ... */
嵌套,处理会复杂一些
Representing Code
这一部分的主要目标是以一种方式表示代码,便于下一步处理。
考虑最简单的算术表达式,为了表现运算符的优先级与结合性,我们需要以树的形式组织算术表达式,并通过后序遍历求值,如下图所示:
因此,直观地看来,我们代码的可行表示是一棵树,它与语言的语法结构(运算符嵌套)相匹配。
这并不是说树是我们代码的唯一可能表示。在第二个实现中,我们将生成字节码。
我们需要更准确地了解该语法是什么。就像上一章中的词法文法(lexical grammars)一样,围绕句法文法(syntactic grammars)有大量的理论。我们首先将乔姆斯基层次结构向上移动一级——上下文无关文法。
上下文无关文法
需要指出,词法文法和句法文法都是一种形式文法(Formal grammar),形式文法采用一些原子的集合,它称之为“字母表”。然后它定义了一组(通常是无限的)在语法中的“字符串”。每个字符串都是字母表中的“字母”序列。在这个意义上,我们来区分词法文法和句法文法:
Terminology | Lexical grammar | Syntactic grammar |
---|---|---|
The “alphabet” is … | Characters | Tokens |
A “string” is … | Lexeme or token | Expression |
It’s implemented by the … | Scanner | Parser |
形式文法需要指出哪些字符串有效,哪些无效,而这就需要一系列的规则(Rules)
Strings created this way are called derivations because each is derived from the rules of the grammar.
Rules are called productions because they produce strings in the grammar.
Each production in a context-free grammar has a head - its name - and a body, which describes what it generates. In its pure form, the body is simply a list of symbols. Symbols come in two delectable flavors:
Restricting heads to a single symbol is a defining feature of context-free grammars. More powerful formalisms like unrestricted grammars allow a sequence of symbols in the head as well as in the body.
-
A terminal is a letter from the grammar’s alphabet. You can think of it like a literal value. In the syntactic grammar we’re defining, the terminals are individual lexemes — tokens coming from the scanner like
if
or1234
.These are called “terminals” , in the sense of an “end point” because they don’t lead to any further “moves” in the game. You simply produce that one symbol.
-
A nonterminal is a named reference to another rule in the grammar. It means “play that rule and insert whatever it produces here”. In this way, the grammar composes.
上面的一些术语为了准确不去翻译,但需要注意的是,在某一步可能会有多个规则都适用(nonterminal 对应的 rule 有多个),这个时候的选择是非确定性的。
为了具体化,我们需要一种写下这些产生式规则的语法,这里使用了一个类似 [Backus – Naur form](https://en.wikipedia.org/wiki/Backus – Naur_form) 的语法。
Yes, we need to define a syntax to use for the rules that define our syntax. Should we specify that metasyntax too? What notation do we use for it? It’s languages all the way down!
由于整个 Lox
的语言的句法文法不算简单,我们会实现 Lox
的一个子集,并逐步加入新的语法,现在我们只考虑下面这些元素:
- Literals. Numbers, strings, Booleans, and
nil
. - Unary expressions. A prefix
!
to perform a logical not, and-
to negate a number. - Binary expressions. The infix arithmetic (
+
,-
,*
,/
) and logic operators (==
,!=
,<
,<=
,>
,>=
) we know and love. - Parentheses. A pair of
(
and)
wrapped around an expression.
使用 metasyntax 可以表示为:
这里的 NUMBER
表示任何数字文字,而 STRING
是任何字符串文字,不要和 nonterminal 混淆了。
Recursion in the grammar is a good sign that the language being defined is context-free instead of regular. In particular, recursion where the recursive nonterminal has productions on both sides implies that the language is not regular.
Tracking the number of required trailing parts is beyond the capabilities of a regular grammar. Regular grammars can express repetition, but they can’t keep count of how many repetitions there are, which is necessary to ensure that the string has the same number of
(
and)
parts.
语法树的表示
回忆一下我们是如何表示 token 的,我们定义了 Token
类,为了区分不同的种类,我们包含了一个简单的 TokenType
枚举类,从某种意义来说,tokens 是 homogeneous 的,然而对于表达式并非如此。
我们将为表达式定义一个基类。然后,对于每种表达式,即表达式下的每个产生式,我们创建一个子类,就像这样:
这里作者还讨论了 Expr
类中只有字段,而没有方法(静态内部类),在 Java
的世界中是否有点奇怪。作者认为,这些类型的存在是为了让 parser 和 interpreter 能够通信,这适用于没有关联行为的简单数据类型。这种风格在 Lisp
和 ML
等所有数据与行为分离的函数式语言中非常自然,但在 Java
中感觉很奇怪。
为了省事 🤣,作者在另一个包中定义了 GenerateAst
类来专门生成这种结构的代码,只要给出每种表达式(静态内部类)的字段和类型即可,就像这样:
语法树的实现
现在我们有一些类了,我们需要将一组行为与每个类相关联,我们可以在 Expr
上添加一个抽象的 interpret()
方法,然后每个子类将实现它来解释自己。
This exact thing is literally called the “Interpreter pattern” in Design Patterns.
然而这种做法扩展性很差(扩展方法,而非类型),这里作者的解释很好,上图:
对于面向对象语言 Java
来说,扩展类型(一个新行)是容易的:
但是扩展方法很难,这意味着向每个现有的类中添加一个新的方法。
而对于函数式编程语言 ML
而言,类型和方法是分离的(Haskell
缓缓打出一个问号 🤨)。要为多种不同类型实现操作,需要定义一个函数,在该函数的主体中使用模式匹配,这使得扩展方法(一个新列)变得容易:
但是扩展类型很难,必须在所有函数的模式匹配中添加一个新的类型匹配。
Languages with multimethods, like Common Lisp’s CLOS, Dylan, and Julia do support adding both new types and operations easily. What they typically sacrifice is either static type checking, or separate compilation.
这里的讨论让我想起了 SICP 的 2.4 Multiple Representations for Abstract Data
这一章节:
这一章节给出了三种不同的解决方案,其中的数据导向风格较为完美:
- 显式分派
- 数据导向
- 消息传递
The Visitor pattern
访问者模式实际上是在面向对象语言中模拟函数式风格。它让我们可以轻松地向该表添加新列。我们可以在一个地方为一组类型定义新操作的所有行为,而不必接触类型本身。
It does this the same way we solve almost every problem in computer science: by adding a layer of indirection.
首先我们要在 Expr
类中添加一个接口:
注意这里实际上没有必要使用不同的名字命名方法,因为参数不同,方法会重载。然而为了适用于那些没有方法重载机制的实现语言,作者显式使用了不同的名字。
在每个静态内部类中添加 accept
方法:
这里的返回值使用了泛型,增加灵活性。
Another common refinement is an additional “context” parameter that is passed to the visit methods and then sent back through as a parameter to accept(). That lets operations take an additional parameter. The visitors we’ll define in the book don’t need that, so I omitted it.
同样的为了省事,这些变化都要反映在 GenerateAst
类中,便于生成这种新结构的代码。
在 Appendix II · Crafting Interpreters 中有完整的生成出的
Expr
类的代码
为了便于调试,我们希望给定一个语法树,生成它的明确字符串表示(这里的泛型便是 String
),即非常明确地显示树的嵌套结构,这里我们使用了类 Lisp
的语法,每个表达式都显式地用括号括起来,并且是前缀表示。
于是我们新建一个类 AstPrinter
这里的 parenthesize
是一个辅助函数,将表达式转换为 Lisp
的形式,具体的实现见代码。
这样的话,我们在 AstPrinter
类中写一个 main()
方法,显式构造一个表达式的语法树,就生成它的明确字符串表示。需要注意的是,这个类只是用来测试,在后续的章节中并不重要。
习题
1
Earlier, I said that the |
, *
, and +
forms we added to our grammar metasyntax were just syntactic sugar. Take this grammar:
Produce a grammar that matches the same language but does not use any of that notational sugar.
Bonus: What kind of expression does this bit of grammar encode?
函数调用,有点怪 🤨
2
The Visitor pattern lets you emulate the functional style in an object-oriented language. Devise a complementary pattern for a functional language. It should let you bundle all of the operations on one type together and let you define new types easily.
Haskell
,类型类使扩展类型变得容易,针对现有类型扩展方法,Haskell
的模式匹配似乎做不到 🤨
Scheme
,使用数据导向风格
3
Define a visitor class for our syntax tree classes that takes an expression, converts it to RPN, and returns the resulting string.
在这里就不需要区分负号和减号了,因为 token 里已经有了足够的信息。例如上面的示例在这里显示为:
当求值到 -
时,发现这个 token 是单目运算符,不会引起歧义。当然如果为了阅读,可以显式地加以区分。
另外在这里 Expr.Grouping
的意义感觉不大,所以没有显示出来。
Parsing Expressions
在上一章中,我们通过上下文无关文法来显式构造语法树。有了 Parser,给定一系列 tokens 构成的字符串,就能自动构造语法树,确定哪些规则可以生成该字符串。
优先级和结合性
在我们上一章给出的上下文无关文法表示中,并没有考虑运算符之间的优先级和结合性,它们都被归为 operator
。这样会带来歧义,例如:
可以被解释为:
或:
为了解决这种歧义,我们需要定义运算符之间优先级和结合性,下面给出定义,其优先级从低到高:
Name | Operators | Associates |
---|---|---|
Equality | == != | Left |
Comparison | > >= < <= | Left |
Term | - + | Left |
Factor | / * | Left |
Unary | ! - | Right |
- 优先级确定了在包含不同运算符的混合的表达式中首先计算哪个运算符。
- 结合性确定了在一系列相同运算符中首先计算哪个运算符。
- 有些语言指定某些运算符对没有相对优先级。这使得在不使用括号的情况下在表达式中混合这些运算符会导致语法错误。同样,一些运算符是非关联的。这意味着在序列中多次使用该运算符是错误的,例如
Haskell
中[1..2]
里的..
。- 原则上,无论将乘法视为左结合还是右结合都没有关系,无论哪种方式都会得到相同的结果。但是在精度有限的计算机世界中,舍入和溢出意味着结合性会影响乘法序列的结果,详见 CSAPP 第二章或 What Every Computer Scientist Should Know About Floating-Point Arithmetic。
- 关于优先级的设计,另见 DESIGN NOTE: LOGIC VERSUS HISTORY。
这就需要我们重写上下文无关文法表示,我们为每个优先级都定义了单独的规则,其最终结果如下:
需要注意的是,这里的每一条规则都能匹配当前优先级以及更高优先级的表达式,所以这里有一种自上而下的感觉。另外,在二元运算符规则的定义中,例如 factor
,我们也可以写成:
虽然这种写法是对的,但这是左递归,我们将要实现的 Parser 无法处理这种情形。
递归下降技术
There is a whole pack of parsing techniques whose names are mostly combinations of “L” and “R” - LL(k), LR(1), LALR - along with more exotic beasts like parser combinators, Earley parsers, the shunting yard algorithm, and packrat parsing. For our first interpreter, one technique is more than sufficient: recursive descent.
递归下降被认为是自顶向下的 Parser,因为它从顶部或最外层的语法规则(这里是表达式)开始,并在最终到达语法树的叶子之前向下进入嵌套的子表达式。与之对应的运算符的优先级是从低到高。
我们很容易实现这样的技术,直接将语法规则直接翻译成代码就可以了:
Grammar notation | Code representation |
---|---|
Terminal | Code to match and consume a token |
Nonterminal | Call to that rule’s function |
` | ` |
* or + | while or for loop |
? | if statement |
在我们的实现中,这个类叫做 Parser
,它有两个字段,一个是 tokens 的序列,一个是目前扫描到的 token 下标(类似 Scanner 中的处理)。
这里再次强调一下目前的含义:下一个被扫描的对象,也就是
peek
到的对象。
对于代码中的辅助函数,其中有一个叫
isAtEnd
,就是判断peek
到的 token 是否为 EOF,这就和Scanner
类中的设计对应起来了。
我们展示 equality
层的代码:
对应的规则是:
对于左操作数,我们递归调用下一层的函数,并将其结果存储在局部变量 expr
中,如果能够匹配 equality
层的运算符,我们就递归调用取得右操作数,并更新 expr
,否则就直接返回 expr
。这也就是上文中每一条规则都能匹配当前优先级以及更高优先级的表达式的含义。
来一个具体的例子,对于表达式 a == b == c == d == e
,其最终构造的语法树如下:
(⊙ o ⊙) 哦是个左偏的语法树,这也对应了运算符的结合性。
对于 comparison
层、term
层和 factor
层,其代码完全类似,unary
层有一点小小的不同。
If you wanted to do some clever Java 8, you could create a helper method for parsing a left-associative series of binary operators given a list of token types, and an operand method handle to simplify this redundant code.
The fact that the parser looks ahead at upcoming tokens to decide how to parse puts recursive descent into the category of predictive parsers.
对于 primary
层,代码如下:
需要特别处理的是括号,当匹配到左括号而没有匹配到右括号时,就得到了一个语法错误。
语法错误
当 Parser 遇到语法错误时,Parser 的必选项:
- 检测到并报告错误
- 不能崩溃或死循环出不来
Parser 的加分项:
- 要快,满足
IDEs
实时的需求 - 报告尽可能多单独的、明显的错误
- 最小化相关联的副作用错误,如果 Parser 感到困惑,它可能会报告大量的错误,但这些错误并不表明代码中存在其他实际问题。
Parser 响应错误并返回解析的方式称为错误恢复(error recovery)。在过去设计的所有恢复技术中,最经得起时间考验的一种被称为恐慌模式(panic mode)。
- 报告错误:
在我们的实现中,响应错误的方式是调用 Parser
类的 error
方法,Parser
类的 error
方法会调用 Lox
类的 error
方法,这是我们在 Lox
类中新写的一个方法,和 Scanner 中错误报告的方法重载。Lox
类的 error
方法会调用原来就有的 report
方法,其中设置了 hadError
。
- 同步:
在 Parser 返回解析之前,Parser 需要重置状态并对齐 tokens 序列。这个过程称为同步。传统的同步位置是在语句之间,我们的实现还没有到达语句的层级,所以我们不会在本章中实际同步,但我们会在准备好这些机制。
在 Parser
类的 error
方法中,接下来会返回(而非抛出)一个 ParseError
对象。
之所以返回错误而不是抛出错误,是因为我们想让Parser中的调用方法决定是否进入恐慌模式,目前的两种可能出现错误的情形(
primary
层失配右括号,primary
层所有情形均失配),调用方法都决定进入恐慌模式(这里原文为 unwind the parser,不太会翻译🤣)。例如,
Lox
限制了可以传递给函数的参数数量。如果传递太多,Parser 需要报告该错误,但它可以而且应该继续解析额外的参数,而不是进入恐慌模式。
之所以使用异常机制,是因为解析过程中的每条规则都是堆栈上的一个调用帧,为了重置状态,我们需要清除那些调用帧。由于我们在语句之间上进行同步,因此我们将在那里捕获异常。
异常被捕获后,Parser 处于正确的状态。剩下的就是对齐 tokens 序列。我们想丢弃一些 tokens,直到我们正好在下一个语句的开头。这个边界可能是分号或者一些关键词,如 for、if、return、var 等,其实现如下:
之所以说可能,是因为我们可以在 for 循环中用分号分隔子句。我们的同步并不完美,但没关系。我们已经准确地报告了第一个错误,因此之后的一切都是尽力而为。
Parser 不会报告隐藏在这些被丢弃 tokens 中的任何其他语法错误,这意味着相关联的副作用错误不会被报告,这是一个不错的权衡。
目前,我们还没有看到这个方法的实际效果,因为我们还没有语句。现在如果发生语法错误,我们进入恐慌模式并一直展开(unwind)到顶部并停止解析。因为无论如何我们只能解析一个表达式,所以这没什么大不了的。
我们重写 Lox
类中的 run
方法进行测试。
习题
1
In C, a block is a statement form that allows you to pack a series of statements where a single one is expected. The comma operator is an analogous syntax for expressions. A comma-separated series of expressions can be given where a single expression is expected (except inside a function call’s argument list). At runtime, the comma operator evaluates the left operand and discards the result. Then it evaluates and returns the right operand.
Add support for comma expressions. Give them the same precedence and associativity as in C. Write the grammar, and then implement the necessary parsing code.
参考 C 运算符优先级 可知,在 C
中,逗号运算符具有最低的优先级,因而介于 expression
层和 equality
层之间,由于逗号运算符是二元运算符,且结合性为从左到右,只要简单的添加一层就可以了:
若输入为:
则输出为:
对于如何求值,见下一章。
2
Likewise, add support for the C-style conditional or “ternary” operator ?:
. What precedence level is allowed between the ?
and :
? Is the whole operator left-associative or right-associative?
C 运算符优先级 中指出 ?:
运算符优先级仅高于逗号和赋值,且结合性为从右到左,写个示例程序:
此时输出为 8
。另外还有一点,条件运算符中部(?
与 :
之间)的表达式分析为如同加括号,忽略其相对于 ?:
的优先级。尝试写出 ?:
运算符的规则:
为此,在 TokenType
枚举类中添加 QUESTION
和 COLON
,在 Scanner
类中添加对应的 switch
语句。
在 Expr
类中添加一个三目运算符的内部类,使用 GenerateAst
类生成:
在 AstPrinter
类中实现对三目运算符的打印:
若输入为:
则输出为:
这里的实现并未考虑在出现
:
前没有出现?
的情形
3
Add error productions to handle each binary operator appearing without a left-hand operand. In other words, detect a binary operator appearing at the beginning of an expression. Report that as an error, but also parse and discard a right-hand operand with the appropriate precedence.
Another way to handle common syntax errors is with error productions. You augment the grammar with a rule that successfully matches the erroneous syntax. The parser safely parses it but then reports it as an error instead of producing a syntax tree.
For example, some languages have a unary
+
operator, like+123
, but Lox does not. Instead of getting confused when the parser stumbles onto a+
at the beginning of an expression, we could extend the unary rule to allow it.This lets the parser consume
+
without going into panic mode or leaving the parser in a weird state.Error productions work well because you, the parser author, know how the code is wrong and what the user was likely trying to do. That means you can give a more helpful message to get the user back on track, like, “Unary ’+’ expressions are not supported.” Mature parsers tend to accumulate error productions like barnacles since they help users fix common mistakes.
略加修改 unary
层即可:
Evaluating Expressions
我们第一个解释器的工作原理是 Tree-walk interpreters,也就是说对于我们可以解析的每种表达式,我们需要一个相应的代码块,它知道如何求值该语法树并产生结果。
Representing Values
In Lox, values are created by literals, computed by expressions, and stored in variables.
The user sees these as Lox objects, but they are implemented in the underlying language our interpreter is written in.
Here, I’m using “value” and “object” pretty much interchangeably.
Later in the C interpreter we’ll make a slight distinction between them, but that’s mostly to have unique terms for two different corners of the implementation — in-place versus heap-allocated data. From the user’s perspective, the terms are synonymous.
这意味着我们需要在 Lox
的动态类型和 Java
的静态类型之间寻找对应关系:
Lox type | Java representation |
---|---|
Any Lox value | Object |
nil | null |
Boolean | Boolean |
number | Double |
string | String |
在表示了值后,我们还需要管理它们所占用的内存,Java
的垃圾回收为我们做到了这一点。
Evaluating Expressions
正如 Representing Code 的部分所言,我们可以在 Expr
上添加一个抽象的 interpret()
方法,然后每个子类将实现它来解释自己,这被称为解释器模式。然而我们已经有了 The Visitor pattern,类比 AstPrinter
类的做法,我们只要创建一个新的类 Interpreter
并实现 Expr.Visitor<Object>
就可以了,这里的泛型类型是 Object
,正如 Lox
中所有的值在 Java
中的对应为 Object
。
在这里具体的实现并不困难,需要强调的有以下几点:
- 对操作数的求值是从左往右进行的,对操作数的类型检查是在所有的操作数的求值之后才进行的。
- 有时候我们要对一个
Object
对象(也就是Lox
中的值)进行强制类型转换。正因为转换发生在运行时,所以我们才说Lox
是动态类型语言。另外,若强制类型转换失败,会触发ClassCastException
,这会在下面得到处理。 - 在我们求值其操作数子表达式之前,我们无法求值一元运算符本身。这意味着我们的解释器是对语法树进行后序遍历,每个节点在执行自己的工作之前先求值其子节点。
- 我们需要为
Lox
定义相等性和真假性的概念。另外补充两点,一是对0/0==0/0
求值的结果为true
,这是因为在实现中使用了Java
中的Double
包装类,这个类定义的equal
方法对NaN
的处理并未遵循 IEEE 754 的要求,即NaN
不满足自反律;二是由于使用了equal
方法,不同的数据类型也可以进行相等性测试。 - 运算符
+
有数字和字符串两种重载,我们使用instanceof
运算符进行显式分派。
运行时错误
前面提到,若强制类型转换失败,会触发 ClassCastException
,该异常会直接让解释器直接崩溃 🤣。为了能够自定义行为,我们在需要强制类型转换前进行显式地检查,对应的方法为 checkNumberOperand
和 checkNumberOperands
,其中可能会抛出自定义异常 RuntimeError
,表明出现了运行时错误。
与之对应的,我们在 Lox
类中定义 runtimeError
方法,并新增字段 hadRuntimeError
,该字段仅在 runFile
方法中会被检查,Lox
的 REPL 并不在意 🤣。
下面是外界调用 Interpreter
类的接口方法,这里需要注意对结果值的字符串化:
另外,为了保证在 REPL 会话中对 run()
的连续调用使用的是相同的解释器(之后会存储全局变量),我们在 Lox
新增了一个字段:
We could simply not detect or report a type error at all. This is what C does if you cast a pointer to some type that doesn’t match the data that is actually being pointed to. C gains flexibility and speed by allowing that, but is also famously dangerous. Once you misinterpret bits in memory, all bets are off.
Few modern languages accept unsafe operations like that. Instead, most are memory safe and ensure — through a combination of static and runtime checks — that a program can never incorrectly interpret the value stored in a piece of memory.
静态和动态类型
这是本节 DESIGN NOTE 中的内容,其中提到了 Object
数组,正是我感兴趣的内容 🤣。
静态和动态类型不是一个非黑即白的选择。事实证明,即使是大多数静态类型语言,在运行时都会进行一些类型检查。类型系统静态检查大多数类型规则,但在生成的代码中插入运行时检查以进行其他操作。
一个更微妙的例子是 Java
和 C#
中的 covariant arrays。数组的静态子类型规则允许不合理的操作。考虑如下的 Java
代码:
协变与逆变,梦回范畴论 🤣
Object
数组类型静态地允许字符串是对象,但在运行时引用的实际整数数组中不应该包含字符串。
为避免这一点,当将值存储在数组中时,JVM 会进行运行时检查以确保它是允许的类型。如果不是,它会抛出一个 ArrayStoreException
。
Java
可以通过在第一行禁止强制转换来避免在运行时检查这一点。它可以使得整数数组不是对象数组。这在静态上是合理的,但它也禁止了仅从数组中读取的常见且安全的代码模式。在 Java
支持泛型之前,这些模式对于可用性特别重要。
注:可以对比对象数组和对象容器的行为,详见
Core Java
泛型类型的继承规则和 Object List 部分。
习题
注:这里实现了对上一章中三目运算符和逗号运算符的求值
1
Allowing comparisons on types other than numbers could be useful. The operators might have a reasonable interpretation for strings. Even comparisons among mixed types, like 3 < "pancake"
could be handy to enable things like ordered collections of heterogeneous types. Or it could simply lead to bugs and confusion.
Would you extend Lox to support comparing other types? If so, which pairs of types do you allow and how do you define their ordering? Justify your choices and compare them to other languages.
- 由于数字的
Lox
的实现中均以浮点型存储,类似3==3.0
的表达式结果为真(这不是隐式类型转换),也许反映了比较运算符的对不同数字类型的重载 - 比较运算符的字符串重载,类似
+
进行显式分派 - 不同类型之间的比较会有隐式类型转换,不太好 🤣
- 另外可见
Python 拾遗
中比较部分
2
Many languages define +
such that if either operand is a string, the other is converted to a string and the results are then concatenated. For example, "scone" + 4
would yield scone4
. Extend the code in visitBinaryExpr()
to support that.
在 PLUS
分支中加两个 if
语句就可以了:
注意这里使用了 stringify
方法,避免 "scone" + 4
变成 scone4.0
3
What happens right now if you divide a number by zero? What do you think should happen? Justify your choice. How do other languages you know handle division by zero, and why do they make the choices they do?
Change the implementation in visitBinaryExpr()
to detect and report a runtime error for this case.
由于使用了浮点型存储数字,结果为 NaN
。
一般来说,只要两个操作数其中至少一个为浮点型,除以零的结果都为 NaN
,而只有两个操作数都为整型,即 0/0
,除以零才会报错。由于使用了浮点型存储数字,不清楚原始的数字类型,只能简单的实现如下: