第四章: 混合(淆)“类”的对象 - 类继承

优质
小牛编辑
126浏览
2023-12-01

类继承

在面向类的语言中,你不仅可以定义一个能够初始化它自己的类,你还可以定义另外一个类 继承 自第一个类。

这第二个类通常被称为“子类”,而第一个类被称为“父类”。这些名词显然来自于亲子关系的比拟,虽然这种比拟有些扭曲,就像你马上要看到的。

当一个家长拥有一个和他有血缘关系的孩子时,家长的遗传性质会被拷贝到孩子身上。明显地,在大多数生物繁殖系统中,双亲都平等地贡献基因进行混合。但是为了这个比拟的目的,我们假设只有一个亲人。

一旦孩子出现,他或她就从亲人那里分离出来。这个孩子受其亲人的继承因素的严重影响,但是独一无二。如果这个孩子拥有红色的头发,这并不意味这他的亲人的头发 曾经 是红色,或者会自动 变成 红色。

以相似的方式,一旦一个子类被定义,它就分离且区别于父类。子类含有一份从父类那里得来的行为的初始拷贝,但它可以覆盖这些继承的行为,甚至是定义新行为。

重要的是,要记住我们是在讨论父 和子 ,而不是物理上的东西。这就是这个亲子比拟让人糊涂的地方,因为我们实际上应当说父类就是亲人的 DNA,而子类就是孩子的 DNA。我们不得不从两套 DNA 制造出(也就是“初始化”)人,用得到的物理上存在的人来与之进行谈话。

让我们把生物学上的亲子放在一边,通过一个稍稍不同的角度来看看继承:不同种类型的载具。这是用来理解继承的最经典(也是争议不断的)的比拟。

让我们重新审视本章前面的 VehicleCar 的讨论。考虑下面表达继承的类的假想代码:

  1. class Vehicle {
  2. engines = 1
  3. ignition() {
  4. output( "Turning on my engine." )
  5. }
  6. drive() {
  7. ignition()
  8. output( "Steering and moving forward!" )
  9. }
  10. }
  11. class Car inherits Vehicle {
  12. wheels = 4
  13. drive() {
  14. inherited:drive()
  15. output( "Rolling on all ", wheels, " wheels!" )
  16. }
  17. }
  18. class SpeedBoat inherits Vehicle {
  19. engines = 2
  20. ignition() {
  21. output( "Turning on my ", engines, " engines." )
  22. }
  23. pilot() {
  24. inherited:drive()
  25. output( "Speeding through the water with ease!" )
  26. }
  27. }

注意: 为了简洁明了,这些类的构造器被省略了。

我们定义 Vehicle 类,假定它有一个引擎,有一个打开打火器的方法,和一个行驶的方法。但你永远也不会制造一个泛化的“载具”,所以在这里它只是一个概念的抽象。

然后我们定义了两种具体的载具:CarSpeedBoat。它们都继承 Vehicle 的泛化性质,但之后它们都对这些性质进行了恰当的特化。一辆车有4个轮子,一艘快艇有两个引擎,意味着它需要在打火时需要特别注意要启动两个引擎。

多态(Polymorphism)

Car 定义了自己的 drive() 方法,它覆盖了从 Vehicle 继承来的同名方法。但是,Cardrive() 方法调用了 inherited:drive(),这表示 Car 可以引用它继承的,覆盖之前的原版 drive()SpeedBoatpilot() 方法也引用了它继承的 drive() 拷贝。

这种技术称为“多态(polymorphism)”,或“虚拟多态(virtual polymorphism)”。对我们当前的情况更具体一些,我们称之为“相对多态(relative polymorphism)”。

多态这个话题比我们可以在这里谈到的内容要宽泛的多,但我们当前的“相对”意味着一个特殊层面:任何方法都可以引用位于继承层级上更高一层的其他(同名或不同名的)方法。我们说“相对”,因为我们不绝对定义我们想访问继承的哪一层(也就是类),而实质上用“向上一层”来相对地引用。

在许多语言中,在这个例子中出现 inherited: 的地方使用了 super 关键字,它基于这样的想法:一个“超类(super class)”是当前类的父亲/祖先。

