Javascript的繼承與多態

本文先對es6發佈以前javascript各類繼承實現方式進行深刻的分析比較,而後再介紹es6中對類繼承的支持以及優缺點討論。最後介紹了javascript面向對象編程中不多被涉及的「多態」,並提供了「運算符重載」的思路。本文假設你已經知道或瞭解了js中原型、原型鏈的概念。javascript

es6以前,javascript本質上不能算是一門面向對象的編程語言,由於它對於封裝、繼承、多態這些面嚮對象語言的特色並無在語言層面上提供原生的支持。可是,它引入了原型(prototype)的概念,可讓咱們以另外一種方式模仿類,並經過原型鏈的方式實現了父類子類之間共享屬性的繼承以及身份確認機制。其實,面向對象的概念本質上來說不是指某種語言特性,而是一種設計思想。若是你深諳面向對象的編程思想,即便用c這種面向過程的語言也能寫出面向對象的代碼(典型的表明就是windows NT 內核實現),而javascript亦是如此!正是因爲javascript自己對面向對象編程沒有一個語言上的支持標準,因此纔有了五花八門、使人眼花繚亂的「類繼承」的代碼。所幸,es6增長了class、extends、static等關鍵字用以在語言層面支持面向對象,可是,仍是有些保守!咱們先列舉出es6以前常見的幾種繼承方案,而後再來一探es6的類繼承機制,最後再討論下javascript多態。java

ES6以前的繼承

原型賦值方式

簡而言之,就是直接將父類的一個實例賦給子類的原型。以下示例:c++

function Person(name){
 this.name=name;
 this.className="person" 
}
Person.prototype.getClassName=function(){
 console.log(this.className)
}

function Man(){
}

Man.prototype=new Person();//1
//Man.prototype=new Person("Davin");//2
var man=new Man;
>man.getClassName()
>"person"
>man instanceof Person
>true
複製代碼

如代碼中1處所示,這種方法是直接new 了一個父類的實例,而後賦給子類的原型。這樣也就至關於直接將父類原型中的方法屬性以及掛在this上的各類方法屬性全賦給了子類的原型,簡單粗暴!咱們再來看看man,它是Man的一個實例,由於man自己沒有getClassName方法,那麼就會去原型鏈上去找,找到的是person的getClassName。這種繼承方式下,全部的子類實例會共享一個父類對象的實例,這種方案最大問題就是子類沒法經過父類建立私有屬性。好比每個Person都有一個名字,咱們在初始化每一個Man的時候要指定一個不一樣名字,而後子類將這個名字傳遞給父類,對於每一個man來講,保存在相應person中的name應該是不一樣的,可是這種方式根本作不到。因此,這種繼承方式,實戰中基本不用!git

調用構造函數方式

function Person(name){
 this.name=name;
 this.className="person" 
}
Person.prototype.getName=function(){
 console.log(this.name)
}
function Man(name){
  Person.apply(this,arguments)
}
var man1=new Man("Davin");
var man2=new Man("Jack");
>man1.name
>"Davin"
>man2.name
>"Jack"
>man1.getName() //1 報錯
>man1 instanceof Person
>true
複製代碼

這裏在子類的在構造函數裏用子類實例的this去調用父類的構造函數,從而達到繼承父類屬性的效果。這樣一來,每new一個子類的實例,構造函數執行完後,都會有本身的一份資源(name)。可是這種辦法只能繼承父類構造函數中聲明的實例屬性,並無繼承父類原型的屬性和方法,因此就找不到getName方法,因此1處會報錯。爲了同時繼承父類原型,從而誕生了組合繼承的方式:es6

組合繼承

function Person(name){
 this.name=name||"default name"; //1
 this.className="person" 
}
Person.prototype.getName=function(){
 console.log(this.name)
}
function Man(name){
  Person.apply(this,arguments)
}
//繼承原型
Man.prototype = new Person();
var man1=new Man("Davin");
> man1.name
>"Davin"
> man1.getName()
>"Davin"

複製代碼

這個例子很簡單,這樣不只會繼承構造函數中的屬性,也會複製父類原型鏈中的屬性。可是,有個問題,Man.prototype = new Person(); 這句執行後,Man的原型以下:github

> Man.prototype
> {name: "default name", className: "person"}
複製代碼

也就是說Man的原型中已經有了一個name屬性,而以後建立man1時傳給構造的函數的name則是經過this從新定義了一個name屬性,至關於只是覆蓋掉了原型的name屬性(原型中的name依然還在),這樣很不優雅。chrome

分離組合繼承

這是目前es5中主流的繼承方式,有些人起了一個吊炸天的名字「寄生組合繼承」。首先說明一下,二者是一回事。分離組合繼承的名字是我起的,一來感受不裝逼會好點,二來,更確切。綜上所述,其實咱們能夠將繼承分爲兩步:構造函數屬性繼承和創建子類和父類原型的連接。所謂的分離就是分兩步走;組合是指同時繼承子類構造函數和原型中的屬性。編程

