3

The Lox Language

你能够为别人做什么比做早餐更好的事情吗?

Anthony Bourdain

我们将用本书的其余部分来照亮Lox语言的每一个黑暗和杂乱的角落,但如果让你在对目标一无所知的情况下,就立即开始为解释器编写代码,这似乎很残忍。

与此同时,我也不想在你编码之前,就把你拖入大量的编程语言和规范的术语中。所以这是一个温和、友好的Lox介绍,它会省去很多细节和边缘情况。后面我们有足够的时间来解决这些问题。

3 . 1Hello, Lox

下面是你对Lox的第一次体验:

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

正如那句//行注释和后面的分号所暗示的那样,Lox的语法是C语言家族的成员之一。(因为print是一个内置语句,而不是库函数,所以字符串周围没有括号。)

这里,我并不是想说C语言具有出色的语法。如果我们想要一些优雅的东西,我们可能会模仿Pascal或Smalltalk。如果我们想要完全体现斯堪的纳维亚家具的极简主义风格,我们会实现一个Scheme。这些都有其优点。

但是,类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自动内存管理

高级语言的存在是为了消除容易出错的低级工作,还有什么比手动管理存储的分配和释放更繁琐的呢?没有人会在抬起头来迎接早晨的阳光时说:“我迫不及待想找到添加free()方法的正确位置,来释放我今天分配的内存的每个字节”。

有两种主要的内存管理技术引用计数跟踪垃圾收集(通常仅称为“垃圾收集”或“ GC”)。 引用计数器的实现要简单得多——我想这就是为什么Perl、PHP和Python一开始都使用该方式的原因。但是,随着时间的流逝,引用计数的限制变得太麻烦了。 所有这些编程语言最终都添加了完整的跟踪GC或至少一种足以清除循环引用的管理方式。

跟踪垃圾收集算法有着可怕的名声。在原始内存的层面上工作是有点折磨人的。调试GC有时会让你在梦中看到“hex dumps”。但是,请记住,这本书是关于驱散魔法和杀死那些怪物的,所以我们要写出自己的垃圾收集器。我想你会发现这个算法相当简单,而且实现起来很有趣。

3 . 3数据类型

在Lox的小宇宙中,构成所有物质的原子是内置的数据类型。只有几个:

3 . 4表达式

如果内置数据类型及其字面量是原子,那么表达式必须是分子。其中大部分大家都很熟悉。

3 . 4 . 1算术运算

Lox具备了你从C和其他语言中了解到的基本算术运算符:

add + me;
subtract - me;
multiply * me;
divide / me;

运算符两边的子表达式都是操作数。因为有两个操作数,它们被称为二元运算符(这与二进制的1和0二元没有关联)。由于运算符固定在操作数的中间,因此也称为中缀运算符,相对的,还有前缀运算符(运算符在操作数前面)和后缀运算符(运算符在操作数后面)。

有一个数学运算符既是中缀运算符也是前缀运算符,-运算符可以对数值取负:

-negateMe;

所有这些运算符都是针对数值的,将任何其他类型操作数传递给它们都是错误的。唯一的例外是+运算符——你也可以传给它两个字符串将它们串接起来。

3 . 4 . 2比较与相等

接下来,我们有几个返回布尔值的运算符。我们可以使用传统的比较运算符来比较数值(并且只能比较数值):

less < than;
lessThan <= orEqual;
greater > than;
greaterThan >= orEqual;

我们可以测试两个任意类型的值是否相等:

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

即使是不同类型也可以:

314 == "pi"; // false.

不同类型的值永远不会相等:

123 == "123"; // false.

我通常是反对隐式类型转换的。

3 . 4 . 3逻辑运算符

取非运算符,是前缀运算符!,如果操作数是true,则返回false,反之亦然:

!true;  // false.
!false; // true.

其他两个逻辑运算符实际上是表达式伪装下的控制流结构。and表达式用于确认两个操作数是否都是true。如果左侧操作数是false,则返回左侧操作数,否则返回右侧操作数:

true and false; // false.
true and true;  // true.

or表达式用于确认两个操作数中任意一个(或者都是)为true。如果左侧操作数为true,则返回左侧操作数,否则返回右侧操作数:

false or false; // false.
true or false;  // true.

andor之所以像控制流结构,是因为它们会短路。如果左操作数为假,and不仅会返回左操作数,在这种情况下,它甚至不会计算右操作数。反过来,(“相对的”?)如果or的左操作数为真,右操作数就会被跳过。

3 . 4 . 4优先级与分组

