【译】精通 JavaScript: 类继承和原型继承的区别?

Electric Guitar

原文:Master the JavaScript Interview: What’s the Difference Between Class & Prototypal Inheritance?

“精通 JavaScript 面试” 是一个系列的文章,旨在帮助面试者准备他们在申请中高级职位时可能遇到的常见问题。这些是我在现实面试中经常提出的问题。如果你想从头开始,可以看 “What is a Closure?” 开始。

注意:这篇文章的例子使用的是 ES6。如果你不知从何开始, 可以参阅 “How to Learn ES6”

与其他大多数语言不同的是, JavaScript 的对象系统是基于 原型(prototypes) , 而不是 类(classes) 。不幸的是,大多数 JavaScript 开发人员都不能深入了解 JavaScript 的对象系统,或者不能充分利用它。其他人确实理解,但是希望它表现得更像基于类继承。导致 JavaScript 中的对象系统十分混乱,这意味着 JavaScript 开发者对 原型(prototypes)和类(classes) 都要了解。

类继承和原型继承的区别是什么?

这可能是一个棘手的问题,你可能需要后续的问答来完善这个答案,因此要特别注意了解它们的差异,以及如何利用这些知识来写出更好的代码。

类继承:类相当于蓝图–对将要创建的对象的描述。

实例通常是使用构建函数和 new 关键字来创建的。ES6 中新增了 class 关键字可以使用。从技术上来说,像 Java 中的类这个概念在 JavaScript 中是不存在的。而是使用构造函数。ES6 中的 class 关键字就是构造函数的语法糖:

1
2
class Foo {}
typeof Foo // 'function'

在 JavaScript 中,类继承的实现建立在原型继承之上,但这并不意味着它们做了同样的事情:

JavaScript 类继承是使用原型链链接子 Constructor.prototype 和父 Constructor.prototype 的委托关系。通常,super()构造器也会被调用。这种机制,形成了单一继承结构,并且创建了面向对象设计的最紧密耦合行为

“Classes inherit from classes and create subclass relationships: hierarchical class taxonomies.”

Prototypal Inheritance: A prototype is a working object instance. Objects inherit directly from other objects.

原型继承模式下,对象实例可以由多个对象源组成,这样使继承更灵活且 [[Prototype]] 委托继承层次浅。换句话说,基于原型继承的面向对象设计不会产生层级分类这样的副作用–这是决定性的区别。

JavaScript 中的实例通常是通过构造函数,对象字面量或 Object.create() 来创建。

“A prototype is a working object instance. Objects inherit directly from other objects.”

为什么这个问题很重要?

继承是代码重用机制的根本:不同对象分享代码的方式。分享代码的方式的重要性是因为如果你弄错了,会产生很多问题,特别是:

类继承产生的 parent/child 对象分类是一个副作用。

这些分类几乎不可能适用于所有的新实例,并且基类的广泛使用导致了脆弱的基类问题,这导致了修复 bug 的难度。事实上,类继承在面向对象设计中引起了许多众所周知的问题:

  • 紧耦合问题 (类继承是面向对象设计中耦合度最高的),这导致了下一个问题

  • 脆弱基类问题

  • 不灵活的层级问题 (新实例最终会导致所有的类都是错误的)

  • 必要的重复问题 (由于层次机构不灵活,新的实例通常是通过复制而不是调整现有代码来实现)

  • 猩猩/香蕉问题 (你需要的是一个香蕉,但是得到的是一个拿着香蕉的猩猩以及整个丛林)

我在一个演讲中深入讨论过这其中的一些问题:“Classical Inheritance is Obsolete: How to Think in Prototypal OO”:

解决所有问题的方法是选择对象组合而不是类继承。

“Favor object composition over class inheritance.”
~ The Gang of Four, “Design Patterns: Elements of Reusable Object Oriented Software”

总结:

是不是所有的继承都有问题?

人们说“优先选择对象组合而不是继承”的时候,其实是要表达“优先选择对象组合而不是类继承”(引用自 “Design Patterns” 的原文)。这是面向对象设计的常识。因为类继承有许多缺陷并会导致许多问题。

当人们谈论类继承时,人们通常会忽略 class 这个单词,这看起来好像所有的继承都有问题–但事实并非如此。

继承是有不同种类的,并且大部分的优秀的。

原型继承的三种方式

在我们深入研究其他类型的继承之前,让我们仔细观察一下类继承的含义:

BassAmp 继承于 GuitarAmpChannelStrip 继承于 BassAmpGuitarAmp。这是面向对象设计的一个错误示范。channel strip 并不是 guitar amp 的一种,而且它根本不需要 cabinet 这个属性。一个比较好的方法是创建一个新的基类给 amps 和 channel strip 继承,但是这种方法依然有局限。

最终,新的基类策略也会失效。

