Javascript的 抽象類、接口、多態與類型轉換

目錄:[ + ]

緣起

工作中需要用到Javascript,關注了Javascript中繼承複用的問題,翻閱了網上的各種關於Javascript繼承的帖子,感覺大都思考略淺,並沒有做過多說明,簡單粗暴的告訴你實現Javascript繼承有1.2.3.4.5幾種方式,名字都叫啥,然後貼幾行代碼就算是玩了。 
不管你們懂沒懂,反正我着實沒懂。 
隨後研讀了《Javascript高級程序設計》的部分章節,對Javascript的繼承機制略有體會。思考之後,遂而分享並且闡述瞭如何實現抽象類、接口、多態甚至是類型轉換的思路。

JS繼承,那就先說“繼承”

凡是玩過1、2種面向對象的語言的人大都不難歸納出繼承所有的幾個特性: 
1. 子類繼承父類中的屬性和方法 
2. 子類、父類的實例對象擁有兩份副本,改了其中之一,另一個實例對象的屬性並不會隨之改變 
3. 子類可覆蓋父類的方法或屬性 
4. 子類和父類的實例對象通過“[對象] instanceof [子類/父類]”判定的結果應該爲true 
5. 子類和父類的實例對象的constructor指針應該分別指向子類和父類的構造函數

構造一個類

說到構造一個Javascript的類,網上的說法五花八門。 
1. 有說JS中根本沒有類,用模擬實現的。對,但是也不對。 
Javascript中的確沒有class關鍵字,但是這並不帶表我們封裝不出一個“類”一樣的東西來。只不過在Javascript中不叫這個名字而已。遂而有人會反駁,在ECMAScript 6標準中要加入class關鍵字了,這不是明顯表示javascript現在不存在“類”麼?對於這樣的擡槓,只能“呵呵”了。 
2. 也有人說JS可以這麼構造一個類:

1 var Person = function(name, age) {
2     this.name = name;
3     this.age = age;
4 };
5 var p = new Person("小王", 10);

注1:此爲代碼1,後面可能作爲引用。 
注2: var Person = function(){}; 等同於 function Persson(){},前一種定義函數的方式沒有名字,故而在var的後面跟上其名字,而後面function定義直接就跟了名字Person了。不過事實上我更喜歡後一種,因爲可以少寫一個var和分號。但是如果在局部作用域要定義一個臨時類,我還是喜歡前一種,這是一種變量的方式。在局部作用域我更喜歡定義變量而不是函數或者類等結構性的東西,C語言後遺症,呵呵。 
注3:其實這種構建類的方式可以說成是通過構造器(constructor)來構造一個類。 
3. 也有人說,應該用Prototype來構造一個類,簡要代碼如下:

1 var Person = function (){};
2 Person.prototype.name = "小王";
3 Person.prototype.age = 10;
4 var p = new Person();

注4:這種構造方式,我們可以暫且稱之爲“用prototype”的方式來構造。 
注5:此爲代碼2,後面可能作引用。 
類的構建方式雖然五花八門,但是大抵都是以上兩種或者其組合的變種。可是我們什麼時候用構造函數來構建?什麼時候用prototype?什麼時候兩者結合使用呢?要明白這個,我們先來看看new關鍵字。

new,你到底幹了什麼事兒?

new關鍵字在絕大多數面向對象的語言中都扮演者舉足輕重的位置,javascript中也不例外。StackOverflow上有一篇帖子關於new關鍵字的玄機,我覺得說的很好:Javascript中的new關鍵字背後到底做了什麼 
翻譯如下,爲了懶得移步的童鞋,PC端的童鞋可以直接點過去。

  1. 創建一個新的簡單的Object類型的的對象; 
  2. 把Object的內部的[[prototype]]屬性設置爲構造函數prototype屬性。這個[[prototype]]屬性在Object內部是無法訪問到的,而構造函數的prototype是可以訪問到的; 
  3. 執行構造函數,如果構造函數中用到了this關鍵字,那就把這個this替換爲剛剛創建的那個object對象。 

注6:其實某個對象的[[prototype]]屬性在很多宿主環境中已經可以訪問到,例如Chrome和IE10都可以,用_proto_就可以訪問到,如果下面出現了_proto_字樣,那就代表一個對象的內部prototype。 
上面說了一大通,又是構造器,又是prototype,不知所云。下面依次解釋。

prototype

prototype屬性在構造函數中可以訪問到,在對象中需要通過prototype訪問到。它到底是什麼?prototype中定義了一個類所共享的屬性和方法。這就意味着:一旦prototype中的某個屬性的值變了,那麼所有這個類的實例的該屬性的值都變了。請看代碼:

01 function Person() {
02 }
03 Person.prototype.name = "小明";
04 var p1 = new Person();
05 console.log(Person.prototype);
06 console.log(p1.__proto__);
07 var p2 = new Person();
08 console.log(p1.name + "\t" + p2.name);
09 Person.prototype.name = "小王";
10 console.log(p1.name + "\t" + p2.name);

注7:此爲代碼3。 輸出結果如下: 
 
通過這個代碼3的實驗,我們可以得出以下結論: 
1. prototype屬性其實就是一個實例對象,其內容爲:Person {name: "小明"} 
2. 通過構造函數可以訪問到prototype屬性,通過對象的_proto_也可以訪問到prototype屬性。 
3. prototype原型指向的內容是所有對象共享的,只要prototype對象的某個屬性或者方法變了,那麼所有的通過這個類new出來的實例對象的該屬性和方法都變了。

this和構造函數

看完了上面的new關鍵字做的第3步,我們不難得出,其實利用constructor的方式來構造類本質:先new一個臨時實例對象,將this關鍵字替換爲臨時實例對象關鍵字,然後使用[對象].[屬性]=xxx的方式來構建一個對象,再將其返回。 
可是這樣帶來一個問題就是:方法不被共享。 
請看代碼4實驗:

01 function Person() {
02     this.name = "小明";
03     this.showName = function() {
04         console.log(this.name)
05     };
06 }
07 var p1 = new Person();
08 var p2 = new Person();
09 p1.showName();
10 p2.showName();
11 p1.showName = function() {
12     console.log("我不是小明,我是小王");
13 }
14 p1.showName();
15 p2.showName();

注8:以上爲代碼4。 
其運行結果爲: 
 
我們知道,類的同一個方法,應該儘量保持共享,因爲他們屬於同一個類,那麼這一個方法應該相同,所以應該保持共享,不然會浪費內存。 
我們的Person類中含有方法showName,雖然p1和p2實例屬於兩個實例對象,但是其showName卻指向了不同的內存塊! 
這可怎麼辦? 
對,請出我們的prototype,它可以實現屬性和方法的共享。請看代碼5實驗:

01 function Person() {
02     this.name = "小明";
03 }
04 Person.prototype.showName = function() {
05     console.log(this.name);
06 }
07 var p1 = new Person();
08 var p2 = new Person();
09 p1.showName();
10 p2.showName();
11 Person.prototype.showName = function() {
12     console.log("我的名字是" this.name);
13 }
14 p1.showName();
15 p2.showName();

注9:以上爲代碼5 。 運行結果如下: 
 
這樣我們非常完美地完成了一個類的構建,他滿足: 
1. 屬性非共享 
2. 方法共享(其實對於需要共享的屬性,我們也可以用prototype來設置) 
但是!大家在使用prototype來設置共享方法的時候千萬不要把構造函數的整個prototype都改寫了。這樣導致的結果就是:constructor不明。 
請看代碼6實驗。

01 function Person1() {
02 }
03 // prototype 沒有完全被改寫
04 Person1.prototype.showName = function() {
05     console.log(this.name);
06 };
07 var p1 = new Person1();
08 console.log(p1 instanceof Person1);
09 console.log(p1.constructor);
10 function Person2() {
11 }
12 // prototype 完全被改寫
13 Person2.prototype = {
14     showName : function() {
15         console.log(this.name);
16     }
17 };
18 var p2 = new Person2();
19 console.log(p2 instanceof Person2);
20 console.log(p2.constructor);

注10:以上爲代碼6 。 運行結果如下: 
 
通過以上代碼6的實驗,我們可以看出:重寫整個prototype會將對象的constructor指針直接指向了Object,從而導致了constructor不明的問題。 
如何解決呢?我們可以通過顯示指定其constructor爲Person即可。 
請看代碼7:

01 function Person2() {
02 }
03 // prototype 完全被改寫
04 Person2.prototype = {
05     constructor : Person2, // 顯示指定其constructor
06     showName : function() {
07         console.log(this.name);
08     }
09 };
10 var p2 = new Person2();
11 console.log(p2 instanceof Person2);
12 console.log(p2.constructor);

注11:以上爲代碼7 。 運行結果如下: 

對象、constructor和prototype三者之間的關係