所有这些运算符都具有与C语言相同的优先级和结合性(当我们开始语法分析时,会进行更详细的说明)。在优先级不满足要求的情况下,你可以使用()来分组:

var average = (min + max) / 2;

我把其他典型的运算符从我们的小型编程语言中去掉了,因为它们在技术上不是很有趣。没有位运算、移位、取模或条件运算符。我不是在给你打分,但如果你通过自己的方式来完成支持这些运算的Lox实现,你会在我心中得到额外的加分。

这些都是表达式形式(除了一些与我们将在后面介绍的特定特性相关的),所以让我们继续。

3 . 5语句

现在我们来看语句。表达式的主要作用是产生一个,语句的主要作用是产生一个作用。由于根据定义,语句不求值,因此必须以某种方式改变世界(通常是修改某些状态,读取输入或产生输出)才能有用。

你已经看到了几种语句。第一个是:

print "Hello, world!";

print语句计算单个表达式并将结果显示给用户。 你还看到了一些语句,例如:

"some expression";

表达式后跟分号“;”可以将表达式提升为语句状态。这被称为(很有想象力)表达式语句

如果你想将一系列语句打包成一个语句,那么可以将它们打包在一个块中:

{
  print "One statement.";
  print "Two statements.";
}

块还会影响作用域,我们将在下一节中进行说明。

3 . 6变量

你可以使用var语句声明变量。如果你省略了初始化操作,变量的值默认为nil

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

一旦声明完成,你自然就可以通过变量名对其进行访问和赋值:

var breakfast = "bagels";
print breakfast; // "bagels".
breakfast = "beignets";
print breakfast; // "beignets".

我不会在这里讨论变量作用域的规则,因为我们在后面的章节中将会花费大量的时间来详细讨论这些规则。在大多数情况下,它的工作方式与你期望的C或Java一样。

3 . 7控制流

如果你不能跳过某些代码,或者不能多次执行某些代码,就很难写出有用的程序。这意味着控制流。除了我们已经介绍过的逻辑运算符之外,Lox直接从C中借鉴了三条语句。

if语句根据某些条件执行两条语句中的一条:

if (condition) {
  print "yes";
} else {
  print "no";
}

只要条件表达式的计算结果为true,while循环就会重复执行循环体:

var a = 1;
while (a < 10) {
  print a;
  a = a + 1;
}

最后,还有for循环:

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

这个循环与之前的 while 循环做同样的事情。大多数现代编程语言也有某种for-in或foreach循环,用于显式迭代各种序列类型[^14]。在真正的编程语言中,这比我们在这里使用的粗糙的C风格for循环要好。Lox只保持了它的基本功能。

3 . 8函数

函数调用表达式与C语言中一样:

makeBreakfast(bacon, eggs, toast);

你也可以在不传递任何参数的情况下调用一个函数:

makeBreakfast();

与Ruby不同的是,在本例中括号是强制性的。如果你把它们去掉,就不会调用函数,只是指向该函数。

如果你不能定义自己的函数,一门编程语言就不能算有趣。在Lox里,你可以通过fun关键字完成:

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

现在是澄清一些术语的好时机。有些人把“parameter”和“argument”混为一谈,好像它们可以互换,而对许多人来说,它们确实可以互换。我们要花很多时间围绕语义学来对其进行分辨,所以让我们在这里把话说清楚:

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

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

如果执行到达代码块的末尾而没有return语句,则会隐式地返回nil

3 . 8 . 1闭包

在Lox中,函数是一等公民,这意味着它们都是真实的值,你可以对这些值进行引用、存储在变量中、传递等等。下面的代码是有效的:

fun addPair(a, b) {
  return a + b;
}

fun identity(a) {
  return a;
}

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

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

fun outerFunction() {
  fun localFunction() {
    print "I'm local!";
  }

  localFunction();
}

如果将内部函数、作为一等公民的函数和块作用域组合在一起,就会遇到这种有趣的情况:

fun returnFunction() {
  var outside = "outside";

  fun inner() {
    print outside;
  }

  return inner;
}

var fn = returnFunction();
fn();

在这里,inner()访问了在其函数体外的外部函数中声明的局部变量。这样可行吗?现在很多编程语言都从Lisp借鉴了这个特性,你应该也知道答案是肯定的。

要做到这一点,inner()必须“保留”它使用的任何外部变量的引用,这样即使在外部函数返回之后,这些变量仍然存在。我们把能做到这一点的函数称为闭包。现在,这个术语经常被用于任何作为一等公民的函数,但是如果函数没有包住任何外部变量,那就有点用词不当了。

