理解JavaScript的核心知識點:原型

JavaScript 中的原型機制一直以來都被衆多開發者(包括本人)低估甚至忽視了,這是因爲絕大多數人沒有想要深刻理解這個機制的內涵,以及越來越多的開發者缺乏計算機編程相關的基礎知識。對於這樣的開發者來說 JavaScript 的原型機制是一個尚待發掘的大寶藏,深入瞭解下去會讓大家在編程這條路上走得更長遠,當然你不能妄想任何一種機制、模式或範式是完美無缺的。

首先,需要來理清一些基礎的計算機編程概念:

編程哲學與設計模式:Programming Philosophy and Design Pattern

計算機編程理念源自於對現實抽象的哲學思考,面向對象編程(OOP)是其一種思維方式,與它並駕齊驅的是另外兩種思路:過程式和函數式編程。這三種方式對應於解決計算機架構問題的三種不同思路。它們也分別代表了不同的編程哲學。

具體實現編程架構的代碼方案可以稱爲設計模式。設計模式是解決具體問題的一種最佳實踐,可以用在設計語言本身,也可以用在具體業務場景中。

三種思路在語言本身的設計和應用業務中是可能混用的,靈活的語言正如 JavaScript ,內部雖然是基於面向對象編程而實現,但在開發過程中也可以運用過程式編程或函數式編程的思路進行具體業務的設計。正因爲這容易造成開發者的混亂,所以特別指出,下面一段討論的是針對語言內部的實現方式而不是應用業務。

面向對象編程語言的核心是對象,針對如何設計出一套語言的對象模型編程大師們又提出了三種不同的模式:類、原型、元類(元類是基於類模型產生的新模型)。三種模型造就了許多不同的編程語言,JavaScript 恰好是原型模式的典型代表,正如 JAVA 是基於類模式的典範,請謹記這一語言本身在設計模式上的區別。

很多語言由於自身的實現而限制了在其中可能應用到業務中的設計模式。但對於 JavaScript 這樣的語言來說,選擇是開放性的,因爲我們經常在應用業務上聽到大家討論類繼承或原型繼承這樣的實現方案,這便是它非常靈活的一個表現。但對於類模式和原型模式,有一些本質上的概念區別和使用混淆是很多人沒有注意到的,下面對這兩種設計模式做一個詳細的討論。

作爲一種設計模式的類:"Class" Design Pattern

基於類的應用或業務架構實現可以稱爲類設計模式,我們在業務開發中不可避免地會使用到繼承的概念便是出自於的範疇。類不專屬於 JavaScript 語言範疇,JavaScript 中實質上也沒有實現真正的基於類設計模式的接口。JavaScript 中一切關於“類”的說法實際上都是一種有名無實的冒充和混淆。

我們通常以爲在 JavaScript 中“類”是必選的,使用它來實現業務架構不僅天經地義而且是唯一的——這是對 JavaScript 的最大誤解。JavaScript 雖然是面向對象的編程語言,但以類作爲對象模型來實現業務需求的方式只能說是一種設計模式:面向對象絕不等同於類

類是一份產品製造說明書,指導生產機器生產符合其定義參數、具有相應功能的產品。它的用途在於規定而不在於實際使用,使用的是通過類製造出來的產品,在 JavaScript 中即對象。我們基於複用、繼承等工業化生產需求而使用類這套設計模式:規定 -> 製造 -> 使用。但我們千萬不能忘記,在工業化時代出現之前,通過手工的方式一樣可以製造產品,如果你需要批量生產模樣一樣的東西才需要這份產品製造說明說。就手段來說要澄清的一個誤區是,類並不是實現功能複用、廣義上的繼承等業務目標的唯一模式

類:What's Class

,是面向對象編程中一種通用對象模型,它是基於一種對現實中事物進行分類的抽象,天生帶有類別層級的觀念,如生物是一級類、動物是一個具有所有生物特性而派生出自己獨有特性的二級類,依照這樣的邏輯還可以繼續推及到其下更多細別的子類,這是一種將所有對象進行樹狀類別組織關聯的思維方式:

類-分類

通過這張圖可以得出一個顯而易見卻容易被忽視的事實:永遠沒有一隻具體的哺乳動物(比如說一隻獅子)等同於哺乳動物這個類別,就像你不等於人類一樣。類是一個並不具有實體的概念,是人爲的發明,爲了將具有類似特性的事物分門別類以適應人腦簡化處理信息的方式,儘管自然並不是出於這樣的目的而生成各種事物的。

JavaScript 中類的概念也是人爲的設計,爲的是更靠近本身以類模式設計而成的語言,儘管它本身是以原型模式設計而成的。因此我們有了 new 一個對象這種操作,爲的是更符合採用類這一設計模式來實踐面向對象編程。所以在此處埋下了第一個令人迷惑的種子:JavaScript 原生基於原型關聯起來的對象與基於類創建的與類關聯起來的對象兩種概念的混淆。對於發現了這一對使人迷惑的概念的開發者來說,便有了第一個疑問:

爲什麼基於原型模式設計而成的 JavaScript 不繼續在業務場景中使用原型設計模式,而是轉而求向類設計模式?

之前有過說明,實踐面向對象編程的方式有三種的,並且沒有任何一種是完美無缺的。所以請把類模式是最好的這種想法拋到九霄雲外吧。暫且將這個問題移到潛意識中去,繼續瞭解一下類範疇的的其他相關概念。

實例:What's Instance

實例的概念基於類之上。正如自然界中單一的個體即是它所屬類別中的一個實例,面嚮對象語言中的一個對象就是它所屬類中的一個實例。語言通過類的規定,生成了具有內存實體的對象。在這樣的語言中,實例和對象的指代物是一致的,我們通常在類設計模式中採用實例來描述一個內存實體,而在編程實踐中使用對象來描述一個內存實體,其實是在不同層面上的語言轉換。理解這種詞語的轉換,對於我們在閱讀各種技術書籍時瞭解作者所選擇的表述視角是有幫助的。

創建實例操作的結果是將類的屬性和方法分別複製到不同的實例對象中,它們持有各自獨立的版本,這也意味着每一個由同一個類創建出的實例都是各自獨立互不影響的個體。

而在 JavaScript 中,事情就變得沒那麼簡單了。不管在它的設計者設計出模擬類模式的原生 API 之前還是之後(當然官方一直有關於類的語法糖的支持),JavaScript 的世界實際上都是由且只由對象組成。當你創建了一個構造器函數或使用 ES6 的類定義語法時,其實質根本沒有真的定義了類,它是由對象僞裝而成的。

在這一事實的基礎上,就能發現既然“類”也是對象,那麼我們本以爲應用類模式建立的類與實例之間的純粹關係就被基於對象的模擬打破了。使用上面那個大自然的歸類例子再來解釋下這是什麼意思:當哺乳動物這一類別是一隻獅子時,它既是具體又是抽象的,作爲一個類這隻獅子囊括了所有的哺乳動物,它是凌駕於其他具體生物之上的;作爲一個具體生物它又是被包含進它本身的...這似乎變成了一個邏輯問題。

人類在採用類這一概念時就已經將這個概念進行了抽象,它不指代任何具體的個體,即便它是一份具有實體的藍圖,也是與遵循它創造出來的物品不相同的東西。而在 JavaScript 裏所發生的正是與之相矛盾的,它對於類模式的模擬實現其實是對類模式的顛覆。

繼承:What's Inheritance

繼承是類範疇裏的重要概念,也是我們之所以要使用類的重要理由。繼承的目的是爲了實現屬性或功能複用,順便減少編寫代碼的機械操作。類模式的繼承操作使子類擁有已經在父類裏定義的屬性或方法,繼承而來的屬性或方法是子類所有的獨立版本,子類可以在此基礎上繼續修改已繼承的屬性或方法,並且擴展屬於自己的屬性或方法。