上面說了那麼多,我想大家都有點被constructor、prototype、對象搞得雲裏霧裏的,其實我剛開始也是這樣。下面我總結敘述一下這三者之間的關係,相信看了之後就會逐漸明白的: 
1. 構造函數有個prototype屬性,這個prototype屬性指向一個實例對象,這個對象的所有的屬性和方法爲所有該構造函數實例化的類所共享! 
2. 對象的創建是通過constructor構造函數來創建的,每當new一次就調用一次構造函數,構造函數內部執行的機制是:new一個臨時Object實例空對象,然後把this關鍵字提換成這個臨時對象,然後依次設置這個臨時對象的各個屬性和方法,最後返回這個臨時實例對象。 
3. 被實例化的對象本身有個_proto_指針,指向創建該對象的構造函數的的prototype對象。 
如果你還是雲裏霧裏的,沒有關係,我們來看下Javascript的Object架構,看完這個你肯定就會明白的一清二楚了。

Javascript的Object架構

 
解釋如下: 
1. var f1 = new Foo();創建了一個Foo對象; 
2. f1對象有個內部__proto__屬性,指向了一個prototype的實例對象Foo.prototype; 
3. Foo.prototype有個constructor屬性,指向了Foo構造函數,這個屬性的值標明瞭,這個f1對象的類型,也即f1 instanceof Foo的結果爲true; 
4. 構造函數Foo有個prototype屬性,指向了prototype實例對象,這個prototype屬性是通過Foo可以直接訪問到的Foo.prototype; 
5. 剩下的解釋,大家能看就看懂,看不懂我後續再出文章解釋吧。與本篇關係不是太大了。

Javascript對象的屬性查找方式

我們訪問一個Javascript對象的屬性(含“方法”)的時候,查找過程到底是什麼樣的呢? 
先找先找對象屬性,對象的屬性中沒有,那就找對象的prototype共享屬性 
請看代碼8:

1 var p = {
2     name : "小明"
3 };
4 //對象中能查找到name
5 console.log(p.name);
6 //對象中找不到myName,查找其prototype屬性,由於p是Object類型的對象,故而查找Object的prototype是否有myName
7 console.log(p.myName);
8 Object.prototype.myName="我的名字是小明";
9 console.log(p.myName);

注12:以上爲代碼8 。 
結果如下: 
 
此處不難理解,不多做解釋。

按照“繼承”理念來實現JS繼承

在我們懂了prototype、constructor、對象、new之後,我們可以真正按照“繼承”的理念來實現javascript的繼承了。

原型鏈

試想一下,如果構造函數的prototype對象的_proto_指針(每個實例對象都有一個proto指針)指向的是另一個prototype對象(我們稱之爲prototype對象2)的話,而prototype對象2的constructor指向的是構建prototype對象2的構造函數。那麼依次往復,就構成了原型鏈。 
上面的話有點繞口,大家多多體會。 
我結合上面的Javascript對象的架構繼續給大家說說: 
1. 大家可以看到Foo.prototype對象的_proto_指向了Object.prototype對象,而這個Object.prototype的constructor屬性指向的是Object構造函數。這裏就是一個簡單的原型鏈。 
2. 所有的類都有原型鏈,最終指向Object。 
大家或許已經懷疑,聽說Javascript的所有的對象都是繼承自Object對象,那麼Javascript繼承是不是就這個原型連搞的鬼呢? 
是,但是不完全是。 
原型鏈只能繼承共享的屬性和方法,對於非共享的屬性和方法,我們需要通過顯示調用父類構造函數來實現 
查找對象的屬性的修正: 
1. 查找對象是否含有該屬性; 
2. 如果沒有改屬性,則查找其prototype是否含有該屬性; 
3. 如果還是沒有,則向上查找原型鏈的上一級,查找其prototype的_proto_所指向的prototype是否含有該屬性,直到查找Object。 
所以很簡單,我們想要實現Javascript的繼承已經呼之欲出了: 
1. 繼承prototype中定義的屬性和方法; 
2. 繼承構造函數中定義的屬性和方法; 
3. 修改子類的prototype對象的constructor指針,使得constructor的判別正確。

繼承構造函數中定義的屬性和方法

我們通過call或者apply方法即可實現父類構造函數調用,然後把當前對象this和參數傳遞給父類,這樣就可以實現繼承構造函數中定義的屬性和方法了。請看代碼9:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 調用Person構造函數,把this和參數傳遞給Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 var m = new Male("小明", 20);
14 console.log(m);
15 console.log(m instanceof Male);
16 console.log(m instanceof Person);
17 console.log(m instanceof Object);

執行結果如下: 
 