可以想象,实现这些功能会增加一些复杂性,因为我们不能再假定变量作用域严格地像栈一样工作,在函数返回时局部变量就消失了。我们将度过一段有趣的时间来学习如何使这些功能工作起来,并高效地实现这些功能。

3 . 9

因为Lox具有动态类型、词法(粗略地说,就是块)作用域和闭包,所以它离函数式编程语言的距离很近。但正如你将看到的,它离成为一种面向对象的语言也有一半的距离。这两种模式都有很多优点,所以我认为有必要分别介绍一下。

类因为没有达到其宣传效果而受到抨击,所以让我先解释一下为什么我把它们放到Lox和这本书中。这里实际上有两个问题:

3 . 9 . 1为什么任何编程语言都想要面向对象?

现在像Java这样的面向对象编程语言已经销声匿迹了,只能在舞台上表演,喜欢它们已经不酷了。为什么有人要用对象来做一门新的语言呢?这不就像发行8轨音乐一样吗?

90年代的“一直都是继承”的狂潮确实产生了一些畸形的类层次结构,但面向对象的编程还是很流行的。数十亿行成功的代码都是用OOP语言编写的,为用户提供了数百万个应用程序。很可能今天大多数在职程序员都在使用面向对象编程语言。他们不可能都错得那么离谱。

特别是,对于动态类型语言来说,对象是非常方便的。我们需要某种方式来定义复合数据类型,用来将一堆数据组合在一起。

如果我们也能把方法挂在这些对象上,那么我们就不需要把函数操作的数据类型的名字作为函数名称的前缀,以避免与不同类型的类似函数发生冲突。比如说,在Racket中,你最终不得不将你的函数命名为hash-copy(复制一个哈希表)和vector-copy(复制一个向量),这样它们就不会互相覆盖。方法的作用域是对象,所以这个问题就不存在了。

3 . 9 . 2为什么Lox是面向对象的?

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

鉴于我们很多人整天都在使用OOP语言,似乎这个世界应该有一些关于如何制作OOP语言的文档。正如你将看到的那样,事实证明这很有趣。没有你担心的那么难,但也没有你想象的那么简单。

3 . 9 . 3类还是原型?

当涉及对象时,实际上有两种方法,原型。 类最先出现,由于C++、Java、C#和其它近似语言的出现,类更加普遍。直到JavaScript意外地占领了世界之前,原型几乎是一个被遗忘的分支。

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

How fields and methods are looked up on classes and instances

基于原型的语言融合了这两个概念。这里只有对象——没有类,而且每个对象都可以包含状态和方法。对象之间可以直接继承(或者用原型语言的术语说是 “委托”):

How fields and methods are looked up in a prototypal system

这意味着原型语言在某些方面比类更基础。它们实现起来真的很整洁,因为它们很简单。另外,它们还可以表达很多不寻常的模式,而这些模式是类所不具备的。

但是我看过很多用原型语言写的代码——包括我自己设计的一些代码。你知道人们一般会怎么使用原型的强大功能和灵活性吗? . . . 他们用它来重新发明类。

我不知道这是为什么,但人们自然而然地似乎更喜欢基于类的(经典?优雅?)风格。原型在语言中更简单,但它们似乎只是通过将复杂性给用户来实现的。所以,对于Lox来说,我们将省去用户的麻烦,直接把类包含进去。

3 . 9 . 4Lox中的类

理由已经说够了,来看看我们实际上拥有什么。在大多数语言中,类包含了一系列的特性。对于Lox,我选择了我认为最闪亮的一点。你可以像这样声明一个类及其方法:

class Breakfast {
  cook() {
    print "Eggs a-fryin'!";
  }

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

类的主体包含其方法。它们看起来像函数声明,但没有fun关键字。当类声明生效时,Lox将创建一个类对象,并将其存储在以该类命名的变量中。就像函数一样,类在Lox中也是一等公民:

// Store it in variables.
var someVariable = Breakfast;

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

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

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

3 . 9 . 5实例化和初始化

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

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

如果一个字段不存在,那么对它进行赋值时就会先创建。

如果你想从方法内部访问当前对象上的字段或方法,可以使用this

class Breakfast {
  serve(who) {
    print "Enjoy your " + this.meat + " and " +
        this.bread + ", " + who + ".";
  }

