13

继承

我们曾经是海洋中的水滴,然后是鱼类,然后是蜥蜴、老鼠,最后是猴子,以及它们之间数百种生物。这双手曾经是鱼鳍,这双手曾经长着爪子!在我人类的口中,我拥有狼的尖牙,兔子凿子的牙齿,以及牛的研磨齿!我们的血液和我们曾经居住的海洋一样咸!当我们害怕时,我们皮肤上的毛发会竖起来,就像我们有毛皮的时候一样。我们是历史!我们成为我们之前的所有生物,我们依然存在。

特里·普拉切特,《天空中的帽子》

你能相信吗?我们已经来到了第二部分的最后一章。我们几乎完成了我们的第一个 Lox 解释器。上一章是一团相互交织的面向对象特性。我无法将它们分开,但我确实设法解开了一个部分。在本章中,我们将通过添加继承来完成 Lox 的类支持。

继承出现在面向对象语言中,一直追溯到第一个面向对象语言,Simula。早些时候,Kristen Nygaard 和 Ole-Johan Dahl 注意到他们编写的模拟程序中类之间的共性。继承为他们提供了一种重用这些相似部分代码的方法。

13 . 1超类和子类

鉴于这个概念是“继承”,你希望他们会选择一个一致的比喻,并将它们称为“父类”和“子类”,但这太容易了。很久以前,C. A. R. Hoare 创造了“子类”一词来指代细化另一种类型的记录类型。Simula 借用了这个词来指代继承自另一个类的。我认为直到 Smalltalk 出现,才有人翻转了拉丁语前缀,得到“超类”来指代关系的另一方。从 C++ 中,你也会听到“基类”和“派生类”。我主要会坚持使用“超类”和“子类”。

我们在 Lox 中支持继承的第一步是,在声明类时指定一个超类。这方面存在很多语法变化。C++ 和 C# 在子类名称后放置一个:,后面跟着超类名称。Java 使用extends代替冒号。Python 将超类放在类名称后的括号中。Simula 将超类的名称放在class关键字之前

在这个阶段,我不想在词法分析器中添加新的保留字或标记。我们没有extends甚至:,所以我们将遵循 Ruby,使用小于号 (<)。

class Doughnut {
  // General doughnut stuff...
}

class BostonCream < Doughnut {
  // Boston Cream-specific stuff...
}

为了将此融入语法,我们在现有的classDecl规则中添加了一个新的可选子句。

classDecl"class" IDENTIFIER ( "<" IDENTIFIER )?
                 "{" function* "}" ;

在类名称之后,你可以放置一个<,后面跟着超类名称。超类子句是可选的,因为你不必拥有超类。与 Java 等其他面向对象语言不同,Lox 没有所有类都继承的根“Object”类,因此,当你省略超类子句时,该类没有超类,甚至没有隐式超类。

我们希望在类声明的 AST 节点中捕获这种新的语法。

      "Block      : List<Stmt> statements",
tool/GenerateAst.java
main() 中
替换 1 行
      "Class      : Token name, Expr.Variable superclass," +
                  " List<Stmt.Function> methods",
      "Expression : Expr expression",
tool/GenerateAst.java,在main() 中,替换 1 行

你可能会惊讶地发现,我们将超类名称存储为 Expr.Variable,而不是 Token。语法将超类子句限制为单个标识符,但在运行时,该标识符将作为变量访问进行评估。在解析器中尽早将名称包装在 Expr.Variable 中,为我们提供了解析器可以挂载解析信息的 对象。

新的解析器代码直接遵循语法。

    Token name = consume(IDENTIFIER, "Expect class name.");
lox/Parser.java
classDeclaration() 中
    Expr.Variable superclass = null;
    if (match(LESS)) {
      consume(IDENTIFIER, "Expect superclass name.");
      superclass = new Expr.Variable(previous());
    }

    consume(LEFT_BRACE, "Expect '{' before class body.");
lox/Parser.java,在classDeclaration() 中

一旦我们(可能)解析了超类声明,我们将它存储在 AST 中。

    consume(RIGHT_BRACE, "Expect '}' after class body.");

