19

第四篇拖了很久了,真是有点不好意思。实话实说,拖延很久的原因主要是没想好怎么写,因为这一篇的主题比较有挑战性:原型和基于原型的继承——啊~我终于说出口了,这下没借口拖延了==

原型

我(个人)不喜欢的,就是讲原型时上来就拿类做比较的,所以我不会这样讲。不过我的确讲过构造器函数,在这方面和类多多少少有共通之处。我的建议是:忘掉类。有很多观点认为“类”学的泛滥是面向对象的过度发展,是一种悲哀,以至于有太多的开发者几乎把面向对象和类划上了等号。在学习原型之前,我请你先记住并品味这句话:

面向对象设计的精髓在于“抽象”二字,类是实现实体抽象的一种手段,但不是唯一一种。

prototype__proto__

事先声明:永远,永远不要在真实的代码里使用 __proto__ 属性,在本文里用它纯粹是用于研究!很快我们会讲到它的替代品,抱歉请忍耐。

在 JavaScript 里,函数是对象(等学完了这一篇,不妨研究一下函数究竟是怎么就成了对象的?),对象嘛,毫无意外的就会有属性(方法也是属性),然后毫无意外的 prototype 就是函数的一个属性,最后毫无意外的 prototype 属性也是一个对象。瞧,多么顺理成章的事情:

function foo() {}
foo.prototype;    // 里面有啥自己去看

好吧,那 prototype 有啥用?呃,如果你把函数就当做函数来用,那它压根没用。不过,若你把函数当作构造器来用的话,新生成的对象就可以直接访问到 prototype 对象里的属性。

// 要充当构造器了,按惯例把首字母大写
function Foo() {}
var f = new Foo();
f.constructor;    // function Foo() {}

想一下,fconstructor 属性哪里来的?如果你想不明白,请用 console.dir(Foo.prototype) 一探究竟。

这说明了一个问题:

函数的原型属性不是给函数自己用的,而是给用函数充当构造器创建的对象使用的。

令人疑惑的是,prototype 属性存在于 Foo 函数对象内,那么由 Foo 创建的实例对象 f 是怎么访问到 prototype 的呢?是通过复制 prototype 对象吗?接着上面的代码我们继续来看:

f.__proto__;                      // Foo {}
Foo.prototype;                    // Foo {}
f.__proto__ === Foo.prototype;    // true

哦~不是复制过来的,而是一个叫做 __proto__ 的属性指向了构造器的 prototype 对象呀。

没错!这就是原型机制的精髓所在,让我们来总结一下所有的细节(包括隐含在表象之下的):

  1. 函数拥有 prototype 属性,但是函数自己不用它
  2. 函数充当构造器的时候可以创建出新的对象,这需要 new 操作符的配合。其工作原理我已经在第一篇做了大部分的阐述
  3. 我尚未提及的是:new 在创建新对象的时候,会赋予新对象一个属性指向构造器的 prototype 属性。这个新的属性在某些浏览器环境内叫做 __proto__
  4. 当访问一个对象的属性(包括方法)时,首先查找这个对象自身有没有该属性,如果没有就查找它的原型(也就是 __proto__ 指向的 prototype 对象),如果还没有就查找原型的原型(prototype 也有它自己的 __proto__,指向更上一级的 prototype 对象),依此类推一直找到 Object 为止

OK,上面的第四点事实上就是 JavaScript 的对象属性查找机制。由此可见:

原型的意义就在于为对象的属性查找机制提供一个方向,或者说一条路线

一个对象,它有许多属性,其中有一个属性指向了另外一个对象的原型属性;而后者也有一个属性指向了再另外一个对象的原型属性。这就像一条一环套一环的锁链一样,并且从这条锁链的任何一点寻找下去,最后都能找到链条的起点,即 Object;因此,我们也把这种机制称作:原型链

现在,我希望统一一下所使用的术语(至少在本文范围内):

  • 函数的 prototype 属性:我们叫它 原型属性原型对象
  • 对象的 __proto__ 属性:我们叫它 原型