  // ...
}

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

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支持单继承。当你声明一个类时,你可以使用小于(<)操作符指定它继承的类:

class Brunch < Breakfast {
  drink() {
    print "How about a Bloody Mary?";
  }
}

这里,Brunch是派生类子类,而Breakfast是基类超类

父类中定义的每个方法对其子类也可用:

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

即使是init()方法也会被继承。在实践中,子类通常也想定义自己的init()方法。但还需要调用原始的初始化方法,以便超类能够维护其状态[^20]。我们需要某种方式能够调用自己实例上的方法,而无需触发实例自身的方法。

与Java中一样,你可以使用super

class Brunch < Breakfast {
  init(meat, bread, drink) {
    super.init(meat, bread);
    this.drink = drink;
  }
}

这就是面向对象的内容。我尽量将功能设置保持在最低限度。本书的结构确实迫使我做了一个妥协。Lox不是一种纯粹的面向对象的语言。在真正的OOP语言中,每个对象都是一个类的实例,即使是像数字和布尔值这样的基本类型。

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

3 . 10标准库

我们快结束了,这就是整个语言,所剩下的就是“核心”或“标准”库——这是一组直接在解释器中实现的功能集,所有用户定义的行为都是建立在此之上。

这是Lox中最可悲的部分。它的标准库已经超过了极简主义,解决彻底的虚无主义。对于本书中的示例代码,我们只需要证明代码在运行,并且在做它应该做的事。为此,我们已经有了内置的print语句。

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

 . . . 就是这样。我知道,有点尴尬,对吧?

如果你想将Lox变成一门实际可用的语言,那么你应该做的第一件事就是对其充实。字符串操作、三角函数、文件I/O、网络、扩展,甚至读取用户的输入都将有所帮助。但对于本书来说,我们不需要这些,而且加入这些也不会教给你任何有趣的东西,所以我把它省略了。

别担心,这门语言本身就有很多精彩的内容让我们忙个不停。

3 . 11挑战

  1. 编写一些示例Lox程序并运行它们(你可以使用我的Lox实现)。试着想出我在这里没有详细说明的边界情况。它是否按照期望运行?为什么?

  2. 这种非正式的介绍留下了很多未说明的东西。列出几个关于语言语法和语义的开放问题。你认为答案应该是什么?

  3. Lox是一门很小的编程语言。你认为缺少哪些功能会使其不适用于实际程序? (当然,除了标准库。)

3 . 12设计笔记:表达式和语句

Lox既有表达式也有语句。有些语言省略了后者。相对地,它们将声明和控制流结构也视为表达式。这类“一切皆表达式”的编程语言往往具有函数式的血统,包括大多数Lisps、SML、Haskell、Ruby和CoffeeScript。

要做到这一点,对于语言中的每一个“类似于语句”的构造,你需要决定它所计算的值是什么。其中有些很简单:

  • if表达式的计算结果是所选分支的结果。同样,switch或其他多路分支的计算结果取决于所选择的情况。
  • 变量声明的计算结果是变量的值。
  • 块的计算结果是序列中最后一个表达式的结果。

有一些是比较复杂的。循环应该计算什么值?在CoffeeScript中,一个while循环计算结果为一个数组,其中包含了循环体中计算到的每个元素。这可能很方便,但如果你不需要这个数组,就会浪费内存。

你还必须决定这些类似语句的表达式如何与其他表达式组合,必须将它们放入语法的优先表中。例如,Ruby允许下面这种写法:

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

这是你所期望的吗?这是你的用户所期望的吗?这对你如何设计“语句”的语法有什么影响?请注意,Ruby有一个显式的end关键字来表明if表达式结束。如果没有它,+4很可能会被解析为else子句的一部分。

把每个语句都转换成表达式会迫使你回答一些类似这样的复杂问题。作为回报,你消除了一些冗余。C语言中既有用于排序语句的块,以及用于排序表达式的逗号操作符。它既有if语句,也有?:条件运算符。如果在C语言中所有东西都是表达式,你就可以把它们统一起来。

取消了语句的语言通常还具有隐式返回的特点——函数自动返回其函数主体所计算得到的任何值,而不需要显式的return语法。对于小型函数和方法来说,这真的很方便。事实上,许多有语句的语言都添加了类似于=>的语法,以便能够定义函数体是计算单一表达式结果的函数。

但是让所有的函数以这种方式工作可能有点奇怪。即使你只是想让函数产生副作用,如果不小心,函数也可能会泄露返回值。但实际上,这些语言的用户并不觉得这是一个问题。

对于Lox,我在其中添加语句是出于朴素的原因。为了熟悉起见,我选择了一种类似于C的语法,而试图把现有的C语句语法像表达式一样解释,会变得非常快。