Crafting Interpreters - The Lox Language

Crafting Interpreters - [1-3] The Lox Language

你可以为某人做些比让他们吃早餐更好的事情吗?

​ - Anthony Bourdain

因为项目的原因(其实是自己懒),第三节迟迟没有翻译更新,之后应该也是随缘,佛系更新(虽然也确实没人康)。不过这本书确实还不错,顺便还能锻炼阅读英文的能力。

我们将用本书的其余部分来详细阐明Lox语言的每个角落,但是!让你立即开始编写解释器的代码,而不了解我们最终会将Lox实现成什么样,那可能不太现实。

同时,在接触到文本编辑器之前,我不想让你了解大量的规范术语。所以对于Lox,我会温和、友好地介绍它。这可能会忽略很多细节和边界情况,但我们有足够的时间稍后再做这些。

3.1 你好,Lox(你好,世界)

这是我们用Lox写的第一段代码:

1
2
// Your first Lox program!
print "Hello, world!";

这两行代码应该是所有人学编程都绕不开的东西。“你好,世界”,简单的一句话,对于编程,或者说整个计算机知识体系大厦来说,就像那个不起眼的门,门外的人只看到了高耸的大厦,而真正吸引人,让这么多计算机专家痴迷的,则是这个大厦里面所包含的精华,即构建信息时代的基石。

– 来自译者的胡言乱语

正如//注释和尾随分号所暗示的那样,Lox的语法是C系列的成员。 (因为打印是一个内置语句,而不是库函数,所以字符串周围没有括号。)

不过现在,我不会夸赞说C具有出色的语法。 如果我们想要的是一种精致的东西,那我们可能会模仿Pascal或Smalltalk。 如果我们想全面了解斯堪的纳维亚家具的极简主义,则我们也有方法去实现。精致和极简各有各的好处。

与c类似的语法相反,你会发现在一门语言中更有价值的东西是:熟悉的感觉。我知道你已经习惯了你所熟悉的风格,因为我们将用于实现Lox的两种语言Java和C也继承了它。在Lox中使用类似的语法可以让你少学一点东西。

3.2 高级语言

虽然这本书的内容比我希望的要多,但它仍然不足以容纳像Java这样的大型语言。为了在这本书中展示两个完整的Lox实现,我们的Lox本身必须非常紧凑。

当我想到那些小而有用的语言时,我首先想到的是高级脚本语言,比如JavaScript、Scheme和Lua。在这三种语言中,Lox看起来最像JavaScript,主要是因为大多数c语法语言都是这样。我们稍后将了解到,Lox确定作用域的方法与Scheme密切相关。我们将在第三部分中构建的C风格的Lox在很大程度上得益于Lua干净、高效的实现。

Lox与这三种语言在其他两个方面有相同之处:

3.2.1 动态类型

Lox是动态类型的。变量可以存储任何类型的值,单个变量甚至可以在不同时间存储不同类型的值。如果你尝试对错误类型的值执行操作,例如,用一个数字除以一个字符串,那么运行时将会检测到错误到并报告。

而静态类型被人喜欢的原因有很多,但是这些原因都没重要到,让Lox放弃动态类型。静态类型系统需要学习和实现大量的工作。跳过它,我们的书会更加的精简。如果我们把类型检查推迟到运行时,我们就能让解释器启动并更快地执行代码。

3.2.2 自动内存管理

高级语言的存在是为了消除容易出错的、低层次的苦差事,没啥比手动管理存储的分配和释放更乏味(c++大佬们可能会乐于其中,我这种渣渣还是希望语言所依附的虚拟机来帮我把这些都安排妥当 😄 )。

有两种主要的内存管理技术:引用计数和跟踪垃圾收集(通常称为垃圾收集或GC)。Ref计数器的实现要简单得多,我认为这就是为什么Perl、PHP和Python一开始都使用它们的原因。但是,随着时间的推移,ref计数的限制变得太麻烦了。所有这些语言最终都添加了完整的跟踪GC,或者至少添加了足够的GC来清理对象循环。

跟踪垃圾收集乍一听还是挺高端,且让人望而却步的,因为直接在内存的层次上操作确实是有点痛苦的。调试GC有时会让你在梦中看到十六进制转储。但是,请记住,这本书是关于驱散魔法和杀死那些怪物的,所以我们要写我们自己的垃圾收集器。我想你会发现这个算法非常简单,写起来也很有趣。

3.3 数据类型