lox/Parser.java
classDeclaration() 中
替换 1 行
    return new Stmt.Class(name, superclass, methods);
  }
lox/Parser.java,在classDeclaration() 中,替换 1 行

如果我们没有解析超类子句,则超类表达式将为null。我们必须确保后面的阶段检查它。其中第一个阶段是解析器。

    define(stmt.name);
lox/Resolver.java
visitClassStmt() 中
    if (stmt.superclass != null) {
      resolve(stmt.superclass);
    }
    beginScope();
lox/Resolver.java,在visitClassStmt() 中

类声明 AST 节点有一个新的子表达式,因此我们遍历并解析它。由于类通常在顶级声明,因此超类名称很可能是一个全局变量,因此这通常不会做任何有用的事情。但是,Lox 允许即使在块内也声明类,因此超类名称可能引用一个局部变量。在这种情况下,我们需要确保它已解析。

因为即使是善意的程序员有时也会编写奇怪的代码,所以我们在这里需要担心一个愚蠢的边缘情况。看看这个

class Oops < Oops {}

这不可能做任何有用的事情,如果我们让运行时尝试运行它,它将破坏解释器对继承链中不存在循环的期望。最安全的事情是在静态地检测这种情况,并将其报告为错误。

    define(stmt.name);

lox/Resolver.java
visitClassStmt() 中
    if (stmt.superclass != null &&
        stmt.name.lexeme.equals(stmt.superclass.name.lexeme)) {
      Lox.error(stmt.superclass.name,
          "A class can't inherit from itself.");
    }

    if (stmt.superclass != null) {
lox/Resolver.java,在visitClassStmt() 中

假设代码在没有错误的情况下解析,则 AST 会传播到解释器。

  public Void visitClassStmt(Stmt.Class stmt) {
lox/Interpreter.java
visitClassStmt() 中
    Object superclass = null;
    if (stmt.superclass != null) {
      superclass = evaluate(stmt.superclass);
      if (!(superclass instanceof LoxClass)) {
        throw new RuntimeError(stmt.superclass.name,
            "Superclass must be a class.");
      }
    }

    environment.define(stmt.name.lexeme, null);
lox/Interpreter.java,在visitClassStmt() 中

如果类具有超类表达式,我们将对其进行评估。由于这可能会评估为其他类型的对象,因此我们必须在运行时检查我们要作为超类的事物实际上是一个类。如果我们允许类似的代码,就会发生不好的事情

var NotAClass = "I am totally not a class";

class Subclass < NotAClass {} // ?!

假设该检查通过,我们将继续执行。执行类声明将类的语法表示形式它的 AST 节点转换为它的运行时表示形式,即 LoxClass 对象。我们还需要将超类传送到那里。我们将超类传递给构造函数。

      methods.put(method.name.lexeme, function);
    }

lox/Interpreter.java
visitClassStmt() 中
替换 1 行
    LoxClass klass = new LoxClass(stmt.name.lexeme,
        (LoxClass)superclass, methods);

    environment.assign(stmt.name, klass);
lox/Interpreter.java,在visitClassStmt() 中,替换 1 行

构造函数将它存储在一个字段中。

lox/LoxClass.java
构造函数LoxClass()
替换 1 行
  LoxClass(String name, LoxClass superclass,
           Map<String, LoxFunction> methods) {
    this.superclass = superclass;
    this.name = name;
lox/LoxClass.java,构造函数LoxClass(),替换 1 行

我们在下面声明它

  final String name;
lox/LoxClass.java
在类LoxClass
  final LoxClass superclass;
  private final Map<String, LoxFunction> methods;
lox/LoxClass.java,在类LoxClass

有了它,我们可以定义作为其他类的子类的类。现在,拥有超类实际上有什么作用呢?

13 . 2继承方法

从另一个类继承意味着,超类中所有正确的事情或多或少也应该对子类正确。在静态类型语言中,这会带来很多含义。子也必须是子类型,并且内存布局受控制,以便你可以将子类的实例传递给期望超类的函数,并且它仍然可以正确地访问继承的字段。

Lox 是一种动态类型语言,因此我们的要求简单得多。基本上,这意味着,如果你可以在超类的实例上调用某些方法,那么当你给定子类的实例时,你应该能够调用该方法。换句话说,方法从超类继承。

这与继承的目标之一一致为用户提供一种在类之间重用代码的方法。在我们的解释器中实现这一点非常容易。

      return methods.get(name);
    }

lox/LoxClass.java
findMethod() 中
    if (superclass != null) {
      return superclass.findMethod(name);
    }

    return null;
lox/LoxClass.java,在findMethod() 中

这就是全部内容。当我们在实例上查找方法时,如果我们在实例的类中没有找到它,我们将递归地向上遍历超类链并在那里查找。试一试

class Doughnut {
  cook() {
    print "Fry until golden brown.";
  }
}

class BostonCream < Doughnut {}

BostonCream().cook();

就这样,我们的继承功能中的一半已经完成,只用了三行 Java 代码。

13 . 3调用超类方法

findMethod() 中,我们在向上遍历超类链之前查找当前类上的方法。如果子类和超类中都存在具有相同名称的方法,则子类方法会优先,或者覆盖超类方法。有点像内部作用域中的变量会遮蔽外部作用域中的变量一样。

如果子类想要完全替换一些超类行为,那就太好了。但在实践中,子类通常想要细化超类的行为。他们想要执行一些特定于子类的工作,但也要执行原始的超类行为。

但是,由于子类已经覆盖了该方法,因此无法引用原始方法。如果子类方法尝试按名称调用它,它只会递归地调用它自己的覆盖方法。我们需要一种方法来说“调用此方法,但在我的超类上直接查找它,并忽略我的覆盖”。Java 使用super来实现这一点,我们将在 Lox 中使用相同的语法。这是一个例子

class Doughnut {
  cook() {
    print "Fry until golden brown.";
  }
}

class BostonCream < Doughnut {
  cook() {
    super.cook();
    print "Pipe full of custard and coat with chocolate.";
  }
}

BostonCream().cook();

如果你运行它,它应该打印

Fry until golden brown.
Pipe full of custard and coat with chocolate.

我们有一种新的表达式形式。super关键字,后面跟着一个点和一个标识符,用于查找具有该名称的方法。与this上的调用不同,搜索从超类开始。

13 . 3 . 1语法

使用this关键字有点像一个神奇的变量,表达式就是那个单独的标记。但使用super,随后的.和属性名称是super表达式的不可分割的部分。你不能单独使用一个裸的super标记。

print super; // Syntax error.

因此,我们在语法规则中为primary规则添加的新子句也包含属性访问。

primary"true" | "false" | "nil" | "this"
               | NUMBER | STRING | IDENTIFIER | "(" expression ")"
               | "super" "." IDENTIFIER ;

通常,super表达式用于方法调用,但与普通方法一样,参数列表不是表达式的部分。相反,一个超类调用是一个超类访问,后面跟着一个函数调用。与其他方法调用一样,你可以获得对超类方法的句柄,并单独调用它。

var method = super.cook;
method();

因此,super表达式本身只包含super关键字的标记和正在查找的方法的名称。相应的语法树节点因此是

      "Set      : Expr object, Token name, Expr value",
tool/GenerateAst.java
main() 中
      "Super    : Token keyword, Token method",
      "This     : Token keyword",
tool/GenerateAst.java,在main()中

按照语法,新的解析代码位于我们现有的primary()方法中。

      return new Expr.Literal(previous().literal);
    }