繼承即是基於現實中類別的多級抽象。前面圖示中所列出的樹狀結構就是對繼承很好的說明。在自然過程中,我們從祖先那裏繼承而來的基因是屬於複製而來的獨立版本,現實中當然不存在繼承而來的一模一樣的基因,但即便是一模一樣的基因序列,也是各自獨立的版本,你身體中的基因再也不是祖先身體中的那個基因了。

尤其強調獨立這個詞,是因爲類模式如實地實現了對自然界這一複製過程的模擬,而在 JavaScript 這一基於原型模式設計的語言中,我們又一次被它的表面類模式糊弄了。

在真正的類模式中,不管是父類還是子類都是獨立封裝好的一份規格,如果一個子類沒有繼承到父類的某一屬性或方法它自身也沒有進行擴展時,它的實例是不可能使用這個屬性或方法的。很明顯 JavaScript 中的繼承“完美解決了這個問題”,即便一個“類”自己沒有繼承也沒有擴展某個屬性或方法,它創造出的實例還可以從祖先那裏借用

結合實例一節所述,於是第二個問題呼之欲出:除了寫法相似之外,JavaScript 中幾乎所有與類相關的概念和行爲都同慣常的類模式不那麼相符,這真的可以被稱爲是類模式的實現麼?

基於以上兩個問題對自己進行了靈魂拷問,終於決定要來仔細瞧瞧 JavaScript 中一直被當做類的影子的那個親骨肉——原型。

作爲一種機制的原型:"Prototype" Mechanism

在詞彙語義上,原型的概念就與類所區別:原型是一個最初的對象。類的邏輯在於將已存在事物劃分層次,達到概括事物或分類的目的;原型的邏輯中沒有抽象的層級,它是根據已存在事物尋找能代表它最初的最本源的那一個,層層溯源,途徑的都是具象的。恐怕原型的概念對於熟稔哲學的人來說比類更爲親切。它在編程上的思想是:新的物體藉由複製原型產生

原型和類

JavaScript 的原型機制就遵循了一定程度原型哲學的思路。而原型機制是 JavaScript 所特有的。原型機制的實現是,對象有一個內部屬性指向另一個對象,將二者聯結起來的屬性的變量名就是我們熟悉的 __proto__,它暴露了內部實現的原型,被指向的對象被稱爲前者的原型,通常用 obj.__proto__ 來指代 obj 這個對象的原型。除此之外別忘記,這只是那個真實的原型對象的別稱。例如 origin 是另一個對象,以下這條語句就建立了這兩個對象的原型關聯關係:

let obj = {}
let origin = {}
obj.__proto__ = origin

你可以使用 origin 引用它指向的那個對象,其實質是一個內存地址,也可以使用 obj.__proto__ 來引用同樣的內存地址。作爲一個單獨個體的對象和一個作爲別的對象的原型的對象是合而爲一的。(實際開發中不要直接使用 __proto__ ,此處只是爲了簡便。應該用 Object.getPrototypeOf() 方法獲取原型對象)

原型機制用一句話概括就是:將單個對象建立起原型關聯關係的過程。

原型:What's Prototype

原型的語義概念上面已經介紹了,現在專門講講 JavaScript 中的原型。在 JavaScript 中,一切都是對象,那麼這個世界總要有一個本源性的對象,就像上圖中的原核生物一樣,從它一生二而生成萬物。的確,這樣的一個被稱爲最初的原型的對象是存在的,它就是 Object.prototype,原因是它再也無法向上追溯到任何對象了:

Object.prototype.__proto__ === null

這裏我們要知道 null 代表的是“沒有”的意思。因此 JavaScript 的世界是從 Object.prototype 開始的。使用過 JavaScript 的開發者必定對這個對象印象深刻,但可能很多人從來沒有從這個視角看待它。

從它衍生出的一個重要的對象是一個函數 Object,它被稱爲構造函數,儘管由 Object 構造函數創建出來的對象的原型都是指向 Object.prototype 的,但它自己的原型對象卻並不是 Object.prototype,而是 Function.prototypeFunction.prototype 的原型才指向的是 Object.prototype,從這裏我們可以隱隱窺見原型繼承的精髓。