在Lox的小小小小小宇宙中,组成所有物质的原子是内建的数据类型。有以下几种:

  • Booleans 布朗值:

    你编代码不可能没有逻辑,同时没有逻辑值你也无法在代码中表示逻辑。“True” “False”,软件的。与一些古老的语言不同的是,Lox有一个专用的布尔类型,它将现有的类型用于表示真与假。

    而布朗值只有两种:

    1
    2
    true; // Not false.
    false; // Not *not* false. 套娃?
  • Numbers 数值:

    Lox只有一种数字:双精度浮点数。由于浮点数还可以表示范围广泛的整数,因此它在保持简单的同时涵盖了很多领域。

    功能齐全的语言有大量的数字语法,十六进制,科学的表示法,八进制,各种有趣的东西。我们将满足于基本的整数和十进制文字:

    1
    2
    1234; // An integer.
    12.34; // a deciaml number.
  • Strings 字符串:

    在第一个例子中我们已经看到了一个字符串文字。像大多数语言一样,它们被括在双引号中:

    1
    2
    3
    "I am a string";
    ""; // The empty string.
    "123"; // This is a string, not a number.

    当我们开始实现它们时,我们将看到,在那些无害的字符序列中隐藏了相当多的复杂性。

  • Nil 空(类似null):

    还有最后一个我们并没有邀请加入到我们这本书中,但似乎总是出现的一位常客。它不代表任何值。它在许多其他语言中被称为null。在Lox中,我们拼写为nil。(当我们实现它时,这将有助于区分当我们讨论Lox的nil和Java或C的null)

    在语言中不使用空值是有很好的理由的,因为空指针错误是对于Java设计人员来说,简直是家常便饭。如果我们在做一种统计类型的语言,可能值得去尝试禁止它。然而,在动态类型的代码中,删除它通常比使用它更烦人。

3.4 表达式

如果内置数据类型及其文字是原子,那么表达式就必须是分子。其中大多数都是我们所熟悉的。

3.4.1 算数

Lox提供了我们熟知的C和其他语言中的基本算术运算符:

1
2
3
4
add + me;
subtract - me;
multiply * me;
divide / me;

操作符两边的子表达式都是操作数。因为有两个,它们被称为二元运算符。(它与二进制的“1 - 0”用法无关)因为操作符固定在操作数的中间,所以这些操作符也称为中缀操作符,而不是前缀操作符(操作符位于操作数的前面)和后缀操作符(它位于操作数的后面)。

同时有一个算术运算符,它既是中缀又是前缀。那就是 - 操作符,其用于取反:

1
-negateMe;

所有这些运算符都用于数字,其他类型都是错误的。唯独有一个例外,那就是 + 操作符,+ 操作符可以连接两个字符串。

3.4.2 比较与等于

and go on,我们还有一些总是返回布尔结果的运算符。我们可以比较数字(而且只能比较数字),同时返回比较的结果:

1
2
3
4
less < than;
lessThen <= orEqual;
greater > than;
greaterThen >= orEqual;

我们可以测试任何类型的两个值是相等,还是不相等:

1
2
1 == 2;          //false
"cat" != "dog"; //true

甚至是不同类型的数据:

1
314 == "pi";     //false

当然,结果肯定为false,任何两种不同类型的数据比较,结果都为false:

1
123 == "123";    // false

正如我说的,我通常是反对隐式转换的。(原作者如是说道,不过译者认为隐式转换在开发者了解其原理的情况下,合理利用,还是很方便的,毕竟高级程序语言就是为了贴合人的思维)

3.4.3 逻辑操作符

如果操作数为真,则作为前缀!的not操作符返回false,反之亦然:

1
2
!true;    //false
!false; //true

另外两个逻辑操作符实际上是伪装为表达式的控制流(Control Flow)构造。and表达式确定两个值是否都为真。如果为false,则返回左操作数,否则返回右操作数:

1
2
false or false;   //false
true or false; //true

and 和 or 之所以说是控制流,是因为它们遵从了短路效应。当 and 操作符左边的操作数为 true 时,该表达式不仅返回 true,其甚至不会执行右边的操作数(当然该操作数也可能是一个表达式)。相反的,如果 or 操作符左边的操作数为 false,那么该表达式会直接返回 false,同时右边的操作数不会执行。

3.4.4 优先级和分组

所有这些运算符都具有相同的优先级和结合性,并且和c语言相同。如果优先级没有符合开发者的期望,则可以使用()对内容进行分组:

1
var average = (min + max) / 2;

因为它们在技术上不是很有趣,所以我从我们的小语言中去掉了典型操作符的其余部分,所以我们的Lox没有按位、移位、模或条件运算符。但如果你能自己实现它,那我会对你刮目相看的 : p

这些是表达形式(除了一些与我们将在后面讨论的特定特性相关的形式),所以让我们再上一层。

