跳到主要内容

第 12 章:类(Classes)

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

每个对象都知道自己是谁。

Alan Kay

函数与闭包已经足够构建许多程序,但对象把状态与操作该状态的行为放在一起。Lox 选择类式面向对象模型:类是可调用对象,调用类创建实例;实例拥有动态字段并从类查找方法;方法通过 this 访问接收者。

12.1 面向对象与类(OOP and Classes)

面向对象还有类、原型和多方法分派等路径。类语言把可共享的行为放到类,把每个对象的状态放到实例;原型语言让对象直接委托给对象。Lox 采用类,因为它熟悉、易于教学,也足以展示方法绑定、初始化和继承等重要实现问题。继承会在下一章加入。

原书插图:类、方法、实例、构造器与字段之间的关系。

本章目标是让如下程序工作:

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

var breakfast = Breakfast();
breakfast.serve("Dear Reader");

12.2 类声明(Class Declarations)

类声明文法:

declaration -> classDecl | funDecl | varDecl | statement ;
classDecl -> "class" IDENTIFIER "{" function* "}" ;

AST 增加:

"Class : Token name, List<Stmt.Function> methods"

解析器处理 class

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);
}

function("method") 复用函数声明的参数和块解析,只是方法前不需要 fun 关键字。declaration() 检查 CLASS 后进入该函数。

运行时,类本身是一个对象。先定义 LoxClass

package com.craftinginterpreters.lox;

class LoxClass implements LoxCallable {
final String name;

LoxClass(String name) {
this.name = name;
}

@Override
public String toString() {
return name;
}
}

初始时它还没有方法表或可调用实现。解释类声明时先在环境中放入占位 null,随后创建类对象并赋回;此时的占位主要为后续超类支持预留,对当前章节的类方法而言,this 是访问实例状态的主要方式:

@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;
}

12.3 创建实例(Creating Instances)

Lox 不使用 new。类实现 LoxCallable,因此调用类就是实例化:

class LoxClass implements LoxCallable {
// name 省略。

@Override
public Object call(Interpreter interpreter, List<Object> arguments) {
return new LoxInstance(this);
}

@Override
public int arity() {
return 0;
}
}

实例保存对其类的引用:

package com.craftinginterpreters.lox;

class LoxInstance {
private final LoxClass klass;

LoxInstance(LoxClass klass) {
this.klass = klass;
}

@Override
public String toString() {
return klass.name + " instance";
}
}

由于 LoxClass 已实现可调用接口,现有调用表达式无需了解“类”这一特殊概念。统一的可调用抽象避免了在解释器中堆叠特例。尚未定义初始化器时类的元数为零。

12.4 实例属性(Properties on Instances)

动态对象可在运行时增加字段:

breakfast.meat = "sausage";
print breakfast.meat;

12.4.1 读取属性(Get Expressions)

AST 新增字段读取和设置:

"Get : Expr object, Token name",
"Set : Expr object, Token name, Expr value"

文法把点访问作为调用层的后缀:

call -> primary ( "(" arguments? ")" | "." IDENTIFIER )* ;
private Expr call() {
Expr expr = primary();

while (true) {
if (match(LEFT_PAREN)) {
expr = finishCall(expr);
} else if (match(DOT)) {
Token name = consume(IDENTIFIER, "Expect property name after '.'.");
expr = new Expr.Get(expr, name);
} else {
break;
}
}
return expr;
}

原书插图:连续点访问与调用被解析成 AST。

12.4.2 设置属性(Set Expressions)

赋值解析器已经会先取得左侧表达式。若左侧是 Expr.Get,就转换为 Expr.Set

if (expr instanceof Expr.Get) {
Expr.Get get = (Expr.Get) expr;
return new Expr.Set(get.object, get.name, value);
}

原书插图:嵌套属性设置的结构。

实例以哈希表存字段:

private final Map<String, Object> fields = new HashMap<>();

Object get(Token name) {
if (fields.containsKey(name.lexeme)) {
return fields.get(name.lexeme);
}
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
}

