圖-電子吉他-Feliciano Guimarães(CC BY 2.0)
“征服JavaScript面試”是我所寫的一個系列文章,旨在幫助那些應聘中、高階JavaScript開發職位的讀者們準備一些常見的面試問題。我自己在實際面試當中也經常會問到這類問題。系列的第一篇文章請參見“什麼是閉包”。
注:本文均以ES6標準做程式碼舉例。如果想了解ES6,可以參閱“ES6學習指南”。
物件在JavaScript語言中使用十分廣泛,學會如何有效地運用物件,有助於工作效率的提升。而不良的物件導向設計,可能會導致程式碼工程的失敗,更嚴重的話還會引發整個公司悲劇。
不同於其它大部分語言,JavaScript是基於原型的物件系統,而不是基於類。遺憾的是,大多數JavaScript開發者對其物件系統理解不到位,或者難以良好地應用,總想按照類的方式使用,其結果將導致程式碼裡的物件使用混亂不堪。所以JavaScript開發者最好對原型和類都能有所瞭解。
類繼承和原型繼承有何區別?
這個問題比較複雜,大家有可能會在評論區各抒己見、莫衷一是。因此,列位看官需要打起十二分的精神學習箇中差異,並將所學良好地運用到實踐當中去。
類繼承:可以把類比作一張藍圖,它描繪了被建立物件的屬性及特徵。。
眾所周知,使用new
關鍵字呼叫建構函式可以建立類的例項。在ES6中,不用class
關鍵字也可以實現類繼承。像Java語言中類的概念,從技術上來說在JavaScript中並不存在。不過JavaScript借鑑了建構函式的思想。ES6中的class
關鍵字,相當於是建立在建構函式之上的一種封裝,其本質依舊是函式。
1 2 |
class Foo {} typeof Foo // 'function' |
雖然JavaScript中的類繼承的實現建立在原型繼承之上,但是並不意味二者具有相同的功能:
JavaScript的類繼承使用原型鏈來連線子類和父類的 [[Prototype]]
,從而形成代理模式。通常情況下,super()
_建構函式也會被呼叫。這種機制,形成了單一繼承結構,以及物件導向設計中最緊密的耦合行為。
“類之間的繼承關係,導致了子類間的相互關聯,從而形成了——基於層級的分類。”
原型繼承: 原型是工作物件的例項。物件直接從其他物件繼承屬性。
原型繼承模式下,物件例項可以由多個物件源所組成。這樣就使得繼承變得更加靈活且[[Prototype]]代理層級較淺。換言之,對於基於原型繼承的物件導向設計,不會產生層級分類這樣的副作用——這是區別於類繼承的關鍵所在。
物件例項通常由工廠函式或者Object.create()
來建立,也可以直接使用Object字面定義。
“原型是工作物件的例項。物件直接從其他物件繼承屬性。”
為什麼搞清楚類繼承和原型繼承很重要?
繼承,本質上講是一種程式碼重用機制——各種物件可以藉此來共享程式碼。如果程式碼共享的方式選擇不當,將會引發很多問題,如:
使用類繼承,會產生父-子物件分類的副作用
這種類繼承的層次劃分體系,對於新用例將不可避免地出現問題。而且基類的過度派生,也會導致脆弱基類問題,其錯誤將難以修復。事實上,類繼承會引發物件導向程式設計領域的諸多問題:
- 緊耦合問題(在物件導向設計中,類繼承是耦合最嚴重的一種設計),緊耦合還會引發另一個問題:
- 脆弱基類問題
- 層級僵化問題(新用例的出現,最終會使所有涉及到的繼承層次上都出現問題)
- 必然重複性問題(因為層級僵化,為了適應新用例,往往只能複製,而不能修改已有程式碼)
- 大猩猩-香蕉問題(你想要的是一個香蕉,但是最終到的卻是一個拿著香蕉的大猩猩,還有整個叢林)
對於這些問題我曾做過深入探討:“類繼承已是明日黃花——探究基於原型的物件導向程式設計思想”
“優先選擇物件組合而不是類繼承。” ~先驅四人,《設計模式:可複用物件導向軟體之道》
裡面很好地總結了:
是否所有的繼承方式都有問題?
人們說“優先選擇物件組合而不是繼承”的時候,其實是要表達“優先選擇物件組合而不是類繼承”(引用自《設計模式》的原文)。該思想在物件導向設計領域屬於普遍共識,因為類繼承方式的先天缺陷,會導致很多問題。人們在談到繼承的時候,總是習慣性地省略類這個字,給人的感覺像是在針對所有的繼承方式,而事實上並非如此。
因為大部分的繼承方式還是很棒的。
三種不同的原型繼承方式
在深入探討其他繼承型別之前,還需要先仔細分析下我所說的類繼承。
你可以在Codepen上找到並測試下這段示例程式。
BassAmp
繼承自 GuitarAmp
, ChannelStrip
繼承自 BassAmp
和 GuitarAmp
。從這個例子我們可以看到物件導向設計發生問題的過程。ChannelStrip實際上並不是GuitarAmp的一種,而且它根本不需要一個cabinet的屬性。一個比較好的解決辦法是建立一個新的基類,供amps和strip來繼承,但是這種方法依然有所侷限。
到最後,採用新建基類的策略也會失效。
更好的辦法就是通過類組合的方式,來繼承那些真正需要的屬性:
認真看這段程式碼,你就會發現:通過物件組合,我們可以確切地保證物件可以按需繼承。這一點是類繼承模式不可能做到的。因為使用類繼承的時候,子類會把需要的和不需要的屬性統統繼承過來。
這時候你可能會問:“唔,是那麼回事。可是這裡頭怎麼沒提到原型啊?”
客官莫急,且聽我一步步道來~首先你要知道,基於原型的物件導向設計方法總共有三種。
- 拼接繼承: 是直接從一個物件拷貝屬性到另一個物件的模式。被拷貝的原型通常被稱為mixins。ES6為這個模式提供了一個方便的工具
Object.assign()
。在ES6之前,一般使用Underscore/Lodash提供的.extend()
,或者 jQuery 中的$.extend()
, 來實現。上面那個物件組合的例子,採用的就是拼接繼承的方式。 - 原型代理:JavaScript中,一個物件可能包含一個指向原型的引用,該原型被稱為代理。如果某個屬性不存在於當前物件中,就會查詢其代理原型。代理原型本身也會有自己的代理原型。這樣就形成了一條原型鏈,沿著代理鏈向上查詢,直到找到該屬性,或者找到根代理
Object.prototype
為止。原型就是這樣,通過使用new
關鍵字來建立例項以及Constructor.prototype
前後勾連成一條繼承鏈。當然,也可以使用Object.create()
來達到同樣的目的,或者把它和拼接繼承混用,從而可以把多個原型精簡為單一代理,也可以做到在物件例項建立後繼續擴充套件。 - 函式繼承:在JavaScript中,任何函式都可以用來建立物件。如果一個函式既不是建構函式,也不是
class
,它就被稱為工廠函式。函式繼承的工作原理是:由工廠函式建立物件,並向該物件直接新增屬性,藉此來擴充套件物件(使用拼接繼承)。函式繼承的概念最先由道格拉斯·克羅克福德提出,不過這種繼承方式在JavaScript中卻早已有之。
這時候你會發現,拼接繼承是JavaScript能夠實現物件組合的祕訣,也使得原型代理和函式繼承更加豐富多彩。
多數人談起JavaScript物件導向設計時,首先想到的都是原型代理。不過你看,可不僅僅只有原型代理。要取代類繼承,原型代理還是得靠邊站,物件組合才是主角。
*為什麼說物件組合能夠避免脆弱基類問題
要搞清楚這個問題,首先要知道脆弱基類是如何形成的:
- 假設有基類
A
; - 類
B
繼承自基類A
; - 類
C
繼承自B
; - 類
D
也繼承自B
;
在C
中呼叫super
方法,該方法將執行類B
中的程式碼。同樣,B
也呼叫super
方法,該方法會執行A
中的程式碼。
C
和D
需要從A
、B
中繼承一些無關聯的特性。此時,D
作為一個新用例,需要從A
的初始化程式碼繼承一些特性,這些特性與C
的略有不同。為了應對以上需求,菜鳥開發人員會去調整A
的初始化程式碼。於是乎,儘管D
可以正常工作,但是C
原本的特性被破壞了。
上面這個例子中,A
和B
為C
和D
提供各種特性。可是,C
和D
不需要來自A
和B
的所有特性,它們只是需要繼承某些屬性。但是,通過繼承和呼叫super
方法,你無法選擇性地繼承,只能全部繼承:
“面嚮物件語言的問題在於,子類會攜帶有父類所隱含的環境資訊。你想要的是一個香蕉,但是最終到的卻是一個拿著香蕉的大猩猩,以及整個叢林”——喬·阿姆斯特朗《程式設計人生》
如果是使用物件組合的方式 設想有如下幾個特性:
1 |
feat1, feat2, feat3, feat4 |
C
需要特性feat1
和 feat3
,而D
需要特性feat1
, feat2
, feat4
:
1 2 |
const C = compose(feat1, feat3); const D = compose(feat1, feat2, feat4); |
假如你發現D
需要的特性與feat1
略有出入。這時候無需改動feat1
,只要建立一個feat1
的定製化版本,就可以做到保持feat2
和feat4
特性的同時,也不會影響到C
,如下:
1 |
const D = compose(custom1, feat2, feat4); |
像這樣靈活的優點,是類繼承方式所不具備的。因為子類在繼承的時候,會連帶著整個類繼承結構。
這種情況下,要適應新的用例,要麼複製現有類層劃分(必然重複性問題),要麼在現有類層結構的基礎上進行重構,就又會導致脆弱基類問題。
而採用物件組合的話,這兩個問題都將迎刃而解。
你真的瞭解原型了嗎?
採用先建立類和建構函式,然後再繼承的方式,並不是正宗的原型繼承,不過是使用原型來模擬類繼承的方法罷了。這裡有一些關於JavaScript中關於繼承的常見誤解,供君參考。
JavaScript中,類繼承模式歷史悠久,而且建立在靈活豐富的原型繼承特性之上(ES6以上的版本亦然)。可是一旦使用了類繼承,就再也享受不到原型靈活強大的特性了。類繼承的所有問題都將始終如影隨形無法擺脫。
在JavaScript中使用類繼承,是一種捨本逐末的行為。
Stamps:可組合式工廠函式
多數情況下,物件組合是通過使用工廠函式來實現:工廠函式負責建立物件例項。如果工廠函式也可以組合呢?快檢視Stamp文件找出答案吧。
(譯者注:感覺原文表達有些不盡興。於是我自作主張地畫了2個圖便於讀者理解。不足之處還請諒解和指正) 圖:類繼承
說明:從圖上可以直接看出單一繼承關係、緊耦合以及層級分類的問題;其中,類8,只想繼承五邊形的屬性,卻得到了繼承鏈上其它並不需要的屬性——大猩猩/香蕉問題;類9只需要把五角星屬性修改成四角形,導致需要修改基類1,從而影響整個繼承樹——脆弱基類/層級僵化問題;否則就需要為9新建基類——必然重複性問題。 圖:原型繼承/物件組合
說明:採用原型繼承/物件組合,可以避免複雜縱深的層級關係。當1需要四角星特性的時候,只需要組合新的特性即可,不會影響到其他例項。