由Javascript的繼承引起的:抽象類、接口、多態,甚至是類型轉換!

##緣起## 工做中須要用到Javascript,關注了Javascript中繼承複用的問題,翻閱了網上的各類關於Javascript繼承的帖子,感受大都思考略淺,並無作過多說明,簡單粗暴的告訴你實現Javascript繼承有1.2.3.4.5幾種方式,名字都叫啥,而後貼幾行代碼就算是玩了。
無論大家懂沒懂,反正我着實沒懂。
隨後研讀了《Javascript高級程序設計》的部分章節,對Javascript的繼承機制略有體會。思考以後,遂而分享而且闡述瞭如何實現抽象類、接口、多態甚至是類型轉換的思路。
##JS繼承,那就先說「繼承」## 凡是玩過一、2種面向對象的語言的人大都不難概括出繼承全部的幾個特性:javascript

  1. 子類繼承父類中的屬性和方法
  2. 子類、父類的實例對象擁有兩份副本,改了其中之一,另外一個實例對象的屬性並不會隨之改變
  3. 子類可覆蓋父類的方法或屬性
  4. 子類和父類的實例對象經過「[對象] instanceof [子類/父類]」斷定的結果應該爲true
  5. 子類和父類的實例對象的constructor指針應該分別指向子類和父類的構造函數

##構造一個類## 說到構造一個Javascript的類,網上的說法五花八門。java

  1. 有說JS中根本沒有類,用模擬實現的。對,可是也不對。
    Javascript中的確沒有class關鍵字,可是這並不帶表咱們封裝不出一個「類」同樣的東西來。只不過在Javascript中不叫這個名字而已。遂而有人會反駁,在ECMAScript 6標準中要加入class關鍵字了,這不是明顯表示javascript如今不存在「類」麼?對於這樣的擡槓,只能「呵呵」了。
  2. 也有人說JS能夠這麼構造一個類:
var Person = function(name, age) {
        this.name = name;
        this.age = age;
    };
    var p = new Person("小王", 10);

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

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

注4:這種構造方式,咱們能夠暫且稱之爲「用prototype」的方式來構造。
注5:此爲代碼2,後面可能做引用。
**類的構建方式雖然五花八門,可是大抵都是以上兩種或者其組合的變種。但是咱們何時用構造函數來構建?何時用prototype?何時二者結合使用呢?**要明白這個,咱們先來看看new關鍵字。
###new,你到底幹了什麼事兒?## new關鍵字在絕大多數面向對象的語言中都扮演者舉足輕重的位置,javascript中也不例外。StackOverflow上有一篇帖子關於new關鍵字的玄機,我以爲說的很好:Javascript中的new關鍵字背後到底作了什麼
翻譯以下,爲了懶得移步的童鞋,PC端的童鞋能夠直接點過去。app

  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中的某個屬性的值變了,那麼全部這個類的實例的該屬性的值都變了。請看代碼:函數

function Person() {
    }
    Person.prototype.name = "小明";
    var p1 = new Person();
    console.log(Person.prototype);
    console.log(p1.__proto__);
    var p2 = new Person();
    console.log(p1.name + "\t" + p2.name);
    Person.prototype.name = "小王";
    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實驗:this

function Person() {
    	this.name = "小明";
    	this.showName = function() {
    		console.log(this.name)
    	};
    }
    var p1 = new Person();
    var p2 = new Person();
    p1.showName();
    p2.showName();
    p1.showName = function() {
    	console.log("我不是小明,我是小王");
    }
    p1.showName();
    p2.showName();

注8:以上爲代碼4。
其運行結果爲:

咱們知道,類的同一個方法,應該儘可能保持共享,由於他們屬於同一個類,那麼這一個方法應該相同,因此應該保持共享,否則會浪費內存。
咱們的Person類中含有方法showName,雖然p1和p2實例屬於兩個實例對象,可是其showName卻指向了不一樣的內存塊
這可怎麼辦?
對,請出咱們的prototype,它能夠實現屬性和方法的共享。請看代碼5實驗:prototype

function Person() {
    	this.name = "小明";
    }
    Person.prototype.showName = function() {
    	console.log(this.name);
    }
    var p1 = new Person();
    var p2 = new Person();
    p1.showName();
    p2.showName();
    Person.prototype.showName = function() {
    	console.log("個人名字是" + this.name);
    }
    p1.showName();
    p2.showName();