function Person(name){
 this.name=name; //1
 this.className="person" 
}
Person.prototype.getName=function(){
 console.log(this.name)
}
function Man(name){
  Person.apply(this,arguments)
}
//注意此處
Man.prototype = Object.create(Person.prototype);
var man1=new Man("Davin");
> man1.name
>"Davin"
> man1.getName()
>"Davin"
複製代碼

這裏用到了Object.creat(obj)方法,該方法會對傳入的obj對象進行淺拷貝。和上面組合繼承的主要區別就是:將父類的原型複製給了子類原型。這種作法很清晰:json

  1. 構造函數中繼承父類屬性/方法,並初始化父類。
  2. 子類原型和父類原型創建聯繫。

還有一個問題,就是constructor屬性,咱們來看一下:windows

> Person.prototype.constructor
< Person(name){
   this.name=name; //1
   this.className="person" 
 }
> Man.prototype.constructor
< Person(name){
   this.name=name; //1
   this.className="person" 
  }
複製代碼

constructor是類的構造函數,咱們發現,Person和Man實例的constructor指向都是Person,固然,這並不會改變instanceof的結果,可是對於須要用到construcor的場景,就會有問題。因此通常咱們會加上這麼一句:

Man.prototype.constructor = Man
複製代碼

綜合來看,es5下,這種方式是首選,也是實際上最流行的。

行文至此,es5下的主要繼承方式就介紹完了,在介紹es6繼承以前,咱們再往深的看,下面是獨家乾貨,咱們來看一下Neat.js中的一段簡化源碼(關於Neat.js,這裏是傳送門Neat.js官網,待會再安利):

//下面爲Neat源碼的簡化
-------------------------
function Neat(){
  Array.call(this)
}
Neat.prototype=Object.create(Array.prototype)
Neat.prototype.constructor=Neat
-------------------------
//測試代碼
var neat=new Neat;
>neat.push(1,2,3,4)
>neat.length //1
>neat[4]=5
>neat.length//2
>neat.concat([6,7,8])//3
複製代碼

如今提問,上面分割線包起來的代碼塊幹了件什麼事?

對,就是定義了一個繼承自數組的Neat對象!下面再來看一下下面的測試代碼,先猜猜一、二、3處執行的結果分別是什麼?指望的結果應該是:

4
5
12345678
複製代碼

而實際上倒是:

4
4
[[1234],678]
複製代碼

吶尼!這不科學啊 !why ?

我曾在阮一峯的一篇文章中看到的解釋以下:

由於子類沒法得到原生構造函數的內部屬性,經過Array.apply()或者分配給原型對象都不行。原生構造函數會忽略apply方法傳入的this,也就是說,原生構造函數的this沒法綁定,致使拿不到內部屬性。ES5是先新建子類的實例對象this,再將父類的屬性添加到子類上,因爲父類的內部屬性沒法獲取,致使沒法繼承原生的構造函數。好比,Array構造函數有一個內部屬性[[DefineOwnProperty]],用來定義新屬性時,更新length屬性,這個內部屬性沒法在子類獲取,致使子類的length屬性行爲不正常。

然而,事實並不是如此!確切來講,並非原生構造函數會忽略掉apply方法傳入的this而致使屬性沒法綁定。要否則1處也不會輸出4了。還有,neat依然能夠正常調用push等方法,但繼承以後原型上的方法有些也是有問題的,如neat.concat。其實能夠看出,咱們經過Array.call(this)也是有用的,好比length屬性可用。可是,爲何會出問?根據症狀,能夠確定的是最終的this確定有問題,但具體是什麼問題呢?難道是咱們漏了什麼地方致使有遺漏的屬性沒有正常初始化?或者就是瀏覽器初始化數組的過程比較特殊,和自定義對象不同?首先咱們看第一種可能,惟一漏掉的可能就是數組的靜態方法(上面的全部繼承方式都不會繼承父類靜態方法)。咱們能夠測試一下:

for(var i in  Array){
 console.log(i,"xx")
}
複製代碼

然而並無一行輸出,也就是說Array並無靜態方法。固然,這種方法只能夠遍歷可枚舉的屬性,若是存在不可枚舉的屬性呢?其實即便有,在瀏覽器看來也應該是數組私有的,瀏覽器不但願你去操做!因此第一種狀況pass。那麼只多是第二種狀況了,而事實,直到es6出來後,才找到了答案:

ES6容許繼承原生構造函數定義子類,由於ES6是先新建父類的實例對象this,而後再用子類的構造函數修飾this,使得父類的全部行爲均可以繼承。

