Skip to content

Crafting Interpreters Part Ⅰ

Posted on:2021.08.13

TOC

Open TOC

WELCOME

Introduction

这一章主要介绍了为什么要学编译原理,以及这本书的构成。

这本书重视实践,所以在理论知识会比其他的编译原理书稍微少一些,当然理论也是非常重要的,这里作者提到了一个概念 Curry – Howard correspondence,说的是计算机程序和数学证明之间的同构关系,似乎很有趣,让我想起了 Coq

为什么要学编译原理

这本书的构成

为新语言开发编译器首先是用现有语言开发的,然后用新语言重写并自行编译,这个过程叫 Bootstrapping,最终的效果便是可以用语言本身实现自己的编译器,称为 Self-hosting

这一章节的 DESIGN NOTE 主要介绍了为编程语言命名的一些原则。

A Map of the Territory

这张图放在开头真的很震撼 🤣

be507464288e4c21947762abb6daec96.png

将这张图分为两个部分:

词法分析和语法分析

词法分析和语法分析看图就能理解了。

语义分析

语义分析,这里书中的小标题叫静态分析,第一件事便是绑定(标识符与实际对象),之后静态语言会进行类型检查,一系列操作后,我们会得到用户程序的语义,我们需要存储这些信息:

中间代码

中间代码作为中间人,为多种源语言和目标平台牵线搭桥(为生成中间代码的每种源语言编写一个前端,然后为每个目标体系结构提供一个后端,这就把乘法变成了加法)。这里介绍了一些中间代码的风格:

代码优化

代码优化,让程序保持相同的语义,同时运行的更高效,CSAPP 第五章便介绍了这方面的内容,当然,许多成功的语言很少进行编译时优化。例如,LuaCPython 生成相对未优化的代码,并将大部分性能工作集中在运行时上。

代码生成

代码生成,这里的代码通常指的是 CPU 运行的类似于原始程序集的指令,而不是人类可能想要阅读的源代码。这里有两个选择,我们是为真正的 CPU 还是虚拟 CPU 生成指令:

注:术语“虚拟机”也指一种不同的抽象。系统虚拟机在软件中模拟整个硬件平台和操作系统。本书中讨论的虚拟机类型是语言虚拟机或进程虚拟机。

运行时

运行时,无论是直接让操作系统运行可执行文件或者让虚拟机执行程序,我们通常需要语言在程序运行时提供一些服务,如垃圾回收或反射(跟踪执行期间每个对象的类型)。

一些捷径与其他路线

一些简单的编译器结合了语法分析、语义分析和代码生成,不需要分配任何语法树或其他中间代码。

这些编译器限制了语言的设计,典型的例子是 PascalC,因为一旦你看到一些表达式,你需要知道足够的信息来正确编译它,这就是为什么在 C 中,不能调用没有被显式声明的函数。

这需要一种结构化的编译技术,称为Syntax-directed translation

有些语言编写的程序在得到语法树(和一些静态分析)后就可以被执行,为了运行该程序,解释器一次遍历语法树的一个分支和一个叶,在每个节点运行时对其进行求值。这就是在本书中构建的第一个解释器的工作原理。

它以编程语言编写的程序的源代码作为输入,并以相同或不同的编程语言生成等效的源代码。它在以大致相同的抽象级别运行的编程语言之间进行转换。

举两个例子:

  1. 在 UNIX 的任何地方都可以使用 C 编译器,并且可以生成高效的代码,因此以 C 为目标语言是让源语言在许多体系结构上运行的好方法。

  2. Web 浏览器的“机器代码”是 JavaScript,所以现在几乎所有语言都有一个针对 JavaScript 的编译器,因为这是让代码在浏览器中运行的主要方式。

总而言之,在做好前端的工作后,如果源语言和目标语言语法类似,可以直接跳过分析,直接输出目标语言的源代码。或者经过分析与优化,生成目标语言的源代码。

执行代码的最快方法是将其编译为机器代码,但可能不知道最终用户的机器支持什么体系结构。