void set(Token name, Object value) {
fields.put(name.lexeme, value);
}

每次字段读取做哈希表查找,对许多实现已经足够;高性能 JavaScript VM 会使用“隐藏类”等优化来避免重复查找。耐人寻味的是,动态语言的这类优化常建立在一个观察上:即使语言允许任意改动对象,大多数真实程序仍以相当规律的形状创建和使用对象。

解释器确保点操作的接收者是实例:

@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.");
}

@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;
}

字段赋值返回被赋值,故 a.b = c 可作为表达式继续使用。

这里的求值顺序是刻意规定的:先求值对象,确认它确为实例,再求值右侧的新值。若接收者不是实例,右侧表达式不会执行;这种细节在右侧具有副作用时对用户可见,两个解释器都必须保持一致。

12.5 类的方法(Methods on Classes)

类声明执行时,把每个方法 AST 转换为 LoxFunction 并放入方法表:

原书插图:object.method(argument) 的语法树。

class LoxClass implements LoxCallable {
final String name;
private final Map<String, LoxFunction> methods;

LoxClass(String name, Map<String, LoxFunction> methods) {
this.name = name;
this.methods = methods;
}

LoxFunction findMethod(String name) {
return methods.get(name);
}
}
Map<String, LoxFunction> methods = new HashMap<>();
for (Stmt.Function method : stmt.methods) {
LoxFunction function = new LoxFunction(method, environment, false);
methods.put(method.name.lexeme, function);
}

LoxClass klass = new LoxClass(stmt.name.lexeme, methods);
environment.assign(stmt.name, klass);

字段优先于方法:用户可把任意值赋给实例字段,字段同名时应覆盖方法。若字段未找到,实例从类中寻找方法:

Object get(Token name) {
if (fields.containsKey(name.lexeme)) return fields.get(name.lexeme);

LoxFunction method = klass.findMethod(name.lexeme);
if (method != null) return method.bind(this);

throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
}

返回方法时立即绑定接收者,因而下面代码可用:

var method = breakfast.serve;
method("reader");

12.6 this

方法需要引用接收者:

class Cake {
taste() {
var adjective = "delicious";
print "The " + this.flavor + " cake is " + adjective + ".";
}
}

扫描器已将 this 识别为关键字;在 AST 中它与普通变量同形:

"This : Token keyword"

primary() 匹配 THIS,构建 Expr.This。Resolver 用额外的类状态检查合法性:

private enum ClassType { NONE, CLASS }
private ClassType currentClass = ClassType.NONE;

@Override
public Void visitClassStmt(Stmt.Class stmt) {
ClassType enclosingClass = currentClass;
currentClass = ClassType.CLASS;

declare(stmt.name);
define(stmt.name);

beginScope();
scopes.peek().put("this", true);
for (Stmt.Function method : stmt.methods) {
resolveFunction(method, FunctionType.FUNCTION);
}
endScope();

currentClass = enclosingClass;
return null;
}

@Override
public Void visitThisExpr(Expr.This expr) {
if (currentClass == ClassType.NONE) {
Lox.error(expr.keyword, "Can't use 'this' outside of a class.");
return null;
}
resolveLocal(expr, expr.keyword);
return null;
}

this 实际上由 bind() 放进闭包环境:

原书插图:方法原始闭包。

原书插图:绑定 this 后的新闭包。

原书插图:调用绑定方法时创建的函数体环境。

LoxFunction bind(LoxInstance instance) {
Environment environment = new Environment(closure);
environment.define("this", instance);
return new LoxFunction(declaration, environment, isInitializer);
}

因此解析器把 this 当变量解析,解释器可复用 lookUpVariable()

@Override
public Object visitThisExpr(Expr.This expr) {
return lookUpVariable(expr.keyword, expr);
}

12.6.1 非法使用 this(Invalid Uses of This)