多态的另一个方面是,一个方法名可以在继承链的不同层级上有多种定义,而且在解析哪个方法在被调用时,这些定义可以适当地被自动选择。

在我们上面的例子中,我们看到这种行为发生了两次:drive()VehicleCar 中定义, 而 ignition()VehicleSpeedBoat 中定义。

注意: 另一个传统面向类语言通过 super 给你的能力,是从子类的构造器中直接访问父类构造器。这很大程度上是对的,因为对真正的类来说,构造器属于这个类。然而在 JS 中,这是相反的 —— 实际上认为“类”属于构造器(Foo.prototype... 类型引用)更恰当。因为在 JS 中,父子关系仅存在于它们各自的构造器的两个.prototype 对象间,构造器本身不直接关联,而且没有简单的方法从一个中相对引用另一个(参见附录A,看看 ES6 中用 super “解决”此问题的 class)。

可以从 ignition() 中具体看出多态的一个有趣的含义。在 pilot() 内部,一个相对多态引用指向了(被继承的)Vehicle 版本的 drive()。而这个 drive() 仅仅通过名称(不是相对引用)来引用 ignition() 方法。

语言的引擎会使用哪一个版本的 ignition()?是 Vehicle 的还是 SpeedBoat 的?它会使用 SpeedBoat 版本的 ignition() 如果你 初始化 Vehicle 类自身,并且调用它的 drive(),那么语言引擎将会使用 Vehicleignition() 定义。

换句话说,ignition() 方法的定义,根据你引用的实例是哪个类(继承层级)而 多态(改变)。

这看起来过于深入学术细节了。不过为了好好地与 JavaScript 的 [[Prototype]] 机制的类似行为进行对比,理解这些细节还是很重要的。

如果类是继承而来的,对这些类本身(不是由它们创建的对象!)有一个方法可以 相对地 引用它们继承的对象,这个相对引用通常称为 super

记得刚才这幅图:

类继承 - 图1

注意对于实例化(a1a2b1、和 b2) 继承(Bar),箭头如何表示拷贝操作。

从概念上讲,看起来子类 Bar 可以使用相对多态引用(也就是 super)来访问它的父类 Foo 的行为。然而在现实中,子类不过是被给与了一份它从父类继承来的行为的拷贝而已。如果子类“覆盖”一个它继承的方法,原版的方法和覆盖版的方法实际上都是存在的,所以它们都是可以访问的。

不要让多态把你搞糊涂,使你认为子类是链接到父类上的。子类得到一份它需要从父类继承的东西的拷贝。类继承意味着拷贝。

多重继承(Multiple Inheritance)

能回想起我们早先提到的亲子和 DNA 吗?我们说过这个比拟有些奇怪,因为生物学上大多数后代来自于双亲。如果类可以继承自其他两个类,那么这个亲子比拟会更合适一些。

有些面向类的语言允许你指定一个以上的“父类”来进行“继承”。多重继承意味着每个父类的定义都被拷贝到子类中。

表面上看来,这是对面向类的一个强大的加成,给我们能力去将更多功能组合在一起。然而,这无疑会产生一些复杂的问题。如果两个父类都提供了名为 drive() 的方法,在子类中的 drive() 引用将会解析为哪个版本?你会总是不得不手动指明哪个父类的 drive() 是你想要的,从而失去一些多态继承的优雅之处吗?

还有另外一个所谓的“钻石问题”:子类“D”继承自两个父类(“B”和“C”),它们两个又继承自共通的父类“A”。如果“A”提供了方法 drive(),而“B”和“C”都覆盖(多态地)了这个方法,那么当“D”引用 drive() 时,它应当使用那个版本呢(B:drive() 还是 C:drive())?

类继承 - 图2

事情会比我们这样窥豹一斑能看到的复杂得多。我们在这里将它们提出来,只是便于我们可以将它和 JavaScript 机制的工作方式比较。

JavaScript 更简单:它不为“多重继承”提供原生机制。许多人认为这是好事,因为省去的复杂性要比“减少”的功能多得多。但是这并不能阻挡开发者们用各种方法来模拟它,我们接下来就看看。