例如:

  • Foo 的原型属性(或原型对象) = Foo.prototype
  • f 的原型 = f.__proto__

统一术语的原因在于,尽管 Foo.prototypef.__proto__ 是等价的,但是 prototype__proto__ 并不一样。当考虑一个固定的对象时,它的 prototype 是给原型链的下方使用的,而它的 __proto__ 则指向了原型链的上方;因此,一旦我们说“原型属性”或者“原型对象”,那么就暗示着这是给它的子子孙孙们用的,而说“原型”则是暗示这是从它的父辈继承过来的。

再换一种说法:对象的原型属性或原型对象不是给自己用的,而对象的原型是可以直接使用的。

__proto__ 的问题

既然 __proto__ 可以访问到对象的原型,那么为什么禁止在实际中使用呢?

这是一个设计上的失误,导致 __proto__ 属性是可以被修改的,同时意味着 JavaScript 的属性查找机制会因此而“瘫痪”,所以强烈的不建议使用它。

如果你确实要通过一个对象访问其原型,ES5 提供了一个新方法:

Object.getPrototypeOf(f)    // Foo {}

这是安全的,尽管放心使用。考虑到低版本浏览器的兼容性问题,可以使用 es5-shim


自有属性和原型属性的区别

由于对象的原型是一个引用而不是赋值,所以更改原型的属性会立刻作用于所有的实例对象。这一特性非常适用于为对象定义实例方法:

function Person(name) {
    this.name = name;
}

Person.prototype.greeting = function () {
    return "你好,我叫" + this.name;
};

var p1 = new Person("张三");
var p2 = new Person("李四");

p1.greeting();    // 你好,我叫张三
p2.greeting();    // 你好,我叫李四

/* 改变实例方法的行为:*/

Person.prototype.greeting = function () {
    return "你好,我叫" + this.name + ",很高兴认识你!";
};

/* 观察其影响:*/

p1.greeting();    // 你好,我叫张三,很高兴认识你!
p2.greeting();    // 你好,我叫李四,很高兴认识你!

然而,改变自有属性则不同,它只会对新创建的实例对象产生影响,接上例:

function Person(name) {
    this.name = "超人";
}

/* 不影响已存在的实例对象 */
p1.greeting();    // 你好,我叫张三,很高兴认识你!

/* 只影响新创建的实例对象 */
var p3 = new Person("王五");
p3.greeting();    // 你好,我叫超人,很高兴认识你!

这个例子看起来有点无厘头,没啥大用,不过它的精神在于:在现实世界中,复杂对象的行为或许会根据情况对其进行重写,但是我们不希望改变对象的内部状态;或者,我们会实现继承,去覆盖父级对象的某些行为而不引向其他相同的部分。在这些情况下,原型会给予我们最大程度的灵活性。

我们如何知道属性是自有的还是来自于原型的?上代码~

p1.hasOwnProperty("name");        // true
p1.hasOwnProperty("greeting");    // false

p1.constructor.prototype.hasOwnProperty("greeting");     // true
Object.getPrototypeOf(p1).hasOwnProperty("greeting");    // true

代码很简单,就不用过度解释了,注意最后两句实际上等价的写法。

小心 constructor

刚才的这一句代码:p1.constructor.prototype.hasOwnProperty("greeting");,其实暗含了一个有趣的问题。

对象 p1 能够访问自己的构造器,这要谢谢原型为它提供了 constructor 属性。接着通过 constructor 属性又可以反过来访问到原型对象,这似乎是一个圈圈,我们来试验一下:

p1.constructor === p1.constructor.prototype.constructor;    // true
p1.constructor === p1.constructor.prototype.constructor.prototype.constructor;    // true

还真是!不过我们不是因为好玩才研究这个的。

尽管我们说:更改原型对象的属性会立即作用于所有的实例对象,但是如果你完全覆盖了原型对象,事情就变得诡异起来了:(阅读接下来的例子,请一句一句验证自己心中所想)

function Person(name) {
    this.name = name;
}