lox/Parser.java
primary()中
    if (match(SUPER)) {
      Token keyword = previous();
      consume(DOT, "Expect '.' after 'super'.");
      Token method = consume(IDENTIFIER,
          "Expect superclass method name.");
      return new Expr.Super(keyword, method);
    }
    if (match(THIS)) return new Expr.This(previous());
lox/Parser.java,在primary()中

一个领先的super关键字告诉我们我们遇到了一个super表达式。在那之后,我们消费预期的.和方法名称。

13 . 3 . 2语义

早些时候,我说一个super表达式从“超类”开始方法查找,但哪个超类?天真的答案是this的超类,即调用周围方法的对象。这巧合地在很多情况下产生了正确的结果,但这并不完全正确。请看

class A {
  method() {
    print "A method";
  }
}

class B < A {
  method() {
    print "B method";
  }

  test() {
    super.method();
  }
}

class C < B {}

C().test();

将此程序翻译成Java、C#或C++,它将打印“A method”,这也是我们希望Lox做的。当此程序运行时,在test()的体内,this是C的一个实例。C的超类是B,但这不是查找应该开始的地方。如果这样做了,我们将遇到B的method()

相反,查找应该从包含super表达式的类的超类开始。在本例中,由于test()是在B中定义的,因此其中的super表达式应该从B的超类(A)开始查找。