于是,当程序从源代码加载时,或者从独立于平台的字节码加载时,我们可以将其编译为其计算机支持的体系结构的本机代码。这被称为即时编译。

这不是捷径,而是危险的攀岩。

编译器与解释器的区别

类似水果与蔬菜

db11c97c426d4b069d8fe318f3402e89.png

Go 语言作为例子

The Lox Language

这里的自制语言叫 Lox,其基本语法和 C 很像。

和一些脚本语言的关系

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).

数据类型

表达式

语句

变量声明

var 关键词,若不初始化则默认为 nil

控制流

我们已经有了 andor 来进行分支,可以使用递归来重复代码,所以理论上已经足够了。不过,用命令式语言编写这样的程序会非常尴尬。

另一方面,Scheme 没有内置的循环。它依赖于递归进行重复。Smalltalk 没有内置的分支,并且依赖动态分派来选择性地执行代码。

函数

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

9c471159f3d240be97e127f043e96cac.png

Prototype-based programming - Wikipedia

1bbe85bbf1f64430a3b3b5c43be3f2c0.png

Waterbed Theory

它认为如果你降低了语言或工具的一部分的复杂性,就会有一种补偿增加了语言或工具的另一部分的复杂性。

这里从 Class-basedPrototype-based,语言实现的复杂性降低,用户使用的复杂度增加

Lox 中,类也是一等公民,使用 < 运算符表达继承,类的 init() 会被隐式继承

Lox 不是纯面向对象语言(因为内置类型不是类的实例)

标准库

内置的 print 语句

内置函数 clock(),用于性能测试

表达式与语句

这一部分的 DESIGN NOTE 讲述了表达式与语句的关系,也曾经是我学习编程语言时遇到的一个疑惑。

Lox 既有表达式也有语句。有些语言省略了后者。相反,它们也将声明和控制流构造视为表达式,如 LispHaskellRuby 等。为了做到这一点,对所有类似语句的表达式,我们需要定义它们所代表的值:

另外,我们要决定这些类似语句的表达式和其他表达式结合时会发生什么:

puts 1 + if true then 2 else 3 end + 4

这是 Ruby 的例子,if 表达式有显式的 end

把语句都变成表达式,将迫使你回答一些像这样一些模糊的问题。作为回报,我们消除了一些语言中的冗余性(对于 C 而言,既有 if 语句,又有 ?: 三元表达式)

不使用语句的语言通常还具有隐式返回特性,函数自动返回其主体计算结果的任何值,而不需要某些显式返回语法。对于小函数和方法,这非常方便。事实上,许多有语句的语言都添加了 => 这样的语法,以便能够定义函数体是计算单个表达式的结果的函数。但让所有函数都以这种方式工作可能有点奇怪。如果不小心,函数将泄漏返回值,即使只是想让它产生副作用。

A TREE-WALK INTERPRETER

前几章进行词法分析、语法分析和代码求值。之后,我们一次添加一个语言特性,将一个简单的计算器发展成一个成熟的脚本语言。

对于本地的代码,在每一章之后会附有相应的完整代码。

对于代码的分析,打算采用如下两种方式:

Scanning

这项任务通常被称为 scanninglexing,在早期,scanning 只是指处理从磁盘读取原始源代码字符并将其缓冲在内存中的代码片段,然后 lexing 是对字符进行有用处理的后续阶段。如今,将源文件读入内存是件小事,因此在编译器中很少有明确的阶段。因此,这两个术语基本上可以互换。

效果

输入

// this is a comment
(( )){} // grouping stuff
!*+-/=<> <= == // operators

输出

LEFT_PAREN ( null
LEFT_PAREN ( null
RIGHT_PAREN ) null
RIGHT_PAREN ) null
LEFT_BRACE { null
RIGHT_BRACE } null
BANG ! null
STAR * null
PLUS + null
MINUS - null
SLASH / null
EQUAL = null
LESS < null
GREATER > null
LESS_EQUAL <= null
EQUAL_EQUAL == null
EOF null

运行方式

让我们看一看这是如何做到的,首先,运行这个程序有两种方式:

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:

(print (eval (read)))

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,并输出

