原型继承相较于经典继承的优势是什么?

296

所以,这些年来我最终停止了拖延,并决定“认真地”学习JavaScript。该语言设计中最令人困惑的元素之一是其继承的实现方式。虽然我有Ruby的经验,很高兴看到闭包和动态类型,但我却无法弄清楚使用其他实例进行继承的对象实例有什么好处。

5个回答

615

注意:我在10年前写下这个答案时,认为继承是有害的。在软件开发中没有任何好理由使用继承。你可以用组合来实现继承的所有功能,并且做得更好。考虑使用代数数据类型


我知道这个答案晚了3年,但我真的认为当前的答案没有提供足够有关原型继承如何比经典继承更好的信息。
首先让我们看看JavaScript程序员在支持原型继承方面最常用的论点(我从当前的答案池中获取这些论点):
1.它很简单。 2.它很强大。 3.它可以导致更小、更少冗余的代码。 4.它是动态的,因此对于动态语言来说更好。
现在这些论点都是有效的,但没有人费心解释为什么。这就像告诉一个孩子学习数学很重要。当然很重要,但孩子肯定不在乎;而你不能通过说它很重要来让孩子喜欢数学。
我认为原型继承的问题在于它是从JavaScript的角度解释的。我喜欢JavaScript,但是JavaScript中的原型继承是错误的。与经典继承不同,原型继承有两种模式:
1.原型继承的原型模式。 2.原型继承的构造函数模式。
很不幸,JavaScript使用了原型继承的构造函数模式。这是因为在创造JavaScript时,Brendan Eich(JS的创建者)想让它看起来像Java(具有经典继承):

当时我们把它推销成Java的小弟弟,就像Visual Basic在微软语言家族中是C++的补充语言一样。

这很糟糕,因为当人们在JavaScript中使用构造函数时,他们认为构造函数从其他构造函数继承。这是错误的。在原型继承中,对象从其他对象继承。构造函数从未涉及其中。这是大多数人感到困惑的地方。
像Java这样具有经典继承的语言的人们更加困惑,因为尽管构造函数看起来像类,但它们的行为并不像类。正如Douglas Crockford所说:
这种间接性旨在使语言对古典训练有素的程序员更加熟悉,但却未能达到预期效果。正如我们可以从 Java 程序员对 JavaScript 的非常低的评价中看到的那样。JavaScript 的构造函数模式并没有吸引古典派的程序员。它也掩盖了 JavaScript 的真正原型本质。结果,很少有程序员知道如何有效地使用该语言。
就是这样。话说得明白清楚。
真正的原型继承
原型继承与对象有关。对象从其他对象继承属性。就是这样简单。有两种使用原型继承创建对象的方法:
1. 创建一个全新的对象。 2. 克隆现有的对象并扩展它。
注意:JavaScript 提供了两种克隆对象的方式——委托和串联。从现在开始,我将使用“克隆”一词专门指代通过委托实现继承,而使用“复制”一词专门指代通过串联实现继承。
够了废话。让我们来看些例子。假设我有一个半径为5的圆:
var circle = {
    radius: 5
};

我们可以通过半径计算圆的面积和周长:
circle.area = function () {
    var radius = this.radius;
    return Math.PI * radius * radius;
};

circle.circumference = function () {
    return 2 * Math.PI * this.radius;
};

现在我想创建另一个半径为10的圆。一种方法是:
var circle2 = {
    radius: 10,
    area: circle.area,
    circumference: circle.circumference
};

然而,JavaScript 提供了一种更好的方式 - 委托。使用 Object.create 函数实现:

var circle2 = Object.create(circle);
circle2.radius = 10;

这就是全部内容。你刚刚在JavaScript中完成了原型继承。很简单吧?你可以拿一个对象,克隆它,进行必要的更改,然后哇啦——你得到了一个全新的对象。
现在你可能会问:“这真的简单吗?每次我想创建一个新圆形时,都需要克隆 circle 并手动分配半径。”好的解决方案是使用一个函数来为你完成繁重的工作:
function createCircle(radius) {
    var newCircle = Object.create(circle);
    newCircle.radius = radius;
    return newCircle;
}

var circle2 = createCircle(10);

事实上,您可以将所有内容组合成一个单独的对象文字,如下所示:
var circle = {
    radius: 5,
    create: function (radius) {
        var circle = Object.create(this);
        circle.radius = radius;
        return circle;
    },
    area: function () {
        var radius = this.radius;
        return Math.PI * radius * radius;
    },
    circumference: function () {
        return 2 * Math.PI * this.radius;
    }
};