3.5 语句式(Statement)

现在我们来到了语句。表达式(Expression)的主要任务是生成值,而语句(Statement)的主要任务是产生效果。根据定义,语句并不会产生任何值,因此表达式想要改变世界,通常是修改某种状态、读取输入或生成输出。

下面是一个语句:

1
print "Hello, world!";

一个 print 语句计算单个表达式的值,并将结果显示给用户,下面又是一个语句:

1
"some expression";

表达式后添加分号 ; ,便提升为语句,这被称为表达式语句。

如果希望在需要一个语句的地方封装一系列语句,可以将它们封装在一个块中:

1
2
3
4
{
print "One statement.";
print "Two statement.";
}

也会影响作用域,那就是以下部分的东西了。。。

3.6 变量

使用var语句声明变量。如果省略了初始化器,则变量的值默认为nil:

1
2
var imAVariable = "here is my value";
var iAmNil;

一旦声明,你自然通过变量名来访问和对其幅值:

1
2
3
4
var breakfast = "bagels";
print breakfast; // "bagels"
breakfast = "beignets";
print breakfast; // "beignets"

在这里我们没有讨论变量作用域的问题,因为后面的章节中,我们会花大量的时间来实现这些规则,其规则也和C与Java一样。

3.7 控制流(Control Flow)

如果不能跳过一些代码,或者不止一次地执行某些代码,那对于开发者来讲,简直是噩梦。所以我们需要控制流,控制代码执行的流向。除了我们已经说的逻辑操作符,Lox直接从C的规范中搬过来了三个控制流。

首当其冲的就是 if 语句,它根据某些条件,来执行两个语句中的一个:

1
2
3
4
5
if (condition) {
print "yes";
} else {
print "no";
}

其次是 while 语句,只要符合条件,它就会不断执行其body中的代码:

1
2
3
4
5
var a = 1;
while (a < 10) {
print a;
a = a + 1;
}

以及最后的 for 循环:

1
2
3
for (var a = 1; a < 10; a = a + 1) {
print a;
}

for 循环的操作与之前的 while 循环相同。大多数现代语言还具有某种 for-in 或 foreach 循环,用于显式地遍历各种序列类型。在真正的语言中,这比我们在这里得到的原始的c风格的for循环要好。不过我们的Lox还是保持原始的味道 。😄

3.8 方法(Function)

Lox的方法调用和C很像:

1
makeBreakfast(bacon, eggs, toast);

你还可以不传任何参数地调用方法:

1
makeBreakfast();

与Ruby不同,括号在Lox中是必需的。如果你把它们去掉,解释器执行时不会调用这个函数,而只是把它当做一个引用。

一个语言不能定义方法,那就不算合格。在Lox中,你可以用 fun 来声明方法:

1
2
3
fun printSum(a, b) {
print a + b;
}

OK,那现在该澄清一些术语。有些人将 “argument”“parameter” 混淆,甚至很多人认为这两个没啥区别。实际上这两个词在语义上是有细微差别的,所以让我们精确定义一下:

  • "argument" 是在调用函数时传递给函数的实际值,函数在调用时,是有一个 argument 列表的。所以其也被称为"actual parameter",即实参
  • "parameter" 是保存函数体内参数值的变量。因此,函数声明有一个 parameter 列表。也可以成为 “formal parameter” 或者就简单地叫 “formals”,即形参

函数的主体总是一个块。在其中,可以使用return语句返回值:

1
2
3
fun returnSum(a, b) {
return a + b;
}

在Lox中,如果执行到块的末尾而没有返回,则隐式返回nil。

3.8.1闭包( Closures)

函数是Lox中的 “first class”,这意味着它是你可以获得引用的、存储在变量中的、传递的以及等等的真实值,比如:

1
2
3
4
5
6
7
8
9
fun addPair(a, b) {
return a + b;
}

fun identity(a) {
return a;
}

print identity(addPair)(1, 2); // Prints "3".

由于函数声明是语句,所以可以在另一个函数中声明局部函数:

1
2
3
4
5
6
7
fun outerFunction() {
fun localFunction() {
print "I'm local!";
}

localFunction();
}

如果结合使用本地函数、一级函数和块作用域,就会遇到这种有趣的情况:

1
2
3
4
5
6
7
8
9
10
11
12
fun returnFunction() {
var outside = "outside";

fun inner() {
print outside;
}

return inner;
}

var fn = returnFunction();
fn();

该语法和Java实际上是大相径庭的,Lox反而更像js