再次強調一下,Object 是一個名字叫做“對象”的函數,Object.prototype 是一個叫做“對象構造器原型”的對象,與其他的原生構造器原型對象一樣,這些對象都是沒有自己獨立名稱的對象。在學習 JavaScript 時,必須好好區分這些基礎概念。

原型鏈:Prototype Chain

原型鏈是原型繼承得以實現的基礎,但其實在原型中使用“繼承”這個詞是不那麼準確的。原型鏈是內部機制通過私有的“原型”屬性實現對象之間的關聯而形成的一條鏈式屬性查找規則。它是單向度的,只能向上回溯,作爲原型的對象無法查找它的繼承者們的任何屬性和方法。

原型鏈機制爲 JavaScript 提供了實現強大功能的基礎,但可以想象,每次查找都是要花費額外開銷的,鏈條越長,開銷越大。它具有一個奇特的特點,即便某個對象上並未定義變量它也不會導致程序報錯,而是得到 undefined,這正是原型鏈機制自動查找屬性的一個後果。在沒有必要的情況下,應該避免編寫造成無謂的原型鏈查找的代碼。

我們時常需要通過判斷一個對象的屬性存在與否實現一些分支判斷,現在假設一條原型鏈是這樣的,

obj5 -> obj4 -> obj3 -> obj2 -> obj1

它們都不具有一個叫做 prop 的屬性,接着實現瞭如下簡化了過程的判斷場景:

let condition = action()
...
if (condition)  obj5.prop = true
...
if (obj5.prop) { ... }

沒有任何問題的代碼對不對?當然,在條件爲true時一切都很完美,但是如果 conditionfalse 呢,最後那條判斷語句就要查找5次最後才能回到判斷,如果鏈條更長呢?

// 解決方案1:不需要中間變量時
obj5.prop = action()

// 解決方案2:需要中間變量時(可能二次改變)
obj5.prop = condition

// 當然還有更多變種...

或許有人覺得不太可能出現這樣的錯誤,但當代碼複雜到一定程度、中間過程非常繁瑣,工期非常緊迫時,一切都是有可能的,大問題都是因爲那些小步驟中一個又一個的將就累積出來的。更何況作爲一個有追求的開發者,即便瀏覽器爲我們的代碼實現了最大程度的性能優化,不應該多一些對自我的要求麼。

原型的作用:Why Prototype

既然類設計模式已經如此流行並深入一代又一代開發者的腦海,那麼爲什麼還會有原型設計模式的立足之地呢?毫無疑問是因爲 JavaScript 的存在。作爲網頁開發腳本的 JavaScript 一直唯我獨尊地統御着這片疆域,至少目前開來還沒有哪一種新的腳本語言能夠取代它的位置。但試想一下假如有一天一種以類模式設計而成的語言可以徹底取代它,原型機制將要消亡的那天大概就要來臨了,沒有哪一種語言能夠像 JavaScript 這樣能夠徹底地實踐原型機制了。

除了上面這個從語言層面來說的使用原型模式的前提,在 JavaScript 編程中使用原型模式而不是類模式實現業務功能也有一個讓人較爲信服的原因。衆所周知使用類和原型的目的都是爲了實現繼承,或者從更本質上來說是功能複用。

而在 JavaScript 中選擇原型模式的理由就在《You Don't Know JS》這本書的章節中。作者敘述地那麼明瞭,也不需要做額外的解析了。在此我只引用兩張圖作爲最直觀的證據:

使用類模式實現繼承的邏輯圖

類繼承邏輯圖

使用原型模式實現繼承的邏輯圖

原型繼承邏輯圖

很多最爲有效的問題處理方式通常都是最簡潔的方式,那些需要通過製造一個問題而去解決另一個問題的方法只會讓人頭腦暈眩,通常如果我們不能三言兩語就點出問題的核心,只能反思自己可能對問題理解得不夠透徹。如果能用一個非常簡單有效的方法實現同樣的結果,我實在是找不出什麼原因非要去採用一個更加複雜的方法。