1. 大家可以看到,m就是一個很簡單的對象,只有name,age,sex三個屬性,不含有showName方法,因爲這個是在Person.prototype中繼承過來的。 
2. m instanceof Person結果爲false, 顯然m.\__proto\__.constructor指向的是Male構造函數,而非Person。 3. 可是m instanceof Object的結果卻爲true,那是因爲m的原型鏈的上一級爲Object類型,故而instance of Object的結果爲true。

繼承prototype中定義的屬性和方法,並且與繼承構造函數結合起來

如何繼承prototype中定義的屬性和方法呢? 
直接把父類的prototype給子類的prototype不就行了。 
的確,這樣是能夠實現方法共享,可是一旦子類的prototype的某個方法被重寫了,那麼父類也會擱着變動,怎麼辦? 
new一個父類!賦值給子類的prototype。 
請看代碼10:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 調用Person構造函數,把this和參數傳遞給Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 // 繼承prototype
14 Male.prototype = new Person();
15 var m = new Male("小明", 20);
16 console.log(m);
17 console.log(m instanceof Male);
18 console.log(m instanceof Person);
19 console.log(m instanceof Object);

結果如下: 
 
大家可以看到m對象不僅有name, age , sex三個屬性,而且通過其原型鏈可以找到showName方法。 
如果大家仔細觀察,會發現多出了兩個undefined值的name和age! 
爲什麼?! 
究其原因,因爲在執行Male.prototype = new Person()的時候,這兩個屬性就在內存中分配了值了。而且改寫了Male的整個prototype,導致Male對象的constructor也跟着變化了,這也不好。 
這並不是我們想要的!我們只是單純的想要繼承prototype,而不想要其他的屬性。 
怎麼辦? 
借用一個空的構造函數,借殼繼承prototype,並且顯示設置constructor 
代碼如下:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 調用Person構造函數,繼承構造函數的屬性,把this和參數傳遞給Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 // 借用一個空的構造函數
14 function F() { }
15 F.prototype = Person.prototype;
16 // 繼承prototype
17 Male.prototype = new F();
18 // 顯示指定constructor
19 Male.prototype.constructor = Male;
20 var m = new Male("小明", 20);
21 console.log(m);
22 m.showName();
23 console.log(m.constructor == Male);
24 console.log(m instanceof Person);
25 console.log(m instanceof Male);
26 console.log(m instanceof F);

執行結果: 
 
我們可喜的將m的constructor正本清源!而且instanceof類型判斷都沒有錯誤(instanceof本質上是通過原型鏈找的,只要有一個原型滿足了那結果就爲true)。

繼承prototype的封裝&測試

上述繼承prototype的代碼很是醜陋,讓我們封裝起來吧。並且測試了一下代碼:

01 // 繼承prototype & 設定subType的constructor爲子類,不跟着prototype變化而變化
02 function inheritPrototype(subType, superType) {
03     // 以下三行可以寫成一個新的函數來完成
04     function F() {
05     }
06     // 把F的prototype指向父類的prototype,修改整個prototype而不是部分prototype
07     F.prototype = superType.prototype;
08     // new F()完成兩件事情,1. 執行F構造函數,爲空;2. 執行F的prototype的內存分配,這裏就是父類,也就是Person的getAge方法
09     // 所以這裏是繼承了父類的getAge()方法,賦值給了proto
10     var proto = new F();
11     // proto的構造函數顯示指定爲子類(由於上面重寫了F的prototype,故而構造函數也變化了)
12     proto.constructor = subType;
13     // 實現真正意義上的prototype的繼承,並且constructor爲子類
14     subType.prototype = proto;
15 }
16 function Person(name, age) {
17     this.name = name;
18     this.age = age;
19     this.getName = function() {
20         return this.name;
21     };
22 }
23 Person.prototype.getAge = function() {
24     return this.age;
25 };
26 function Male(name, age) {
27     Person.apply(this, [name, age]); // 借用構造函數繼承屬性
28     this.sex = "男";
29     this.getSex = function() {
30         return this.sex;
31     };
32 }
33 inheritPrototype(Male, Person);
34 // 方法覆蓋
35 Male.prototype.getAge = function() {
36     return this.age + 1;
37 };
38 var p = new Person("好女人", 30);
39 var m = new Male("好男人", 30);
40 console.log(p);
41 console.log(m);
42 console.log(p.getAge());
43 console.log(m.getAge());

運行結果爲: 
 