請注意我加粗的文字。「全部」,這個詞很微妙,不是「沒有」,那麼言外之意就是說es5是部分了。根據我以前的測試(在es5下),下標操做和concat在chrome下是有問題的,而大多數函數都是正常的,固然,不一樣瀏覽器可能不同,這應該也是jQuery每次操做後的結果集以一個新的擴展後的數組的形式返回而不是自己繼承數組(而後再直接返回this的)的主要緣由,畢竟jQuery要兼容各類瀏覽器。而Neat.js面臨的問題並無這麼複雜,只需把有坑的地方繞過去就行。言歸正傳,在es5中,像數組同樣的,瀏覽器不讓咱們愉快與之玩耍的對象還有:

Boolean()
Number()
String()
Array()
Date()
Function()
RegExp()
Error()
Object()
複製代碼

es6的繼承方式

es6引入了class、extends、super、static(部分爲ES2016標準)

class Person{
  //static sCount=0 //1
  constructor(name){
     this.name=name; 
     this.sCount++;
  }
  //實例方法 //2
  getName(){
   console.log(this.name)
  }
  static sTest(){
    console.log("static method test")
  }
}

class Man extends Person{
  constructor(name){
    super(name)//3
    this.sex="male"
  }
}
var man=new Man("Davin")
man.getName()
//man.sTest()
Man.sTest()//4
輸出結果:
Davin
static method test
複製代碼

ES6明確規定,Class內部只有靜態方法,沒有靜態屬性,因此1處是有問題的,ES7有一個靜態屬性的提案,目前Babel轉碼器支持。熟悉java的可能對上面的代碼感受很親切,幾乎是自解釋的。咱們大概解釋一下,按照代碼中標號對應:

  1. constructor爲構造函數,一個類有一個,至關於es5中構造函數標準化,負責一些初始化工做,若是沒有定義,js vm會定義一個空的默認的構造函數。
  2. 實例方法,es6中能夠不加"function"關鍵字,class內定義的全部函數都會置於該類的原型當中,因此,class自己只是一個語法糖。
  3. 構造函數中經過super()調用父類構造函數,若是有super方法,須要時構造函數中第一個執行的語句,this關鍵字在調用super以後纔可用。
  4. 靜態方法,在類定義的外部只能經過類名調用,內部能夠經過this調用,而且靜態函數是會被繼承的。如示例中:sTest是在Person中定義的靜函數,能夠經過Man.sTest()直接調用。

es6和es5繼承的區別

大多數瀏覽器的ES5實現之中,每個對象都有__proto__屬性,指向對應的構造函數的prototype屬性。Class做爲構造函數的語法糖,同時有prototype屬性和__proto__屬性,所以同時存在兩條繼承鏈。

(1)子類的__proto__屬性,表示構造函數的繼承,老是指向父類。

(2)子類prototype屬性的__proto__屬性,表示方法的繼承,老是指向父類的prototype屬性。

class A {
}

class B extends A {
}

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true
複製代碼

上面代碼中,子類B__proto__屬性指向父類A,子類Bprototype屬性的__proto__屬性指向父類Aprototype屬性。

這樣的結果是由於,類的繼承是按照下面的模式實現的:

class A {
}

class B {
}

// B的實例繼承A的實例
Object.setPrototypeOf(B.prototype, A.prototype);

// B繼承A的靜態屬性
Object.setPrototypeOf(B, A);
複製代碼

Object.setPrototypeOf的簡單實現以下:

Object.setPrototypeOf = function (obj, proto) {
  obj.__proto__ = proto;
  return obj;
}
複製代碼

所以,就獲得了上面的結果。

Object.setPrototypeOf(B.prototype, A.prototype);
// 等同於
B.prototype.__proto__ = A.prototype;

Object.setPrototypeOf(B, A);
// 等同於
B.__proto__ = A;
複製代碼

這兩條繼承鏈,能夠這樣理解:做爲一個對象,子類(B)的原型(__proto__屬性)是父類(A);做爲一個構造函數,子類(B)的原型(prototype屬性)是父類的實例。

Object.create(A.prototype);
// 等同於
B.prototype.__proto__ = A.prototype;
複製代碼

es6繼承的不足

  1. 不支持靜態屬性(除函數)。
  2. class中不能定義私有變量和函數。class中定義的全部函數都會被放倒原型當中,都會被子類繼承,而屬性都會做爲實例屬性掛到this上。若是子類想定義一個私有的方法或定義一個private 變量,便不能直接在class花括號內定義,這真的很不方便!

總結一下,和es5相比,es6在語言層面上提供了面向對象的部分支持,雖然大多數時候只是一個語法糖,但使用起來更方便,語意化更強、更直觀,同時也給javascript繼承提供一個標準的方式。還有很重要的一點就是-es6支持原生對象繼承。

更多es6類繼承資料請移步:MDN Classess

多態