如上鋪墊了一大堆概念,到底能從中得出什麼結論?——你爲什麼想在 JavaScript 的業務開發中使用類模式而不是原型模式?

原型模式作爲 JavaScript 原生的設計模式卻沒有得到開發者足夠的理解,這與官方挖空心思強行模擬類模式的引導不無關係。

一位國外開發者 Eric Elliott 作了一個尖銳的比喻:

Using class inheritance in JavaScript is like driving your new Tesla Model S to the dealer and trading it in for a rusted out 1973 Ford Pinto.

翻譯:在 JavaScript 中使用類繼承就像把你嶄新的特斯拉Model S開到交易商那換了一輛生鏽的1973年的福特平託。

這種比喻何以見得恐怕通過上面那兩張圖的比較已經有了一個大致的理解,即便是不打算放棄類模式的開發方式,深入理解這種爭議的緣由更助於提高我們的開發能力。我們需要時不時停下來多問問幾個爲什麼。

模式之爭:The War of Pattern

一直以來在 JavaScript 中使用類繼承還是原型繼承似乎不是什麼值得爭論的事情。但目前越來越多的國外開發者開始意識到原型模式在 JavaScript 中的自然性與邏輯簡潔性。類模式與原型模式開始升級爲不同陣營實現功能複用的爭論點。

原型與類:Prototype vs. Class

如果我說在 JavaScript 中使用類模式實現繼承是不符合目前人類大腦思維模式的複雜度的,我相信深入理解其中緣由的大多數人是會認可的,證據還是上面那張圖,有多少人能夠清晰地把上面的邏輯復演出來呢?恐怕大多數人都會在來來往往的直線曲線中迷失了方向,畢竟這樣的方式要求你不僅要對類、子類和實例的關係把握精準,還要時刻銘記着它們暗中的原型關聯關係,對於初學者來說這種雙重性關係一定是會在未來學習的道路上橫梗多年的坎。所以才需要在此尤爲強調類與原型的種種區別。

但如果只是將注意力集中在對象之間的原型關聯關係上,事情就簡單多了。要清楚的是隻要 JavaScript 語言本身的實現不改變,對象的原型關聯關係是我們無法擺脫的。

不過原型與類的爭論已經屬於“舊時代”的爭論,在隨後開發者們對原型模式更加深入的理解基礎上,形成了更深刻的認識和結論,“現代爭論”不再是原型與類的衝突,而是原型更新、更本質的行爲委託

原型與委託:Prototype vs. Delegation

前面有提到過在原型裏說“繼承”是不準確的,原因是名副其實的類繼承的行爲本質上是複製,而 JavaScript 裏無論是用何種方式實現“繼承”,它的本質行爲都不是複製。

這裏要澄清一個可能的誤會,JavaScript 當然是支持複製的,然而成熟的開發者都知道複製與引用原型上的方法可是完全不一樣的內存消耗,也正是由於 JavaScript 的原型機制才得以通過不增加副本的方式實現“繼承”,所以就此排除了這種使用複製實現“繼承”的方式。

那麼在 JavaScript 裏“繼承”的本質又是什麼呢?許多開發者共同倡導了一種新的概念——委託。這種機制可以這樣簡單地理解:所謂的“繼承”其實是對象委託其原型們代勞辦事,繼承者藉助原型上的方法實現功能。這個新的說法確實是比較生動地描述了原型繼承機制的本質的。

以後或許開發者們會達成共識,把使用原型模式實現繼承的方式稱爲原型委託,如此更符合它的實際情況。但究竟想使用哪種模式進行開發最終還是在於個人的選擇,官方對類模式的不懈支持當然無法讓衆多開發者立即摒棄類語法糖,要從類轉換到純粹的原型上,是需要耗費思路轉換和習慣改變的成本的,希望對這個核心知識點的剖析能夠使學習者們更好地理解 JavaScript 的本質語言特性,啓發來者們更多的深入思考。

參考文獻:Reference

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章