[譯]對象組合中的寶藏(軟件編寫)(第十三部分)

(譯註:該圖是用 PS 將煙霧處理成方塊狀後獲得的效果,參見 flickr。)javascript

這是 「軟件編寫」 系列文章的第十三部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數式編程和組合化軟件(compositional software)技術(譯註:關於軟件可組合性的概念,參見維基百科 < 上一篇 | << 返回第一篇前端

「經過對象的組合裝配或者組合對象來得到更復雜的行爲」 ~ Gang of Four,《設計模式:可複用面向對象軟件的基礎》java

「優先考慮對象組合而不是類繼承。」 ~ Gang of Four,《設計模式:可複用面向對象軟件的基礎》android

軟件開發中最多見的錯誤之一就是對於類繼承的過分使用。類繼承是一個代碼複用機制,實例對象和基類構成了 **是一個(is-a)**關係。若是你想要使用 is-a 關係來構建應用程序,你將陷入麻煩,由於在面向對象設計中,類繼承是最緊的耦合形式,這種耦合會引發下面這些常見問題:ios

  • 脆弱的基類問題
  • 猩猩/香蕉問題
  • 不得已的重複問題

類繼承是經過從基類中抽象出一個可供子類繼承或者重載的公共接口來實現複用的。抽象有兩個重要的方面:git

  • 泛化(Generalization):該過程提取了服務於廣泛用例的共享屬性和行爲。
  • 具化(Specialization):該過程提供了一個被特殊用例須要的實現細節。

目前,有許多方式去完成泛化和具化。注入簡單函數、高階函數、以及對象組合都能很好地代替類繼承。github

不幸的是,對象組合很是容易被曲解,許多開發者都難於用對象組合的方式來思考問題。如今,是時候更深層次地探索這一主題了。編程

什麼是對象組合?

「在計算機科學中,一個組合數據類型或是複合數據類型是任意的一個能夠經過編程語言原始數據類型或者其餘數據類型構造而成的數據類型。構成一個複合類型的操做又稱爲組合。」 ~ Wikipedia後端

造成對象組合疑雲的緣由之一是,任何將原始數據類型組裝到一個複合對象的過程都是對象組合的一個形式,可是繼承技術卻常常與對象組合做對比,即使它們是全然不一樣的兩件事。這種二義性的產生是因爲對象組合的語法(grammer)和語義(semantic)間存在着一個差異。設計模式

當咱們談論到對象組合 vs 類繼承時,咱們並不是在談論一個具體的技術:咱們是在談論組件對象(component objects)間的語義關聯耦合程度。咱們談論的是意義而非語法,人們一般一葉障目而不見泰山,沒法區別兩者,並陷入到語法細節中去。

GoF 建議道 「優先使用對象組合而不是類繼承」,這啓示了咱們將對象看做是更小,耦合更鬆的對象的組合,而不是大量從一個統一的基類繼承而來。GoF 將緊耦合對象描述爲 「它們造成了一個統一的系統,你沒法在對其餘類不知情或者不更改的狀況下修改或者刪除某個類。這讓系統結構變得緊密,從而難於認知、修改及維護。」

三種不一樣形式的對象組合

在《設計模式中》,GoF 聲稱:「你將一次又一次的在設計模式中看到對象組合」,而且描述了不一樣類型的組合關係,包括有聚合(aggregation)和委託(delegation)。

《設計模式》的做者最初是使用 C++ 和 Smalltalk(Java 的前身)進行工做的。相較於 JavaScript,它們在運行時構建和改變對象關係要更加複雜,因此,GoF 在敘述對象組合時沒用牽涉任何的實現細節也是能夠理解的。然而,在 JavaScript 中,脫離動態對象擴展(也稱爲 鏈接(concatenation))去討論對象組合是不可能的。

相較於《設計模式》中對象組合的定義,出於對 JavaScript 適用性以及構造一個更清晰的泛化的考慮,咱們會稍作發散。例如,咱們不會要求聚合須要隱式控制子類對象的生命期。對於動態對象擴展的語言來講,這並不正確。

若是選擇了一個錯誤的公理,會讓咱們在得出有用泛化時受到沒必要要的限制,強制咱們爲具備相同大意的特殊用例起一個名字。軟件開發者不喜歡重複作不須要的事兒。

  • 聚合(Aggregation):一個對象是由一個可枚舉的子對象集合構成。換言之,一個對象能夠包含其餘對象。每一個子對象都保留了它本身的引用,所以它能夠在信息不丟失的狀況下直接從聚合對象中解構出來。
  • 鏈接(Concatenation):一個對象經過向現有對象增長屬性而構成。屬性能夠一個個鏈接或者是從現有對象中拷貝。例如,jQuery 插件經過鏈接新的方法到 jQuery 委託原型 —— jQuery.fn 上而構建。
  • 委託(Delegation):一個對象直接指向或者委託到另外一個對象。例如,Ivan Sutherland 的畫板 中的實例都含有 「master」 的引用,其被委託來共享屬性。Photoshop 中的 「smart objects」 則做爲了委託到外部資源的局部代理。JavaScript 的原型(prototype)也是代理:數組實例的方法指向了內置的數組原型 Array.prototype 上的方法,對象實例的方法則指向了 Object.prototype 上,等等。

須要注意的是這三種對象組合形式並非彼此互斥的。咱們可以使用聚合來實現委託,在 JavaScript 中,類繼承也是經過委託實現的。許多軟件系統用了不止一種組合,例如 jQuery 插件使用了鏈接來擴展 jQuery 委託原型 —— jQuery.fn。當客戶端代碼調用插件上的方法,請求將會被委託給鏈接到 jQuery.fn 上的方法。

後文的代碼實例中的將會共享下面這段初始化代碼:

const objs = [
  { a: 'a', b: 'ab' },
  { b: 'b' },
  { c: 'c', b: 'cb' }
];
複製代碼

聚合

聚合表示一個對象是由一個可枚舉的子對象集合構成。一個聚合對象就是包含了其餘對象的對象。聚合中的每個子對象都保留了各自的引用,所以可以輕易地從聚合中解構出來。聚合對象能夠表現爲不一樣類型的數據結構。

例子

  • 數組(Arrays)
  • 映射(Maps)
  • 集合(Sets)
  • 圖(Graphs)
  • 樹(Trees)
  • DOM 節點 (一個 DOM 節點能包含子節點)
  • UI 組件(一個組件能包含子組件)

什麼時候使用

當集合中的成員須要共享相同的操做時(集合中的某個元素須要和其餘元素共享一樣的接口),能夠考慮使用聚合,例如可迭代對象(iterables)、棧、隊列、樹、圖、狀態機或者是它們的組合。

注意事項

聚合適用於爲集合元素應用一個統一抽象,例如爲集合中的每一個成員應用一個將標量轉換爲向量的函數(如:array.map(fn))等等。可是,若是有成百上千或者成千上萬甚至上百萬個子對象,那麼流式處理更加高效。

代碼示例

數組聚合:

const collection = (a, e) => a.concat([e]);
const a = objs.reduce(collection, []);
console.log( 
  'collection aggregation',
  a,
  a[1].b,
  a[2].c,
  `enumerable keys: ${ Object.keys(a) }`
);
複製代碼

這將生成:

collection aggregation
[{"a":"a","b":"ab"},{"b":"b"},{"c":"c","b":"cb"}]
b 
c
enumerable keys: 0,1,2
複製代碼

使用 pairs 進行的鏈表聚合:

const pair = (a, b) => [b, a];
const l = objs.reduceRight(pair, []);
console.log(
  'linked list aggregation',
  l,
  `enumerable keys: ${ Object.keys(l) }`
);
/* linked list aggregation [ {"a":"a","b":"ab"}, [ {"b":"b"}, [ {"c":"c","b":"cb"}, [] ] ] ] enumerable keys: 0,1 */
複製代碼

鏈表構成了其餘數據結構或者聚合的基礎,例如數組、字符串以及各類形態的樹。可能還有其餘類型的聚合,但咱們在此不會對它們都進行深度探究。

鏈接

鏈接表示一個對象經過向現有對象增長屬性而構成。

例子

  • jQuery 插件經過鏈接被添加到 jQuery.fn
  • 狀態 reducer(例如:Redux)
  • 函數式 mixin

什麼時候使用

只要裝配數據對象的過程是在運行時,就考慮使用鏈接,例如,合併 JSON 對象、從多個源中合併應用狀態、以及不可變狀態的更新(經過將新的數據混合到前一步狀態)等等。

注意事項

  • 謹慎地改變現有對象。共享的可變狀態是滋生 bug 的溫牀。
  • 可使用鏈接來模擬類繼承和 is-a 關係。這也會面臨和類繼承同樣的問題。多考慮組合小的、獨立的對象,而不是從一個 「基礎」 實例上繼承屬性,亦或使用差分繼承(differential inheritance,譯註:參看 MDN - Differential inheritance in JavaScript
  • 注意隱式的內在組件依賴。
  • 鏈接時的順序可以解決屬性名衝突:後進有效(last-in wins)。這一點對於默認值和重載行爲頗有幫助,但若是順序無關的話,也會形成問題。
const c = objs.reduce(concatenate, {});
const concatenate = (a, o) => ({...a, ...o});
console.log(
  'concatenation',
  c,
  `enumerable keys: ${ Object.keys(c) }`
);
// concatenation { a: 'a', b: 'cb', c: 'c' } enumerable keys: a,b,c
複製代碼

委託

委託表示一個對象直接指向或者委託到另外一個對象。

例子

  • JavaScript 內置類型使用了委託來讓內置方法調用原型鏈上的方法。例如,數組實例的方法指向了內置的數組原型 Array.prototype 上的方法,對象實例則指向了 Object.prototype,等等。
  • jQuery 插件依賴了委託去讓全部 jQuery 實例共享內置方法和插件方法。
  • Ivan Sutherland 畫板的 「masters」 則是動態委託(委託在被建立後仍會被修改)。對於委託對象的修改將馬上影響到全部對象實例。
  • Photoshop 使用了被叫作 「smart objects」 的委託來引用被定義在不一樣文件的圖像和資源。更改 smart objects 引用的對象(譯註:例如修改被引用的圖像)將影響全部 smart object 的實例。

什麼時候使用

  • 節約內存:當存在許多對象實例時,委託對於在各個實例間共享相同屬性或者方法將會頗有用,避免了更多的內存分配。
  • 動態更新大量實例:當對象的許多實例共享同一個狀態時,這個狀態須要動態更新,且該狀態的更改能當即做用到每一個實例時,也須要委託。例如 Ivan Sutherland 畫板的 「master」 和 Photoshop 的 「smart objects」。

注意事項

  • 委託一般用來模擬 JavaScript 中的類繼承(固然,如今有了 extends 關鍵字),但這實際上不多須要。
  • 委託能夠被用來精確模擬類繼承的行爲和限制。實際上,經過原型委託鏈,JavaScript 構建了基於靜態委託模型的類繼承,從而避免了 is-a 的思考方式。
  • 在使用諸如 Object.keys(instanceObj) 這樣公共枚舉機制時,委託屬性是不可枚舉的。
  • 委託是經過犧牲了屬性檢索性能來得到內存上的節約的,一些 JavaScript 引擎的優化會關閉動態委託(在建立後仍會改變的委託)。然而,即使在最慢的場景下,屬性檢索性能仍能有百萬級的 ops —— 除非你正構建一個服務於對象操做或者圖形程序的工具函數庫,例如 RxJS 或是 three.js,不然對象屬性檢索都不會成爲你的性能瓶頸。
  • 須要區分實例狀態和委託狀態。(譯註:相似於區分實例對象的自由屬性和原型鏈上的屬性)
  • 在動態委託上共享狀態不是實例安全的。對狀態的改變將會做用到全部實例,這是滋生 bug 的溫牀。
  • ES6 的類並無建立動態委託。動態委託可能會在 Babel 編譯後的代碼中正常工做,但沒法在真正的 ES6 環境下工做。

代碼示例

const delegate = (a, b) => Object.assign(Object.create(a), b);

const d = objs.reduceRight(delegate, {});

console.log(
  'delegation',
  d,
  `enumerable keys: ${ Object.keys(d) }`
);

// delegation { a: 'a', b: 'ab' } enumerable keys: a,b

console.log(d.b, d.c); // ab c
複製代碼

結論

咱們已經學到了:

  • 全部由其餘對象或者原始類型對象構成的對象都是複合對象

  • 建立複合對象的過程叫作組合

  • 存在不一樣形式的組合。

  • 當咱們組合對象時,對象間關係和依賴的不一樣取決於對象是如何被組合的。

  • is-a 關係(由類繼承所構成的關係)在面向對象設計中是最緊的耦合,實踐中應當儘可能避免。

  • GoF 建議咱們經過組裝若干小的特性以造成一個更大的總體來進行對象組合,而不是從一個單一的基類或者基礎對象繼承。「優先考慮對象組合而不是類繼承」。

  • 聚合將對象組合到一個可枚舉的集合中,該集合的每一個成員都保留有各自的引用,例如數組、DOM 樹等等。

  • 委託經過將對象的委託鏈鏈接到一塊兒來進行對象組合,委託鏈上的對象直接指向另外一個對象,或者將屬性檢索委託到了另外一個對象,例如 [].map 委託到了 Array.prototype.map()

  • 鏈接經過用新的屬性擴展示有對象來進行對象組合,例如 Object.assign(destination, a, b){...a, ...b}

  • 不一樣類型的對象組合不是彼此互斥的。委託是聚合的一個子集,鏈接則可用來構造委託和聚合等等。

目前不僅存在三種類型的對象組合。也能夠經過 相識(acquaintance)或聯合(association)來構建對象間鬆散、動態的關係,在這種關係下,對象被做爲參數傳遞給了另外一個對象(依賴注入)等等。

全部的軟件開發都是組合。可以經過輕鬆、靈活的方式來組合對象,也存在脆弱而不牢靠的方式來組合對象。一些對象組合的形式構成了對象間鬆耦合的關係,一些則構成了緊耦合。

竭力尋找一種變動小的程序需求時只須要變動小部分代碼實現的組合方式。代碼應當清楚且明練地描述你的意圖,而且記住:在你須要類繼承時,其實有更好的方式替代它。

須要 JavaScript 進階訓練嗎?

DevAnyWhere 能幫助你最快進階你的 JavaScript 能力,如組合式軟件編寫,函數式編程一節 React:

  • 直播課程
  • 靈活的課時
  • 一對一輔導
  • 構建真正的應用產品

https://devanywhere.io/

Eric Elliott「編寫 JavaScript 應用」 (O’Reilly) 以及 「跟着 Eric Elliott 學 Javascript」 兩書的做者。他爲許多公司和組織做過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是不少機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一塊兒。_


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索