JavaScript 的繼承與多型

wendux發表於2019-03-04

本文先對es6釋出之前javascript各種繼承實現方式進行深入的分析比較,然後再介紹es6中對類繼承的支援以及優缺點討論。最後介紹了javascript物件導向程式設計中很少被涉及的“多型”,並提供了“運算子過載”的思路。本文假設你已經知道或瞭解了js中原型、原型鏈的概念。

es6之前,javascript本質上不能算是一門物件導向的程式語言,因為它對於封裝、繼承、多型這些面嚮物件語言的特點並沒有在語言層面上提供原生的支援。但是,它引入了原型(prototype)的概念,可以讓我們以另一種方式模仿類,並通過原型鏈的方式實現了父類子類之間共享屬性的繼承以及身份確認機制。其實,物件導向的概念本質上來講不是指某種語言特性,而是一種設計思想。如果你深諳物件導向的程式設計思想,即使用c這種程式導向的語言也能寫出物件導向的程式碼(典型的代表就是windows NT 核心實現),而javascript亦是如此!正是由於javascript本身對物件導向程式設計沒有一個語言上的支援標準,所以才有了五花八門、令人眼花繚亂的“類繼承”的程式碼。所幸,es6增加了class、extends、static等關鍵字用以在語言層面支援物件導向,但是,還是有些保守!我們先列舉出es6之前常見的幾種繼承方案,然後再來一探es6的類繼承機制,最後再討論下javascript多型。

ES6之前的繼承

原型賦值方式

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

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應該是不同的,但是這種方式根本做不到。所以,這種繼承方式,實戰中基本不用!

呼叫建構函式方式

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處會報錯。為了同時繼承父類原型,從而誕生了組合繼承的方式:

組合繼承

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的原型如下:

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

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

分離組合繼承

這是目前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物件進行淺拷貝。和上面組合繼承的主要區別就是:將父類的原型複製給了子類原型。這種做法很清晰:

  1. 建構函式中繼承父類屬性/方法,並初始化父類。
  2. 子類原型和父類原型建立聯絡。

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

> 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物件!下面再來看一下下面的測試程式碼,先猜猜1、2、3處執行的結果分別是什麼?期望的結果應該是:

4
5
12345678複製程式碼

而實際上卻是:

4
4
[[1,2,3,4],6,7,8]複製程式碼

吶尼!這不科學啊 !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複製程式碼

其中1、2、3、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類繼承和多型。如要轉載請註明作者和原文連結。最後向大家安利一下我的開源專案:fly.js ,歡迎star。如文中有誤,歡迎斧正。

參考資料

相關文章