第五章 繼承
學習如何創建對象時理解面向對象的第一步。第二部時理解繼承。在傳統面向對象的語言中,類從其他類繼承屬性。然而在javascript中,繼承可以發生在沒有類的繼承關係的對象之間。如果你看過之前的文章,那麼這種繼承機制你已經熟悉了。就是原型對象。
5.1 原型對象鏈和Object.prototype
javascript內建的繼承方法被成爲原型對象鏈,又可稱爲原型對象繼承。如你在第四章所學,原型對象的屬性可經由對象實例訪問,這就是繼承的一種形式,對象實例繼承了原型對象的屬性。因爲原型對象也是一種對象,它也有自己的原型對象並繼承其屬性,這就是原型對象鏈:對象繼承其原型對象,而原型對象繼承它的原型對象,以此類推。
所有的對象,包括那些你自己定義的對象都自動繼承自Object,除非你另有指定。更確切的說,所有對象都繼承自Object.prototype。任何以對象字面形式定義的對象,其[[Prototype]]的值都被設爲Object.prototype。這意味着它繼承Object.prototype的屬性,
var book={
title:"The Principles of Object-Oriented JavaScript"
};
var prototype=Object.getPrototypeOf(book);
console.log(prototype === Object.prototype); //true
book 的對象原型是Object.prototype。這裏不需要多餘的代碼來指定,因爲這是創新建對象的默認行爲。這個關係意味着book會自動接受來自Object.prototype的方法。
5.1.1 繼承自Object.prototype的方法
前幾章裏用到的多個方法其實都定義在Object.prototype上的,因此可以被其他對象繼承,這些方法如下。
hasOwnProperty() 檢查是否存在一個給定名字的自有屬性
propertyIsEnumerable() 檢查一個自有屬性是否可枚舉
isPrototypeOf() 檢查一個對象是否是另一個對象的原型對象
valueOf 返回一個對象的值表達
toString() 返回一個對象的字符串表達
這5種方法經由繼承出現在所有對象中。當需要讓對象在javascript中以一致的方式工作時,最後兩個尤其重要,有時甚至會想起要自己定義它們。
1. valueOf()
每當一個操作符被用於一個對象時就會調用 valueOf()方法。valueOf()默認返回對象實例本身。原始封裝類型重寫鏈valueOf
()以使得它對String返回一個字符串,對Boolean返回一個布爾,對Number返回一個數字。類似的,Date對象的valueOf()方法返回一個epoch實際,單位時毫秒(正如Date.prototype.getTime()所爲)。
這允許你寫出下列代碼來對Date做比較。
var now = new Date();
var earlier=new Date(2010,1,1);
console.log(now >earlier); //true
本例中,now時一個代表當前實際的Date,而earlier時一個過去的實際,當使用大於操作符時,在實際比較錢,在兩個對象上都調用了valueOf()方法。你甚至可以對兩個Date相減來獲得它們在epoch事件上的差值。
如果你的對象也要這樣使用操作符,你可以定義自己的valueOf()方法。定義的時候你並沒有改變操作符的行爲,僅僅定義了操作符默認行爲使用的值。
2. toString()
一旦valueOf()返回的時一個引用而不是原始值的時候,就會回退調用toString()方法。另外,當javascript期望一個字符串時,也可以對原始值隱式調用toString()。例如,當加號操作符的一個邊時一個字符串時,另一邊會自動轉換成字符串。如果另一邊時一個原始值,會自動被轉換成一個字符串表達,如果另一邊時一個引用值,則會調用valueOf()。如果valueOf()返回一個引用值,則調用toString()。如下面代碼。
var book={
title:"The Principles of Object-Oriented JavaScript"
};
var message ="Book ="+book;
console.log(message);
這段代碼以"Book="和book來構造字符串。因爲book是一個對象,此時調用它的toString()方法。該方法繼承自Object。prototype,大部分javascript引擎返回默認值"[object Object]"。 如過你對這個值滿意,雞不需要改變對象的toString()方法。但定義自己的toString()方法有時可以爲此類字符串轉換提供包含更多信息的值,假設你想要之前的腳本記錄的名字。請看下例。
var book={
title:"The Principle of Object-Oriented JavaScript",
toString:function(){
return "[Book"+this.title +"]"
}};
var message ="Book = "+book;
console.log(message);
這段代碼爲book自定義的toString()方法與繼承來的版本相比,返回更有用的值。大多數時候,你不需要自定義toString()方法,但必要時你該知道怎麼做。
5.1.2 修改Object.prototype
所有的對象某默認繼承自Object.prototype,所以改變Object.prototype會影響所有的對象,這是非常危險的,第四章告誡過你不要修改內建對象的原型對象,到了Obejct.prototype,這個告誡要加倍。查看下面的代碼會發生什麼。
Object.prototype.add=function(value){
return this+value;
}
var book={
title:"The Principles of Object-Oriented JavaScript"
};
console.log(book.add(5));
console.log("title".add("end"));
console.log(document.add(true));
console.log(window.add(t));
添加Object.prototype.add()會導致所有的對象都有了一個add()方法,不管這樣合理不合理。不僅僅給開發者,同時也給Javascript委員會帶來了問題:它不得不把新方法添加到各種不同的地方,因爲給Object.prototype添加方法可能會帶來不可預知的問題。
這個問題的另一個方面在於給Object.prototype添加可枚舉屬性。在之前的例子裏,Object.prototype.add()是一個可枚舉屬性,這意味着它會出現在for-in循環中,如下。
var empty={}
for(var property in empty){
console.log(property)};
這裏,一個空對象依然會暑促“add”作爲其屬性,就是因爲它存在與其原型對里且爲可 枚舉屬性。考慮到javascript中使用for-in頻繁的程度,爲Object.prototype添加可枚舉屬性會影響大量代碼,因爲這個原因,Douglas Crockford推薦在for-in循環中始終使用hasOwnProperty(),如下;
var empty={};
for(var property in empty){
if(empty hasOwnProperty(property)){
console.log(property);
};
}
不過這個方法雖然可以有效過濾那些不想要的原型對象的屬性,但也同時限制了for-in屬性,使其只能用於自有屬性,這也許不是你想要的,對你來說,最靈活的做法還是不要修改Object.prototype。
5.2 對象繼承
對象繼承是最簡單的繼承類型。你唯一需要要做的就是指定哪個對象時新對象的[[Prototype]]。對象字面形式都會隱式指定Object.prototype爲其[[Prototype]],你可以可以使用Object.create()方法顯式指定。
Object.create()接受兩個參數,第一個參數時需要被設置爲新對象的[[Prototype]]的對象。第二個可選參數時一個屬性描述對象,其格式如你在Object.fineProperties()中使用的一樣。
var book ={
title:"The Principles of Object-Oriented JavaScript"
};
//is the same as
var book=Object.create(Object.prototype,{
title:{
configurable:true,
enumerable:true,
value:"The Principles of Object-Oriented JavaScript",
writable:true
}
});
兩種聲明具有相同的效果。第一種聲明使用對象字面形式來定義一個具有單一屬性title的對象。該對象自動繼承自Object.prototype,且其屬性別默認設置爲可配置,可枚舉和可寫。第二種聲明使用Obejct.create()顯示做了同樣的操作。兩個book對象的行爲完全一致。但你可能永遠不會這樣寫出直接繼承自Object.prototype的代碼,畢竟那是默認行爲。繼承自其他對象則更有趣多了。如下。
var person1={
name:"Nicholas",
sayName:function(){
console.log(this.name);
}
};
var person2=Object.create(person1,{
name:{
configurable:true,
enumerable:true,
value:"Greg",
writable:true
}
});
person1.sayName();
person2.sayName();
console.log(person1.hasOwnProperty("sayName"));
console.log(person1.isPrototypeOf(person2));
console.log(person2.hasOwnProperty("sayName"));
這段代碼創建來一個對象person1,具有一個name屬性和一個sayName()方法。對象person2繼承自person1,也就繼承來name和sayName()。然而person2在通過Object.create()創建時還定義了一個自有屬性name()。該自有屬性隱藏並太低了原型對象的同名屬性。所以,person1.sayName()輸出“Nicholas”,而person2.sayName()輸出“Greg”。
請記住,sayName()依然只存在於person1並被person2繼承。
本例person2的繼承鏈長於person1.對像person2繼承自person1而person1繼承自Object.prototype。
當訪問一個對象的屬性時,javascript引擎會執行一個搜索過程。如果在對象實例上發現該屬性(就是說是個自有屬性),該屬性值就會被使用。如果對象實例上沒有發現該屬性,則搜索[[Prototype]]。如果仍然沒有發現,則繼續搜索該對象的[[Prototype]],知道繼承鏈末端,末端通常時一個Object.prototype,其[[Prototype]]被置爲null。
也可以通過Object.create()創建[[Prototype]爲null的對象,
var nakedObject=Object.create(null);
console.log("toString" in nakedObject);
console.log("valueOf" in nakedObject);
本例中的nakedObject是 一個沒有原型對象鏈的對象。這意味着toString()和valueOf()等內建方法都不存在於該對象上。實際上,該對象完全是一個沒有任何預定義屬性的白板,這使得它稱爲一個完美的哈希容器,因爲兒不會發生跟繼承來的屬性名字衝突。除此之外這種對象也沒有別的用處啦,你不能把它當成一個其他繼承自Object.prototype的對象一樣使用。例如,無論何時當你對nakedObject使用操作符時,你都會得到一個“Cannot convert object to primitive value”的錯誤。這只是一個有趣的javascript語言詭計,是你可以創建出一個沒有原型對象的對象。
5.3 構造函數繼承
javascript中的對象繼承也是構造函數繼承的基礎。還記得第四章提到,幾乎所有的函數多有prototype屬性,它可以被修改或替換。該prototype屬性被自動設置爲一個新的繼承自Obejct.prototype的泛用對象,該對象有一個自有屬性constructor。實際上,javascript引擎爲你做了下面的事情。
function YourConstructor(){
//initialization
}
//JavaScript engine does this for you behind the scenes
YourConstructor.prototype=Object.create(Object.prototype){
constructor:{
configurable:true,
enumerable:true,
value:YourConstructor,
writable:true
}
});
你不需要做額外的工作,這段代碼幫你把構造函數的prototype屬性設置爲一個繼承自Object.prototype的對象。這意味着YourConstructor創建出來的任何對象都繼承自Object.prototype。YourConstructor是Object的子類。而Object時YourConstructor的父類。
由於prototype屬性可寫,你可以通過改寫它來改變原型對象鏈。考慮下面的例子。
function Rectangle(length,width){
this.length =length;
this.width =width;
}
Rectangle.prototype.getArea=function(){
return this.length*this.width;
};
Rectangle.prototype.toString =function(){
return "[Rectangle"+this.length+"x"+this.width+"]";
};
function Square(size){
this.length=size;
this.width=size;
}
Square.prototype=new Rectangle();
Square.prototype.constructor=Square;
Square.prototype.toString =function(){
return "[Square"+this.length+"X"+this.width+"]";
};
var rect=new Rectangle(5,10);
var square=new Square(6);
console.log(rect.getArea());
console.log(square.getArea());
console.log(rect.toString());
console.log(square.toString());
console.log(rect instanceof Square);
console.log(rect instanceof Object);
console.log(square instanceof Square);
console.log(square instanceof Rectangle);
console.log(square instanceof Object);
這段代碼里有兩個構造函數:Rectangle和Square。Square構造函數的prototype屬性被改寫爲Rectangle的一個對象實例。此時不需要給Rectangle的調用提供參數,因爲它們不需要被使用,而且如果提供了,那麼所有的Square的對象都會共享同樣的唯獨。用這種方式改版原型對象鏈時,你需要確保構造函數不會在參數確實時拋出錯誤(很多構造函數包含的初始化邏輯會需要參數)且構造函數不會改變任何全局狀態,比如追蹤有多少對象實例被創建等。Square.prototype被改寫後,其constructor屬性會被重置爲Square。
然後,rect作爲Rectangle的對象實例被創建,而square則被作爲Square的實例創建,兩個對象都有getArea()方法,因爲那繼承自Square.prototype。instanceof操作符認爲變量square同時是Square,Rectangle和Object的對象實例,因爲instanceof使用原型對象鏈檢查對象類型。
Square.prototype並不真的需要被改寫爲一個Rectangle敵對性,比較Rectangle構造函數並沒有真的爲Square做什麼必須要的事情。事實上,唯一相關的部分是Square.prototype需要指向Rectangle.prototype,使得繼承得以實現。這意味着你可以用Object.create()簡化例子
function Square(size){
this.length=size;
this.width=size;
};
Square.prototype=Object.create(Rectangle.prototype,{
constructor:{
configurable:true,
enumerable:true,
value:Square,
writable:true
}
});
Square.prototype.toString=function(){
return " [Square"+this.length+"X"+this.width+"]";
};
在這個版本的代碼中,Square.prototype被改寫成爲一個新的繼承自Rectangle.prototype的對象,而Rectangle構造函數沒有被調用。這意味着,你不再需要擔心不參加調用構造函數會導致的錯誤。除此之外,這段代碼和前面的代碼行爲完全一致。原型對象鏈完好無缺,所有的Square對象實例都繼承自Rectangle.prototype且其constructor屬性也都在同樣的地方被重置。
注意:在對原型對象添加屬性前要確保你已經改寫鏈原型對象,否則在改寫前會丟失之前的方法。
5.4 構造函數的竊取
由於javascript中的繼承是通過原型對象鏈來實現的,因此不需要調用對象的父類的構造函數,如果你確實需要在子類構造函數中調用父類構造函數,那你就需要利用javascript函數工作的特性。
在第二章中學過call()和apply()方法允許你在調用函數時提供不同的this值。那正好時構造函數竊取的關鍵。只需要在子類的構造函數中用call()或者apply()調用父類的構造函數,並將新的對象傳進去即可。實際上,就是用自己的對象竊取父類的構造函數。如下例。
function Rectangle(length,width){
this.length =length;
this.width=width;
};
Rectangle.prototype.getArea=function(){
return this.length*this.width;
};
Rectangle.prototype.toString=function(){
return "[Rectangle"+this.length+"X"+this.width+"]";
};
//inherits from Rectangle
function Square(size){
Rectangle.call(this,size,size);
//optional:add new properties of override existing ones here
}
Square.prototype=Object.create(Rectangle.prototype,{
constructor:{
configurable:true,
enumerable:true,
value:Square,
writable:true
}
});
Square.prototype.toString()=function(){
return "[Rectangle"+this.length+"X"+this.width+"]";
}
var square=new Square(6);
console.log(square,length);
console.log(square.width);
console.log(square.getArea());
Square構造函數調用了Rectangle構造函數,並傳入了this和size兩次(一次作爲length,另一次作爲width)。這麼做會在新對象上創建length和width屬性並讓它們等於size,這是一種避免在構造函數里重新定義你希望繼承的屬性的手段。你可以在調用完父類的構造函數後繼續添加新的屬性或覆蓋已有的屬性。
這個分兩步走的過程在你需要完成自定義類型之間的繼承時比較喲用。你經常需要修改一個構造函數。你也經常需要在子類的構造函數中調用父類的構造函數。一般來說,需要修改prototype來繼承方法並用構造函數竊取來設置屬性。由於這種做法模仿了那些基於類的語言的類繼承,通常被稱爲僞類繼承。
5.5 訪問父類方法
在前面的例子中,Square類型有自己的toString()方法隱藏類其原型對象的toString()方法。子類提供新功能覆蓋父類的方法十分常見,但如果你還想訪問父類的方法該怎麼辦呢?在其他語言中,可以用super.toString(),但在javascript中沒有類似的方式。代替的方法是通過call()或apply()調用父類的原型對象的方法時傳入一個子類的對象。
function Rectangle(length,width)(
this.length =length;
this.width=width;
)
Rectangle.prototype.getArea=function(){
return this.length*this.width;
};
Rectangle.prototype.toString=function(){
return "[Rectangle"+this.length+"X"+this.width+"]";
}
function Square(size){
Rectangle.call(this,size,size);
};
Square.prototype=Object.create(Rectangle.prototype,{
constructor:{
configurable:true,
enumerable:true,
value:Square,
writable:true
}
});
square.prototype.toString=function(){
var text=Rectangle.prototype.toString.call(this);
return text.replace("Rectangle","Square");
}
在這個版本的代碼中,Square.prototype.toString()通過call()調用Rectangle.prototype.toString()。該方法只需要在返回文本結果前用"Square"替換"Rectangle"。這種做法看上去可能有一點冗長,但這是唯一的訪問父類方法的手段。
5.6 總結
javascript通過原型對象鏈支持繼承。當將一個對象的[[Prototype]]設置爲另一個對象時,就在這兩個對象之間創建了一條原型鏈。所有的泛用對象都自動繼承自Object.prototype。如果你想要創建一個繼承自其他對象的對象,你可以用Object.create()指定[[Prototype]]爲一個新的對象。
可以在構造函數中創建原型對象鏈來完成自定義類型之間的繼承。通過將構造函數的prototype屬性設置爲某一個對象,就建立了自定義類型對象和該對象的繼承關係。構造函數的多有對象實例共享同一個原型對象,所以它們都繼承自該對象。這個技術在繼承其他對象的方法時工作得時分好,但你不能用原型對象繼承自有屬性。
爲了正確繼承自由屬性,可以使用構造函數竊取。只需要call()或apply()調用父類的構造函數,就可以在子類里完成各種初始化。結合構造函數竊取和原型對象鏈時javascript中最常見的繼承手段。由於和基於類的繼承相似,這個組合經常被稱爲僞類繼承。可以通過直接訪問父類原型對象的方式訪問父類的方法。當你這麼做時,你必須以call()或apply()執行父類方法並傳入一個子類的對象。