private static void run(String source) {
Scanner scanner = new Scanner(source);
List<Token> tokens = scanner.scanTokens();
// For now, just print the tokens.
for (Token token : tokens) {
System.out.println(token);
}
}

错误处理

我们需要对处理过程中出现的错误进行处理,这些用于处理错误的工具实际上构成了用户界面的很大一部分。当错误出现时,我们应该向用户提供他们所需要的所有信息。

另外,将生成错误的代码与报告错误的代码分开是一种良好的工程实践。目前我们的错误报告函数位于 Lox 类中,而非在 Scanner 类中。在理想情况下,我们会有一个某种类型的 ErrorReporter 接口,传递给 scannerparser,以便我们可以更改不同的报告策略。

在这里,为了实现的简单化,我们在 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

我们需要区分这两个概念:

于是我们需要思考 token 的数据结构,作者为 token 设计了四个字段,其中的 TokenType 是一个枚举类,定义了 token 的所有可能的类型,对于字面量,我们将其保存在字段 literal 中,字段 line 提供了错误处理时所需的行号。

final TokenType type;
final String lexeme;
final Object literal;
final int line;

从源代码的第一个字符开始,scanner 会找出该字符所属的 lexeme,并使用该字符以及该 lexeme 的后续字符。当它到达该 lexeme 的末尾时,便得到一个 token。然后从源代码中的下一个字符开始,循环并再次执行。

决定一种特定语言如何将字符分组为 lexeme 的规则称为 lexical grammar。例如,Lox 对于标识符具有与 C 相同的规则,因而我们可以使用正则表达式来匹配这个 lexeme

[a-zA-Z_][a-zA-Z_0-9]*

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,像 LexFlex 这样的工具是专门设计来让你做到这一点的。因为我们的目标是了解 scanner 是如何完成它的工作的,所以我们不会这样做。

Scanner

下面是外界调用 Scanner 类的接口 scanTokens()

List<Token> scanTokens() {
while (!isAtEnd()) {
// We are at the beginning of the next lexeme.
start = current;
scanToken();
}
tokens.add(new Token(EOF, "", null, line));
return tokens;
}

Scanner 类中,我们需要一些字段来定位源文件中 lexeme 的位置,对某个正在被处理的 lexeme,其起始字符下标为 start,目前扫描到的字符下标为 current。只要还没有扫描完所有的 lexeme,我们便不断调用 scanToken()。最后加上一个 EOFtoken

private void addToken(TokenType type) {
addToken(type, null);
}
private void addToken(TokenType type, Object literal) {
String text = source.substring(start, current);
tokens.add(new Token(type, text, literal, line));
}

所以现在的核心便是 scanToken() 方法,我们需要分一下类:

LEFT_PAREN, RIGHT_PAREN, LEFT_BRACE, RIGHT_BRACE,
COMMA, DOT, MINUS, PLUS, SEMICOLON, SLASH, STAR,

其中的 SLASH 也就是 / 需要单独处理(除号还是注释)

BANG, BANG_EQUAL,
EQUAL, EQUAL_EQUAL,
GREATER, GREATER_EQUAL,
LESS, LESS_EQUAL,

左边一列符号是右边一列符号的前缀,所以需要区分

对于换行符,需要将行数加一(这里怀疑不同平台标准不一致)

IDENTIFIER, STRING, NUMBER,

字符串以双引号开头,数字和标识符则在 default 分支中处理

这里需要稍微提一下,Lox 支持多行字符串,对于输入

var str = "this is a
multi-line string";

其结果为

VAR var null
IDENTIFIER str null
EQUAL = null
STRING "this is a
multi-line string" this is a
multi-line string
SEMICOLON ; null
EOF null

另外,Lox 目前不支持负数字面量,对于输入

var num = -123;

其结果为

VAR var null
IDENTIFIER num null
EQUAL = null
MINUS - null
NUMBER 123 123.0
SEMICOLON ; null
EOF null

Lox 将所有的数字字面量存储为浮点数

对于剩下的字面量(布尔型和 nil),视其为关键词