更好的方法是,可以使用对象组合继承你真正需要的东西:

认真看这段代码,你就会发现:通过对象组合,我们可以确切地保证对象可以按需继承。这和类继承不同。当你继承于一个类,你会继承所有的属性,即使是你不需要的。

在这一点上,你可能会问自己,“这很好,但是原型在哪里呢?”

为了理解这一点,你必须了解有三种不同的基于原型的面向对象设计。

拼接继承 (Concatenative inheritance):通过复制源对象的属性直接一个对象继承另一个对象的过程。在 JavaScript 中,源对象的属性通常被称作 mixins。从 ES6 开始,JavaScript 使用 Object.assign() 来实现这个过程。在 ES6 之前,通常使用 Underscore/Lodash.extend()jQuery$.extend() 等来实现。上面的对象组合的例子使用了连接继承。

原型委托 (Prototype delegation):在 JavaScript 中,对象有自己委托的原型,这个原型也有自己委托的原型,以此类推一直到 Object.prototype 作为根原型,这样就形成了一个原型链。如果一个在对象中找不到的属性,可以沿着原型链一直查找。当你使用 new 创建实例以及 Constructor.prototype 连接到这个实例形成链接。你也可以使用 Object.create() 来实现,或者与拼接混用,从而可以把多个原型简化为单一委托,或者在对象创建后进行扩展。

函数继承 (Functional inheritance):在 JavaScript 中,任何函数都可以创建对象。如果这个函数既不是构造函数也不是 class,那就是工厂函数 (factory function)。函数继承的原理是通过工厂函数创建对象并通过直接赋予属性(使用连接继承)。Douglas Crockford 创造了这个术语,但在 JavaScript 中已经广泛使用了。

现在你会意识到,拼接继承是在 JavaScript 中实现对象组合的秘诀,这使得原型委托和函数继承更加有趣。

大多数人提到 JavaScript 的面向对象设计时,首先想到的是原型委托。现在你会发现他们错过了很多。原型委托不是类继承的最佳替换,对象组合才是。

为什么对象组合能改避免脆弱基类问题

要搞清脆弱基类这个问题,首先你要理解这个问题是如何形成的:

  1. A 是基类
  2. B 继承于 A
  3. C 继承于 B
  4. D 继承于 B

C 调用 super, 会执行 B 中的代码,B 调用 super, 会执行 A 中的代码。

AB 中包含了 CD 不需要的特性。 D 是一个新实例, CD 在 A 初始化的代码有少许不同。所以萌新开发者会去调整 A 的初始化代码。由于依赖于之前 A 中的代码被修改了,尽管 D 正常工作了,但是 C 被破坏了。

我们有不同的方式可以从 AB 中得到 CD 需要的属性。CD 并不需要 AB 的所有特性。它们只想继承一些已经定义在 AB 中的属性。但是通过 super 来实现继承,这不是选择性的继承而是继承所有的属性。

“…the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.” ~ Joe Armstrong — “Coders at Work”

使用组合 (Composition)

想象下你拥有的是特性 (features) 而不是类 (classes):

1
feat1, feat2, feat3, feat4

C 需要 feat1feat3D 需要 feat1feat2feat4

1
2
const C = compose(feat1, feat3)
const D = compose(feat1, feat2, feat4)

现在你发现,D 需要 feat1 的行为有些许不同。这并不需要改变 feat1,而是创造一个自定义的 feat1 并且使用它。不需要改变 feat2feat4

1
const D = compose(custom1, feat2, feat4)

C 不会受到影响。

类继承无法实现这一点的原因是,当你使用类继承时,你得到的是类这个整体。

如果你为了适配新的实例,要么复制现有类的一部分(必然性重复问题),要么重构依赖于现有类的所有内容使得修改有的类适配于新的实例,这会导致脆弱基类问题。

对象组合可以避免这两个问题。

你自认为了解原型,但是…

如果你的所学是,构建类或者构造函数而不是原型继承。那么你学到的是使用原型来模仿类继承。了解更多 – Common Misconceptions About Inheritance in JavaScript

在 JavaScript 中,长久以来类继承寄生在非常丰富灵活的原生的原型继承之上, ES6 以来的 class 也是一样,当你使用类继承,原型继承的强大能力和灵活性都不能得到应用。事实上,你正在把自己圈在角落并且选择所有的类继承和它所带来的问题。

Using class inheritance in JavaScript is like driving your new Tesla Model S to the dealer and trading it in for a rusted out 1983 Ford Pinto.

Stamps:可组合的工厂函数

大多数情况下,通过多个工厂函数实现对象组合:工厂函数是用来创建对象的。如果工厂函数也可以组合呢?它被称作 (Stamps) – The Stamp Specification

探索 ‘Master the JavaScript Interview’ 系列