var p1 = new Person("张三");

Person.prototype.greeting = function () {
    return "你好,我叫" + this.name;
};

p1.name;                      // 张三
p1.greeting();                // 你好,我叫张三
p1.constructor === Person;    // true

/* so far so good, but... */

Person.prototype = {
    say: function () {
        return "你好,我叫" + this.name;
    }
};

p1.say();                    // TypeError: Object #<Person> has no method 'say'
p1.constructor.prototype;    // Object { say: function }

呃?Person 的原型属性里明明有 say 方法呀?原型对象不是即时生效的吗?


原型继承

若是只为了创建一种对象,原型的作用就无法全部发挥出来。我们会进一步利用原型和原型链的特性来拓展我们的代码,实现基于原型的继承。

原型继承是一个非常大的话题范围,慢慢地你会发现,尽管原型继承看起来没有类继承那么的规整(相对而言),但是它却更加灵活。无论是单继承还是多继承,甚至是 Mixin 及其他连名字都说不上来的继承方式,原型继承都有办法实现,并且往往不止一种办法。

不过让我们先从简单的开始:

function Person() {
    this.klass = '人类';
}

Person.prototype.toString = function () {
    return this.klass;
};

Person.prototype.greeting = function () {
    return '大家好,我叫' + this.name + ', 我是一名' + this.toString() + '。';
};

function Programmer(name) {
    this.name = name;
    this.klass = '程序员';
}

Programmer.prototype = new Person();
Programmer.prototype.constructor = Programmer;

这是一个非常好的例子,它向我们揭示了以下要点:

var someone = new Programmer('张三');

someone.name;          // 张三
someone.toString();    // 程序员
someone.greeting();    // ‌大家好,我叫张三, 我是一名程序员。

我来捋一遍:

  1. 倒数第二行,new Person() 创建了对象,然后赋给了 Programmer.prototype 于是构造器原型属性就变成了 Person 的实例对象。
  2. 因为 Person 对象拥有重写过的 toString() 方法,并且这个方法返回的是宿主对象的 klass 属性,所以我们可以给 Programmer 定义一个 greeting() 方法,并在其中使用继承而来的 toString()
  3. someone 对象调用 toString() 方法的时候,this 指向的是它自己,所以能够输出 程序员 而不是 人类

还没完,继续看:

// 因为 Programmer.prototype.constructor = Programmer; 我们才能得到:
someone.constructor === Programmer;    ‌// true

// 这些结果体现了何谓“链式”原型继承
‌‌someone instanceof Programmer;         ‌// true
‌‌someone instanceof Person;             //‌ true
‌‌someone instanceof Object;             ‌// true

方法重载

上例其实已经实现了对 toString() 方法的重载(这个方法的始祖对象是 Object.prototype),秉承同样的精神,我们自己写的子构造器同样可以通过原型属性来重载父构造器提供的方法:

Programmer.prototype.toString = function () {
    return this.klass + "(码农)";
}

var codingFarmer = new Programmer("张三");
codingFarmer.greeting();    // 大家好,我叫张三, 我是一名程序员(码农)。

属性查找与方法重载的矛盾

思维活跃反应快的同学或许已经在想了:

为什么一定要把父类的实例赋给子类的原型属性,而不是直接用父类的原型属性呢?

好问题!这个想法非常有道理,而且这么一来我们还可以减少属性查找的次数,因为向上查找的时候跳过了父类实例的 __proto__,直接找到了(如上例)Person.prototype

然而不这么做的理由也很简单,如果你这么做了:

Programmer.prototype = Person.prototype;

由于 Javascript 是引用赋值,因此等号两端的两个属性等于指向了同一个对象,那么一旦你在子类对方法进行重载,连带着父类的方法也一起变化了,这就失去了重载的意义。因此只有在确定不需要重载的时候才可以这么做。


n͛i͛g͛h͛t͛i͛r͛e͛
31.1k 声望3.1k 粉丝

正在更新 Elixir 语言的系列文章:[链接]