在上面代码中,inner()访问在周围函数中其主体之外声明的局部变量。这是真的可以吗(来自Java程序员疑惑的眼光)?现在很多语言都从Lisp借用了这个特性,所以答案是肯定的。

为了实现这一点,inner()必须保留对它所使用的任何周围变量的引用,这样即使在外部函数返回之后,这些变量仍然存在。我们将执行此操作的函数称为闭包(Closures)。这个术语经常用于很多一级函数,尽管如果函数没有关闭任何变量,它就有点用词不当。

读到这里,你的想法没错,实现这些会增加一些(serious ?)复杂性,因为我们不能再假设变量作用域严格地像堆栈一样工作,即局部变量在函数返回时就蒸发掉了。后面我们会具体陈述这方面的实现。

3.9 类

由于Lox具有动态类型、词法(简单来说就是块)作用域和闭包,它距离成为函数式语言仍然还有一半的距离。不过你也看到了,它差不多已经有面向对象语言的内味(那种味道)了。这两种范式都有很多优点,所以我认为有必要介绍其中的一些。

类是面向对象语言中不可获取的东西,但是它的表现并没有像宣传的那样那么耀眼,不过还是让我先解释一下为什么我仍把它放在Lox和本书中。这里有两个问题:

3.9.1 为什么无论任何一种语言,都想成为一种面向对象的语言?

现在,像Java这样的面向对象语言已经火过了,并在很多场合有应用,人们对其可能有点审美疲劳了,不再那么喜欢它们了。那么为什么会有人用对象创造一种新语言呢?这不就像发行8音轨的音乐吗?(译者不太懂这个梗)

不过的确,90年代的“无论何时无论何物全都有继承”的热潮产生了一些可怕的类层次结构,但面向对象编程仍然相当出色。今天,很可能大多数工作的程序员都在使用面向对象的语言,总不能是这些经验老到的程序员们都错了吧。

特别是,对于动态类型语言,对象非常方便。我们需要一些方法来定义复合数据类型,以便将大量的东西捆绑在一起。

如果我们也可以挂起这些方法,那么就可以避免在所有函数前面加上它们操作的数据类型的名称,以避免与针对不同类型的类似函数发生冲突。比如,在Racket软件中,你不得不给函数命名为hash-copy(复制哈希表)和vector-copy(复制矢量),这样它们就不会相互踩到。方法的作用域被限定在对象上,这样问题就消失了。

3.9.2 那为什么Lox也要是面向对象呢?

我可以声明对象是groovy,但仍然超出了本书的范围。大多数编程语言书籍,尤其是那些试图实现一门完整语言的书籍,都忽略了对象。对我来说,这意味着这个话题没有被很好地涵盖。在如此广泛的范式下,这种遗漏让我感到悲哀。

考虑到我们中有多少人整天都在使用OOP语言,我们似乎可以使用一些文档来说明如何创建OOP。如你所见,这很有趣。不像你担心的那么难,但也不像你想象的那么简单。

3.9.3 类还是原型?

对于对象,实际上有两种方法,类和原型。首先出现的是类,由于c++、Java、c#和朋友的帮助,类更加常见。原型实际上是一个被遗忘的分支,直到JavaScript意外地接管了世界。(指js在web端的称霸)

在基于类的语言中,有两个核心概念:实例和类。实例存储每个对象的状态,并引用实例的类。类包含方法和继承链。要在实例上调用方法,总是存在间接级别。查找实例类,然后在那里找到方法:

类与实例

基于原型的语言合并了这两个概念。只有对象,没有类,每个单独的对象可以包含状态和方法。对象可以直接相互继承(或者用原型术语表示委托)

原型

这意味着原型语言在某种程度上比类更基础。它们实现起来非常好,因为它们非常简单。此外,它们还可以表示许多不寻常的模式,这些模式是类引导您避开的。

但是我看过很多用原型语言编写的代码,包括一些我自己设计的。你知道人们通常用原型的强大和灵活性做什么吗?他们用它来重新创建类。(哈哈哈,本末倒置)

我不知道为什么,但是人们似乎很自然地更喜欢基于类的(经典?优雅的?)的风格。在语言中,原型更简单,但它们似乎只能通过将复杂性推给用户来实现。因此,对于Lox,我们将为用户省去麻烦并直接编写类。

3.9.4 Lox中的类

有足够的理论基础,我们了解到:类包含了大多数语言中的一系列特性。对于Lox,我选择了我认为最合适的东西,比如像这样声明一个类及其方法:

1
2
3
4
5
6
7
8
9
class Breakfast {
cook() {
print "Eggs a-fryin'!";
}

serve(who) {
print "Enjoy your breakfast, " + who + ".";
}
}

