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

吳曉軍發表於2019-03-03

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

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

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

“通過物件的組合裝配或者組合物件來獲得更復雜的行為” ~ Gang of Four,《設計模式:可複用物件導向軟體的基礎》

“優先考慮物件組合而不是類繼承。” ~ Gang of Four,《設計模式:可複用物件導向軟體的基礎》

軟體開發中最常見的錯誤之一就是對於類繼承的過度使用。類繼承是一個程式碼複用機制,例項物件和基類構成了 **是一個(is-a)**關係。如果你想要使用 is-a 關係來構建應用程式,你將陷入麻煩,因為在物件導向設計中,類繼承是最緊的耦合形式,這種耦合會引起下面這些常見問題:

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

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

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

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

不幸的是,物件組合非常容易被曲解,許多開發者都難於用物件組合的方式來思考問題。現在,是時候更深層次地探索這一主題了。

什麼是物件組合?

“在電腦科學中,一個組合資料型別或是複合資料型別是任意的一個可以通過程式語言原始資料型別或者其他資料型別構造而成的資料型別。構成一個複合型別的操作又稱為組合。” ~ 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章