The call chain flowing through the classes.

因此,为了评估super表达式,我们需要访问调用周围的类定义的超类。唉,在我们执行super表达式的解释器中的那个点,我们没有很容易地获得它。

我们可以在LoxFunction中添加一个字段来存储对拥有该方法的LoxClass的引用。解释器将保留对当前正在执行的LoxFunction的引用,以便我们可以在遇到super表达式时稍后查找它。从那里,我们将获得方法的LoxClass,然后是它的超类。

这有很多管道。在上一章中,当我们需要添加对this的支持时,我们遇到了类似的问题。在那种情况下,我们使用我们现有的环境和闭包机制来存储对当前对象的引用。我们能为存储超类做类似的事情吗??好吧,如果答案是“否”,我可能不会谈论它,所以 . . . 是的。

一个重要的区别是我们访问方法时绑定了this。相同的方法可以被调用不同的实例,每个实例都需要自己的this。对于super表达式,超类是类声明本身的固定属性。每次你评估某个super表达式时,超类总是相同的。

这意味着我们可以在类定义执行时创建超类环境一次。在我们定义方法之前,我们创建一个新的环境将类的超类绑定到名称super

The superclass environment.

当我们为每个方法创建LoxFunction运行时表示时,这就是它们将在其闭包中捕获的环境。稍后,当方法被调用并且this被绑定时,超类环境成为方法环境的父级,如下所示

The environment chain including the superclass environment.

这有很多机制,但我们会一步一步地完成它。在我们能够在运行时创建环境之前,我们需要在解析器中处理相应的范围链。

      resolve(stmt.superclass);
    }
lox/Resolver.java
visitClassStmt() 中
    if (stmt.superclass != null) {
      beginScope();
      scopes.peek().put("super", true);
    }
    beginScope();
lox/Resolver.java,在visitClassStmt() 中

如果类声明有一个超类,那么我们创建一个新的范围围绕所有方法。在这个范围内,我们定义了名称“super”。完成解析类的所有方法后,我们丢弃该范围。

    endScope();

lox/Resolver.java
visitClassStmt() 中
    if (stmt.superclass != null) endScope();

    currentClass = enclosingClass;
lox/Resolver.java,在visitClassStmt() 中

这是一个小的优化,但我们只在类确实有一个超类的情况下创建超类环境。当没有超类时,创建它毫无意义,因为根本没有超类可以存储在其中。

有了范围链中定义的“super”,我们能够解析super表达式本身。

lox/Resolver.java
visitSetExpr()之后添加
  @Override
  public Void visitSuperExpr(Expr.Super expr) {
    resolveLocal(expr, expr.keyword);
    return null;
  }
lox/Resolver.java,在visitSetExpr()之后添加

我们解析super标记就像它是一个变量一样。解析存储了沿着环境链需要走多少步才能找到存储超类的环境。

这段代码在解释器中是镜像的。当我们评估一个子类定义时,我们创建一个新的环境。

        throw new RuntimeError(stmt.superclass.name,
            "Superclass must be a class.");
      }
    }

    environment.define(stmt.name.lexeme, null);
lox/Interpreter.java
visitClassStmt() 中
    if (stmt.superclass != null) {
      environment = new Environment(environment);
      environment.define("super", superclass);
    }
    Map<String, LoxFunction> methods = new HashMap<>();
lox/Interpreter.java,在visitClassStmt() 中