注9:以上爲代碼5 。 運行結果以下:

這樣咱們很是完美地完成了一個類的構建,他知足:翻譯

  1. 屬性非共享
  2. 方法共享(其實對於須要共享的屬性,咱們也能夠用prototype來設置)
    可是!你們在使用prototype來設置共享方法的時候千萬不要把構造函數的整個prototype都改寫了。這樣致使的結果就是:constructor不明。 請看代碼6實驗。
function Person1() {
    }
    // prototype 沒有徹底被改寫
    Person1.prototype.showName = function() {
    	console.log(this.name);
    };
    var p1 = new Person1();
    console.log(p1 instanceof Person1);
    console.log(p1.constructor);
    function Person2() {
    }
    // prototype 徹底被改寫
    Person2.prototype = {
    	showName : function() {
    		console.log(this.name);
    	}
    };
    var p2 = new Person2();
    console.log(p2 instanceof Person2);
    console.log(p2.constructor);

注10:以上爲代碼6 。 運行結果以下:

經過以上代碼6的實驗,咱們能夠看出:重寫整個prototype會將對象的constructor指針直接指向了Object,從而致使了constructor不明的問題。
如何解決呢?咱們能夠經過顯示指定其constructor爲Person便可。
請看代碼7:設計

function Person2() {
    }
    // prototype 徹底被改寫
    Person2.prototype = {
    	constructor : Person2, // 顯示指定其constructor
    	showName : function() {
    		console.log(this.name);
    	}
    };
    var p2 = new Person2();
    console.log(p2 instanceof Person2);
    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架構### Javascript Object Layout
解釋以下:

  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:

var p = {
    	name : "小明"
    };
    //對象中能查找到name
    console.log(p.name);
    //對象中找不到myName,查找其prototype屬性,因爲p是Object類型的對象,故而查找Object的prototype是否有myName
    console.log(p.myName);
    Object.prototype.myName="個人名字是小明";
    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:

function Person(name, age) {
    	this.name = name;
    	this.age = age;
    }
    Person.prototype.showName = function() {
    	console.log(this.name);
    };
    function Male(name, age) {
    	// 調用Person構造函數,把this和參數傳遞給Person
    	Person.apply(this, arguments);
    	this.sex = "男";
    }
    var m = new Male("小明", 20);
    console.log(m);
    console.log(m instanceof Male);
    console.log(m instanceof Person);
    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:

function Person(name, age) {
    	this.name = name;
    	this.age = age;
    }
    Person.prototype.showName = function() {
    	console.log(this.name);
    };
    function Male(name, age) {
    	// 調用Person構造函數,把this和參數傳遞給Person
    	Person.apply(this, arguments);
    	this.sex = "男";
    }
    // 繼承prototype
    Male.prototype = new Person();
    var m = new Male("小明", 20);
    console.log(m);
    console.log(m instanceof Male);
    console.log(m instanceof Person);
    console.log(m instanceof Object);

結果以下:

你們能夠看到m對象不只有name, age , sex三個屬性,並且經過其原型鏈能夠找到showName方法。
若是你們仔細觀察,會發現多出了兩個undefined值的name和age
爲何?!
究其緣由,由於在執行Male.prototype = new Person()的時候,這兩個屬性就在內存中分配了值了。並且改寫了Male的整個prototype,致使Male對象的constructor也跟着變化了,這也很差。
這並非咱們想要的!咱們只是單純的想要繼承prototype,而不想要其餘的屬性。
怎麼辦?
借用一個空的構造函數,借殼繼承prototype,而且顯示設置constructor
代碼以下:

function Person(name, age) {
    	this.name = name;
    	this.age = age;
    }
    Person.prototype.showName = function() {
    	console.log(this.name);
    };
    function Male(name, age) {
    	// 調用Person構造函數,繼承構造函數的屬性,把this和參數傳遞給Person
    	Person.apply(this, arguments);
    	this.sex = "男";
    }
    // 借用一個空的構造函數
    function F() { }
    F.prototype = Person.prototype;
    // 繼承prototype
    Male.prototype = new F();
    // 顯示指定constructor
    Male.prototype.constructor = Male;
    var m = new Male("小明", 20);
    console.log(m);
    m.showName();
    console.log(m.constructor == Male);
    console.log(m instanceof Person);
    console.log(m instanceof Male);
    console.log(m instanceof F);

