Javascript的繼承與多態
本文先對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對象進行淺拷貝。和上面組合繼承的主要區別就是:將父類的 原型 復制給了子類原型。這種做法很清晰:
- 構造函數中繼承父類屬性/方法,并初始化父類。
- 子類原型和父類原型建立聯系。
還有一個問題,就是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
1,2,3,4,5,6,7,8
而實際上卻是:
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的可能對上面的代碼感覺很親切,幾乎是自解釋的。我們大概解釋一下,按照代碼中標號對應:
- constructor為構造函數,一個類有一個,相當于es5中構造函數標準化,負責一些初始化工作,如果沒有定義,js vm會定義一個空的默認的構造函數。
- 實例方法,es6中可以不加"function"關鍵字, class內定義的所有函數都會置于該類的原型當中 ,所以,class本身只是一個語法糖。
- 構造函數中通過super()調用父類構造函數,如果有super方法,需要時構造函數中第一個執行的語句,this關鍵字在調用super之后才可用。
- 靜態方法,在類定義的外部只能通過類名調用,內部可以通過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 ,子類 B 的 prototype 屬性的 __proto__ 屬性指向父類 A 的 prototype 屬性。
這樣的結果是因為,類的繼承是按照下面的模式實現的:
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繼承的不足
- 不支持靜態屬性(除函數)。
- 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類繼承和多態。如要轉載請注明作者和原文鏈接。最后向大家安利一下我的開源項目:Neat.js ,歡迎star。如文中有誤,歡迎斧正。
參考資料
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/valueOf
- http://es6.ruanyifeng.com/#docs/class
來自:https://juejin.im/post/5912753ba22b9d005817524e