Skip to main content

第 3 章:Lox 语言(The Lox Language)

原文:Robert Nystrom, Crafting Interpreters, Chapter 3。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。

还能有什么事,比为别人做早餐更美好?

Anthony Bourdain

本书余下部分会照亮 Lox 语言的每个角落;但在立刻让你为解释器埋头写代码前,至少先看一眼最终要做出的东西。

同时也不想在你碰到文本编辑器前,就让你陷在大段语言律师式的规范文字中。因此这是一份温和、友好的 Lox 入门,会省略许多细节与边界情况,后面还有充足时间处理它们。

教程若不能亲手试代码便不够有趣;此时你还没造出 Lox 解释器,不过可先使用作者仓库中的现成实现

3.1 Hello, Lox

先尝一口 Lox:

这里说的是语言 Lox;若没尝过腌制冷熏鲑鱼,也值得一试。

// 你的第一个 Lox 程序!
print "Hello, world!";

// 行注释与末尾分号表明,Lox 的语法属于 C 家族。字符串外没有圆括号,是因为 print 是内建语句,不是库函数。

我不会声称 C 的语法绝佳。若追求优雅,大概会模仿 Pascal 或 Smalltalk;若追求斯堪的纳维亚家具般的极简,会选择 Scheme。它们都有优点。但类 C 语法有一种更常珍贵的特性:熟悉感。实现 Lox 的 Java 和 C 同样继承这种风格,因此相似的 Lox 语法让你少学一件事。

Lox 避开了 C 最棘手的类型声明语法。Dennis Ritchie 的“声明反映使用”本意是让变量声明模仿取得基础类型值时需进行的操作;这个想法聪明,却没有很好地经受实践检验。Lox 没有静态类型,因此不必面对它。

3.2 高级语言(A High-Level Language)

即使本书比原先希望的更厚,仍装不下一门像 Java 那样庞大的语言。要在书中放下两套完整 Lox 实现,Lox 本身必须相当紧凑。

想到小而有用的语言,我会想到 JavaScript、Scheme 和 Lua 这样的高级“脚本”语言。Lox 外观最像 JavaScript,主要因为多数 C 语法语言都如此;它的作用域方式则更接近 Scheme。第 III 部分构建的 C 风味 Lox,大量借鉴 Lua 简洁高效的实现。

JavaScript 今天已用于巨型应用,很难再叫“小脚本语言”;但 Brendan Eich 最初只用十天就在 Netscape Navigator 中写出 JS 解释器,为网页按钮制作动画。它从拼凑式起步,因此变量提升、动态绑定 this、数组空洞和隐式转换等地方仍露出胶带与回形针;Lox 有时间从容设计,因而力求更整洁。

3.2.1 动态类型(Dynamic Typing)

Lox 是动态类型语言。变量能存储任何类型的值,同一变量甚至可在不同时间存储不同类型。若对错误类型做操作,例如用数字除字符串,错误会在运行时被发现并报告。

静态类型有很多吸引人的理由,但对 Lox 而言不如动态类型的务实理由重要。静态类型系统学习和实现的工作量都很大;跳过它能让语言更简单、书更短,并让解释器更早开始运行代码,因为类型检查延后到运行时。

有趣的是,用来实现 Lox 的 Java 与 C 都是静态类型语言。

3.2.2 自动内存管理(Automatic Memory Management)

高级语言的存在是为了消除易错的底层苦工,而手动管理存储分配与释放无疑最枯燥。没有人早晨醒来会说:“等不及找出今天分配的每个字节该在何处正确调用 free() 了!”

内存管理有两项主要技术:引用计数(reference counting)和追踪式垃圾收集(tracing garbage collection,通常简称 GC)。引用计数实现简单,因此 Perl、PHP、Python 起初都使用它;但随着时间推移,它的限制会变得棘手,这些语言最终都加入了完整追踪 GC,或至少加入足以清理对象环的机制。

追踪式垃圾收集名声可怕。在原始内存层面工作确实惊险,调试 GC 有时会让人梦见十六进制转储。但本书的目标就是驱散魔法、战胜怪物,所以我们确实会自己写一个垃圾收集器。你会发现其算法相当简单,实现起来也很有趣。

实践中引用计数与追踪更像连续谱的两端,而非对立阵营:引用计数系统常加入追踪以处理环,分代收集器的写屏障乍看也像 retain 调用。想深入可阅读 A Unified Theory of Garbage Collection

3.3 数据类型(Data Types)