AND, CLASS, ELSE, FALSE, FUN, FOR, IF, NIL, OR,
PRINT, RETURN, SUPER, THIS, TRUE, VAR, WHILE,

对于标识符和关键词的处理,这里有一个原则叫 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 是不允许 /* ... */ 嵌套的,也就是说,当遇到 /*,只要遇到 */,就与第一个 /* 结合,*/ 后面的内容则不视为注释。

case '/':
if (match('/')) {
// A comment goes until the end of the line.
while (peek() != '\n' && !isAtEnd()) advance();
} else if (match('*')){
nonNest();
} else {
addToken(SLASH);
}
break;

其中 nonNest() 为私有方法

private void nonNest() {
String sub = source.substring(start);
int index = sub.indexOf("*/");
if (index != -1)
current += index;
else
Lox.error(line, "Unexpected character.");
}

若允许 /* ... */ 嵌套,处理会复杂一些

Representing Code

这一部分的主要目标是以一种方式表示代码,便于下一步处理。

考虑最简单的算术表达式,为了表现运算符的优先级与结合性,我们需要以的形式组织算术表达式,并通过后序遍历求值,如下图所示:

57493ccc78c249beaf22128ca5d104e5.png

因此,直观地看来,我们代码的可行表示是一棵树,它与语言的语法结构(运算符嵌套)相匹配。

这并不是说树是我们代码的唯一可能表示。在第二个实现中,我们将生成字节码。

我们需要更准确地了解该语法是什么。就像上一章中的词法文法(lexical grammars)一样,围绕句法文法(syntactic grammars)有大量的理论。我们首先将乔姆斯基层次结构向上移动一级——上下文无关文法。

上下文无关文法

应该如何理解「上下文无关文法」?

需要指出,词法文法和句法文法都是一种形式文法(Formal grammar),形式文法采用一些原子的集合,它称之为“字母表”。然后它定义了一组(通常是无限的)在语法中的“字符串”。每个字符串都是字母表中的“字母”序列。在这个意义上,我们来区分词法文法和句法文法:

TerminologyLexical grammarSyntactic grammar
The “alphabet” is …CharactersTokens
A “string” is …Lexeme or tokenExpression
It’s implemented by the …ScannerParser

