类
如果你对事物的本质没有透彻的了解,你就没有权利去爱或恨任何事物。伟大的爱来自于对所爱对象的深刻了解,如果你对它了解甚少,你就只能爱它一点点,甚至根本无法爱它。
列奥纳多·达·芬奇
我们已经写了十一章,你机器上的解释器几乎已经成为一个完整的脚本语言。它可以使用一些内置的数据结构,比如列表和映射,并且它肯定需要一个核心库来进行文件 I/O、用户输入等等。但是语言本身已经足够了。我们已经有了类似于 BASIC、Tcl、Scheme(减去宏)以及早期版本的 Python 和 Lua 的简单过程式语言。
如果是在 80 年代,我们就会到此为止。但是今天,许多流行的语言都支持“面向对象编程”。将其添加到 Lox 中将为用户提供一套熟悉的工具来编写更大的程序。即使你个人不喜欢 OOP,本章和下一章也将帮助你理解其他人如何设计和构建对象系统。
12 . 1OOP 和类
面向对象编程有三个主要路径:类、原型和多方法。类是最早出现的,也是最流行的风格。随着 JavaScript(以及在较小程度上 Lua)的兴起,原型比以前更广为人知。我将在后面进一步讨论它们。对于 Lox,我们采取了,嗯,经典的方法。
既然你已经和我一起编写了大约一千行 Java 代码,我假设你不需要详细介绍面向对象。主要目标是将数据与作用于它的代码捆绑在一起。用户通过声明一个类来做到这一点,该类
-
公开一个构造函数来创建和初始化类的新的实例
-
提供一种在实例上存储和访问字段的方法
-
定义一组由该类所有实例共享的函数,这些函数作用于每个实例的状态。
这就是最基本的了。大多数面向对象语言,甚至可以追溯到 Simula,也实现了继承来跨类重用行为。我们将在下一章中添加它。即使不考虑它,我们还有很多东西要处理。这是一章很长的内容,只有在拥有了以上所有部分之后,所有内容才会真正融合在一起,所以要做好准备。
12 . 2类声明
就像我们平时一样,我们要从语法开始。class
语句引入了一个新的名称,因此它存在于 declaration
语法规则中。
declaration → classDecl | funDecl | varDecl | statement ; classDecl → "class" IDENTIFIER "{" function* "}" ;
新的 classDecl
规则依赖于我们之前定义的 function
规则。为了让你回忆起来
function → IDENTIFIER "(" parameters? ")" block ; parameters → IDENTIFIER ( "," IDENTIFIER )* ;
通俗地说,类声明就是 class
关键字,后面跟着类的名称,然后是一个花括号括起来的主体。在这个主体内部,有一个函数声明列表。与函数声明不同的是,函数没有前导的 fun
关键字。每个函数都是一个名称、参数列表和主体。以下是一个例子
class Breakfast { cook() { print "Eggs a-fryin'!"; } serve(who) { print "Enjoy your breakfast, " + who + "."; } }
像大多数动态类型语言一样,字段没有在类声明中显式列出。实例是松散的数据包,你可以根据需要使用普通的命令式代码自由地向它们添加字段。
在我们的 AST 生成器中,classDecl
语法规则拥有自己的语句 节点。
"Block : List<Stmt> statements",
在 main() 中
"Class : Token name, List<Stmt.Function> methods",
"Expression : Expr expression",
它存储了类的名称和主体内的函数。函数由现有的 Stmt.Function 类表示,我们用它来表示函数声明 AST 节点。这为我们提供了函数所需的所有状态位:名称、参数列表和主体。
类可以出现在任何允许使用命名声明的地方,由前导的 class
关键字触发。
try {
在 declaration() 中
if (match(CLASS)) return classDeclaration();
if (match(FUN)) return function("function");
它调用了
在 declaration() 之后添加
private Stmt classDeclaration() { Token name = consume(IDENTIFIER, "Expect class name."); consume(LEFT_BRACE, "Expect '{' before class body."); List<Stmt.Function> methods = new ArrayList<>(); while (!check(RIGHT_BRACE) && !isAtEnd()) { methods.add(function("method")); } consume(RIGHT_BRACE, "Expect '}' after class body."); return new Stmt.Class(name, methods); }
这个方法比大多数其他解析方法包含更多内容,但它大致遵循了语法。我们已经消费了 class
关键字,所以我们接下来寻找预期的类名,然后是开括号。进入主体后,我们不断解析函数声明,直到遇到闭括号。每个函数声明都由调用 function()
来解析,我们在引入函数的那一章中定义了它。
就像我们在解析器中的任何开放式循环中一样,我们也检查是否遇到了文件末尾。在正确的代码中,这种情况不会发生,因为类应该在末尾有一个闭括号,但它确保了解析器在用户出现语法错误并且忘记正确结束类主体的情况下不会陷入无限循环。
我们将名称和函数列表包装在一个 Stmt.Class 节点中,然后就完成了。之前,我们会直接跳入解释器,但现在我们需要先将节点通过解析器传递。
在 visitBlockStmt() 之后添加
@Override public Void visitClassStmt(Stmt.Class stmt) { declare(stmt.name); define(stmt.name); return null; }
我们现在还不用担心解析函数本身,所以现在我们只需要使用其名称来声明类即可。声明类作为局部变量并不常见,但 Lox 允许这样做,所以我们需要正确地处理它。
现在我们来解释类声明。
在 visitBlockStmt() 之后添加
@Override public Void visitClassStmt(Stmt.Class stmt) { environment.define(stmt.name.lexeme, null); LoxClass klass = new LoxClass(stmt.name.lexeme); environment.assign(stmt.name, klass); return null; }
这看起来很像我们执行函数声明的方式。我们在当前环境中声明类的名称。然后我们将类语法节点转换为 LoxClass,即类的运行时表示。我们循环回来,将类对象存储在我们之前声明的变量中。这种两阶段的变量绑定过程允许在类自身的函数中引用该类。
我们将在本章中不断完善它,但 LoxClass 的第一个草稿看起来像这样
创建新文件
package com.craftinginterpreters.lox; import java.util.List; import java.util.Map; class LoxClass { final String name; LoxClass(String name) { this.name = name; } @Override public String toString() { return name; } }
仅仅是名称的包装器。我们甚至还没有存储函数。不是很有用,但它确实有一个 toString()
函数,所以我们可以编写一个简单的脚本并测试类对象是否真的正在解析和执行。
class DevonshireCream { serveOn() { return "Scones"; } } print DevonshireCream; // Prints "DevonshireCream".
12 . 3创建实例
我们有了类,但它们还没有做任何事情。Lox 没有你可以直接在类本身调用的“静态”函数,所以如果没有实际的实例,类就毫无用处。因此,实例是下一步。
虽然一些语法和语义在 OOP 语言之间相当标准,但创建新实例的方式却有所不同。Ruby 遵循 Smalltalk,通过在类对象本身调用一个函数来创建实例,这是一种递归地优雅的方法。一些语言,比如 C++ 和 Java,有专门用于生成新对象的 new
关键字。Python 让你像函数一样“调用”类本身。(JavaScript 一如既往地奇怪,它有点像两者兼而有之。)
我在 Lox 中采取了一种最小化的方法。我们已经有了类对象,我们也有函数调用,所以我们将使用类对象上的调用表达式来创建新的实例。这就像类是一个工厂函数,生成它自身的实例。对我来说,这感觉很优雅,而且还省去了我们引入像 new
这样的语法。因此,我们可以跳过前端,直接进入运行时。
现在,如果你尝试这样做
class Bagel {} Bagel();
你会得到一个运行时错误。visitCallExpr()
检查被调用对象是否实现了 LoxCallable
,并报告了一个错误,因为 LoxClass 还没有实现。不是还没有实现,而是它还没有实现。
import java.util.Map;
替换 1 行
class LoxClass implements LoxCallable {
final String name;
实现该接口需要两个函数。
在 toString() 之后添加
@Override public Object call(Interpreter interpreter, List<Object> arguments) { LoxInstance instance = new LoxInstance(this); return instance; } @Override public int arity() { return 0; }
有趣的是 call()
。当您“调用”一个类时,它会为被调用的类实例化一个新的 LoxInstance 并将其返回。arity()
函数用于让解释器验证您是否向可调用对象传递了正确数量的参数。现在,我们假设您不能传递任何参数。当我们使用用户定义的构造函数时,我们会重新审视这一点。
这让我们来到了 LoxInstance,它是 Lox 类的实例的运行时表示。同样,我们的第一个实现从很小开始。
创建新文件
package com.craftinginterpreters.lox; import java.util.HashMap; import java.util.Map; class LoxInstance { private LoxClass klass; LoxInstance(LoxClass klass) { this.klass = klass; } @Override public String toString() { return klass.name + " instance"; } }
和 LoxClass 一样,它也很简单,但我们才刚刚开始。如果你想尝试一下,这里有一个要运行的脚本
class Bagel {} var bagel = Bagel(); print bagel; // Prints "Bagel instance".
这个程序并没有做太多,但它已经开始做一些事情了。
12 . 4实例的属性
我们有实例,所以我们应该让它们有用。我们现在处于一个岔路口。我们可以先添加行为—方法—或者我们可以从状态—属性开始。我们将采用后者,因为正如我们将会看到的那样,两者以一种有趣的方式相互交织,如果我们先让属性工作起来,会更容易理解它们。
Lox 在处理状态方面遵循 JavaScript 和 Python。每个实例都是一个开放的命名值的集合。实例类的方法可以访问和修改属性,但外部代码也可以。属性使用 .
语法访问。
someObject.someProperty
表达式后跟 .
和标识符从表达式求值的对象中读取具有该名称的属性。该点具有与函数调用表达式中的圆括号相同的优先级,因此我们通过用以下内容替换现有的 call
规则将其插入语法中
call → primary ( "(" arguments? ")" | "." IDENTIFIER )* ;
在主要表达式之后,我们允许一系列任何混合的带括号的调用和带点的属性访问。 “属性访问”这个词太长了,所以从现在起,我们将称它们为“获取表达式”。
12 . 4 . 1获取表达式
语法树节点是
"Call : Expr callee, Token paren, List<Expr> arguments",
在 main() 中
"Get : Expr object, Token name",
"Grouping : Expr expression",
遵循语法,新的解析代码位于我们现有的 call()
方法中。
while (true) {
if (match(LEFT_PAREN)) {
expr = finishCall(expr);
在 call() 中
} else if (match(DOT)) { Token name = consume(IDENTIFIER, "Expect property name after '.'."); expr = new Expr.Get(expr, name);
} else { break; } }
那里的外部 while
循环对应于语法规则中的 *
。我们沿着令牌构建一个调用和获取的链,就像这样
新的 Expr.Get 节点的实例被馈送到解析器中。
在 visitCallExpr() 后添加
@Override public Void visitGetExpr(Expr.Get expr) { resolve(expr.object); return null; }
好吧,这没什么大不了的。由于属性是动态查找的,因此它们不会被解析。在解析期间,我们只递归进入点左侧的表达式。实际的属性访问发生在解释器中。
在 visitCallExpr() 后添加
@Override public Object visitGetExpr(Expr.Get expr) { Object object = evaluate(expr.object); if (object instanceof LoxInstance) { return ((LoxInstance) object).get(expr.name); } throw new RuntimeError(expr.name, "Only instances have properties."); }
首先,我们计算正在访问其属性的表达式。在 Lox 中,只有类的实例具有属性。如果对象是其他类型的,比如数字,那么在它上面调用 getter 会导致运行时错误。
如果对象是 LoxInstance,那么我们要求它查找属性。现在该为 LoxInstance 提供一些实际的状态了。一个映射就足够了。
private LoxClass klass;
在类 LoxInstance 中
private final Map<String, Object> fields = new HashMap<>();
LoxInstance(LoxClass klass) {
映射中的每个键都是一个属性名称,相应的键值是该属性的值。要在实例上查找属性
在 LoxInstance() 后添加
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); } throw new RuntimeError(name, "Undefined property '" + name.lexeme + "'."); }
我们需要处理的一个有趣的边缘情况是,如果实例没有具有给定名称的属性会发生什么。我们可以静默地返回一些虚拟值,比如 nil
,但我使用 JavaScript 等语言的经验是,这种行为掩盖错误的次数比它做任何有用的事情的次数更多。相反,我们将使其成为运行时错误。
所以我们首先要做的是看看实例是否真的有一个具有给定名称的字段。只有这样我们才会返回它。否则,我们将引发错误。
请注意我从谈论“属性”转变为谈论“字段”。两者之间存在细微的区别。字段是直接存储在实例中的命名状态位。属性是命名的,嗯,东西,获取表达式可能返回的。每个字段都是一个属性,但正如我们将会看到稍后,并非每个属性都是一个字段。
从理论上讲,我们现在可以读取对象上的属性。但是由于没有办法实际将任何状态塞入实例,因此没有字段可以访问。在我们测试读取之前,我们必须支持写入。
12 . 4 . 2设置表达式
设置器使用与获取器相同的语法,除了它们出现在赋值的左侧。
someObject.someProperty = value;
在语法领域,我们将赋值规则扩展到允许在左侧使用带点的标识符。
assignment → ( call "." )? IDENTIFIER "=" assignment | logic_or ;
与获取器不同,设置器不会链接。但是,对 call
的引用允许在最后一个点之前使用任何高优先级表达式,包括任意数量的获取器,就像这样
请注意,这里只有最后部分,即 .meat
是设置器。.omelette
和 .filling
部分都是获取表达式。
正如我们有两个单独的 AST 节点用于变量访问和变量赋值一样,我们需要一个第二个设置器节点来补充我们的获取器节点。
"Logical : Expr left, Token operator, Expr right",
在 main() 中
"Set : Expr object, Token name, Expr value",
"Unary : Token operator, Expr right",
如果您不记得的话,我们在解析器中处理赋值的方式有点奇怪。我们无法轻易判断一系列令牌是赋值的左侧,直到我们到达 =
。现在我们的赋值语法规则在左侧有 call
,它可以扩展到任意大的表达式,最终的 =
可能距离我们需要知道正在解析赋值的位置有很多令牌。
相反,我们所做的技巧是将左侧解析为一个普通的表达式。然后,当我们在它之后遇到等号时,我们获取我们已经解析的表达式并将其转换为赋值的正确语法树节点。
我们向该转换添加另一个子句,以处理将左侧的 Expr.Get 表达式转换为相应的 Expr.Set。
return new Expr.Assign(name, value);
在 assignment() 中
} else if (expr instanceof Expr.Get) { Expr.Get get = (Expr.Get)expr; return new Expr.Set(get.object, get.name, value);
}
那就是解析我们的语法。我们将该节点推送到解析器中。
在 visitLogicalExpr() 后添加
@Override public Void visitSetExpr(Expr.Set expr) { resolve(expr.value); resolve(expr.object); return null; }
同样,与 Expr.Get 一样,属性本身是动态计算的,因此没有要解析的内容。我们只需要递归进入 Expr.Set 的两个子表达式即可,即正在设置其属性的对象以及它被设置为的值。
这将我们带到解释器。
在 visitLogicalExpr() 后添加
@Override public Object visitSetExpr(Expr.Set expr) { Object object = evaluate(expr.object); if (!(object instanceof LoxInstance)) { throw new RuntimeError(expr.name, "Only instances have fields."); } Object value = evaluate(expr.value); ((LoxInstance)object).set(expr.name, value); return value; }
我们计算正在设置其属性的对象,并检查它是否是 LoxInstance。如果不是,那就是运行时错误。否则,我们计算正在设置的值并将其存储在实例中。这依赖于 LoxInstance 中的一个新方法。
在 get() 后添加
void set(Token name, Object value) { fields.put(name.lexeme, value); }
这里没有真正的魔法。我们将值直接塞入字段所在的 Java 映射中。由于 Lox 允许在实例上自由创建新字段,因此无需查看键是否已存在。
12 . 5类上的方法
您可以创建类的实例并将数据塞入其中,但类本身实际上不做任何事情。实例只是映射,所有实例或多或少都相同。为了让它们感觉像是类的实例,我们需要行为—方法。
我们有用的解析器已经解析了方法声明,所以我们在这方面很好。我们也不需要为方法调用添加任何新的解析器支持。我们已经有了 .
(获取器)和 ()
(函数调用)。“方法调用”只是将它们链接在一起。
这提出一个有趣的问题。当这两个表达式被拉开时会发生什么?假设本例中的 method
是 object
类上的一个方法,而不是实例上的一个字段,以下代码应该做什么?
var m = object.method; m(argument);
该程序“查找”方法并将结果—无论是什么—存储在一个变量中,然后稍后调用该对象。这是允许的吗?您可以将方法视为实例上的函数吗?
另一个方向呢?
class Box {} fun notMethod(argument) { print "called function with " + argument; } var box = Box(); box.function = notMethod; box.function("argument");
该程序创建一个实例,然后在一个字段中存储一个函数。然后它使用与方法调用相同的语法调用该函数。这可以吗?
不同的语言对这些问题的答案不同。人们可以写一篇关于它的论文。对于 Lox,我们将说这两个问题的答案都是肯定的,它可以工作。我们有一些理由证明这一点。对于第二个例子—调用存储在字段中的函数—我们希望支持这一点,因为一等函数很有用,将它们存储在字段中是一件非常正常的事情。
第一个例子比较模糊。一种动机是用户通常希望能够将子表达式提升到局部变量中,而不会改变程序的含义。您可以将此
breakfast(omelette.filledWith(cheese), sausage);
转换成这个
var eggs = omelette.filledWith(cheese); breakfast(eggs, sausage);
它做的是同一件事。同样,由于方法调用中的 .
和 ()
是两个单独的表达式,因此您应该能够将查找部分提升到一个变量中,然后稍后调用它。我们需要仔细考虑当你查找方法时得到的是什么东西,以及它的行为方式,即使在像这样奇怪的情况下
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var method = jane.sayName; method(); // ?
如果你获取某个实例上的方法的句柄并在稍后调用它,它是否会“记住”它被拉出的实例?方法内部的 this
是否仍然引用那个原始对象?
这是一个更病态的例子,可以让你头脑混乱
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var bill = Person(); bill.name = "Bill"; bill.sayName = jane.sayName; bill.sayName(); // ?
最后一行是否打印“Bill”,因为那是我们通过它调用方法的实例,还是打印“Jane”,因为那是我们第一次获取方法的实例?
在 Lua 和 JavaScript 中的等效代码会打印“Bill”。这些语言实际上没有“方法”的概念。所有内容都像是字段中的函数,因此不清楚jane
“拥有”sayName
比bill
更甚。
但是,Lox 拥有真正的类语法,因此我们确实知道哪些可调用项是方法,哪些是函数。因此,像 Python、C# 和其他语言一样,当第一次获取方法时,我们将让方法将this
“绑定”到原始实例。Python 将这些称为绑定方法。
实际上,这通常是你想要的。如果你获取某个对象上方法的引用,以便以后将其用作回调,你希望记住它所属的实例,即使该回调碰巧存储在另一个对象上的某个字段中。
好的,这些语义需要加载到你的脑海中。暂时忘记边缘情况。我们稍后再回来处理这些。现在,让我们让基本的方法调用正常工作。我们已经在类主体中解析了方法声明,所以下一步是解析它们。
define(stmt.name);
在visitClassStmt()中
for (Stmt.Function method : stmt.methods) { FunctionType declaration = FunctionType.METHOD; resolveFunction(method, declaration); }
return null;
我们遍历类主体中的方法并调用我们为处理函数声明编写的resolveFunction()
方法。唯一的区别是我们传递了一个新的 FunctionType 枚举值。
NONE,
FUNCTION,
在枚举FunctionType中
在上一行添加“,”
METHOD
}
这在我们解析this
表达式时将很重要。现在,不要担心它。有趣的东西在解释器中。
environment.define(stmt.name.lexeme, null);
在visitClassStmt()中
替换 1 行
Map<String, LoxFunction> methods = new HashMap<>(); for (Stmt.Function method : stmt.methods) { LoxFunction function = new LoxFunction(method, environment); methods.put(method.name.lexeme, function); } LoxClass klass = new LoxClass(stmt.name.lexeme, methods);
environment.assign(stmt.name, klass);
当我们解释类声明语句时,我们将类的语法表示—它的 AST 节点—转换为它的运行时表示。现在,我们需要为类中包含的方法也执行此操作。每个方法声明都将发展成为一个 LoxFunction 对象。
我们将所有这些包装到一个 map 中,以方法名称为键。这将存储在 LoxClass 中。
final String name;
在类LoxClass中
替换 4 行
private final Map<String, LoxFunction> methods; LoxClass(String name, Map<String, LoxFunction> methods) { this.name = name; this.methods = methods; }
@Override public String toString() {
实例存储状态,类存储行为。LoxInstance 有它自己的字段 map,而 LoxClass 获取方法 map。即使方法由类拥有,它们仍然是通过该类的实例访问的。
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); }
在get()中
LoxFunction method = klass.findMethod(name.lexeme); if (method != null) return method;
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
当查找实例上的属性时,如果我们没有找到匹配的字段,我们将查找实例类上具有该名称的方法。如果找到,我们将返回它。这是“字段”和“属性”之间的区别变得有意义的地方。当访问属性时,你可能会获得一个字段—存储在实例上的状态—或者你可能会命中定义在实例类的定义方法。
方法使用此查找
在LoxClass()之后添加
LoxFunction findMethod(String name) { if (methods.containsKey(name)) { return methods.get(name); } return null; }
你可能猜到,这个方法以后会变得更加有趣。现在,对类的方法表进行简单的 map 查找足以让我们开始。试一试
class Bacon { eat() { print "Crunch crunch crunch!"; } } Bacon().eat(); // Prints "Crunch crunch crunch!".
12 . 6这个
我们可以在对象上定义行为和状态,但它们还没有绑定在一起。在方法内部,我们无法访问“当前”对象的字段—调用该方法的实例—也无法对同一个对象调用其他方法。
为了获取该实例,它需要一个名称。Smalltalk、Ruby 和 Swift 使用“self”。Simula、C++、Java 和其他语言使用“this”。Python 通过约定使用“self”,但从技术上讲,你可以随意命名它。
对于 Lox,由于我们通常遵循 Java 风格,所以我们将使用“this”。在方法体内,this
表达式将评估为调用该方法的实例。或者更确切地说,由于方法是作为两个步骤访问和调用的,所以它将引用从中访问该方法的对象。
这使得我们的工作更难。看一下
class Egotist { speak() { print this; } } var method = Egotist().speak; method();
在倒数第二行,我们从类实例中获取speak()
方法的引用。这返回一个函数,该函数需要记住它被拉出的实例,以便以后,在最后一行,当函数被调用时,它仍然可以找到它。
我们需要在访问方法时获取this
,并将其以某种方式附加到该函数,以便只要我们需要它,它就会保留下来。嗯 . . . 一种方法是存储一些附加数据,这些数据会保留在函数周围,对吧?这听起来很像一个闭包,不是吗?
如果我们将this
定义为某种隐藏变量,它位于一个环境中,该环境围绕着查找方法时返回的函数,那么在主体中使用this
以后将能够找到它。LoxFunction 已经具有保留周围环境的能力,所以我们拥有了所需的机制。
让我们通过一个示例来了解它是如何工作的
class Cake { taste() { var adjective = "delicious"; print "The " + this.flavor + " cake is " + adjective + "!"; } } var cake = Cake(); cake.flavor = "German chocolate"; cake.taste(); // Prints "The German chocolate cake is delicious!".
当我们第一次评估类定义时,我们会为taste()
创建一个 LoxFunction。它的闭包是围绕类的环境,在这种情况下是全局环境。因此,我们在类的方法 map 中存储的 LoxFunction 看起来像这样
当我们评估cake.taste
获取表达式时,我们会创建一个新的环境,将this
绑定到访问该方法的对象(此处为cake
)。然后,我们使用同一个代码创建了一个新的 LoxFunction,但使用该新环境作为其闭包。
这是评估方法名称的获取表达式时返回的 LoxFunction。当该函数稍后由()
表达式调用时,我们会照常为方法主体创建一个环境。
主体环境的父级是我们之前创建的环境,用于将this
绑定到当前对象。因此,主体内部对this
的任何使用都将成功解析为该实例。
将我们的环境代码重复用于实现this
也可以处理方法和函数相互作用的有趣情况,例如
class Thing { getCallback() { fun localFunction() { print this; } return localFunction; } } var callback = Thing().getCallback(); callback();
例如,在 JavaScript 中,通常从方法内部返回回调。该回调可能希望保留并保留对原始对象—this
值—的访问权限,该方法与之相关联。我们对闭包和环境链的现有支持应该能够正确地执行所有这些操作。
让我们编写代码。第一步是添加新的语法用于this
。
"Set : Expr object, Token name, Expr value",
在 main() 中
"This : Token keyword",
"Unary : Token operator, Expr right",
解析很简单,因为它是一个单独的标记,我们的词法分析器已经将其识别为保留字。
return new Expr.Literal(previous().literal); }
在primary()中
if (match(THIS)) return new Expr.This(previous());
if (match(IDENTIFIER)) {
当你到达解析器时,你会开始看到this
是如何像变量一样工作的。
在visitSetExpr()之后添加
@Override public Void visitThisExpr(Expr.This expr) { resolveLocal(expr, expr.keyword); return null; }
我们使用“this”作为“变量”的名称来解析它,就像任何其他局部变量一样。当然,这现在还无法正常工作,因为“this”没有在任何作用域中声明。让我们在visitClassStmt()
中修复它。
define(stmt.name);
在visitClassStmt()中
beginScope(); scopes.peek().put("this", true);
for (Stmt.Function method : stmt.methods) {
在我们进入并开始解析方法主体之前,我们推送一个新的作用域,并在其中定义“this”,就好像它是一个变量一样。然后,在我们完成后,我们会丢弃那个周围的作用域。
}
在visitClassStmt()中
endScope();
return null;
现在,每当遇到this
表达式(至少在方法内部)时,它都会解析为在方法主体块外部的隐式作用域中定义的“局部变量”。
解析器为this
有一个新的作用域,因此解释器需要为它创建一个相应的环境。记住,我们必须始终使解析器的作用域链与解释器的链接环境保持同步。在运行时,我们在实例上找到方法后创建环境。我们将之前简单地返回方法的 LoxFunction 的代码行替换为以下内容
LoxFunction method = klass.findMethod(name.lexeme);
在get()中
替换 1 行
if (method != null) return method.bind(this);
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
注意对bind()
的新调用。看起来像这样
在LoxFunction()之后添加
LoxFunction bind(LoxInstance instance) { Environment environment = new Environment(closure); environment.define("this", instance); return new LoxFunction(declaration, environment); }
没什么大不了的。我们在方法的原始闭包内部嵌套了一个新的环境。有点像闭包中的闭包。当方法被调用时,它将成为方法主体环境的父级。
我们在该环境中声明“this”作为变量,并将其绑定到给定的实例,即访问该方法的实例。瞧,返回的 LoxFunction 现在携带了自己的持久世界,其中“this”绑定到该对象。
剩下的任务是解释这些this
表达式。与解析器类似,它与解释变量表达式相同。
在visitSetExpr()之后添加
@Override public Object visitThisExpr(Expr.This expr) { return lookUpVariable(expr.keyword, expr); }
继续尝试使用之前那个蛋糕的示例。使用不到二十行代码,我们的解释器处理方法内部的this
,即使在它与嵌套类、方法内部的函数、方法的句柄等发生交互的各种奇怪方式中也是如此。
12 . 6 . 1无效的 this 使用
等等。如果你尝试在方法外部使用this
会发生什么?关于
print this;
或者
fun notAMethod() { print this; }
如果你不在方法中,this
将没有指向的实例。我们可以为它提供一些默认值,例如nil
,或者将其设为运行时错误,但用户显然犯了一个错误。他们越早发现并修复该错误,他们就越高兴。
我们的解析阶段是静态检测此错误的好地方。它已经检测到函数外部的return
语句。我们将对this
执行类似的操作。根据我们现有的 FunctionType 枚举,我们定义了一个新的 ClassType 枚举。
}
在枚举FunctionType之后添加
private enum ClassType { NONE, CLASS } private ClassType currentClass = ClassType.NONE;
void resolve(List<Stmt> statements) {
是的,它可以是布尔值。当我们进行继承时,它将获得第三个值,因此现在是枚举。我们还添加了一个相应的字段currentClass
。它的值告诉我们,在遍历语法树时,我们当前是否在类声明中。它最初为NONE
,这意味着我们不在类声明中。
当我们开始解析类声明时,我们会更改它。
public Void visitClassStmt(Stmt.Class stmt) {
在visitClassStmt()中
ClassType enclosingClass = currentClass; currentClass = ClassType.CLASS;
declare(stmt.name);
与currentFunction
类似,我们将字段的先前值存储在局部变量中。这让我们可以利用 JVM 来维护一个currentClass
值的堆栈。这样一来,如果一个类嵌套在另一个类中,我们就不会丢失先前值。
解析完方法后,我们会通过恢复旧值来“弹出”该堆栈。
endScope();
在visitClassStmt()中
currentClass = enclosingClass;
return null;
当我们解析this
表达式时,currentClass
字段会提供我们需要的数据,以便在表达式没有嵌套在方法体内部时报告错误。
public Void visitThisExpr(Expr.This expr) {
在visitThisExpr()中
if (currentClass == ClassType.NONE) { Lox.error(expr.keyword, "Can't use 'this' outside of a class."); return null; }
resolveLocal(expr, expr.keyword);
这将帮助用户正确使用this
,并且让我们不必在解释器中运行时处理误用。
12 . 7构造函数和初始化器
现在我们可以用类做几乎所有事情了,随着我们接近本章的结尾,我们发现自己奇怪地关注着开头。方法和字段让我们将状态和行为封装在一起,以便对象始终保持在有效配置中。但是,我们如何确保一个全新的对象从良好状态开始呢?
为此,我们需要构造函数。我发现构造函数是语言设计中最棘手的部分之一,如果你仔细观察大多数其他语言,你会发现裂缝围绕对象构造,设计接缝并不完全匹配。也许出生时刻总有些混乱。
“构造”一个对象实际上是一对操作
-
运行时分配新实例所需的内存。在大多数语言中,此操作处于用户代码可以访问的底层。
-
然后,调用一段用户提供的代码来初始化未形成的对象。
后者是我们听到“构造函数”时通常会想到的,但语言本身通常在我们到达该点之前已经为我们做了一些基础工作。事实上,我们的 Lox 解释器在创建新的 LoxInstance 对象时已经涵盖了这一点。
我们现在将完成剩下的部分—用户定义的初始化—。语言有各种各样的表示法来表示设置新对象的代码块。C++、Java 和 C# 使用一个与类名匹配的方法。Ruby 和 Python 将其称为init()
。后者既简洁又简短,所以我们也将这样做。
在 LoxClass 的 LoxCallable 实现中,我们添加了几行代码。
List<Object> arguments) { LoxInstance instance = new LoxInstance(this);
在 call() 中
LoxFunction initializer = findMethod("init"); if (initializer != null) { initializer.bind(instance).call(interpreter, arguments); }
return instance;
当调用一个类时,在创建 LoxInstance 后,我们查找“init”方法。如果我们找到了,我们立即绑定并调用它,就像普通的函数调用一样。参数列表将被转发。
该参数列表意味着我们还需要调整类声明其参数个数的方式。
public int arity() {
在arity()中
替换 1 行
LoxFunction initializer = findMethod("init"); if (initializer == null) return 0; return initializer.arity();
}
如果存在初始化器,则该方法的参数个数决定了调用类本身时必须传递的参数个数。不过,为了方便起见,我们并不要求类定义初始化器。如果你没有初始化器,参数个数仍然为零。
基本上就是这样。由于我们在调用init()
方法之前绑定了它,因此它可以在其主体内部访问this
。这与传递给类的参数一起,是你需要用来设置新实例的方式。
12 . 7 . 1直接调用 init()
与往常一样,探索这个新的语义领域会发现一些奇怪的东西。考虑一下
class Foo { init() { print this; } } var foo = Foo(); print foo.init();
你可以通过直接调用init()
方法来“重新初始化”一个对象吗?如果你这样做,它会返回什么?一个合理的答案是nil
,因为这似乎是主体返回的内容。
但是—我一般不喜欢为了满足实现而妥协—如果我们说init()
方法总是返回this
,即使是直接调用,这也会让 clox 的构造函数实现变得更加容易。为了保持 jlox 与此兼容,我们在 LoxFunction 中添加了一些特殊的代码。
return returnValue.value; }
在 call() 中
if (isInitializer) return closure.getAt(0, "this");
return null;
如果函数是初始化器,我们将覆盖实际的返回值,并强制返回this
。这依赖于一个新的isInitializer
字段。
private final Environment closure;
在类LoxFunction中
替换 1 行
private final boolean isInitializer; LoxFunction(Stmt.Function declaration, Environment closure, boolean isInitializer) { this.isInitializer = isInitializer;
this.closure = closure; this.declaration = declaration;
我们不能简单地查看 LoxFunction 的名称是否为“init”,因为用户可能已经定义了一个名为“init”的函数。在这种情况下,没有this
可以返回。为了避免这种奇怪的边缘情况,我们将直接存储 LoxFunction 是否代表一个初始化器方法。这意味着我们需要回到创建 LoxFunction 的几个地方并进行修复。
public Void visitFunctionStmt(Stmt.Function stmt) {
在visitFunctionStmt()中
替换 1 行
LoxFunction function = new LoxFunction(stmt, environment, false);
environment.define(stmt.name.lexeme, function);
对于实际的函数声明,isInitializer
始终为 false。对于方法,我们检查名称。
for (Stmt.Function method : stmt.methods) {
在visitClassStmt()中
替换 1 行
LoxFunction function = new LoxFunction(method, environment, method.name.lexeme.equals("init"));
methods.put(method.name.lexeme, function);
然后在bind()
中,当我们创建将this
绑定到方法的闭包时,我们将传递原始方法的值。
environment.define("this", instance);
在bind()中
替换 1 行
return new LoxFunction(declaration, environment, isInitializer);
}
12 . 7 . 2从 init() 中返回
我们还没有走出困境。我们一直在假设用户编写的初始化器不会显式返回一个值,因为大多数构造函数都不会返回。如果用户尝试这样做,会发生什么呢?
class Foo { init() { return "something else"; } }
它肯定不会按照他们的意愿执行,所以我们不妨将其设置为静态错误。回到解析器,我们在 FunctionType 中添加另一个情况。
FUNCTION,
在枚举FunctionType中
INITIALIZER,
METHOD
我们使用访问的方法名称来确定我们是否正在解析初始化器。
FunctionType declaration = FunctionType.METHOD;
在visitClassStmt()中
if (method.name.lexeme.equals("init")) { declaration = FunctionType.INITIALIZER; }
resolveFunction(method, declaration);
当我们稍后遍历return
语句时,我们会检查该字段,并使从init()
方法内部返回一个值成为错误。
if (stmt.value != null) {
在visitReturnStmt()中
if (currentFunction == FunctionType.INITIALIZER) { Lox.error(stmt.keyword, "Can't return a value from an initializer."); }
resolve(stmt.value);
我们还没有完成。我们静态禁止从初始化器返回值,但你仍然可以使用空的早期return
。
class Foo { init() { return; } }
这实际上有时很有用,所以我们不想完全禁止它。相反,它应该返回this
而不是nil
。这是 LoxFunction 中的一个简单修复。
} catch (Return returnValue) {
在 call() 中
if (isInitializer) return closure.getAt(0, "this");
return returnValue.value;
如果我们处于初始化器中并执行return
语句,我们将再次返回this
,而不是返回该值(该值始终为nil
)。
呼!这是一系列任务,但我们的奖励是,我们的小解释器已经发展出一个完整的编程范式。类、方法、字段、this
和构造函数。我们的婴儿语言看起来已经成熟了。
挑战
-
我们在实例上拥有方法,但无法定义可以直接在类对象本身调用的“静态”方法。添加对它们的支撐。使用在方法之前的
class
关键字来表示挂在类对象上的静态方法。class Math { class square(n) { return n * n; } } print Math.square(3); // Prints "9".
你可以通过任何你喜欢的方. 然而,Smalltalk 和 Ruby 使用的“元类”是一种特别优雅的方法。提示:使 LoxClass 扩展 LoxInstance 并从那里开始。
-
大多数现代语言都支持“getter”和“setter”—类上的成员,它们看起来像字段读取和写入,但实际上执行用户定义的代码。扩展 Lox 以支持 getter 方法。这些方法在没有参数列表的情况下声明。当访问具有该名称的属性时,将执行 getter 的主体。
class Circle { init(radius) { this.radius = radius; } area { return 3.141592653 * this.radius * this.radius; } } var circle = Circle(4); print circle.area; // Prints roughly "50.2655".
-
Python 和 JavaScript 允许你从类自身的方法之外自由访问对象的字段。Ruby 和 Smalltalk 封装了实例状态。只有类上的方法才能访问原始字段,由类决定暴露哪些状态。大多数静态类型语言提供了
private
和public
等修饰符,以控制每个成员的外部可访问性。这些方法之间的权衡是什么,为什么语言可能偏好其中一种方法?
设计说明:原型和力量
在本章中,我们引入了两个新的运行时实体,LoxClass 和 LoxInstance。前者是对象行为所在的,后者是用于状态的。如果你可以在单个对象(在 LoxInstance 内部)直接定义方法会怎么样?在这种情况下,我们根本不需要 LoxClass。LoxInstance 将成为定义对象行为和状态的完整包。
我们仍然需要一种方法,无需使用类来跨多个实例重用行为。我们可以让 LoxInstance 委托直接给另一个 LoxInstance 以重用其字段和方法,类似于继承。
用户将他们的程序建模为一个对象星座,其中一些对象相互委托以反映共同点。用作委托者的对象表示“规范”或“原型”对象,其他对象对其进行细化。结果是一个更简单的运行时,只有一个内部结构,LoxInstance。
这就是这个范式被称为原型的原因。它是 David Ungar 和 Randall Smith 在一种名为 Self 的语言中发明的。他们从 Smalltalk 开始,并进行了上述思维练习,看看他们可以简化到什么程度。
原型在很长一段时间里都是一个学术好奇,它引人入胜,产生了有趣的研. 但它并没有在更大的编程世界中产生影响。也就是说,直到 Brendan Eich 将原型塞进 JavaScript,后者迅速席卷全球。关于 JavaScript 中的原型已经写了太多太多文字。这是否表明原型很出色还是令人困惑—或者两者兼而有之!—这是一个开放的问题。
我不会讨论我认为原型是否适合语言。我制作过原型和基于类的语言,我对它们都抱有复杂的看法。我想讨论的是简洁在语言中的作用。
原型比类更简单—语言实现者需要编写的代码更少,用户需要学习和理解的概念也更少。这是否意味着它们更好?我们这些语言狂热者有一种迷恋极简主义的倾向。就我个人而言,我认为简单只是等式的一部分。我们真正想要给用户的是力量,我将其定义为
power = breadth × ease ÷ complexity
这些都不是精确的数值度量。我在这里用数学作为类比,而不是实际量化。
-
广度是指语言允许你表达的不同事物的范围。C语言具有很高的广度—它被用于从操作系统到用户应用程序再到游戏的方方面面。像 AppleScript 和 Matlab 这样的领域特定语言的广度较低。
-
易用性是指让语言按照你的意愿运行需要付出多少努力。“可用性”可能是另一个术语,尽管它比我想要引入的更多负担。 “高级”语言往往比“低级”语言更容易使用。大多数语言都有一个“颗粒度”,某些事物比其他事物更容易表达。
-
复杂性是指语言(包括其运行时、核心库、工具、生态系统等)的大小。人们谈论语言规范有多少页,或者它有多少关键字。这是用户在能够在系统中高效工作之前需要加载到他们大脑中的内容。它是简单性的反义词。
降低复杂性确实会提高力量。分母越小,结果值越大,因此我们对简单性有益的直觉是有效的。但是,在降低复杂性时,我们必须注意不要在过程中牺牲广度或易用性,否则总体的力量可能会下降。如果 Java 去掉了字符串,它将是一个严格意义上的更简单的语言,但它可能无法很好地处理文本操作任务,也无法像以前一样容易完成任务。
因此,艺术在于找到可以省略的偶然复杂性—语言特性和交互方式,它们不会通过增加语言的广度或易用性来发挥作用。
如果用户想用对象类别来表达他们的程序,那么将类烘焙到语言中会增加执行此操作的易用性,希望能够以足够大的幅度来弥补增加的复杂性。但如果用户没有使用你的语言,那么请随时将类排除在外。