前言
前言的第一句话原先是“解释器非常神奇”,但一位不愿透露姓名的审稿人说,这样写太蠢了。好吧,Christian,我不同意这种说法。我仍然认为解释器很神奇。让我来解释一下。
从表面上看,解释器很简单:输入文本,然后得到一些输出。也就是说,解释器是将其他程序作为输入来生成一些内容的程序。这看上去很简单,但你越深入思考,就越会发现它让人着迷。给解释器提供字母、数字和特殊字符等看似随机的内容,输出后这些内容突然就变得有意义了。这是因为解释器解读了这些内容,它让无意义的内容变得有意义。计算机是只能明白0和1的机器,现在却能理解我们提供的奇怪语言并进行反馈。这归功于解释器,它读取语言并同步翻译给计算机。
我一直在问自己:解释器是如何工作的?当脑海中第一次冒出这个问题时,我就知道,只有自己写一个解释器,才能从中得到令自己满意的答案。于是我就这么做了。
现在有许多书、文章、博客和教程介绍解释器。这些资料大体可分为两类:一类是极其重视理论的大部头,这类资料更适合那些对这方面已经有了深刻理解的人;另一类则篇幅很短,蜻蜓点水般介绍解释器,重点介绍使用像黑盒一样的外部工具,来实现一些只能作为示例的解释器。
在学习中,挫折感主要来自第二类资料,因为其中介绍的解释器只能解释语法极其简单的语言。而我并不想走捷径,我真的很想明白解释器的工作原理,包括理解其中的语法分析器和词法分析器。特别是,我想知道类C语言以及其中的大括号和分号是怎么解析的。学术类的教科书中有我想知道的答案,但其中冗长的理论解释和晦涩的数学公式对我来说还是困难了一点。
一边是教科书,用900页的篇幅介绍编译器;一边是博客,用50行Ruby代码编写了一个Lisp解释器,而我想要的是介于两者之间的东西。
所以我为大家编写了这本我理想中的书。本书适用于那些希望对底层知识有所了解,即想了解事物运作机制的人。
本书将从零开始,为一门新语言编写一个解释器,其中不会用到任何第三方的工具和库。最后实现的解释器无法用于生产环境,其性能并不等同于完全成熟的解释器,所实现的语言也会缺少某些特性,但我们能从其中学到许多知识。
由于解释器种类繁多且各不相同,因此很难为解释器给出明确的定义。但至少有一个功能是所有解释器都有的,那就是将源代码作为输入并求值,中途不会生成任何可见且之后还能再次运行的中间结果。编译器就不同,它会将源代码转换成底层系统可以理解的另一种语言。
有些解释器很精简,甚至不包括解析步骤,只是单纯地解释输入。
除此之外,也有精心设计的解释器。有些解释器进行了高层次优化,使用了高级解析和求值技术。有些则不会直接求值,而是将输入编译成称为字节码的内部形式,之后再求值。还有更高级的JIT解释器,它会将需要执行的输入即时转换成机器代码。
有些解释器则介于上述两类之间,这类解释器会解析源代码,构建抽象语法树(AST),然后对这棵树求值。这类解释器称为树遍历(tree-walking)解释器,因为其工作方式是在AST上遍历并解释。
本书将构建这样一个树遍历解释器。
我们将构建自己的词法分析器、语法分析器、树表示形式和求值器(evaluator),其中涉及词法单元(token)、抽象语法树、如何构建抽象语法树、如何求值,以及如何使用新的数据结构和内置函数扩展所用的编程语言。
Monkey编程语言和解释器
每个解释器都用来解释一种特定的编程语言,这就是实现一门编程语言的方式。没有对应的编译器或解释器,一门编程语言只不过是一个想法或一种规范。
我们将要解析和求值的语言叫Monkey。这是专为本书设计的语言,其唯一实现就是本书中构建的解释器。
Monkey具有以下特性:
- 类C语法
- 变量绑定
- 整型和布尔型
- 算术表达式
- 内置函数
- 头等函数和高阶函数
- 闭包
- 字符串数据结构
- 数组数据结构
- 哈希数据结构
本书剩余部分会详细研究并实现这些特性。现在先来看看Monkey长什么样。
在Monkey中绑定值和名称的方法如下:
let age = 1;
let name = "Monkey";
let result = 10 * (20 / 2);
除了整型、布尔型和字符串,Monkey解释器还支持数组和哈希表。下面展示如何将一个整型数组绑定到一个名称上:
let myArray = [1, 2, 3, 4, 5];
下面是一个哈希表,其中的值和键进行了关联:
let thorsten = {"name": "Thorsten", "age": 28};
下面使用索引表达式访问数组和哈希表中的元素:
myArray[0] // => 1
thorsten["name"] // => "Thorsten"
let
语句还可以用来绑定函数和名称。下面是将两个数字相加的一个函数:
let add = fn(a, b) { return a + b; };
Monkey不仅支持return
语句,还支持隐式返回值,这意味着可以省略return
:
let add = fn(a, b) { a + b; };
调用函数很简单:
add(1, 2);
下面是一个复杂一点的函数fibonacci
,它会返回斐波那契数列的第n项:
let fibonacci = fn(x) {
if (x == 0) {
0
} else {
if (x == 1) {
1
} else {
fibonacci(x - 1) + fibonacci(x - 2);
}
}
};
注意,fibonacci
函数在递归中调用了自身。
Monkey还支持一类特殊的函数,即高阶函数。这类函数以其他函数为参数,如下所示:
let twice = fn(f, x) {
return f(f(x));
};
let addTwo = fn(x) {
return x + 2;
};
twice(addTwo, 2); // => 6
这里的twice
接受了两个参数:函数addTwo
和整数2
。这段代码调用了addTwo
两次,第一次以2
为参数,第二次以第一次的返回值为参数。最后一行代码返回结果6
。
是的,我们可以在函数调用中将函数作为参数。Monkey中的函数只是值,与整数或字符串一样。具有这个特性的函数称为“头等函数”(first-class function)。
本书构建的解释器将实现所有这些特性。解释器会在REPL中对Monkey源代码进行词法分析和语法分析,将代码构建成称为抽象语法树的中间表示,然后对该树求值。解释器包含以下几个主要部分:
- 词法分析器
- 语法分析器
- 抽象语法树(AST)
- 内部对象系统
- 求值器
我们将完全按照此顺序自上而下构建这几部分,即从源代码到输出的顺序。这种方法的缺点是在第1章还无法生成简单的Hello World
,但好处是更容易理解各部分如何协同工作以及数据如何在程序内部流动。
至于为什么名字叫Monkey?好吧,因为猴子是美妙、优雅、迷人且有趣的动物,就像我们的解释器一样。
另外,书名是什么意思呢?
为什么用Go语言
读者应该已经注意到本书的书名和其中的“用Go语言”了。是的,我们将用Go语言自制解释器。为什么用Go?
我喜欢用Go编写代码。我喜欢使用这门语言、其标准库及其提供的工具。除此之外,我还认为Go具有的一些属性使其非常适合本书。
Go很容易阅读和理解。即使对于Go初学者,本书中的Go代码也浅显易懂。我相信哪怕读者从未写过一行Go代码,也可以跟着本书学习。
另一个原因是Go提供了出色的工具。本书的重点是编写解释器,包括理解其背后的思想和概念并完成其实现。借助gofmt
带来的Go通用格式样式以及内置的测试框架,我们可以专注于解释器本身,而不必分心于第三方库、工具和依赖。除了Go语言提供的工具以外,本书不会使用任何其他工具。
更重要的一个原因是,本书中展现出了Go的代码风格与其他更底层的语言(C、C++和Rust)非常相似。这或许是Go本身的原因,Go侧重于简单和朴素的美感,与上述语言之间没有什么难以转换的编程语言结构;抑或是本书中我编写Go所使用的风格与这些语言相似。总之,书中的Go代码不会取巧地使用任何元编程技巧,这些复杂的技巧会让代码在一段时间后就难以理解。书中也没有用冗长的篇幅来解释面向对象的设计和模式,最后还来一句“看,这很简单”。
所有这些因素使得本书的代码在概念和技术上都易于理解,并且可以复用。读者在阅读完本书后,可以很轻松地用另一种语言编写自己的解释器。本书旨在给读者理解和构造解释器提供一个起点,相信书中的代码也体现出了这个初衷。
如何使用本书
本书既不是参考书,也不是描述解释器实现概念并在附录中附加代码的理论读物。本书需要按顺序阅读,我建议读者从头到尾,一边阅读,一边敲出书中的代码并调试。
每章的代码和内容都以其之前的章节为基础。每一章都会构建解释器的一部分,一点一点积累,直至完成。为了使后续操作更容易,本书附带了一个名为code的文件夹,其中包含代码。可以在此处下载:
https://interpreterbook.com/waiig_code_1.7.zip
code文件夹有多个子文件夹,每个子文件夹对应一章的内容,且含有对应章节的最终结果。
有时我只会在书中提及完整代码中的某些内容,不会完整列出相关代码,因为完整的代码会占用太多篇幅,其中有些只是测试文件或不重要的细节。读者可以在对应章节的文件夹中找到完整的代码。
跟随书中示例进行操作需要哪些工具?并不多:一个文本编辑器和Go语言。Go版本高于1.0应该都可以,但事先声明:本书最初编写时使用的是Go 1.7,而本书的最新版使用的是Go 1.14。
如果读者使用Go >= 1.13,则code文件夹中的代码应该是“开箱即用”的。
如果使用的是不支持Go模块的旧版本Go,那么建议使用direnv工具,它可以根据.envrc文件修改shell环境。本书随附的code文件夹中的每个子文件夹都有一个.envrc文件,该文件可为该子文件夹正确设置GOPATH
。这样就可以轻松地使用不同章节的代码了。
有了这些准备之后,让我们开始吧!
更多信息
扫描下方二维码,即可获取电子书相关信息及读者群通道入口。