在这个环境中,我们存储对超类的引用(超类的实际LoxClass对象,现在我们已经进入运行时了)。然后我们为每个方法创建LoxFunctions。它们将捕获当前环境(我们刚刚绑定“super”的环境)作为它们的闭包,像我们需要的这样保留超类。完成后,我们弹出环境。

    LoxClass klass = new LoxClass(stmt.name.lexeme,
        (LoxClass)superclass, methods);
lox/Interpreter.java
visitClassStmt() 中
    if (superclass != null) {
      environment = environment.enclosing;
    }
    environment.assign(stmt.name, klass);
lox/Interpreter.java,在visitClassStmt() 中

我们准备解释super表达式本身。有一些移动部件,所以我们将分段构建此方法。

lox/Interpreter.java
visitSetExpr()之后添加
  @Override
  public Object visitSuperExpr(Expr.Super expr) {
    int distance = locals.get(expr);
    LoxClass superclass = (LoxClass)environment.getAt(
        distance, "super");
  }
lox/Interpreter.java,在visitSetExpr()之后添加

首先,我们一直在进行的工作。我们通过在适当的环境中查找“super”来查找周围类的超类。

当我们访问方法时,我们还需要将this绑定到访问方法的对象。在像doughnut.cook这样的表达式中,对象是通过评估doughnut获得的任何东西。在像super.cook这样的super表达式中,当前对象隐式地是相同的我们正在使用的当前对象。换句话说,是this。即使我们在超类上查找方法实例仍然是this

不幸的是,在super表达式中,我们没有一个方便的节点供解析器挂载到this的跳跃次数。幸运的是,我们确实控制着环境链的布局。绑定“this”的环境总是在存储“super”的环境中。

    LoxClass superclass = (LoxClass)environment.getAt(
        distance, "super");
lox/Interpreter.java
visitSuperExpr()中
    LoxInstance object = (LoxInstance)environment.getAt(
        distance - 1, "this");
  }
lox/Interpreter.java,在visitSuperExpr()中

将距离偏移一个看起来在内部环境中查找“this”。我承认这不是最优雅的代码,但它有效。

现在我们准备从超类开始查找和绑定方法。

    LoxInstance object = (LoxInstance)environment.getAt(
        distance - 1, "this");
lox/Interpreter.java
visitSuperExpr()中
    LoxFunction method = superclass.findMethod(expr.method.lexeme);
    return method.bind(object);
  }
lox/Interpreter.java,在visitSuperExpr()中

这几乎与查找get表达式的method代码完全相同,只是我们在超类而不是当前对象的类上调用findMethod()

基本上就是这样。当然,除了我们可能无法找到该方法之外。所以我们也检查了这一点。

    LoxFunction method = superclass.findMethod(expr.method.lexeme);
lox/Interpreter.java
visitSuperExpr()中
    if (method == null) {
      throw new RuntimeError(expr.method,
          "Undefined property '" + expr.method.lexeme + "'.");
    }

    return method.bind(object);
  }
lox/Interpreter.java,在visitSuperExpr()中

有了它!拿走早些时候的BostonCream示例,试一试。假设你我一切都做对了,它应该先煎一下,然后填上奶油。

13 . 3 . 3super的无效使用

与之前的语言特性一样,我们的实现当用户编写正确的代码时会做正确的事,但我们还没有让解释器防范不良代码。特别是,请考虑

class Eclair {
  cook() {
    super.cook();
    print "Pipe full of crème pâtissière.";
  }
}

此类有一个super表达式,但没有超类。在运行时,评估super表达式的代码假设“super”已成功解析并将被找到在环境中。这将在这里失败,因为由于没有超类,所以没有超类的周围环境。JVM将抛出异常并让我们的解释器瘫痪。

heck,甚至还有更简单的错误使用super

super.notEvenInAClass();

我们可以在运行时通过检查“super”的查找是否成功来处理这些错误。但我们可以静态地(仅通过查看源代码)判断Eclair没有超类,因此其中的super表达式将无法工作。同样,在第二个例子中,我们知道super表达式甚至不在方法体中。

尽管Lox是动态类型的,但这并不意味着我们希望将所有内容都推迟到运行时。如果用户犯了错误,我们希望尽早帮助他们找到它。因此,我们将在解析器中静态地报告这些错误。