在 Lox 的小宇宙中,组成一切物质的原子是内建数据类型,数量不多:

  • 布尔值(Booleans)。 没有逻辑就无法编程,没有布尔值就无法逻辑。truefalse 是软件的阴阳。Lox 有专用布尔类型,而不是复用其他类型表示真假;这趟远征虽艰苦,却不至于野蛮。

    这是 Lox 中唯一以人名命名的数据类型,来自 George Boole;他在 1864 年去世,近一个世纪后数字计算机才把他的代数变成电路。若看到自己的名字遍布数十亿行 Java 代码,他大概会很惊讶。

    true; // 不是 false。
    false; // 不是“不是 false”。
  • 数字(Numbers)。 Lox 只有一种数字,即双精度浮点数。浮点数也能表示广泛范围的整数,因此能覆盖很多需求而保持简单。完整语言会提供十六进制、科学记数法、八进制等各种语法;Lox 仅提供基本整数和小数字面量。

    1234; // 整数。
    12.34; // 小数。
  • 字符串(Strings)。 和多数语言一样,字符串用双引号包围。

    "I am a string";
    ""; // 空字符串。
    "123"; // 这是字符串,不是数字。

    看似无害的字符序列背后藏着大量复杂性:字符究竟是 ASCII、Unicode、码点还是字形簇?如何编码?每个字符大小固定还是可变?

  • Nil。 最后还有一个永远没被邀请却总会出现的内建值,表示“没有值”。许多语言称为 null;Lox 拼作 nil,也便于与 Java、C 的 null 区分。禁止空值有充分理由,因为空指针错误是行业祸根;在静态类型语言中很值得尝试。但动态类型语言里,消除它往往比保留它更麻烦。

3.4 表达式(Expressions)

若内建数据类型及字面量是原子,表达式就是分子。大部分都很熟悉。

3.4.1 算术(Arithmetic)

Lox 提供 C 等语言中熟悉的基本算术运算符:

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

运算符两侧的子表达式称为操作数(operand);因为有两个,称为二元(binary)运算符,这与由 0 和 1 组成的“二进制”无关。运算符固定在操作数中间,因此也是中缀(infix)运算符;相对地,运算符在前面的是前缀(prefix),在后面的是后缀(postfix)。

也存在操作数与运算符交错的多元 mixfix 运算符,最常见的是 C 系语言的条件运算符:condition ? thenArm : elseArm;。少数语言允许自定义运算符并指定其位置形式,即 fixity。

- 同时是中缀与前缀运算符,前缀形式用于对数字取反:

-negateMe;

这些运算符都只接受数字,传入其他类型是错误。+ 例外:也可接受两个字符串并将其拼接。

3.4.2 比较与相等(Comparison and Equality)

接下来是总会产生布尔结果的运算符。可用老式比较运算符比较数字,并且只能比较数字:

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

任何类型的两个值都可测试相等或不等,甚至类型不同:

1 == 2; // false。
"cat" != "dog"; // true。
314 == "pi"; // false。
123 == "123"; // false。

不同类型的值永远不相等;Lox 不做隐式转换。

3.4.3 逻辑运算符(Logical Operators)

前缀 ! 是非运算符:操作数为真则返回 false,反之返回 true

!true; // false。
!false; // true。

另两个逻辑运算符实际上是伪装成表达式的控制流结构。and 判断两个值是否都为真:左操作数为假时返回左操作数,否则返回右操作数。or 判断至少一个是否为真:左操作数为真时返回左操作数,否则返回右操作数。

true and false; // false。
true and true; // true。
false or false; // false。
true or false; // true。

andor短路and 的左操作数为假时,右侧甚至不会求值;or 的左操作数为真时,也会跳过右侧。这里使用单词而非 &&||,因为 Lox 没有对应的单字符按位 &| 运算符,而且它们本质上更像控制流而非简单运算。

3.4.4 优先级与分组(Precedence and Grouping)

这些运算符的优先级与结合性都符合你从 C 获得的预期。若优先级不合意,可用 () 分组:

var average = (min + max) / 2;

位运算、移位、取模和条件运算符在技术上并不特别有趣,因此没有放进 Lox。若你在自己的实现中加入它们,会得到作者心中的加分。

3.5 语句(Statements)

表达式的主要任务是产生,语句的任务是产生效果。语句按定义不求值为值,因此要有用,就必须以其他方式改变世界:通常是修改状态、读取输入或产生输出。

print 语句对一个表达式求值并把结果显示给用户:

print "Hello, world!";