var circle2 = circle.create(10);

JavaScript中的原型继承

如果您注意上面的程序,create函数创建了一个circle的克隆,并为其分配了新的radius,然后返回它。这正是JavaScript中构造函数所做的事情:

function Circle(radius) {
    this.radius = radius;
}

Circle.prototype.area = function () {
    var radius = this.radius;
    return Math.PI * radius * radius;
};

Circle.prototype.circumference = function () {         
    return 2 * Math.PI * this.radius;
};

var circle = new Circle(5);
var circle2 = new Circle(10);

JavaScript中的构造函数模式是原型模式的倒置。不创建对象,而是创建构造函数。使用“new”关键字将构造函数内的“this”指针绑定到构造函数原型的克隆体上。
听起来很混乱?这是因为JavaScript中的构造函数模式不必要地使事情复杂化。这就是大多数程序员难以理解的原因。
他们不是想象对象从其他对象继承,而是认为构造函数从其他构造函数继承,然后变得非常困惑。
那么原型继承相较于经典继承的优势是什么?让我们再次梳理最常见的论点,并解释其原因。
1. 原型继承更为简单 CMS 在他的回答中指出:
“我认为原型继承的主要优势在于其简单性。”
让我们考虑一下刚刚做过的事情。我们创建了一个半径为 5 的对象 circle。然后,我们克隆它并将克隆体的半径设置为 10。
因此,使原型继承起作用只需要两件事:
1. 创建新对象的方法(例如对象字面量)。 2. 扩展现有对象的方法(例如 Object.create)。
相比之下,经典继承要复杂得多。在经典继承中,您需要:
  1. 类。
  2. 对象。
  3. 接口。
  4. 抽象类。
  5. 最终类。
  6. 虚拟基类。
  7. 构造函数。
  8. 析构函数。

你有一个想法。原型继承更易于理解、实现和推理。

正如史蒂夫·耶吉在他的经典博客文章“ N00b的画像”中所说:

元数据是对其他内容的任何一种描述或模型。代码中的注释只是计算的自然语言描述。使元数据成为元数据的是它不是严格必需的。如果我有一只有族谱文件的狗,而我失去了文件,我仍然有一只完全有效的狗。

同样地,类只是元数据。类并非继承所必需的。然而,有些人(通常是新手)发现使用类更舒适。这给了他们一种虚假的安全感。

我们也知道静态类型只是元数据,它们是专门针对两类读者的一种特殊注释:程序员和编译器。静态类型讲述了计算的故事,以帮助这两个读者群体理解程序的意图。但是在运行时可以丢弃静态类型,因为它们最终只是样式化的注释。它们就像家谱文件:它可能会让某些不安全的人格类型对他们的狗更满意,但狗肯定不在乎。
正如我之前所说,类给人一种虚假的安全感。例如,即使您的代码非常易读,Java 中仍然会出现太多 NullPointerException。我发现经典继承通常会妨碍编程,但这可能只是 Java 的问题。Python 有一个很棒的经典继承系统。
2. 原型继承非常强大
来自经典背景的大多数程序员认为经典继承比原型继承更强大,因为它具有:
1. 私有变量。 2. 多重继承。
这个说法是错误的。我们已经知道JavaScript支持通过闭包实现私有变量,但多重继承呢?JavaScript中的对象只有一个原型。
事实上,原型继承支持从多个原型继承。原型继承只是指一个对象继承另一个对象。实际上有两种实现原型继承的方式:
1.委托或差异继承 2.克隆或串联继承
是的,JavaScript只允许对象委托给另一个对象。然而,它允许您复制任意数量的对象的属性。例如,_.extend就是这样做的。
当然,许多程序员不认为这是真正的继承,因为instanceofisPrototypeOf说另外一种情况。但是,通过将原型的数组存储在每个通过串联继承自原型的对象上,可以很容易地解决这个问题。
function copyOf(object, prototype) {
    var prototypes = object.prototypes;
    var prototypeOf = Object.isPrototypeOf;
    return prototypes.indexOf(prototype) >= 0 ||
        prototypes.some(prototypeOf, prototype);
}