多態(Polymorphism)按字面的意思就是「多種狀態」。在面嚮對象語言中,接口的多種不一樣的實現方式即爲多態。這是標準定義,在c++中實現多態的方式有虛函數、抽象類、模板,在java中更粗暴,全部函數都是「虛」的,子類均可以重寫,固然java中沒有虛函數的概念,咱們暫且把相同簽名的、子類和父類能夠有不一樣實現的函數稱之爲虛函數,虛函數和模版(java中的範型)是支持多態的主要方式,由於javascript中沒有模版,因此下面咱們只討論虛函數,下面先看一個例子:

function Person(name,age){
 this.name=name
 this.age=age
}
Person.prototype.toString=function(){
 return "I am a Person, my name is "+ this.name
}
function Man(name,age){
  Person.apply(this,arguments)
}
Man.prototype = Object.create(Person.prototype);
Man.prototype.toString=function(){
  return "I am a Man, my name is"+this.name;
}
var person=new Person("Neo",19)
var man1=new Man("Davin",18)
var man2=new Man("Jack",19)
> person+""
> "I am a Person, my name is Neo"
> man1+""
> "I am a Man, my name isDavin"
> man1<man2 //指望比較年齡大小 1
> false
複製代碼

上面例子中,咱們分別在子類和父類實現了toString方法,其實,在js中上述代碼原理很簡單,對於同名函數,子類會覆父類的,這種特性其實就是虛函數,只不過js中不區分參數個數,也不區分參數類型,只看函數名稱,若是名稱相同就會覆蓋。如今咱們來看註釋1,咱們指望直接用比較運算符比較兩個man的大小(按年齡),怎麼實現?在c++中有運算符重載,但java和js中都沒有,所幸的是,js能夠用一種變通的方法來實現:

function Person(name,age){
 this.name=name
 this.age=age
}
Person.prototype.valueOf=function(){
 return this.age
}
function Man(name,age){
  Person.apply(this,arguments)
}

Man.prototype = Object.create(Person.prototype);
var person=new Person("Neo",19)
var man1=new Man("Davin",18)
var man2=new Man("Jack",19)
var man3=new Man("Joe",19)

>man1<19//1
>true
>person==19//2
>true
>man1<man2//3
>true
>man2==man3 //4 注意
>true
>person==man2//5
>false
複製代碼

其中一、二、三、5在全部js vm下結果都是肯定的。可是4並不必定!javascript規定,對於比較運算符,若是一個值是對象,另外一個值是數字時,會先嚐試調用valueOf,若是valueOf未指定,就會調用toString;若是是字符串時,則先嚐試調用toString,若是沒指定,則嘗試valueOf,若是二者都沒指定,將拋出一個類型錯誤異常。若是比較的兩個值都是對象時,則比較的時對象的引用地址,因此如果對象,只有自身===自身,其它狀況都是false。如今咱們回過頭來看看示例代碼,前三個都是標準的行爲。而第四點取決於瀏覽器的實現,若是嚴格按照標準,這應該算是chrome的一個bug ,可是,咱們的代碼使用時雙等號,並不是嚴格相等判斷,因此瀏覽器的相等規則也會放寬。值得一提的是5,雖然person和man2 age都是19,可是結果倒是false。總結一下,chrome對相同類的實例比較策略是先會嘗試轉化,而後再比較大小,而對非同類實例的比較,則會直接返回false,不會作任何轉化。 因此個人建議是:若是數字和類實例比較,永遠是安全的,能夠放心玩,若是是同類實例之間,能夠進行非等比較,這個結果是能夠保證的,不要進行相等比較,結果是不能保證的,通常相等比較,變通的作法是:

var equal= !(ob1<ob2||ob1>ob2) 
//不小於也不大於,就是等於,前提是比較操做符兩邊的對象要實現valueOf或toString
複製代碼

固然相似toString、valueOf的還有toJson方法,但它和重載沒有什麼關係,故不冗述。

數學運算符

讓對象支持數學運算符本質上和讓對象支持比較運算符原理相似,底層也都是經過valueOf、toString來轉化實現。可是經過這種覆蓋原始方法模擬的運算符重載有個比較大侷限就是:返回值只能是數字!而c++中的運算符重載的結果能夠是一個對象。試想一下,若是咱們如今要實現一個複數類的加法,複數包括實部與虛部,加法要同時應用到兩個部分,而相加的結果(返回值)仍然是一個複數對象,這種狀況下,javascript也就無能爲力了。

總結

本文系統的介紹了javascript類繼承和多態。如要轉載請註明做者和原文連接。最後向你們安利一下個人開源項目:Neat.js ,歡迎star。如文中有誤,歡迎斧正。

參考資料

做者:lazydu 連接:https://www.jianshu.com/p/5cb692658704 來源:簡書 著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。
相關文章
相關標籤/搜索