print 被直接做成语言的一部分而不是核心库函数,是一个有用的权宜之计:在实现函数定义、名称查找和调用的全套机制前,尚在开发中的解释器就能输出内容。

表达式后接分号会升级为表达式语句

"some expression";

当只需要一条语句的位置想放一组语句,可用(block)包住它们:

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

块还会影响作用域。

3.6 变量(Variables)

使用 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 控制流(Control Flow)

若不能跳过部分代码或反复执行某些代码,就很难写出有用程序。除已介绍的逻辑运算符外,Lox 直接从 C 取来三种语句。

if 根据条件执行两条语句之一:

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

while 在条件表达式为真时反复执行主体:

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

最后是 for 循环,它与上面的 while 做同一件事:

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

现代语言往往还提供 for-inforeach 来迭代序列。真实语言中这比粗糙的 C 风格 for 更好;Lox 保持基础。for-in 需要迭代器协议的动态分派,而本书完成控制流时还没有它,因此不再回头加入。

理论上已有 andor 可分支,再用递归即可重复,足以表达控制流;但在命令式语言中会很别扭。Scheme 没有内建循环、依赖递归重复;Smalltalk 没有内建分支、依赖动态分派选择执行。do while 在 Lox 中被省略,因为不常见且不会教会 while 之外的新内容。

3.8 函数(Functions)

函数调用表达式与 C 相同:

makeBreakfast(bacon, eggs, toast);
makeBreakfast();

圆括号不可省略;若省略,名称只是引用函数而不调用。使用 fun 定义函数:

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

这里区分术语:实参(argument)是调用函数时传入的实际值,因此调用拥有实参列表;形参(parameter)是函数体内持有实参值的变量,因此函数声明拥有形参列表。

静态类型语言如 C 有时区分“声明”和“定义”函数:声明只绑定名称与类型,供调用点做类型检查;定义还提供函数体。Lox 动态类型,函数声明已经完整给出函数体,二者没有实用差别。语言在 fnfunfuncfunction 间各有偏好,作者仍期待某处出现 functfunctifunctio

函数体总是一个块,可用 return 返回值:

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

执行抵达块末尾而未遇到 return 时,会隐式返回 nil

3.8.1 闭包(Closures)

Lox 中函数是一等值(first class):可取得引用、保存到变量、传递给其他函数等。

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

fun identity(a) {
return a;
}

print identity(addPair)(1, 2); // 输出 "3"。

由于函数声明是语句,也可在另一个函数中声明局部函数。将局部函数、一等函数与块作用域结合,会得到下面情形:

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

fun inner() {
print outside;
}

return inner;
}

var fn = returnFunction();
fn();

inner() 访问了外围函数中、函数体之外声明的局部变量。要使它可行,inner() 必须“抓住”所使用外围变量的引用,让它们在外层函数返回后仍然存在。这样做的函数称为闭包(closure)。实现闭包会增加复杂度:不能再假设变量作用域严格像栈,局部变量不会在函数返回瞬间消失。

Peter J. Landin 创造了 closure 一词;他在著名论文 The Next 700 Programming Languages 中还创造了大量语言术语。实现闭包时,需有数据结构把函数代码与其依赖的外围变量捆在一起,因而称其“闭合并持有”变量。如今这个词也常泛指任意一等函数,即使它并未捕获变量。

3.9 类(Classes)

Lox 有动态类型、词法作用域和闭包,约有一半是函数式语言;但也有一半是面向对象语言。两种范式都很有价值,因此本书各覆盖一部分。

3.9.1 为什么语言要面向对象?

20 世纪 90 年代“任何时候都用继承”的热潮确实制造了可怕的类层次,但面向对象编程(OOP)仍很有价值。数十亿行成功代码以 OOP 语言写成,交付了数百万应用;今天工作中的程序员很可能多数仍在使用面向对象语言。

对动态类型语言尤其如此:需要某种方式定义复合数据类型并把一团数据捆在一起。若还能把方法挂在对象上,就无须在所有函数名前加其处理数据类型的名字来避免冲突,例如 hash-copyvector-copy;方法受对象作用域约束,问题自然消失。

3.9.2 为什么 Lox 要面向对象?

多数编程语言书,特别是尝试实现完整语言的书,会略去对象。对于如此普及的范式,这意味着相关主题没有得到足够覆盖。大量开发者整天使用 OOP 语言,世界也需要一点关于如何创造它的资料。它比你害怕的简单,却也比你想象的复杂。

3.9.3 类还是原型?(Classes or Prototypes)