因此,原型继承和经典继承一样强大。实际上,它比经典继承更加强大,因为在原型继承中,您可以手动选择要从不同原型复制的属性以及要省略的属性。
在经典继承中,选择要继承哪些属性是不可能的(或者至少非常困难)。他们使用虚拟基类和接口来解决菱形问题
然而,在JavaScript中,您很可能永远不会听说过菱形问题,因为您可以完全控制要从哪些原型继承哪些属性。
3. 原型继承更少冗余
这一点有些难以解释,因为经典继承并不一定导致代码更冗余。事实上,无论是经典继承还是原型继承,都用于减少代码的冗余。
一个论点是,大多数具有经典继承的编程语言是静态类型的,并要求用户显式声明类型(与具有隐式静态类型的Haskell不同)。因此,这导致了更冗长的代码。

Java因此行为而臭名昭著。我清楚地记得Bob Nystrom在他关于Pratt Parsers的博客文章中提到了以下轶事:

你得喜欢Java的“请用四份副本签字”的官僚主义水平。

再次说明,我认为这只是因为Java太糟糕了。

一个有效的论点是,并非所有支持经典继承的语言都支持多重继承。Java再次出现在脑海中。是的,Java有接口,但这并不足够。有时你真的需要多重继承。

由于原型继承允许多重继承,如果使用原型继承编写需要多重继承的代码,那么比使用具有经典继承但没有多重继承的语言更少冗余。

4. 原型继承是动态的

Prototypal inheritance的最重要优点之一是可以在创建原型后向其添加新属性。这允许您向原型添加新方法,这些方法将自动提供给委托给该原型的所有对象。
在经典继承中不可能做到这一点,因为一旦创建了一个类,就无法在运行时修改它。这可能是原型继承相对于经典继承的最大优势,并且应该位于首位。然而,我喜欢把最好的留到最后。
结论
原型继承很重要。教育JavaScript程序员放弃构造函数模式的原型继承,转而使用原型模式的原型继承至关重要。
我们需要开始正确地教授JavaScript,这意味着向新程序员展示如何使用原型模式编写代码,而不是构造函数模式。
不仅使用原型模式解释原型继承更容易,而且还会使程序员变得更好。

31
你一直在使用「克隆(clone)」这个词,但其实是不正确的。Object.create会创建一个具有指定原型的新对象。你的措辞让人误以为原型被克隆了。 - Pavel Horal
6
尽管我喜欢你的第四点,但要考虑维护几千行多个JavaScript文件的情况。我们需要在每个文件中查找变量。变量名称中的小错误会造成严重后果。编译器无法指出这种错误。迄今为止,还没有编写好的IDE可以自动建议或自动更正。变量名称无法重构。IDE也不能建议第三方模块的接口。JavaScript程序员必须查看其他人的代码以理解其含义。Java有jar文件可供内省。静态类型语言具有所有这些优点。 - samarjit samanta
8
虽然我欣赏你回答的严谨和热情,但我认为并非只有新手才能欣赏为静态分析设计的语言的美。也许一种方法是采用你所提到的关于与狗有关的文件(元数据)的类比。假设这些文件提供了一份给狗进行医疗治疗的清单信息。当然,这些信息并不是让你的狗成为狗所必需的。但是在下一次治疗狗时,它为兽医提供了一些参考依据。 - Doug
9
“克隆”绝对是一个不恰当的用词。克隆被定义为某物的精确复制,而这不是constructors或者Object.create正在发生的事情。没有复制正在进行。现在回答的措辞存在更多混淆的风险。 - Andy E
9
@Aadit:没有必要这么防御。您的回答非常详细,值得投票支持。我并不是建议“链接”应该成为“克隆”的替代品,而是它更恰当地描述了对象与其从原型继承的连接,无论您是否断言自己对术语“克隆”的定义。更改或不更改完全取决于您自己的选择。 - Andy E
显示剩余30条评论

46

让我来直接回答这个问题。

原型继承有以下优点:

  1. 它更适用于动态语言,因为继承与所处的环境一样动态。 (这在 JavaScript 中的适用性应该是显而易见的。) 这使得您能够快速地进行类定制,而无需大量基础设施代码。
  2. 相比传统的类/对象二分法方案,实现原型对象方案更容易。
  3. 它消除了对象模型周围的复杂边缘的需要,如“元类”(我从未遇到过喜欢元类的人...抱歉!)或“特征值”等。

然而,它也有以下缺点:

  1. 对原型语言进行类型检查并非不可能,但非常困难。大多数原型语言的“类型检查”都是纯运行时“鸭子类型”样式的检查。这并不适用于所有环境。
  2. 类似地,通过静态(或通常甚至是动态的!)分析优化方法分派也很困难。它可以(我强调:可以)非常容易地变得非常低效。
  3. 同样,在原型语言中创建对象可能(而且通常是)比在更传统的类/对象二分法方案中更慢。

