Skip to main content

第 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,以便类名可以在自己的方法中被引用;随后创建类对象并赋回:

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

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

@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、结束时恢复,确保嵌套类不会破坏外围状态。

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() 在语法上允许。无论通过类调用还是直接方法调用,初始化器的结果都是该实例本身;这保持了“初始化器不能交付另一个返回值”的统一规则。

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. 让类对象也能拥有字段或方法,形成元类模型。类的类应该是什么?
  2. 实现 getter:无参数方法允许写作属性访问。如何区分字段、普通方法和 getter?
  3. 将字段访问限制为声明过的字段,或设计私有字段语法。动态语言的灵活性会失去什么?
  4. init 返回其他值并定义语义,比较 JavaScript 构造函数与 Lox 当前选择。
  5. 为对象实现自定义 toString/显示协议,而不把 Java 的 toString() 直接暴露给用户。

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

类模型把“实例状态”与“共享行为”分开,易于阅读和实现;原型模型则让对象直接委托给对象,核心机制更小、更统一。原型可表达极强的动态模式,例如在个别对象上附加行为、运行时改变委托关系。

但语言中的机制越强大,越可能将复杂性移到用户代码。许多原型语言用户最终自行建立类式构造、构造器约定和共享原型。Lox 直接提供类,不是因为原型能力不足,而是因为它更匹配多数读者熟悉的组织方式,也让本书能集中展示面向对象实现的重要部件。

设计语言时,“最小核心”与“用户必须重复搭建的抽象”需要平衡。把机制留给库可以保持语言小,但若每个程序都不得不重复实现同一模式,语言本身或许应该承认这一模式。