Lox 语言
还有什么比为别人做早餐更美好的事呢?
安东尼·波登
在本书的剩余部分,我们将阐明 Lox 语言的每一个角落,但如果你没有至少对我们最终要做什么有所了解,就立即开始为解释器编写代码,似乎有点残酷。
与此同时,我不想在你能接触你的文本 编辑器 之前,让你经历语言律师和规范化的冗长文字。所以,这将是对 Lox 的一个温和、友好的介绍。它会省略很多细节和边缘情况。我们以后有充足的时间来处理这些。
3 . 1你好,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 非常相似。我们在 第三部分 中构建的 Lox 的 C 风格很大程度上依赖于 Lua 清晰、高效的实现。
Lox 与这三种语言共享另外两个方面
3 . 2 . 1动态类型
Lox 是动态类型的。变量可以存储任何类型的值,一个变量甚至可以在不同时间存储不同类型的值。如果你尝试对错误类型的值执行操作—例如,将一个数字除以一个字符串—那么错误将在运行时被检测和报告。
有很多理由喜欢 静态 类型,但它们并不超过为 Lox 选择动态类型的务实理由。静态类型系统需要大量的工作来学习和实现。跳过它可以让你得到一种更简单的语言和一本更短的书。如果我们将类型检查推迟到运行时,我们将更快地让我们的解释器运行并执行代码片段。
3 . 2 . 2自动内存管理
高级语言的存在是为了消除容易出错的低级苦差事,还有什么比手动管理存储的分配和释放更乏味的呢?没有人会带着“我迫不及待地想找到今天分配的每一字节内存的正确位置,以便调用 free()
”这样的想法迎接早晨的太阳!
管理内存的主要有两种 技术:引用计数和跟踪垃圾收集(通常只称为垃圾收集或GC)。引用计数的实现要简单得多—我认为这就是 Perl、PHP 和 Python 都是从使用引用计数开始的。但是,随着时间的推移,引用计数的局限性变得越来越难以处理。所有这些语言最终都添加了完整的跟踪 GC,或者至少添加了足够的跟踪 GC 来清理对象循环。
跟踪垃圾收集有一个令人恐惧的名声。在原始内存级别工作确实有点可怕。调试 GC 有时会让你在梦中看到十六进制转储。但请记住,本书的目的是消除魔法并杀死这些怪物,所以我们将编写自己的垃圾收集器。我认为你会发现算法非常简单,实现起来很有趣。
3 . 3数据类型
在 Lox 的小宇宙中,构成所有物质的原子是内置数据类型。只有几个
-
布尔值。没有逻辑,你就不能编程;没有布尔值,你就不能逻辑推理。“真”和“假”,软件的阴阳。与一些古老的语言重新利用现有类型来表示真假不同,Lox 有一种专门的布尔值类型。我们可能在这段旅程中很艰苦,但我们不是野蛮人。
显然,有两个布尔值,每个值都有一个字面量。
true; // Not false. false; // Not *not* false.
-
数字。Lox 只有一个数字类型:双精度浮点数。由于浮点数也可以表示各种整数,因此可以覆盖很多领域,同时保持简单。
功能齐全的语言有很多用于表示数字的语法—十六进制、科学计数法、八进制,各种有趣的东西。我们将满足于基本的整数和小数字面量。
1234; // An integer. 12.34; // A decimal number.
-
字符串。我们已经在第一个示例中看到过一个字符串字面量。与大多数语言一样,它们用双引号括起来。
"I am a string"; ""; // The empty string. "123"; // This is a string, not a number.
正如我们将在实现它们时看到的,在那个简朴的 字符 序列中隐藏着相当多的复杂性。
-
Nil。最后一个内置值,它从来不参加聚会,但似乎总会出现。它表示“没有值”。在许多其他语言中,它被称为“空”。在 Lox 中,我们把它拼写成
nil
。(当我们开始实现它时,这将有助于区分我们谈论的是 Lox 的nil
还是 Java 或 C 的null
。)有很多理由反对在语言中使用空值,因为空指针错误是我们的行业的祸根。如果我们正在做静态类型语言,那么禁止它将是值得尝试的。然而,在动态类型语言中,消除它往往比保留它更令人讨厌。
3 . 4表达式
如果内置数据类型及其字面量是原子,那么表达式一定是分子。大多数表达式都很熟悉。
3 . 4 . 1算术运算
Lox 提供了你在 C 和其他语言中所熟悉和喜爱的基本算术运算符
add + me; subtract - me; multiply * me; divide / me;
运算符两边的子表达式是操作数。因为有两个操作数,所以它们被称为二元运算符。(它与“二元”的“一和零”用法无关。)因为运算符是固定在操作数中间的,所以它们也被称为中缀运算符(与前缀运算符相反,前缀运算符在操作数之前,而后缀运算符在操作数之后)。
一个算术运算符实际上是既是中缀运算符又是前缀运算符。-
运算符也可以用来对一个数字取反。
-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逻辑运算符
非运算符(前缀!
)如果其操作数为真则返回false
,反之亦然。
!true; // false. !false; // true.
另外两个逻辑运算符实际上是以表达式形式存在的控制流结构。一个and
表达式用来判断两个值是否都为真。如果左操作数为假,则返回左操作数;否则返回右操作数。
true and false; // false. true and true; // true.
而一个or
表达式用来判断两个值中至少有一个(或两者)是否为真。如果左操作数为真,则返回左操作数;否则返回右操作数。
false or false; // false. true or false; // true.
and
和or
像控制流结构的原因是它们会短路。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"; }
一个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
循环,用于明确地迭代各种序列类型。在一个真正的语言中,这比我们这里得到的粗陋的C风格for
循环要好得多。Lox保持了它的基本性。
3 . 8函数
函数调用表达式看起来与在C语言中一样。
makeBreakfast(bacon, eggs, toast);
您还可以调用一个函数而不向它传递任何东西。
makeBreakfast();
与例如Ruby不同的是,在这种情况下,括号是必须的。如果您省略它们,则名称不会调用函数,它只是引用函数。
如果你不能定义自己的函数,那么一门语言就不会很有趣。在Lox中,您可以使用fun
来定义函数。
fun printSum(a, b) { print a + b; }
现在是澄清一些术语的好时机。有些人把“参数”和“参数”混为一谈,而且对许多人来说,它们确实是这样的。我们将花很多时间来细致入微地分析语义,所以让我们 sharpening our words. 从现在开始
-
参数是您在调用函数时实际传递给函数的值。因此,函数调用具有参数列表。有时你会听到实际参数用于这些情况。
-
参数是用于在函数体内部保存参数值的变量。因此,函数声明具有参数列表。其他人称之为形式参数或简称形式参数。
函数体始终是一个块。在其中,您可以使用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)仍然非常棒。数十亿行成功的代码是用OOP语言编写的,它们将数百万个应用程序交付给了满意的用户。今天,大多数工作的程序员可能都在使用面向对象语言。他们不可能完全错了。
特别是对于动态类型语言,对象非常方便。我们需要某种方法来定义复合数据类型,以便将一堆东西捆绑在一起。
如果我们也能在这些数据类型上挂载方法,那么我们就无需在所有函数名前面加上它们操作的数据类型名称,以避免与不同类型的类似函数发生冲突。例如,在 Racket 中,你最终不得不将函数命名为 `hash-copy`(复制哈希表)和 `vector-copy`(复制向量),以避免它们相互冲突。方法的作用域是对象,因此这个问题就消失了。
3 . 9 . 2为什么 Lox 是面向对象的?
我可以声称对象很棒,但仍然超出了本书的范围。大多数编程语言书籍,特别是那些试图实现一整套语言的书籍,都省略了对象。对我来说,这意味着这个主题没有得到很好的讲解。对于如此广泛的范式,这种省略让我感到难过。
鉴于我们中的许多人整天都在使用面向对象的语言,所以这个世界似乎需要一些关于如何构建面向对象语言的文档。正如你将看到的,它最终变得非常有趣。并没有你想象的那么难,但也没有你想象的那么简单。
3 . 9 . 3类或原型
在对象方面,实际上有两种方法,类 和 原型。类出现得比较早,而且由于 C++、Java、C# 及其同类语言,更加常见。原型是一个几乎被遗忘的分支,直到 JavaScript 意外地接管了世界。
在基于类的语言中,有两个核心概念:实例和类。实例存储每个对象的狀態,并拥有一个指向该实例所属类的引用。类包含方法和继承链。要调用实例上的方法,总是需要一层间接层。你查找实例的类,然后在那里找到方法。
基于原型的语言融合了这两个概念。只有对象—没有类—每个单独的对象都可以包含状态和方法。对象可以直接继承自彼此(或在原型术语中“委托给”)
这意味着,在某些方面,原型语言比类更基础。它们实现起来非常简洁,因为它们非常简单。此外,它们可以表达许多类无法表达的非寻常模式。
但我已经看过很多用原型语言编写的代码—包括 我自己设计的一些代码。你知道人们通常如何利用原型的所有强大功能和灵活性吗? . . . 他们用它们来重新发明类。
我不知道为什么会这样,但人们似乎天生就更喜欢基于类的(经典?优雅?)风格。原型确实在语言层面更简单,但它们似乎只是通过将复杂性推给了用户来实现这一点。所以,对于 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()` 方法。但原始的 `init()` 方法也需要被调用,以便超类能够保持其状态。我们需要某种方法来调用我们自己实例上的方法,而不触发我们自己的方法。
就像在 Java 中一样,你可以使用 `super` 来实现这一点。
class Brunch < Breakfast { init(meat, bread, drink) { super.init(meat, bread); this.drink = drink; } }
关于面向对象编程就这些了。我试图将功能集保持在最小范围内。本书的结构确实迫使我做出了一种妥协。Lox 不是一种纯面向对象的语言。在真正的面向对象语言中,每个对象都是一个类的实例,即使是像数字和布尔值这样的原始值。
因为我们在开始使用内置类型之后很久才实现类,所以这将很困难。因此,原始类型的数值在作为类的实例的意义上不是真正的对象。它们没有方法或属性。如果我试图让 Lox 成为一个供真实用户使用的真实语言,我会修复这个问题。
3 . 10标准库
我们快完成了。这就是整套语言,所以剩下的就是“核心”或“标准”库—在解释器中直接实现的功能集,所有用户定义的行为都是在此基础上构建的。
这是 Lox 最悲惨的部分。它的标准库超出了极简主义,并且接近彻底的虚无主义。对于书中的示例代码,我们只需要演示代码正在运行并按预期执行。为此,我们已经有了内置的 `print` 语句。
稍后,当我们开始优化时,我们将编写一些基准测试,看看执行代码需要多长时间。这意味着我们需要跟踪时间,因此我们将定义一个内置函数 `clock()`,它返回程序启动以来的秒数。
然后 . . . 就这些了。我知道,对吧?这很尴尬。
如果你想把 Lox 变成一个真正有用的语言,你应该做的第一件事就是完善它。字符串操作、三角函数、文件 I/O、网络,甚至从用户那里读取输入都会有所帮助。但是,我们在这本书中不需要任何这些东西,添加它们也不会教你任何有趣的东西,所以我省略了它。
不用担心,语言本身会有很多令人兴奋的东西让我们忙碌。
挑战
-
编写一些 Lox 示例程序并运行它们(你可以使用我仓库中提供的 Lox 实现 我的仓库)。尝试找出我没有在这里指定的边缘情况行为。它是否按照你的预期执行?为什么或者为什么不?
-
这个非正式介绍省略了很多未指定内容。列出你对语言的语法和语义的几个未解决问题。你认为答案应该是什么?
-
Lox 是一种非常小的语言。你认为它缺少哪些功能会让它在真实的程序中难以使用?(当然,除了标准库之外。)
设计说明:表达式和语句
Lox 既有表达式也有语句。有些语言省略了后者。相反,它们将声明和控制流结构也视为表达式。这些“一切都是表达式”的语言往往具有函数式血统,其中包括大多数 Lisp、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 语句语法解释为表达式会很快变得很奇怪。