我认为您可以从上面的文字之间读出传统类/对象方案的相应优缺点。当然,每个领域都有更多的优势和劣势,所以我会把剩下的内容留给其他回答者。


3
嘿看,一个不喜欢狂热追捧的简明回答,真希望它能成为该问题的最佳答案。 - siliconrockstar
今天我们拥有动态即时编译器,可以在代码运行时编译代码,为每个部分创建不同的代码片段。由于大量的优化工作已经完成,即使使用原型,JavaScript 实际上比使用传统类的 Ruby 或 Python 更快。 - aoeu256
这肯定应该是最佳答案。 - Andrew

28

在原型继承中,其主要优点在于其简单性。

该语言的原型性质可能会让受过经典训练的人感到困惑,但事实证明这实际上是一个非常简单而强大的概念,差异化继承

您不需要进行分类,代码更小,更少冗余,对象从其他更通用的对象继承。

如果您从原型的角度来看待问题,很快就会发现您不需要类...

原型继承将在不久的将来变得更加流行,ECMAScript第5版规范引入了Object.create方法,以一种非常简单的方式产生从另一个对象实例继承的新对象实例:

var obj = Object.create(baseInstance);

所有浏览器供应商都正在实施这个新版本的标准,我认为我们将开始看到更多的纯原型继承...


12
你的代码更小,更少冗余……为什么?我查看了“差异继承”的维基百科链接,没有任何支持这些说法的内容。为什么经典继承会导致更大、更冗余的代码呢? - Zephyr was a Friend of Mine
4
没错,我同意Noel的观点。原型继承只是完成工作的一种方式,但这并不代表它是正确的方式。对于不同的工作,不同的工具会有不同的表现。原型继承有其适用的场合,它是一个非常强大且简单的概念。话虽如此,JavaScript缺乏真正的封装和多态性支持,使其处于极大的劣势。这些方法学原理已经存在比JavaScript更长的时间,并且它们在原则上是可靠的。因此,认为原型继承更好只是完全错误的心态。 - redline
1
你可以使用基于原型的继承来模拟基于类的继承,但反之则不行。这可能是一个很好的论点。此外,我认为封装更多地是一种约定而非语言特性(通常可以通过反射打破封装)。关于多态性 - 你所获得的只是在检查方法参数时不必编写一些简单的“if”条件(如果目标方法在编译期间解析,则会稍微快一点)。在这里没有真正的JavaScript缺点。 - Pavel Horal

11

这两种方法其实没有太大的区别。需要理解的基本思想是,当JavaScript引擎读取一个对象的属性时,它首先检查该实例,如果该属性丢失,则检查原型链。以下是一个显示原型和经典之间差异的示例:

原型

var single = { status: "Single" },
    princeWilliam = Object.create(single),
    cliffRichard = Object.create(single);

console.log(Object.keys(princeWilliam).length); // 0
console.log(Object.keys(cliffRichard).length); // 0

// Marriage event occurs
princeWilliam.status = "Married";

console.log(Object.keys(princeWilliam).length); // 1 (New instance property)
console.log(Object.keys(cliffRichard).length); // 0 (Still refers to prototype)

传统的实例方法(因为每个实例都存储自己的属性而效率低下)

function Single() {
    this.status = "Single";
}

var princeWilliam = new Single(),
    cliffRichard = new Single();

console.log(Object.keys(princeWilliam).length); // 1
console.log(Object.keys(cliffRichard).length); // 1

高效的经典算法

function Single() {
}

Single.prototype.status = "Single";

var princeWilliam = new Single(),
    cliffRichard = new Single();

princeWilliam.status = "Married";

console.log(Object.keys(princeWilliam).length); // 1
console.log(Object.keys(cliffRichard).length); // 0
console.log(cliffRichard.status); // "Single"

可以看出,既然可以操纵以经典方式声明的“类”的原型,那么使用原型继承就没有任何好处了。它是经典方法的子集。


2
从其他关于这个主题的答案和资源来看,你的回答似乎是在说:“原型继承是 JavaScript 中添加的语法糖的一个子集,允许出现经典继承的外观”。OP似乎是在询问JS中原型继承相对于其他语言中的经典继承的好处,而不是比较JavaScript中的实例化技术。 - A Fader Darkly

1

22
我认为最好将链接内容概括说明,而不是仅复制粘贴链接(我自己过去也这样做)。这是因为链接/网站会失效,导致您丢失对问题的回答,并可能影响搜索结果。 - James Westgate
第一个链接并没有回答为什么使用原型继承的问题,它只是在描述它。 - viebel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接