类的主体包含它的方法,它们看起来像函数声明,但是没有fun关键字。当执行类声明时,它创建一个类对象并将其存储在以类命名的变量中。与函数一样,类是Lox中的第一类(first class):

1
2
3
4
5
// Store it in variables.
var someVariable = Breakfast;

// Pass it to functions.
someFunction(Breakfast);

接下来,我们需要一种创建实例的方法。我们可以添加一些new关键字,但为了保持简单,在Lox中,类本身是实例的工厂函数。像调用函数一样调用一个类,它就会为自己生成一个新的实例:

1
2
var breakfast = Breakfast();
print breakfast; // "Breakfast instance".

3.9.5 实例化和初始化

只有行为的类并不是特别有用。面向对象编程背后的思想是将行为和状态封装在一起。为此,Lox的类中需要字段。与其他动态类型语言一样,Lox允许开发者自由地向对象添加属性:

1
2
breakfast.meat = "sausage";
breakfast.bread = "sourdough";

如果该变量,或者说叫属性,不存在的话,那么对其赋值则会创建它。

如果你想在一个方法里访问当前对象实例中的一个变量或者方法,你需要用到那个老牌,且有用的 this

1
2
3
4
5
6
class Breakfast {
serve(who) {
print "Enjoy your " + this.meat + " and " +
this.bread + ", " + who + ".";
}
}

在对象中封装数据的一部分是为了确保对象在创建时处于有效状态。为此,可以定义初始化器。如果类有一个名为init()的方法,则在构造对象时自动调用该方法,传递给类的任何参数都被转发给它的初始化器:

1
2
3
4
5
6
7
8
9
10
11
12
class Breakfast {
init(meat, bread) {
this.meat = meat;
this.bread = bread;
}

// ...
}

var baconAndToast = Breakfast("bacon", "toast");
baconAndToast.serve("Dear Reader");
// "Enjoy your bacon and toast, Dear Reader."

3.9.6 继承

每种面向对象语言都允许您不仅定义方法,而且跨多个类或对象重用它们。为此,Lox支持单继承。在声明类时,可以使用 < 指定它继承的类:

1
2
3
4
5
class Brunch < Breakfast {
drink() {
print "How about a Bloody Mary?";
}
}

在这里,Brunch是派生类或子类,而Breakfast是基类或超类。超类中定义的每个方法对它的子类也是可用的:

1
2
var benedict = Brunch("ham", "English muffin");
benedict.serve("Noble Reader");

甚至init()方法也会被继承。实际上,子类通常也希望定义自己的init()方法。但是也需要调用原始类,以便超类能够维护其状态。我们需要一些方法在我们自己的实例上调用一个方法而不触及我们自己的方法。

正如Java中的一样,我们是可以使用super关键字:

1
2
3
4
5
6
class Brunch < Breakfast {
init(meat, bread, drink) {
super.init(meat, bread);
this.drink = drink;
}
}

OK all right,我们的Lox就是这些了。我尽量把它所包含的内容降到最低。这本书的结构确实迫使我们做出妥协。Lox不是一种纯粹的面向对象语言。在真正的OOP语言中,每个对象都是类的实例,即使是像数字和布尔值这样的原始对象。

因为我们直到开始使用内置类型后才实现类,所以实现类会很困难。因此,从类的实例的意义上说,基本类型的值不是真正的对象。它们没有方法或属性。如果我试图让Lox成为针对实际用户的一种真正的语言,我就会解决这个问题。

3.10 标准库

我们要做的基本快到头了。这就是整个语言,剩下的就是核心或标准库,这是解释器中直接实现的一组功能,所有用户定义的行为都是在其上构建的。

但我们的Lox则没那么理想了。它的标准库超越了极简主义,接近于彻底的虚无主义。对于书中的示例代码,我们只需要演示代码正在运行并执行它应该执行的操作。为此,我们已经有了内置的print语句。

之后,在开始优化时,我们将编写一些基准测试,看看执行代码需要多长时间。这意味着我们需要跟踪时间,因此我们将定义一个内置函数clock(),它返回自应用程序启动以来的秒数。

所有的库就是这样。emmmm,我知道,就这么点标准库,确实是有点尬。

如果你想将Lox变成一种实际有用的语言,则首先要做的就是充实它。 字符串操作,三角函数,文件I / O,联网,扩展,甚至从用户读取输入都将有所帮助。 但是我们在本书中不需要任何内容,添加它也不会教给您任何有趣的东西,因此我将其省略。

别担心,语言本身会有很多令人兴奋的东西让我们忙个不停。

原文链接:Introduction

阅读并翻译者:Javen Liu