javascript:繼承

南郭竽發表於2018-04-27

ECMAScript中描述了原型鏈的概念,並將原型鏈作為實現繼承的主要方法。其基本思想是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。建構函式、原型和例項的關係:每個建構函式都有一個原型物件,原型物件都包含一個指向建構函式的指標。而例項都包含一個指向原型物件的內部指標。那麼,假如我們讓原型物件等於另一個型別的例項,結果會怎麼樣呢?顯然,此時的原型物件將包含一個指向另一個原型的指標,相應的,另一個原型中也包含著一個指向另一個建構函式的指標。假如另一個原型又是另一個型別的例項,那麼上述關係依然成立,如此層層遞進,就構成了例項與原型的鏈條。這就是所謂的原型鏈的基本概念。


根據上面的描述,我們可以寫出這樣一段程式碼,看這樣是否就是實現了繼承。

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

Apple.prototype.sayName = function () {
    console.log(this.name);
};

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

Banana.prototype = new Apple('蘋果');

Banana.prototype.constructor = Banana;

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

Cherry.prototype = new Banana('香蕉');
Cherry.prototype.constructor = Cherry;

var ch = new Cherry('櫻桃');
console.log(ch);

檢視在Chrome下,輸出的ch是什麼?其繼承狀態又如何。

這裡寫圖片描述

通過檢視chChrome下的輸出,可以看到,結果就是的確實現了繼承。

原型鏈的問題

原型鏈雖然很強大,可以用它來實現繼承,但是它也存在一些問題。其中,最主要的問題來著包含引用型別值的原型。前面介紹過包含引用型別值的原型屬性會被所有例項共享;而這也正是為什麼要再建構函式中,而不是原型物件中定義屬性的原因。在通過原型來實現繼承時,原型實際上會變成另一個型別的例項。於是,原先的例項屬性也就順理成章地變成了現在的(子型別物件的)原型屬性了。

通過在上面的原型繼承的程式碼中新增幾行程式碼即可看出問題:

function Apple(name) {
    this.name = name;
    this.friends = ['cats'];
}

Apple.prototype.sayName = function () {
    console.log(this.name);
};

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

Banana.prototype = new Apple('蘋果');

Banana.prototype.constructor = Banana;

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

Cherry.prototype = new Banana('香蕉');
Cherry.prototype.constructor = Cherry;

var ch = new Cherry('櫻桃');
console.log(ch);

var c2 = new Cherry('桃子?');
ch.friends.push('dogs');

console.log(c2);
console.log("ch:" + ch.friends + " #### c2:" + c2.friends);

最後一行的輸出為:ch:cats,dogs #### c2:cats,dogs

也就是說,改變了Cherry的某一個例項的屬性,會導致該型別的全部例項的這個屬性都會被改變。這並不是我們想要的效果,但是符合原型的邏輯。

原型鏈的第二個問題是:在建立子型別的例項時,不能向超型別的建構函式中傳遞引數。實際上,應該說是沒有辦法在不影響所有物件例項的情況下,給超型別的建構函式傳遞引數。有鑑於此,再加上原型中屬性共享問題,實踐中很少單獨使用原型鏈

3.2 借用建構函式

在解決原型中包含引用型別值所帶來問題的過程中,開發人員開始使用一種叫做借用建構函式(constructor stealing)的技術(有時候也叫做偽造物件或經典繼承)。這種技術的基本思想相當簡單,即在子型別建構函式的內部呼叫超型別建構函式。別忘了,函式只不過是在特定環境中執行程式碼的物件,因此通過使用apply()call()方法也可以在(將來)新建立的物件上執行建構函式,如下所示:

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {

    SuperType.call(this); // 這裡的 this 是什麼? 當然是 SubType 型別的物件
    // todo:注意,這裡並沒有把 SuperType()當成建構函式呼叫,而是當成普通函式呼叫了。
}

var instance = new SubType();
instance.colors.push('black');
console.log(instance.colors); // [ 'red', 'blue', 'green', 'black' ]

var instance2 = new SubType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]

上述程式碼其實等效於下面的寫法:

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {

    // SuperType.call(this); // 這裡的 this 是什麼? 當然是 SubType 型別的物件
    // 另外這句程式碼到底執行了什麼呢? --> 相當於如下的程式碼:

}
var instance = new SubType();
instance.SuperType = SuperType;
instance.SuperType();

instance.colors.push('black');
console.log(instance.colors); // [ 'red', 'blue', 'green', 'black' ]

var instance2 = new SubType();
instance2.SuperType = SuperType;
instance2.SuperType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]

通過輸出可以看出,以上兩種寫法的效果是相同的。

而且,這實際上並不是什麼繼承。從console.log(instance);Chrome控制檯輸出可以明顯看出這一點:

SubType {colors: Array(4)}
colors
:
(4) ["red", "blue", "green", "black"]
__proto__
:
Object

通過 chrome檢視會更直接(建議把以上任意一種程式碼方到chrome下執行。)。