首先,我们在枚举中添加一个新情况,用于跟踪正在访问的当前代码周围的类类型。

    NONE,
    CLASS,
lox/Resolver.java
在枚举ClassType
在上一行添加“,”
    SUBCLASS
  }
lox/Resolver.java,在枚举ClassType中,在上一行添加“,”

我们将使用它来区分我们是在当前正在访问的代码周围的类内,还是不在类内。当我们解析类声明时,如果类是子类,我们将设置它。

    if (stmt.superclass != null) {
lox/Resolver.java
visitClassStmt() 中
      currentClass = ClassType.SUBCLASS;
      resolve(stmt.superclass);
lox/Resolver.java,在visitClassStmt() 中

然后,当我们解析super表达式时,我们会检查我们当前是否在一个允许这样做的地方。

  public Void visitSuperExpr(Expr.Super expr) {
lox/Resolver.java
visitSuperExpr()中
    if (currentClass == ClassType.NONE) {
      Lox.error(expr.keyword,
          "Can't use 'super' outside of a class.");
    } else if (currentClass != ClassType.SUBCLASS) {
      Lox.error(expr.keyword,
          "Can't use 'super' in a class with no superclass.");
    }

    resolveLocal(expr, expr.keyword);
lox/Resolver.java,在visitSuperExpr()中

如果没有(糟糕!),用户犯了一个错误。

13 . 4结论

我们成功了!最后的错误处理部分是完成Lox的Java实现所需的最后一段代码。这是一个真正的成就,你应该为此感到自豪。在过去的十几个章节和一千多行代码中,我们学习并实现了 . . . 

我们从头开始做到了这一切,没有任何外部依赖或神奇工具。只有你和我的文本编辑器,Java标准库中的几个集合类,以及JVM运行时。

这标志着第二部分的结束,但不是本书的结束。休息一下。也许编写一些有趣的Lox程序并在你的解释器中运行它们。(你可能想添加一些本地方法,比如读取用户输入。)当你精神焕发并准备好了,我们将踏上我们的下一个冒险

挑战

  1. Lox只支持单继承(一个类可以有一个超类,这是跨类重用方法的唯一方法)。其他语言探索了各种方法,以便更自由地跨类重用和共享功能:mixin、trait、多重继承、虚拟继承、扩展方法等。

    如果你要将类似的功能添加到Lox中,你会选择哪一个,为什么?如果你感觉勇敢(在这一点上你应该感觉勇敢),那就去添加它吧。

  2. 在 Lox 中,与大多数其他面向对象语言一样,在查找方法时,我们从类层次结构的底部开始向上查找——子类的优先于超类的。为了从覆盖方法中调用超类方法,可以使用 `super`。

    语言 BETA 采取了 相反的方法。当您调用一个方法时,它从类层次结构的顶部开始,然后向下查找。超类方法优先于子类方法。为了调用子类方法,超类方法可以调用 `inner`,这有点像 `super` 的反面。它会沿着层次结构向下链接到下一个方法。

    超类方法控制何时何地允许子类细化其行为。如果超类方法根本没有调用 `inner`,那么子类就无法覆盖或修改超类行为。

    移除 Lox 当前的覆盖和 `super` 行为,并将其替换为 BETA 的语义。简而言之

    • 在调用类的方法时,优先使用类继承链中最高的方法。

    • 在方法体内部,对 `inner` 的调用会在包含 `inner` 的类和 `this` 的类之间,沿着继承链寻找具有相同名称的最近子类方法。如果没有匹配的方法,`inner` 调用将不执行任何操作。

    例如

    class Doughnut {
      cook() {
        print "Fry until golden brown.";
        inner();
        print "Place in a nice box.";
      }
    }
    
    class BostonCream < Doughnut {
      cook() {
        print "Pipe full of custard and coat with chocolate.";
      }
    }
    
    BostonCream().cook();
    

    这应该打印

    Fry until golden brown.
    Pipe full of custard and coat with chocolate.
    Place in a nice box.
    
  3. 在我介绍 Lox 的章节中,我向您挑战提出一些您认为该语言缺少的功能。现在您已经知道如何构建解释器了,请实现其中一项功能。