一般而言,JavaScript是基於原型的物件導向典範,然而,對於許多開發者來說,原型鏈的繼承方式不易理解且難以掌握,因而有了各式各樣模擬類別的方式,以便將原型繼承的彈性約束到一定程度。
而為了有個共同遵守的風格,ECMAScript 6規範了類別語法,但ES6並沒有因此而成為基於類別的語言,骨子裡依舊是基於原型!
函式與類別的差異
在ES6,若使用class Person {}定義了類別,其中定義了constructor與toString方法,很容易對應傳統基於function定義建構式的方式,class語法在許多方面來看,是function建構式的語法蜜糖,不過,實際上還是有許多不同。
首先,Person雖然是Function的實例,然而不會是全域上的特性。Person沒有Hoist的效果,在Person定義之前,沒辦法結合new來建構實例,Person就像是使用let宣告的變數,而Person雖然是Function的實例,卻不能當普通函式來呼叫,否則會引發TypeError。
在class中定義的方法,包含get、set方法,確實會是類別原型物件上的特性。例如,在Person.prototype上,確實是有toString方法,而在定義Person類別後,於Person.prototype直接添加特性,同時,Person建構的實例,也能取用對應的特性。
不過,類別中定義的方法,無法列舉——無論使用for in、Object.hasOwnProperty、Object.keys都無法列舉,在toString的特性描述器(Property descriptor)中,enumerable特性會是false。
唯一能從Person.prototype取得類別上定義特性的方式,是透過Object.getOwnPropertyNames,因為它會傳回可列舉和不可列舉之特性名稱,但是,這對於Symbol特性不管用,因為無論特性描述器的enumerable特性是否為false,本來就都無法透過for in、Object.hasOwnProperty、Object.keys列舉Symbol,無論enumerable特性是否為false(類別中定義Symbol特性方法時的情況),取得Symbol特性的方式,都是透過Object.getOwnPropertySymbols。
不同場合中的super
要說為何基於原型的JavaScript中,始終有開發者追求基於類別的模擬,原因之一,大概就是在實現繼承時,基於原型的方式對開發者來說,有許多難以掌握,或者實作上複雜、難以閱讀的地方。而ES6在繼承這方面,使用extends來實現繼承,不用追查原型鏈,就語法實作上著實簡化許多。
然而,在開始使用ES6的繼承之後,若必須於子類別定義建構式,第一個遇到的就是須呼叫super()的問題,它代表著呼叫父建構式,而確實也解決了過去自行模擬繼承時,必須得呼叫父建構式的難點。
只不過,建構式中一定得明確呼叫super(),而且在呼叫super()之前,不能取用this。而有些基於類別的語言,確實要求一定得呼叫super(),例如Java,這主要是要求父類建構初始化必須先完成,才能進行子類建構初始流程,Java的super()甚至必須是第一行,否則會編譯錯誤。
不過,ES6中的super()主要是為了創造this參考的物件(更具體地說,就是最頂層父類建構式return的物件),然後再從父類至子類逐層執行初始流程,這點跟基於原型時實作繼承的方式就有差異了——基於原型實作繼承時,會先在子建構式中創造出this參考的物件,然後再呼叫父初始流程。
在建構式或方法中,都可以使用super關鍵字,如果super後接上了特性是為了取值,這時super代表的是父類的prototype,因此,通常會是子類重新定義了同名方法之後,用來呼叫父類的同名方法時使用。換言之,如果在父類的prototype上,定義了x特性為10,那麼super.x取得的也會是10。
不過,若是透過super.x設定為100,父類prototype.x並不會被修改,而是在父類建構式傳回的物件上設定x特性為100,然而在子類中,父類建構式傳回的物件,就會是this參考的物件,也就是說super.x = 100相當於this.x = 100,此時super等同於this!
在ES6的類別中,可以定義static方法,而在有父子繼承關係的類別時,子類static方法中若出現super,此時的super代表著父類別,因而這時的應用,就是透過super來呼叫父類的同名static方法。
extends Object?null?
既然談到super()與super關鍵字,來問幾個問題。
class A {}中若定義了建構式,可以使用super()嗎?如果當中定義了static方法,使用super.name的話會得到什麼值呢?在JavaScript中,所有的物件都會是Object的實例,那麼,class A {}會等同於class A extends Object {}嗎?
class A {}時若定義了建構式,不能使用super(),這會是語法錯誤;在static方法中,super.name會是空字串,表示沒有任何父類別;class A {}不同於class A extends Object {},因為後者的父類別是Object,而前者就單純只是個基礎類別,沒有父類別,就像是個普通函式,如同沒有ES6的class語法之前,利用function來定義建構式那樣,而A.__proto__會是Function.prototype。
實際上,只要是new可以使用的對象,就是extends可以使用的的對象,而類別的__proto__會被設定為extends的對象,因此,在ES6的類別語法中,可透過類別的__proto__瞭解類別繼承關係,如果一路向上,最後一定會是Function.prototype。就算是class A extends Object {},A.__proto__會是Object,而Object.__proto__是Function.prototype,因為原生的Object本來就是個普通函式。
照這來看,ES6的類別語言本身,並沒有定義一個頂層的基礎類別,任何沒有extends的class定義,都會是個基礎類別,而這些基礎類別基本上,就是個普通函式(除了不能直接呼叫外),畢竟,JavaScript本質上就是個基於原型的語言,類別語法只是個模擬罷了。
但extends有個特例,也就是extends null。就語義上來說,extends null是真正沒有繼承任何類別(或者說也不是個類別了),預設無法使用new來建構,這是因為無法super()(null不是個類別,也不是個函式),也就無法產生this,真的想要能夠new的話,必須明確定義constructor(),並在最後return一個與與目前類別無關的物件。
在extends null下,原型鏈最後會中斷,類別的prototype.__proto__會是undefined。在既有的JavaScript中,null本來就什麼也不能做,只是一個extends null的類別可以做什麼呢?我想,此時所得到的,是定義一個Null extends null {},用來真正代表Null型態吧!然而對於動態定型的JavaScript來說,這樣的意義並不大,只能說是特別為null做出的邊角案例(corner case)考量。
模擬終究是模擬
就算在ECMAScript 6中,提供了類別語法,不過JavaScript終究是基於原型的語言,類別語法只是模擬,目的在於提供一致的風格基礎。如果打算用類別語言來約束原型的彈性,可以用ES6類別來解決需求的情況下,建議直接採用,以減少類別風格不一致而帶來的溝通與轉換問題。
使用類別語法時,應避免混用原型操作,這只會造成更大的困擾。當然,若需要的類別特性,無法使用ES6類別語法來實現,基於原型的方式仍然適用,這時瞭解類別與原型間的關係,可能是無法避免的,或者更進一步地,開發者經常得處理原型的問題,那麼就別使用類別語法了,因為,此時需要的,並不是類別的約束,而是原型的彈性了!
專欄作者
熱門新聞
2024-08-28
2024-08-26
2024-08-30
2024-08-26
2024-08-27
2024-08-24