征服 JavaScript 面試:類繼承和原型繼承的區別

發表於2017-01-30

t018bd413601b3b9a4a

圖-電子吉他-Feliciano Guimarães(CC BY 2.0)

“征服JavaScript面試”是我所寫的一個系列文章,旨在幫助那些應聘中、高階JavaScript開發職位的讀者們準備一些常見的面試問題。我自己在實際面試當中也經常會問到這類問題。系列的第一篇文章請參見“什麼是閉包”

注:本文均以ES6標準做程式碼舉例。如果想了解ES6,可以參閱“ES6學習指南”

原文連結:https://medium.com/javascript-scene/master-the-javascript-interview-what-s-the-difference-between-class-prototypal-inheritance-e4cd0a7562e9#.d84c324od

物件在JavaScript語言中使用十分廣泛,學會如何有效地運用物件,有助於工作效率的提升。而不良的物件導向設計,可能會導致程式碼工程的失敗,更嚴重的話還會引發整個公司悲劇

不同於其它大部分語言,JavaScript是基於原型的物件系統,而不是基於。遺憾的是,大多數JavaScript開發者對其物件系統理解不到位,或者難以良好地應用,總想按照類的方式使用,其結果將導致程式碼裡的物件使用混亂不堪。所以JavaScript開發者最好對原型和類都能有所瞭解。

類繼承和原型繼承有何區別?

這個問題比較複雜,大家有可能會在評論區各抒己見、莫衷一是。因此,列位看官需要打起十二分的精神學習箇中差異,並將所學良好地運用到實踐當中去。

類繼承:可以把類比作一張藍圖,它描繪了被建立物件的屬性及特徵。

眾所周知,使用new關鍵字呼叫建構函式可以建立類的例項。在ES6中,不用class關鍵字也可以實現類繼承。像Java語言中類的概念,從技術上來說在JavaScript中並不存在。不過JavaScript借鑑了建構函式的思想。ES6中的class關鍵字,相當於是建立在建構函式之上的一種封裝,其本質依舊是函式。

雖然JavaScript中的類繼承的實現建立在原型繼承之上,但是並不意味二者具有相同的功能:

JavaScript的類繼承使用原型鏈來連線子類和父類的 [[Prototype]],從而形成代理模式。通常情況下,super()_建構函式也會被呼叫。這種機制,形成了單一繼承結構,以及物件導向設計中最緊密的耦合行為

“類之間的繼承關係,導致了子類間的相互關聯,從而形成了——基於層級的分類。”

原型繼承: 原型是工作物件的例項。物件直接從其他物件繼承屬性。

原型繼承模式下,物件例項可以由多個物件源所組成。這樣就使得繼承變得更加靈活且[[Prototype]]代理層級較淺。換言之,對於基於原型繼承的物件導向設計,不會產生層級分類這樣的副作用——這是區別於類繼承的關鍵所在。

物件例項通常由工廠函式或者Object.create()來建立,也可以直接使用Object字面定義。

原型是工作物件的例項。物件直接從其他物件繼承屬性。”

為什麼搞清楚類繼承和原型繼承很重要?

繼承,本質上講是一種程式碼重用機制——各種物件可以藉此來共享程式碼。如果程式碼共享的方式選擇不當,將會引發很多問題,如:

使用類繼承,會產生父-子物件分類的副作用

這種類繼承的層次劃分體系,對於新用例將不可避免地出現問題。而且基類的過度派生,也會導致脆弱基類問題,其錯誤將難以修復。事實上,類繼承會引發物件導向程式設計領域的諸多問題:

  • 緊耦合問題(在物件導向設計中,類繼承是耦合最嚴重的一種設計),緊耦合還會引發另一個問題:
  • 脆弱基類問題
  • 層級僵化問題(新用例的出現,最終會使所有涉及到的繼承層次上都出現問題)
  • 必然重複性問題(因為層級僵化,為了適應新用例,往往只能複製,而不能修改已有程式碼)
  • 大猩猩-香蕉問題(你想要的是一個香蕉,但是最終到的卻是一個拿著香蕉的大猩猩,還有整個叢林)

對於這些問題我曾做過深入探討:“類繼承已是明日黃花——探究基於原型的物件導向程式設計思想”

“優先選擇物件組合而不是類繼承。” ~先驅四人,《設計模式:可複用物件導向軟體之道》

裡面很好地總結了:

是否所有的繼承方式都有問題?

人們說“優先選擇物件組合而不是繼承”的時候,其實是要表達“優先選擇物件組合而不是類繼承”(引用自《設計模式》的原文)。該思想在物件導向設計領域屬於普遍共識,因為類繼承方式的先天缺陷,會導致很多問題。人們在談到繼承的時候,總是習慣性地省略這個字,給人的感覺像是在針對所有的繼承方式,而事實上並非如此。

因為大部分的繼承方式還是很棒的。

三種不同的原型繼承方式

在深入探討其他繼承型別之前,還需要先仔細分析下我所說的類繼承

你可以在Codepen上找到並測試下這段示例程式

BassAmp 繼承自 GuitarAmp, ChannelStrip 繼承自 BassAmpGuitarAmp。從這個例子我們可以看到物件導向設計發生問題的過程。ChannelStrip實際上並不是GuitarAmp的一種,而且它根本不需要一個cabinet的屬性。一個比較好的解決辦法是建立一個新的基類,供amps和strip來繼承,但是這種方法依然有所侷限。

到最後,採用新建基類的策略也會失效。

更好的辦法就是通過類組合的方式,來繼承那些真正需要的屬性:

修改後的程式碼

認真看這段程式碼,你就會發現:通過物件組合,我們可以確切地保證物件可以按需繼承。這一點是類繼承模式不可能做到的。因為使用類繼承的時候,子類會把需要的和不需要的屬性統統繼承過來。