執行結果:

咱們可喜的將m的constructor正本清源!並且instanceof類型判斷都沒有錯誤(instanceof本質上是經過原型鏈找的,只要有一個原型知足了那結果就爲true)。 ##繼承prototype的封裝&測試 上述繼承prototype的代碼非常醜陋,讓咱們封裝起來吧。而且測試了一下代碼:

// 繼承prototype & 設定subType的constructor爲子類,不跟着prototype變化而變化
    function inheritPrototype(subType, superType) {
        // 如下三行能夠寫成一個新的函數來完成
        function F() {
        }
        // 把F的prototype指向父類的prototype,修改整個prototype而不是部分prototype
        F.prototype = superType.prototype;
        // new F()完成兩件事情,1. 執行F構造函數,爲空;2. 執行F的prototype的內存分配,這裏就是父類,也就是Person的getAge方法
        // 因此這裏是繼承了父類的getAge()方法,賦值給了proto
        var proto = new F();
        // proto的構造函數顯示指定爲子類(因爲上面重寫了F的prototype,故而構造函數也變化了)
        proto.constructor = subType;
        // 實現真正意義上的prototype的繼承,而且constructor爲子類
        subType.prototype = proto;
    }
    function Person(name, age) {
        this.name = name;
        this.age = age;
        this.getName = function() {
            return this.name;
        };
    }
    Person.prototype.getAge = function() {
        return this.age;
    };
    function Male(name, age) {
        Person.apply(this, [name, age]); // 借用構造函數繼承屬性
        this.sex = "男";
        this.getSex = function() {
            return this.sex;
        };
    }
    inheritPrototype(Male, Person);
    // 方法覆蓋
    Male.prototype.getAge = function() {
        return this.age + 1;
    };
    var p = new Person("好女人", 30);
    var m = new Male("好男人", 30);
    console.log(p);
    console.log(m);
    console.log(p.getAge());
    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對象的方法了。關於類型轉換的代碼以下:

// 繼承prototype & 設定subType的constructor爲子類,不跟着prototype變化而變化
   function inheritPrototype(subType, superType) {
       // 如下三行能夠寫成一個新的函數來完成
       function F() {
       }
       // 把F的prototype指向父類的prototype,修改整個prototype而不是部分prototype
       F.prototype = superType.prototype;
       // new F()完成兩件事情,1. 執行F構造函數,爲空;2. 執行F的prototype的內存分配,這裏就是父類,也就是Person的getAge方法
       // 因此這裏是繼承了父類的getAge()方法,賦值給了proto
       var proto = new F();
       // proto的構造函數顯示指定爲子類(因爲上面重寫了F的prototype,故而構造函數也變化了)
       proto.constructor = subType;
       // 實現真正意義上的prototype的繼承,而且constructor爲子類
       subType.prototype = proto;
   }
   function Person(name, age) {
       this.name = name;
       this.age = age;
       this.getName = function() {
           return this.name;
       };
   }
   Person.prototype.getAge = function() {
       return this.age;
   };
   function Male(name, age) {
       Person.apply(this, [name, age]); // 借用構造函數繼承屬性
       this.sex = "男";
       this.getSex = function() {
           return this.sex;
       };
   }
   inheritPrototype(Male, Person);
   // 方法覆蓋
   Male.prototype.getAge = function() {
       return this.age + 1;
   };
   var p = new Person("好女人", 30);
   var m = new Male("好男人", 30);
   console.log(p);
   console.log(m);
   // 將m轉換爲Person類型從而調用Person類的方法
   m.\__proto\__ = Person.prototype;
   console.log(p.constructor == Person);
   console.log(m.constructor == Male);
   console.log(m instanceof Male);
   console.log(m instanceof Person);
   console.log(p.getAge());
   console.log(m.getAge());
   // 將m轉換爲Male類型從而調用Male類的方法
   m.\__proto\__ = Male.prototype;
   console.log(p.constructor == Person);
   console.log(m.constructor == Male);
   console.log(m instanceof Male);
   console.log(m instanceof Person);
   console.log(p.getAge());
   console.log(m.getAge());

運行結果:

你們能夠看到類型轉換以後,調getAge()方法的不一樣了吧。

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

相關文章
相關標籤/搜索