对象主要有两种路径:原型。前者先出现,并因 C++、Java、C# 等更常见;原型曾几乎被遗忘,直到 JavaScript 意外统治世界。

类语言有实例与类:实例保存每个对象状态并引用其类,类保存方法和继承链。调用实例方法时,需要先查找实例类,再在那里查找方法。在静态类型 C++ 中,这往往基于实例的静态类型在编译时完成,叫静态分派动态分派则在运行时查看实际实例的类,静态语言中的虚方法和 Lox 这样的动态语言中的全部方法都如此工作。

原书插图:类式系统中字段与方法的查找路径。

原型语言合并了这两个概念:只有对象,没有类;每个对象都能包含状态和方法,且对象可以直接继承或委托给另一个对象。原型在语言实现层面非常简单,也能表达许多类不鼓励的模式;但人们通常会用这种强大灵活性重新发明类。原型似乎只是把复杂性从语言推给用户,因此 Lox 直接内建类。

实践中二者边界模糊:JavaScript 的构造函数会强烈推动类式对象,类语言 Ruby 又允许向单个实例附加方法。Perl 创造者 Larry Wall 将这种现象称为“水床理论”:必要复杂性无法消除,只会在你压下的一处从另一处鼓起;原型语言没有消除类的复杂性,而是让用户构建类式元编程库来承担它。

原书插图:原型系统中字段与方法的查找路径。

3.9.4 Lox 中的类(Classes in Lox)

类体包含方法,外观像函数声明但没有 fun

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

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

执行类声明时,Lox 创建一个类对象并将其存入以类命名的变量。与函数一样,类也是一等值:

var someVariable = Breakfast;
someFunction(Breakfast);

Lox 不需要 new:类本身是实例的工厂函数,像调用函数一样调用类即可产生新实例。

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

3.9.5 实例化与初始化(Instantiation and Initialization)

只有行为的类不够实用。OOP 要把行为和状态封装在一起,因此需要字段。Lox 如其他动态语言一样,可自由给对象添加属性;向不存在字段赋值会创建它:

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

在方法内通过 this 访问当前对象的字段或方法。为确保对象创建时处于有效状态,可定义初始化器:类中名为 init() 的方法会在对象构造时自动调用,传给类的参数也会转发给它。

class Breakfast {
init(meat, bread) {
this.meat = meat;
this.bread = bread;
}

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

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

3.9.6 继承(Inheritance)

为跨多个类复用方法,Lox 支持单继承。声明类时用小于号 < 指明其继承的类:

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

这里 Brunch派生类子类Breakfast基类超类。超类的每个方法都可供子类使用,init() 也会被继承。子类通常也会定义自己的 init(),并用 super 调用原初始化器,以维持超类状态:

选择 < 而非 extends 是为了避免新关键字,Lox 也未把 : 留作继承用途;Ruby 同样使用 <。从类型论看也不完全随意:子类实例集合小于超类实例集合,常记为 <:。Lox 与 Smalltalk、Ruby 一样继承初始化器,而 C++、Java、C# 不继承构造器。

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

Lox 的面向对象特性刻意保持最小。它不是纯 OOP 语言:数字和布尔值等基本类型不是类实例,没有方法或属性。因为本书在实现基本类型很久之后才实现类;若为真实用户设计 Lox,应修正这一点。

3.10 标准库(The Standard Library)

语言本体至此结束,余下是“核心”或“标准”库:直接在解释器中实现、供所有用户行为构建其上的功能集合。

Lox 的标准库极度贫乏。书中样例只需展示代码在运行并按预期工作,已有内建 print 语句。稍后优化时需要做基准测试,因此再定义一个内建函数 clock(),返回程序启动以来的秒数。

就这些。若要把 Lox 变成真正有用的语言,首要任务是扩充标准库:字符串操作、三角函数、文件 I/O、网络,甚至读取用户输入都会有帮助。本书不需要这些,加入它们也不会教授特别有趣的实现知识,因此留给读者扩展。

挑战(Challenges)

  1. 写一些样例 Lox 程序并运行。试着找出本章未规定的边界行为:它是否符合预期?为什么符合或不符合?
  2. 这份非正式介绍留下大量未规定之处。列出你对语言语法和语义的若干开放问题,并说明你认为答案应是什么。
  3. 除标准库外,Lox 缺少哪些会令真实程序难用的特性?

设计笔记:表达式与语句(Expressions and Statements)

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 语句语法解释成表达式会很快变得古怪。