JavaScript中的函式繼承

WaterMan發表於2018-12-04

物件導向和基於物件

幾乎每個開發人員都有物件導向語言(比如C++、C#、Java)的開發經驗。在傳統物件導向的語言中,有兩個非常重要的概念——類和例項。定義了一些事物公共的行為和方法;而例項則是類的一個具體實現。我們還知道,物件導向程式設計有三個重要的概念——封裝、繼承和多型。 但是在Javascript的世界中,所有的這一切特性似乎都不存在。因為Javascript本身不是物件導向的語言,而是基於物件的語言。Javascript中所有事物都是物件,包括字串、陣列、日期,甚至是函式,請看一個有趣的例項:

//定義一個函式 
function add(a,b){
	add.invokeTimes++;
	return a+b;
}
//因為函式本身也是物件,在這裡我們為add定義一個屬性,用來記錄次函式被呼叫的次數
add.invokeTimes = 0;
add(1,1);
add(2,2);
console.log(add.invokeTimes);//2
複製程式碼

模擬Javascript中類和繼承

在物件導向的語言中,我們使用類來建立一個自定義物件。然而Javascript中所有事物都是物件,那麼用什麼方法來建立自定義物件呢? 在這裡我們引入一個新概念——原型(prototype),我們可以簡單的把prototype看做是一個模板,新建立的自定義物件都是這個模板(prototye)的一個拷貝(實際上不是拷貝而是連結,只不過這種連結是不可見,給人的感覺好像是拷貝)。 使用prototype建立自定義物件的一個例子:

//建構函式
function Person(name,gender){
	this.name = name;
	this.gender = gender;
}
//定義Person的原型,原型中的屬性可以被自定義物件引用
Person.prototype = {
	getName:function(){
		return this.name;
	},
	getGender:function() {
		return this.gender;
	}
}
複製程式碼

這裡我們把函式Person稱為建構函式,也就是建立自定義物件的函式。可以看出,Javascript通過結構函式和原型的方式模擬實現了類的功能。 建立自定義物件(例項化類):

var Person1 = new Person("張三","男");
console.log(Person1.getName());//張三
var Person2 = new Person("娜娜","女");
console.log(Person2.getName());//娜娜
複製程式碼

當程式碼var Person1 = new Person("張三","男")執行時,其實內部做了如下幾件事情:

建立一個空白物件(new Object())。 拷貝Person.prototype中的屬性(鍵值對)到這個空物件中(我們前面提到,內部實現時不是拷貝而是一個隱藏的連結)。 將這個物件通過this關鍵字傳遞到建構函式中並執行建構函式。 將這個物件賦值給變數Person1。

為了證明prototype模板並不是被拷貝到例項化的物件中,而是一種連結的方式,請看如下例項:

function Person(name,gender){
	this.name = name;
	this.gender= gender;
}
Person.prototype.age = 20;
var Person1 = new Person('娜娜','女');
console.log(Person1.age);

//覆蓋prototype中的age屬性
Person1.age = 25;
console.log(Person1.age);//25
delete Person1.age;
//在刪除例項屬性age後,此屬性值又從prototype中獲取
console.log(Person1.age);//20
複製程式碼

Javascript繼承的幾種方式

為了闡述Javascript繼承的幾種方式,首先我們提前約定共同語言:

//約定
function Fun(){
	//私有屬性
	var val = 1;        //私有基本屬性
	var arr = [1];      //私有引用屬性
	function fun() {}   //私有函式(引用屬性)
	
	//例項屬性
	this.val = 1;              //公有基本屬性
	this.arr = [1];            //公有引用屬性
	this.fun = function(){};   //公有函式(引用屬性)
}
//原型屬性
Fun.prototype.val = 1;             //原型基本屬性
Fun.prototype.arr = [1];           //原型引用屬性
Fun.prototype.fun = function(){};  //原型函式(引用屬性)
複製程式碼

一、簡單原型鏈實現繼承

這是實現繼承最簡單的方式了。 如果“貓”的prototype物件,指向一個Animal的示例,那麼所有“貓”的例項,就能繼承Animal了。

具體實現

function Animal(){
	this.species = "動物";
	this.classes = ['脊椎動物','爬行動物'];
}
function Cat(name,color){
	this.name = name;
	this.color = color;
}
//將Cat的prototype物件指向一個Animal的例項
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

var cat1 = new Cat("大毛","黃色");
var cat2 = new Cat("二毛","白色");
cat1.classes.push('哺乳動物');
cat1.species = '哺乳動物';
console.log(cat1.species);//哺乳動物
console.log(cat2.species);//動物
console.log(cat1.classes);//["脊椎動物", "爬行動物", "哺乳動物"]
console.log(cat2.classes);//["脊椎動物", "爬行動物", "哺乳動物"]
複製程式碼

我們將Cat的prototype物件指向一個Animal的示例。

Cat.prototype = new Animal();
複製程式碼

它相當於完全刪除了prototype物件原先的值,然後賦予一個新值。

Cat.prototype.constructor = Cat;
複製程式碼

任何一個prototype物件都有一個constructor屬性,指向它的建構函式。如果沒有“Cat.prototype = new Animal(); ”這一行,Cat.prototype.constructor是指向Cat的;加了這一行以後,Cat.prototype.constructor指向Animal。

console.log(Cat.prototype.constructor == Animal);//true
複製程式碼

更重要的是,每一個例項也有一個constructor屬性,預設呼叫prototype物件的constructor屬性。

console.log(cat1.constructor = Cat.prototype.constructor);//true
複製程式碼

因此,在執行“Cat.prototype = new Animal();”這一行之後,cat1.constructor也指向了Animal!

console.log(cat1.constructor == Animal);//true
複製程式碼

這顯然會導致繼承鏈的紊亂(cat1明明是建構函式Cat生成的),因此我們必須手動糾正,將Cat.prototype物件的constructor值改為Cat。 這一點很重要,程式設計時務必遵守。如果替換裡prototype物件

o.prototype = {};
複製程式碼

那麼,下一步必然是為新的prototype物件加上contructor屬性,並將這個屬性指回原來的建構函式。

o.prototype.constructor = o;
複製程式碼

存在的問題

1.修改cat1.classes後cat2.classes也發生了變化,因為來自原型物件的引用屬性是所有例項共享的。 可以這樣理解:執行cat1.classes.push('哺乳動物');先對cat1進行屬性查詢,找遍了例項屬性(在本例中沒有例項屬性),沒找到,就開始順著原型鏈向上找,拿到了cat1的原型物件,一查詢,發現有classes屬性。於是給classes末尾插入了‘哺乳動物’,所喲cat2.classes也發生了變化。

2.建立子類例項時,無法向父類建構函式傳遞引數。

二、借用建構函式和call或者apply方法

簡單原型鏈真夠簡單,可是存在兩個致命的缺點簡直無法使用,於是上世紀末的Jsers就想辦法修復了這兩個缺陷,然後就出現了借用建構函式這種方式。

具體實現

function Animal(species){
	this.species = species;
	this.classes = ['脊椎動物','爬行動物'];
}
function Cat(name,color,species){
	Animal.call(this,species);//核心
	this.name = name;
	this.color = color;
}

var cat1 = new Cat("大毛","黃色",'動物');
var cat2 = new Cat("二毛","白色",'哺乳動物');

cat1.classes.push('哺乳動物');
console.log(cat1.species);//動物
console.log(cat2.species);//哺乳動物

console.log(cat1.classes);//["脊椎動物", "爬行動物", "哺乳動物"]
console.log(cat2.classes);//["脊椎動物", "爬行動物"]
複製程式碼

核心

借父類的建構函式來增強子類例項,等於是把父類的例項屬性複製了一份給子類例項裝上了(完全沒有用到原型)。

優缺點

優點: 1.解決了子類例項共享父類引用屬性的問題; 2.建立子類例項時,可以向父類建構函式傳參。 缺點: 無法實現函式複用,每個子類例項都持有一個新的fun函式,太多了就會影響效能,記憶體爆炸。

三、組合繼承(最常用)

目前我們借用建構函式方式還是有問題(無法實現函式複用),沒關係,接著修復,於是出現了組合繼承。

具體實現

function Animal(species){
	//只在此處宣告基本屬性和引用屬性
	this.species = species;
	this.classes = ['脊椎動物','爬行動物'];
}
//在此處宣告函式
Animal.prototype.eat = function(){
	console.log('動物必須吃東西獲取能量');
}
Animal.prototype.run = function(){
	console.log('動物正在跑動');
}
function Cat(name,color,species){
	Animal.call(this,species);//核心
	this.name = name;
	this.color = color;
}
Cat.prototype = new Animal();

var cat1 = new Cat("大毛","黃色",'動物');
var cat2 = new Cat("二毛","白色",'哺乳動物');

cat1.classes.push('哺乳動物');
console.log(cat1.species);//動物
console.log(cat2.species);//哺乳動物

console.log(cat1.classes);//["脊椎動物", "爬行動物", "哺乳動物"]
console.log(cat2.classes);//["脊椎動物", "爬行動物"]
console.log(cat1.eat === cat2.eat);//true
複製程式碼

具體實現

把例項函式都放在原型物件上,以實現函式複用。同時還要保留借用建構函式方式的優點,通過Animal.call(this,species)繼承父類的基本屬性和引用屬性並保留能傳參的優點;通過Cat.prototype = new Animal(),繼承父類函式,實現函式複用。

優缺點

優點: 1.不存在引用屬性共享的問題 2.可傳參 3.函式可以複用 缺點: (一點小瑕疵)子類原型上有一份多餘的父類例項屬性,因為父類建構函式被呼叫了兩次,生成了兩份,而子類例項上的那一份遮蔽了子類原型上的。又是記憶體浪費,不過已經改進了很多。

四、直接繼承prototype(改進簡單原型鏈繼承)

第四種方法是對第二種方法的改進。由於Animal物件中,不變的屬性都可以直接寫入Animal.prototype。所以,我們也可以讓Cat()跳過Animal(),直接繼承Animal.prototype。

具體實現

function Animal(){}
Animal.prototype.species = '動物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
//將Cat的prototype物件指向Animal的prototype物件,這樣就實現了繼承
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

var cat1 = new Cat('大毛','黃色');
console.log(cat1.species);//動物
複製程式碼

優缺點

優點: 與第一種方法相比,這樣做的優點是效率比較高(不用執行和建立Animal的示例了),比較省記憶體。 缺點: Cat.prototype和Animal.prototype現在指向了同一個物件,那麼任何對Cat.prototype的修改,都會反映到Animal.prototype。 Cat.prototype.constructor = Cat,把Animal.prototype物件的constructor屬性也改掉了

console.log(Animal.prototype.constructor);//Cat
複製程式碼

五、利用空物件作為中介(寄生組合繼承)

由於“直接繼承prototype”存在上述的缺點,所以就有了以下方法,利用一個空物件作為中介。

function Animal(){}
Animal.prototype.species = '動物';
function Cat(name,color){
	Animal.call(this);
	this.name = name;
	this.color = color;
}

//利用空物件作為中介,核心
var F = function(){};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;
複製程式碼

F是空物件,所以幾乎不佔記憶體。這時,修改Cat的prototype物件,就不會影響到Animal的prototype物件。

console.log(Animal.prototype.constructor);//Animal
複製程式碼

將上述方法封裝成一個函式,便於使用

function extend(Child,Parent){
	var F = function(){};
	F.prototype = Parent.prototype;
	Child.prototype = new F();
	Child.prototype.constructor = Child;
	Child.uber = Parent.prototype;
}
複製程式碼

使用方法如下:

function Animal(){}
Animal.prototype.species = '動物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
extend(Cat,Animal);
var cat1 = new Cat('大毛','黃色');
console.log(cat1.species);//動物
複製程式碼

函式的最後一行

Child.uber = Parent.prototype;
複製程式碼

為子物件設一個uber屬性,這個屬性直接指向父物件的prototype屬性。(uber是一個德語詞,意思是"向上"、"上一層"。)這等於在子物件上開啟一條通道,可以直接呼叫父物件的方法。這一行放在這裡,只是為了實現繼承的完備性,純屬備用性質。

六、拷貝繼承

上面是採用prototype物件,實現繼承。我們也可以換一種思路,純粹採用“拷貝”方法實現繼承。簡單說,就是把父物件的所有屬性和方法,拷貝進子物件。 定義一個函式,實現屬性拷貝的目的:

function extend(Child,Parent){
	var p = Parent.prototype;
	var c = Child.prototype;
	for(var i in p){
		c[i] = p[i];
	}
	c.uber = p;
}
複製程式碼

這個函式的作用就是將父物件的prototype物件中的屬性,一一拷貝給Child物件的prototype物件。 繼承的具體實現如下:

function Animal(){}
Animal.prototype.species = '動物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
extend(Cat,Animall);
var cat = new Cat('大毛','黃色');
console.log(cat.species);//動物
複製程式碼

相關文章