console.log(instance instanceof SubType); // true
console.log(instance instanceof SuperType); // false

以上兩句程式碼也可以明確這一點!

個人以為,借用建構函式模式,僅僅是給每個例項物件建立了不共享了例項屬性。並且這種方式並沒有實現原型繼承。

3.3 組合繼承

組合繼承(combination inheritance),有時候也叫做偽經典繼承,指的是將原型鏈和借用建構函式的技術組合到一塊,從而發揮二者紙廠的一種繼承模式。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用建構函式來實現對例項屬性的繼承。這樣,既通過在原型上定義方法實現了函式複用,又能保證每個例項都有它自己的屬性,如下:

// 第一段
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}
// 第二段
SuperType.prototype.sayName = function () {
    console.log(this.name);
};

// 第三段
function SubType(name, age) {
    SuperType.call(this, name); // --> this.colors = ['red','blue','green']
    this.age = age;
}
// 第四端
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType; 
// 第五段
SubType.prototype.sayAge = function () {
    console.log(this.age);
    // 這裡的 this 是誰?
    // 現在還看不出來,要看呼叫者,但是可以猜測,這個方法的呼叫者一定是一個 SubType 型別的物件,
    // 所以,這裡的 this 就是一個 一個 SubType 型別的物件
};
// 第六段
var s1 = new SubType('Tom', 29);
s1.colors.push('black');
console.log(s1.colors);
s1.sayName();
s1.sayAge();

var s2 = new SubType('Ann', 33);
console.log(s2.colors);
s2.sayName();
s2.sayAge();

上述程式碼就解決了原型物件上面定義的屬性會被例項共享的尷尬(此尷尬參見:3.1 原型鏈)。

-<>- 先來分析一下上面的程式碼,為什麼這樣就 既實現了函式複用,又能保證每個例項都有它自己的屬性

前面的兩段程式碼不用分析,就是典型的組合使用建構函式和原型模式的程式碼。然後是第三段程式碼:

// 3
function SubType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}

注意這裡的SuperType.call(this, name);,千萬不要被SuperType是建構函式的概念給唬住了。(每個建構函式都可以是普通函式。只要不是通過new操作符去呼叫的函式,都是普通函式。)在這句程式碼裡,使用了call()語法。call(thisValue,args),因為是call(),也就是相當於SubType的例項有一個普通函式叫做SuperType,然後在此時呼叫了。而這一呼叫,就是給自己新增了兩個屬性namecolors

然後後面一句this.age = age;就不用說了。

然後是第4段程式碼:

// 4
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType; 

這段程式碼,在原型鏈中已經見識過,就是將當前建構函式的原型重寫為父型別的例項。(並且重寫自己建構函式。)通過修改原型,實現了繼承。

然後是第5段程式碼:

// 5
SubType.prototype.sayAge = function () {
    console.log(this.age);
};

這段程式碼也很簡單,就是通過自己的原型屬性,新增一個共享方法。

然後第6段程式碼是驗證性程式碼,也不必解釋了。

通過對上述程式碼的分析可知:實現函式複用的程式碼段是[第4段程式碼],保證每個例項都有它自己的屬性的程式碼是[第3段程式碼]

在這個例子中,SuperType建構函式定義了兩個屬性:namecolorsSubperType的原型定義了一個方法sayName()SubType建構函式在呼叫SubperType建構函式時傳入了name引數。(實際上,這裡是把SuperType()當成普通函式去使用的。)緊接著,又定義了它自己的屬性age。然後,將SuperType的例項賦值給SubType的原型,然後又再該新原型上定義了方法sayAge()。這樣一來就可以讓兩個不同的SubType例項既分別擁有自己的屬性(包括colors屬性),又可以使用相同的方法了。


以上的實現都有一些不足之處,不過組合繼承可以作為常用方法。相對於組合繼承,還有一直更好的方法。叫做寄生組合式繼承

function extend(Child, Parent) {

    var F = function () {
    };

    F.prototype = Parent.prototype;

    Child.prototype = new F();

    Child.prototype.constructor = Child;

    Child.uber = Parent.prototype;

}

function Fruit(name) {
    this.name = name;
    this.coolors = ['red', 'blue', 'green'];
}

Fruit.prototype.sayName = function () {
    console.log(this.name);
};

function Banana(name, age) {
    Fruit.call(this, name); // 這一句是必須要的哦
    this.age = age;
}


extend(Banana, Fruit);
Banana.prototype.sayAge = function () {
    console.log(this.age);
};  
// 子型別原型方法寫在後面是對的,如果父型別的原型中有同名方法,子型別的方法可以遮蔽父型別的。

var banana = new Banana('香蕉', 12);
console.log(banana);

其中的extend方法來自阮一峰——Javascript物件導向程式設計(二):建構函式的繼承

為什麼這樣寫,其實和 組合繼承很類似,這樣寫是為了減少記憶體佔用。詳細分析可以參閱連結。

好了,以後繼承可以都使用這種寄生組合模式了。

相關文章