形式文法需要指出哪些字符串有效,哪些无效,而这就需要一系列的规则(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.

上面的一些术语为了准确不去翻译,但需要注意的是,在某一步可能会有多个规则都适用(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 的一个子集,并逐步加入新的语法,现在我们只考虑下面这些元素:

使用 metasyntax 可以表示为:

expression → literal
| unary
| binary
| grouping ;
literal → NUMBER | STRING | "true" | "false" | "nil" ;
grouping → "(" expression ")" ;
unary → ( "-" | "!" ) expression ;
binary → expression operator expression ;
operator → "==" | "!=" | "<" | "<=" | ">" | ">="
| "+" | "-" | "*" | "/" ;

这里的 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 枚举类,从某种意义来说,tokenshomogeneous 的,然而对于表达式并非如此。

我们将为表达式定义一个基类。然后,对于每种表达式,即表达式下的每个产生式,我们创建一个子类,就像这样:

abstract class Expr {
static class Binary extends Expr {
Binary(Expr left, Token operator, Expr right) {
this.left = left;
this.operator = operator;
this.right = right;
}
final Expr left;
final Token operator;
final Expr right;
}
// Other expressions...
}

这里作者还讨论了 Expr 类中只有字段,而没有方法(静态内部类),在 Java 的世界中是否有点奇怪。作者认为,这些类型的存在是为了让 parserinterpreter 能够通信,这适用于没有关联行为的简单数据类型。这种风格在 LispML 等所有数据与行为分离的函数式语言中非常自然,但在 Java 中感觉很奇怪。

为了省事 🤣,作者在另一个包中定义了 GenerateAst 类来专门生成这种结构的代码,只要给出每种表达式(静态内部类)的字段和类型即可,就像这样:

"Binary : Expr left, Token operator, Expr right",
"Grouping : Expr expression",
"Literal : Object value",
"Unary : Token operator, Expr right"

语法树的实现

现在我们有一些类了,我们需要将一组行为与每个类相关联,我们可以在 Expr 上添加一个抽象的 interpret() 方法,然后每个子类将实现它来解释自己。

This exact thing is literally called the “Interpreter pattern” in Design Patterns.

然而这种做法扩展性很差(扩展方法,而非类型),这里作者的解释很好,上图:

aba402bf206c49158c86771dd0462324.png

对于面向对象语言 Java 来说,扩展类型(一个新行)是容易的:

518dfb0a5ac348c88fa859735e338476.png

但是扩展方法很难,这意味着向每个现有的类中添加一个新的方法。

而对于函数式编程语言 ML 而言,类型和方法是分离的(Haskell 缓缓打出一个问号 🤨)。要为多种不同类型实现操作,需要定义一个函数,在该函数的主体中使用模式匹配,这使得扩展方法(一个新列)变得容易:

42db7123203948549e4144cc2a6fd602.png

但是扩展类型很难,必须在所有函数的模式匹配中添加一个新的类型匹配。

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 这一章节:

4f8107a7f7a34a38987d46b71cd5f4ce.png

这一章节给出了三种不同的解决方案,其中的数据导向风格较为完美:

The Visitor pattern

访问者模式实际上是在面向对象语言中模拟函数式风格。它让我们可以轻松地向该表添加新列。我们可以在一个地方为一组类型定义新操作的所有行为,而不必接触类型本身。

It does this the same way we solve almost every problem in computer science: by adding a layer of indirection.

首先我们要在 Expr 类中添加一个接口:

interface Visitor<R> {
R visitBinaryExpr(Binary expr);
R visitGroupingExpr(Grouping expr);
R visitLiteralExpr(Literal expr);
R visitUnaryExpr(Unary expr);
}

注意这里实际上没有必要使用不同的名字命名方法,因为参数不同,方法会重载。然而为了适用于那些没有方法重载机制的实现语言,作者显式使用了不同的名字。

在每个静态内部类中添加 accept 方法:

@Override
<R> R accept(Visitor<R> visitor) {
return visitor.visitBinaryExpr(this);
}

这里的返回值使用了泛型,增加灵活性。

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 的语法,每个表达式都显式地用括号括起来,并且是前缀表示。

(* (- 123) (group 45.67))

于是我们新建一个类 AstPrinter

class AstPrinter implements Expr.Visitor<String> {
String print(Expr expr) {
return expr.accept(this);
}
@Override
public String visitBinaryExpr(Expr.Binary expr) {
return parenthesize(expr.operator.lexeme,
expr.left, expr.right);
}
// ...
}

这里的 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:

expr → expr ( "(" ( expr ( "," expr )* )? ")" | "." IDENTIFIER )+
| IDENTIFIER
| NUMBER

Produce a grammar that matches the same language but does not use any of that notational sugar.

expr → expr calls
expr → IDENTIFIER
expr → NUMBER
calls → calls call
calls → call
call → "(" ")"
call → "(" arguments ")"
call → "." IDENTIFIER
arguments → expr
arguments → arguments "," expr

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.

package lox;
class AstRPN implements Expr.Visitor<String> {
String print(Expr expr) {
return expr.accept(this);
}
@Override
public String visitBinaryExpr(Expr.Binary expr) {
return RPN(expr.operator.lexeme + " ",
expr.left, expr.right);
}
@Override
public String visitGroupingExpr(Expr.Grouping expr) {
return RPN("", expr.expression);
}
@Override
public String visitLiteralExpr(Expr.Literal expr) {
if (expr.value == null) return "nil ";
return expr.value.toString() + " ";
}
@Override
public String visitUnaryExpr(Expr.Unary expr) {
return RPN(expr.operator.lexeme + " ", expr.right);
}
private String RPN(String name, Expr ... exprs) {
StringBuilder builder = new StringBuilder();
for (Expr expr : exprs)
builder.append(expr.accept(this));
builder.append(name);
return builder.toString();
}
public static void main(String[] args) {
Expr first = new Expr.Binary(
new Expr.Literal(1),
new Token(TokenType.STAR, "*", null, 1),
new Expr.Literal(2));
Expr second = new Expr.Binary(
new Expr.Literal(4),
new Token(TokenType.MINUS, "-", null, 1),
new Expr.Literal(3));
Expr expression = new Expr.Binary(
new Expr.Grouping(first),
new Token(TokenType.STAR, "*", null, 1),
new Expr.Grouping(second));
System.out.println(new AstRPN().print(expression));
}
}

在这里就不需要区分负号和减号了,因为 token 里已经有了足够的信息。例如上面的示例在这里显示为:

123 - 45.67 *

当求值到 - 时,发现这个 token 是单目运算符,不会引起歧义。当然如果为了阅读,可以显式地加以区分。

另外在这里 Expr.Grouping 的意义感觉不大,所以没有显示出来。

Parsing Expressions

在上一章中,我们通过上下文无关文法来显式构造语法树。有了 Parser,给定一系列 tokens 构成的字符串,就能自动构造语法树,确定哪些规则可以生成该字符串。

优先级和结合性

在我们上一章给出的上下文无关文法表示中,并没有考虑运算符之间的优先级和结合性,它们都被归为 operator。这样会带来歧义,例如:

6 / 3 - 1

可以被解释为:

(6 / 3) - 1

或:

6 / (3 - 1)

为了解决这种歧义,我们需要定义运算符之间优先级和结合性,下面给出定义,其优先级从低到高:

NameOperatorsAssociates
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

这就需要我们重写上下文无关文法表示,我们为每个优先级都定义了单独的规则,其最终结果如下:

expression → equality ;
equality → comparison ( ( "!=" | "==" ) comparison )* ;
comparison → term ( ( ">" | ">=" | "<" | "<=" ) term )* ;
term → factor ( ( "-" | "+" ) factor )* ;
factor → unary ( ( "/" | "*" ) unary )* ;
unary → ( "!" | "-" ) unary
| primary ;
primary → NUMBER | STRING | "true" | "false" | "nil"
| "(" expression ")" ;

需要注意的是,这里的每一条规则都能匹配当前优先级以及更高优先级的表达式,所以这里有一种自上而下的感觉。另外,在二元运算符规则的定义中,例如 factor,我们也可以写成:

factor → factor ( "/" | "*" ) unary
| unary ;

虽然这种写法是对的,但这是左递归,我们将要实现的 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.

7ac3e675b984409694854bdc303da7d1.png

递归下降被认为是自顶向下的 Parser,因为它从顶部或最外层的语法规则(这里是表达式)开始,并在最终到达语法树的叶子之前向下进入嵌套的子表达式。与之对应的运算符的优先级是从低到高。

我们很容易实现这样的技术,直接将语法规则直接翻译成代码就可以了:

Grammar notationCode representation
TerminalCode to match and consume a token
NonterminalCall to that rule’s function
``
* or +while or for loop
?if statement

在我们的实现中,这个类叫做 Parser,它有两个字段,一个是 tokens 的序列,一个是目前扫描到的 token 下标(类似 Scanner 中的处理)。

这里再次强调一下目前的含义:下一个被扫描的对象,也就是 peek 到的对象。

对于代码中的辅助函数,其中有一个叫 isAtEnd,就是判断 peek 到的 token 是否为 EOF,这就和 Scanner 类中的设计对应起来了。

我们展示 equality 层的代码:

private Expr equality() {
Expr expr = comparison();
while (match(BANG_EQUAL, EQUAL_EQUAL)) {
Token operator = previous();
Expr right = comparison();
expr = new Expr.Binary(expr, operator, right);
}
return expr;
}

对应的规则是:

equality → comparison ( ( "!=" | "==" ) comparison )* ;

对于左操作数,我们递归调用下一层的函数,并将其结果存储在局部变量 expr 中,如果能够匹配 equality 层的运算符,我们就递归调用取得右操作数,并更新 expr,否则就直接返回 expr。这也就是上文中每一条规则都能匹配当前优先级以及更高优先级的表达式的含义。

来一个具体的例子,对于表达式 a == b == c == d == e,其最终构造的语法树如下:

8c1b371181984b71ad63dd782a95c6d7.png

(⊙ 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 层,代码如下:

private Expr primary() {
if (match(FALSE)) return new Expr.Literal(false);
if (match(TRUE)) return new Expr.Literal(true);
if (match(NIL)) return new Expr.Literal(null);
if (match(NUMBER, STRING)) {
return new Expr.Literal(previous().literal);
}
if (match(LEFT_PAREN)) {
Expr expr = expression();
consume(RIGHT_PAREN, "Expect ')' after expression.");
return new Expr.Grouping(expr);
}
throw error(peek(), "Expect expression.");
}

需要特别处理的是括号,当匹配到左括号而没有匹配到右括号时,就得到了一个语法错误。

语法错误

Parser 遇到语法错误时,Parser 的必选项:

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 等,其实现如下:

private void synchronize() {
advance();
while (!isAtEnd()) {
if (previous().type == SEMICOLON) return;
switch (peek().type) {
case CLASS:
case FUN:
case VAR:
case FOR:
case IF:
case WHILE:
case PRINT:
case RETURN:
return;
}
advance();
}
}

之所以说可能,是因为我们可以在 for 循环中用分号分隔子句。我们的同步并不完美,但没关系。我们已经准确地报告了第一个错误,因此之后的一切都是尽力而为。

Parser 不会报告隐藏在这些被丢弃 tokens 中的任何其他语法错误,这意味着相关联的副作用错误不会被报告,这是一个不错的权衡。

目前,我们还没有看到这个方法的实际效果,因为我们还没有语句。现在如果发生语法错误,我们进入恐慌模式并一直展开(unwind)到顶部并停止解析。因为无论如何我们只能解析一个表达式,所以这没什么大不了的。

Expr parse() {
try {
return expression();
} catch (ParseError error) {
return null;
}
}

我们重写 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 层之间,由于逗号运算符是二元运算符,且结合性为从左到右,只要简单的添加一层就可以了:

private Expr expression() {
return comma();
}
private Expr comma() {
Expr expr = equality();
while (match(COMMA)) {
Token operator = previous();
Expr right = equality();
expr = new Expr.Binary(expr, operator, right);
}
return expr;
}

若输入为:

1+1==2,3==4,5

则输出为:

(, (, (== (+ 1.0 1.0) 2.0) (== 3.0 4.0)) 5.0)

对于如何求值,见下一章。

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 运算符优先级 中指出 ?: 运算符优先级仅高于逗号和赋值,且结合性为从右到左,写个示例程序:

#include<stdio.h>
int main() {
int var = 1 == 2 ? 3 == 4 ? 3, 4 : 6 : 7 ? 8 : 9 ? 10 : 0;
printf("%d", var);
}

此时输出为 8。另外还有一点,条件运算符中部(?: 之间)的表达式分析为如同加括号,忽略其相对于 ?: 的优先级。尝试写出 ?: 运算符的规则:

private Expr ternary() {
Expr expr = equality();
if (match(QUESTION)) {
Expr mid = expression();
if (match(COLON)) {
Expr right = ternary();
expr = new Expr.Ternary(expr, mid, right);
} else {
throw error(peek(), "Expect ':' after expression.");
}
}
return expr;
}

为此,在 TokenType 枚举类中添加 QUESTIONCOLON,在 Scanner 类中添加对应的 switch 语句。

Expr 类中添加一个三目运算符的内部类,使用 GenerateAst 类生成:

"Ternary : Expr left, Expr mid, Expr right"

AstPrinter 类中实现对三目运算符的打印:

@Override
public String visitTernaryExpr(Expr.Ternary expr) {
StringBuilder builder = new StringBuilder();
builder.append("(? ");
builder.append(expr.left.accept(this));
builder.append(" (: ");
builder.append(expr.mid.accept(this));
builder.append(" ");
builder.append(expr.right.accept(this));
builder.append("))");
return builder.toString();
}

若输入为:

1 == 2 ? 3 == 4 ? 3, 4 : 6 : 7 ? 8 : 9 ? 10 : 0

则输出为:

(? (== 1.0 2.0) (: (? (== 3.0 4.0) (: (, 3.0 4.0) 6.0)) (? 7.0 (: 8.0 (? 9.0 (: 10.0 0.0))))))

这里的实现并未考虑在出现 : 前没有出现 ? 的情形

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.

unary → ( "!" | "-" | "+" ) unary
| primary ;

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 层即可:

private Expr unary() {
if (match(BANG, MINUS)) {
Token operator = previous();
Expr right = unary();
return new Expr.Unary(operator, right);
} else if (match(SLASH, STAR, PLUS, GREATER, GREATER_EQUAL, LESS, LESS_EQUAL, BANG_EQUAL, EQUAL_EQUAL)) {
Token operator = previous();
Expr right = unary();
throw error(operator, "Without a left-hand operand.");
}
return primary();
}

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 typeJava representation
Any Lox valueObject
nilnull
BooleanBoolean
numberDouble
stringString

在表示了值后,我们还需要管理它们所占用的内存,Java 的垃圾回收为我们做到了这一点。

Evaluating Expressions

正如 Representing Code 的部分所言,我们可以在 Expr 上添加一个抽象的 interpret() 方法,然后每个子类将实现它来解释自己,这被称为解释器模式。然而我们已经有了 The Visitor pattern,类比 AstPrinter 类的做法,我们只要创建一个新的类 Interpreter 并实现 Expr.Visitor<Object> 就可以了,这里的泛型类型是 Object,正如 Lox 中所有的值在 Java 中的对应为 Object

在这里具体的实现并不困难,需要强调的有以下几点:

运行时错误

前面提到,若强制类型转换失败,会触发 ClassCastException,该异常会直接让解释器直接崩溃 🤣。为了能够自定义行为,我们在需要强制类型转换前进行显式地检查,对应的方法为 checkNumberOperandcheckNumberOperands,其中可能会抛出自定义异常 RuntimeError,表明出现了运行时错误

与之对应的,我们在 Lox 类中定义 runtimeError 方法,并新增字段 hadRuntimeError,该字段仅在 runFile 方法中会被检查,LoxREPL 并不在意 🤣。

下面是外界调用 Interpreter 类的接口方法,这里需要注意对结果值的字符串化:

void interpret(Expr expression) {
try {
Object value = evaluate(expression);
System.out.println(stringify(value));
} catch (RuntimeError error) {
Lox.runtimeError(error);
}
}

另外,为了保证在 REPL 会话中对 run() 的连续调用使用的是相同的解释器(之后会存储全局变量),我们在 Lox 新增了一个字段:

private static final Interpreter interpreter = new Interpreter();

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 数组,正是我感兴趣的内容 🤣。

静态和动态类型不是一个非黑即白的选择。事实证明,即使是大多数静态类型语言,在运行时都会进行一些类型检查。类型系统静态检查大多数类型规则,但在生成的代码中插入运行时检查以进行其他操作。

一个更微妙的例子是 JavaC# 中的 covariant arrays。数组的静态子类型规则允许不合理的操作。考虑如下的 Java 代码:

协变与逆变,梦回范畴论 🤣

Object[] stuff = new Integer[1];
stuff[0] = "not an int!";

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.

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 语句就可以了:

case PLUS:
if (left instanceof Double && right instanceof Double)
return (double) left + (double) right;
if (left instanceof String && right instanceof String)
return (String) left + (String) right;
if (left instanceof String && right instanceof Double)
return (String) left + String.valueOf(stringify(right));
if (right instanceof String && left instanceof Double)
return String.valueOf(stringify(left)) + (String) right;
throw new RuntimeError(expr.operator, "Operands must be two numbers or two strings.");

注意这里使用了 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,除以零才会报错。由于使用了浮点型存储数字,不清楚原始的数字类型,只能简单的实现如下:

case SLASH:
checkNumberOperands(expr.operator, left, right);
if ((double) right == 0)
throw new RuntimeError(expr.operator, "/ by zero");
return (double) left / (double) right;