這時候你可能會問:“唔,是那麼回事。可是這裡頭怎麼沒提到原型啊?”

客官莫急,且聽我一步步道來~首先你要知道,基於原型的物件導向設計方法總共有三種。

  1. 拼接繼承: 是直接從一個物件拷貝屬性到另一個物件的模式。被拷貝的原型通常被稱為mixins。ES6為這個模式提供了一個方便的工具Object.assign()。在ES6之前,一般使用Underscore/Lodash提供的.extend(),或者 jQuery 中的$.extend(), 來實現。上面那個物件組合的例子,採用的就是拼接繼承的方式。
  2. 原型代理:JavaScript中,一個物件可能包含一個指向原型的引用,該原型被稱為代理。如果某個屬性不存在於當前物件中,就會查詢其代理原型。代理原型本身也會有自己的代理原型。這樣就形成了一條原型鏈,沿著代理鏈向上查詢,直到找到該屬性,或者找到根代理Object.prototype為止。原型就是這樣,通過使用new關鍵字來建立例項以及Constructor.prototype前後勾連成一條繼承鏈。當然,也可以使用Object.create()來達到同樣的目的,或者把它和拼接繼承混用,從而可以把多個原型精簡為單一代理,也可以做到在物件例項建立後繼續擴充套件。
  3. 函式繼承:在JavaScript中,任何函式都可以用來建立物件。如果一個函式既不是建構函式,也不是 class,它就被稱為工廠函式。函式繼承的工作原理是:由工廠函式建立物件,並向該物件直接新增屬性,藉此來擴充套件物件(使用拼接繼承)。函式繼承的概念最先由道格拉斯·克羅克福德提出,不過這種繼承方式在JavaScript中卻早已有之。

這時候你會發現,拼接繼承是JavaScript能夠實現物件組合的祕訣,也使得原型代理和函式繼承更加豐富多彩。

多數人談起JavaScript物件導向設計時,首先想到的都是原型代理。不過你看,可不僅僅只有原型代理。要取代類繼承,原型代理還是得靠邊站,物件組合才是主角

*為什麼說物件組合能夠避免脆弱基類問題

要搞清楚這個問題,首先要知道脆弱基類是如何形成的:

  1. 假設有基類A
  2. B繼承自基類A
  3. C繼承自B
  4. D也繼承自B

C中呼叫super方法,該方法將執行類B中的程式碼。同樣,B也呼叫super方法,該方法會執行A中的程式碼。

CD需要從AB中繼承一些無關聯的特性。此時,D作為一個新用例,需要從A的初始化程式碼繼承一些特性,這些特性與C的略有不同。為了應對以上需求,菜鳥開發人員會去調整A的初始化程式碼。於是乎,儘管D可以正常工作,但是C原本的特性被破壞了。

上面這個例子中,ABCD提供各種特性。可是,CD不需要來自AB的所有特性,它們只是需要繼承某些屬性。但是,通過繼承和呼叫super方法,你無法選擇性地繼承,只能全部繼承:

“面嚮物件語言的問題在於,子類會攜帶有父類所隱含的環境資訊。你想要的是一個香蕉,但是最終到的卻是一個拿著香蕉的大猩猩,以及整個叢林”——喬·阿姆斯特朗《程式設計人生》

如果是使用物件組合的方式 設想有如下幾個特性:

C需要特性feat1feat3,而D 需要特性feat1, feat2, feat4

假如你發現D需要的特性與feat1略有出入。這時候無需改動feat1只要建立一個feat1的定製化版本,就可以做到保持feat2feat4特性的同時,也不會影響到C,如下:

像這樣靈活的優點,是類繼承方式所不具備的。因為子類在繼承的時候,會連帶著整個類繼承結構

這種情況下,要適應新的用例,要麼複製現有類層劃分(必然重複性問題),要麼在現有類層結構的基礎上進行重構,就又會導致脆弱基類問題

而採用物件組合的話,這兩個問題都將迎刃而解。

你真的瞭解原型了嗎?

採用先建立類和建構函式,然後再繼承的方式,並不是正宗的原型繼承,不過是使用原型來模擬類繼承的方法罷了。這裡有一些關於JavaScript中關於繼承的常見誤解,供君參考。

JavaScript中,類繼承模式歷史悠久,而且建立在靈活豐富的原型繼承特性之上(ES6以上的版本亦然)。可是一旦使用了類繼承,就再也享受不到原型靈活強大的特性了。類繼承的所有問題都將始終如影隨形無法擺脫

在JavaScript中使用類繼承,是一種捨本逐末的行為。

Stamps:可組合式工廠函式

多數情況下,物件組合是通過使用工廠函式來實現:工廠函式負責建立物件例項。如果工廠函式也可以組合呢?快檢視Stamp文件找出答案吧。

(譯者注:感覺原文表達有些不盡興。於是我自作主張地畫了2個圖便於讀者理解。不足之處還請諒解和指正) t0178c2bc72eeecf1c2圖:類繼承

說明:從圖上可以直接看出單一繼承關係、緊耦合以及層級分類的問題;其中,類8,只想繼承五邊形的屬性,卻得到了繼承鏈上其它並不需要的屬性——大猩猩/香蕉問題;類9只需要把五角星屬性修改成四角形,導致需要修改基類1,從而影響整個繼承樹——脆弱基類/層級僵化問題;否則就需要為9新建基類——必然重複性問題。 征服 JavaScript 面試:類繼承和原型繼承的區別圖:原型繼承/物件組合

說明:採用原型繼承/物件組合,可以避免複雜縱深的層級關係。當1需要四角星特性的時候,只需要組合新的特性即可,不會影響到其他例項。

相關文章