this 不是普通全局变量:类外使用没有接收者,必须由 Resolver 报错;在嵌套函数中使用则合法,因为该函数闭包能捕获外层方法绑定的 thiscurrentClass 在进入类声明时设为 CLASS、结束时恢复,确保嵌套类不会破坏外围状态。Resolver 的作用域栈中人为加入 this,因此变量解析的距离机制无需为它另写一套规则。

12.7 构造器与初始化器(Constructors and Initializers)

Lox 使用名为 init 的方法作为初始化器。类调用时若存在它,应先创建实例、绑定并调用初始化器:

@Override
public Object call(Interpreter interpreter, List<Object> arguments) {
LoxInstance instance = new LoxInstance(this);
LoxFunction initializer = findMethod("init");
if (initializer != null) {
initializer.bind(instance).call(interpreter, arguments);
}
return instance;
}

@Override
public int arity() {
LoxFunction initializer = findMethod("init");
if (initializer == null) return 0;
return initializer.arity();
}

为让初始化器无论有没有显式 return 都返回实例,函数对象记录 isInitializer

class LoxFunction implements LoxCallable {
private final boolean isInitializer;

LoxFunction(Stmt.Function declaration,
Environment closure, boolean isInitializer) {
this.declaration = declaration;
this.closure = closure;
this.isInitializer = isInitializer;
}

@Override
public Object call(Interpreter interpreter, List<Object> arguments) {
Environment environment = new Environment(closure);
// 绑定参数。
try {
interpreter.executeBlock(declaration.body, environment);
} catch (Return returnValue) {
if (isInitializer) return closure.getAt(0, "this");
return returnValue.value;
}
if (isInitializer) return closure.getAt(0, "this");
return null;
}
}

Resolver 对初始化器使用单独的函数类型:

private enum FunctionType { NONE, FUNCTION, INITIALIZER }

12.7.1 直接调用 init()(Invoking init() Directly)

初始化器仍是一个可绑定的方法,所以 instance.init() 在语法上允许。无论通过类调用还是直接方法调用,初始化器的结果都是该实例本身;这一针对 clox 实现便利性的取舍,保持了“初始化器不能交付另一个返回值”的统一规则。

12.7.2 从 init() 返回(Returning from init()

解析类方法时,名称为 init 就传入 INITIALIZER。此外,初始化器不能返回值:

if (stmt.value != null) {
if (currentFunction == FunctionType.INITIALIZER) {
Lox.error(stmt.keyword,
"Can't return a value from an initializer.");
}
resolve(stmt.value);
}

可以直接调用 instance.init(),它仍会返回绑定的实例;这是 Lox 统一“方法就是函数”的自然结果。

挑战(Challenges)

  1. 支持类对象上的静态方法:用方法前的 class 关键字声明,令 Math.square(3) 可调用。可探索让 LoxClass 继承 LoxInstance 的元类方案。
  2. 支持 getter:无参数列表的方法以字段读取形式调用,例如 circle.area
  3. 比较 Python/JavaScript 的自由字段访问、Ruby/Smalltalk 的实例状态封装,以及静态语言的 private/public 成员访问控制;分析语言为何会选择不同方案。

设计笔记:原型与力量(Prototypes and Power)

类模型把“实例状态”与“共享行为”分开;若把方法直接放在单个实例中,再让实例委托给另一个实例以复用字段和方法,运行时只需 LoxInstance 一种核心实体,这便是原型模型。David Ungar 和 Randall Smith 在 Self 中提出它,随后 JavaScript 使其广为人知。

原型在实现和概念上比类更简单,但“简单”只是语言力量的一部分。可以用一个非精确的思维公式衡量:力量 = 广度 × 易用性 / 复杂度。广度是语言能表达的事物范围,易用性是表达所需工作量,复杂度是用户为高效使用语言和生态必须学习的总量。

降低复杂度会提升力量,但不能以牺牲广度或易用性为代价。真正的目标是移除没有带来相应收益的偶然复杂度。若用户习惯以对象类别组织程序,内建类带来的易用性可能足以抵消其额外概念;若不是,则应当让类留在语言之外。