至此,我們已經完成了真正意義上的javascript繼承! 
讓我們再來回頭驗證一下,TDD嘛~呵呵 
1. 子類繼承父類中的屬性和方法。Check! 
2. 子類、父類的實例對象擁有兩份副本,改了其中之一,另一個實例對象的屬性並不會隨之改變。Check!通過constructor繼承屬性,由於採用了new,故而每個實例對象的屬性肯定是有不同的副本。 
3. 子類可覆蓋父類的方法或屬性。Check!由於方法的繼承是採用繼承prototype來實現的,借F的prototype來繼承,所以所有被繼承的方法都在new F()的一剎那存在了F中,而F是一個空構造函數,故而沒有多餘的屬性,只有被繼承的方法。我們再將這個F實例對象指向子類構造函數的prototype對象,即可實現方法繼承。從而在改寫子類的prototype中的方法並不會影響到父類的prototype中的方法,從而實現方法重寫! 
4. 子類和父類的實例對象通過“[對象] instanceof [子類/父類]”判定的結果應該爲true。Check!原型鏈沒有斷掉。子類的_proto_指向F,F的_proto_指向父類。 5. 子類和父類的實例對象的constructor指針應該分別指向子類和父類的構造函數。Check!我們在寫的過程中顯示制定了constructor,所以constructor指針的指向也不會錯。

總結

我們是通過: 
1. 繼承父類的構造函數來實現屬性繼承; 
2. 借中間函數F,繼承父類的prototype來實現方法繼承&方法覆蓋; 
3. 顯示指定constructor防止prototype改寫帶來的問題。 
至此,較爲漂亮的完成了Javascript的繼承! 
通過此思路,想要實現抽象類,接口等面向對象的概念應該也不是難事吧。呵呵。 
抽象類:父類構造函數中只有方法定義,則該父類即爲抽象父類。 
接口:父類構造函數中方法定義爲空。 
多態:父類中調用一個未實現的函數,在子類中實現即可。 
類型轉換:把中間層F斷掉,重新指定實例對象的_proto_指向的prototype對象,那麼F中繼承的方法將不復存在,故而調用方法就是直接調用被指向的prototype對象的方法了。關於類型轉換的代碼如下:

01 // 繼承prototype & 設定subType的constructor爲子類,不跟着prototype變化而變化
02 function inheritPrototype(subType, superType) {
03     // 以下三行可以寫成一個新的函數來完成
04     function F() {
05     }
06     // 把F的prototype指向父類的prototype,修改整個prototype而不是部分prototype
07     F.prototype = superType.prototype;
08     // new F()完成兩件事情,1. 執行F構造函數,爲空;2. 執行F的prototype的內存分配,這裏就是父類,也就是Person的getAge方法
09     // 所以這裏是繼承了父類的getAge()方法,賦值給了proto
10     var proto = new F();
11     // proto的構造函數顯示指定爲子類(由於上面重寫了F的prototype,故而構造函數也變化了)
12     proto.constructor = subType;
13     // 實現真正意義上的prototype的繼承,並且constructor爲子類
14     subType.prototype = proto;
15 }
16 function Person(name, age) {
17     this.name = name;
18     this.age = age;
19     this.getName = function() {
20         return this.name;
21     };
22 }
23 Person.prototype.getAge = function() {
24     return this.age;
25 };
26 function Male(name, age) {
27     Person.apply(this, [name, age]); // 借用構造函數繼承屬性
28     this.sex = "男";
29     this.getSex = function() {
30         return this.sex;
31     };
32 }
33 inheritPrototype(Male, Person);
34 // 方法覆蓋
35 Male.prototype.getAge = function() {
36     return this.age + 1;
37 };
38 var p = new Person("好女人", 30);
39 var m = new Male("好男人", 30);
40 console.log(p);
41 console.log(m);
42 // 將m轉換爲Person類型從而調用Person類的方法
43 m.__proto__ = Person.prototype;
44 console.log(p.constructor == Person);
45 console.log(m.constructor == Male);
46 console.log(m instanceof Male);
47 console.log(m instanceof Person);
48 console.log(p.getAge());
49 console.log(m.getAge());
50 // 將m轉換爲Male類型從而調用Male類的方法
51 m.__proto__ = Male.prototype;
52 console.log(p.constructor == Person);
53 console.log(m.constructor == Male);
54 console.log(m instanceof Male);
55 console.log(m instanceof Person);
56 console.log(p.getAge());
57 console.log(m.getAge());

運行結果: 
 
大家可以看到類型轉換之後,調getAge()方法的不同了吧。

【文獻引用】 
1.《Professional Javascript for Web Developers》 3rd. Edition 第六章

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章