[譯]為什麼在使用了類之後會使得組合變得愈發困難(軟體編寫)(第九部分)

吳曉軍發表於2017-09-30

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)
Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)
(譯註:該圖是用 PS 將煙霧處理成方塊狀後得到的效果,參見 flickr。)

注意:這是 “軟體編寫” 系列文章的第十部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 Composability)。後續還有更多精彩內容,敬請期待!
< 上一篇 | << 返回第一篇

前文中,我們仔細審視了工廠函式,並且也看到了在使用了函式式 mixins 之後,它們能很好地服務於函式組合。現在,我們還將更加仔細地看看類,驗證 class 的機制是如何妨礙了組合式軟體編寫。

但我們並不完全否定類,一些優秀的類使用案例和如何更加安全地使用類也是本文將會探討的。

ES6 擁有了一個便捷的 class 語法,這也讓你不免懷疑為什麼我們還需要工廠函式。二者最顯著的區別是建構函式以及 class 要使用 new 關鍵字。但 new 究竟做了什麼?

  • 建立了一個新的物件,並且將建構函式中的 this 繫結到了該物件。
  • 如果你沒有顯式地在建構函式中返回其他物件,那麼建構函式將隱式地返回 this
  • 將物件的 [[Prototype]] (一個內部引用) 屬性設定為 Constructor.prototype,從而有 Object.getPrototypeOf(instance) === Constructor.prototype
  • 宣告建構函式引用,令 instance.constructor === Constructor

所有的這些都意味著,與工廠函式不同,類並不是完成組合式函式 mixin 的好手段。雖然你仍可以使用 class 來完成組合,但在後文中你將看到,這是一個非常複雜的過程,你的煞費苦心並不值當。

委託原型

最終,你可能需要將類重構為工廠函式,但是如果你要求呼叫者使用 new 關鍵字,那麼重構將會以各種你無法預見到的方式打破原有的客戶端程式碼。首先,不同於類和建構函式,工廠函式不會自動地構造一條委託原型鏈。

[[Prototype]] 連結是服務於原型委託的,如果你有數以百萬計的物件,它將能幫你節約記憶體,亦或當你需要在程式中在 16 毫秒內的渲染迴圈中訪問一個物件成千上萬的屬性時,它能夠帶來一些微小的效能提升。

如果你並不需要記憶體或者效能上的微型優化,[[Prototype]] 連結就弊大於利了。在 JavaScript 中,原型鏈加強了 instanceof 運算子,但不幸的是,由於以下兩個原因,instanceof 並不可靠:

在 ES5 中,Constructor.prototype 連結是動態可重配的,這一特性在你需要建立抽象工廠時顯得尤為方便,但是如果你使用了該特性,當 Constructor.prototype 引用的物件和 [[Prototype]] 屬性指向的不是同一物件時,instanceof 會引起偽陰性(false negative),即丟失了物件和所屬類的關係:

class User {
  constructor ({userName, avatar}) {
    this.userName = userName;
    this.avatar = avatar;
  }
}
const currentUser = new User({
  userName: 'Foo',
  avatar: 'foo.png'
});
User.prototype = {}; // 重配了 User 原型
console.log(
  currentUser instanceof User, // <-- false -- 糟糕!
  // 但是該物件的形態確實滿足 User 型別
  // { avatar: "foo.png", userName: "Foo" }
  currentUser
);複製程式碼

Chrome 意識到了這個問題,所以在屬性描述之中,將 Constructor.prototypeconfigurable 屬性設定為了 false。然而,Babel 就沒有實現類似的行為,所以 Babel 編譯後的程式碼將表現得和 ES5 的建構函式一樣。而當你試圖重新配置 Constructor.prototype 屬性時,V8 將靜默失敗。無論是哪種方式,你都得不到你想要的結果。更加糟糕的是,重新設定 Constructor.prototype 會是前後矛盾的,因此我不推薦這樣做。

更常見的問題是,JavaScript 會擁有多個執行上下文 -- 相同程式碼所在的記憶體沙盒會訪問不同的實體記憶體地址。例如,如果在父 frame 中有一個建構函式,且在 iframe 中有相同的建構函式,那麼父 frame 中的 Constructor.prototypeiframe 中的 Constructor.prototype 將不會引用相同的記憶體位置。這是因為 JavaScript 中的物件值在底層是記憶體引用的,而不同的 frame 指向記憶體的不同記憶體位置,所以 === 將會檢查失敗。

instanceof 的另一個問題是,它是一個名義上的型別檢查而非結構型別檢查,這意味著如果你開始使用了 class 並在之後切換到了抽象工廠,所有呼叫了 instanceof 的程式碼將不再能明白新的實現,即便這些程式碼都滿足了介面約束。例如,你已經構建了一個音樂播放器介面,之後產品團隊要求你為視訊播放也提供支援,之後的之後,又叫你支援全景視訊。視訊播放器物件和音樂播放器物件是使用一致的控制策略:播放,停止,倒回,快進。

但是如果你使用了 instanceof 作為物件型別檢查,所有實現了你的視訊介面類的物件不會滿足程式碼中已經存在的 foo instanceof AudioInterface 檢查。

這些檢查本應當成功的,然而現在卻失敗了。在其他語言中,通過允許一個類宣告其所實現的介面,實現了可共享介面,從而也就解決了上面的問題。但在 JavaScript 中,這一點尚不能做到。

在 JavaScript 中,如果你不需要委託原型連結([[Prototype]])的話,就打斷委託原型鏈,讓每次物件的型別判斷檢查都失敗,錯就錯個徹底,這才是使用 instanceof 的最好方式。這樣的處理方式你也不會對物件型別判斷的可靠性產生誤解。這其實是讓你不要相信 instanceof,它也就無法對你撒謊了。

.contructor 屬性

.constructor 在 JavaScript 中已經鮮有使用了,它本該很有用,將它放入你的物件例項中也會是個好主意。但大多數情況下,如果你不嘗試使用它來進行型別檢測的話,它會是毛病重重的,並且,它也是不安全的,原因和 instanceof 不安全的原因一樣。

理論上來說.constructor 對於建立通用函式很有用,這些通用函式能夠返回你傳入物件的新例項。

實踐中,在 JavaScript 中,有許多不同的方式來建立新的例項。即使是一些微不足道的目的,讓物件保持一個其建構函式的引用,和知道如何使用建構函式夠例項化新的物件也並不是一件事兒,我們可以看到下面這個例子,如何建立一個與指定物件同型別的空例項,首先,我們藉助於 new 及物件的 .constructor 屬性:

// 返回任何傳入物件型別的空例項?
const empty = ({ constructor } = {}) => constructor ?
  new constructor() :
  undefined
;
const foo = [10];
console.log(
  empty(foo) // []
);複製程式碼

對於陣列型別來說,這段程式碼工作良好。那麼我們試試返回 Promise 型別的空物件:

// 返回任何傳入物件型別的空例項?
const empty = ({ constructor } = {}) => constructor ?
  new constructor() :
  undefined
;
const foo = Promise.resolve(10);
console.log(
  empty(foo) // [TypeError: Promise resolver undefined is
             //  not a function]
);複製程式碼

注意到程式碼中的 new 關鍵字,這是問題的來源。可以認為,在任何工廠函式中使用 new 關鍵字是不安全的,有時它會造成錯誤。

要使上述程式碼正確工作,我們需要有一個標準的方式來傳入一個新的值到新的例項中,這個方式將使用一個不需要 new 的標準工廠函式。對此,這裡有個規範:任何建構函式或者工廠方法都需要一個 .of() 的靜態方法.of() 是一個工廠函式,它能根據你傳入的物件,返回對應型別的新例項。

現在,我們可以使用 .of() 來建立一個更好的通用 empty() 函式:

// 返回任何傳入物件型別的空例項?
const empty = ({ constructor } = {}) => constructor.of ?
  constructor.of() :
  undefined
;
const foo = [23];
console.log(
  empty(foo) // []
);複製程式碼

不幸的是,.of() 靜態方法才開始在 JavaScript 中得到支援。Promise 物件沒有 .of() 靜態方法,但有一個與之行為一致的靜態方法 .resolve(),因此,我們的通用工廠函式無法工作在 Promise 物件上:

// 返回任意物件型別的空例項?
const empty = ({ constructor } = {}) => constructor.of ?
  constructor.of() :
  undefined
;
const foo = Promise.resolve(10);
console.log(
  empty(foo) // undefined
);複製程式碼

同樣地,如果字串、數字、object、map、weak map、set 等型別也提供了 .of() 靜態方法,那麼 .constructor 屬性將成為 JavaScript 中更加有用的特性。我們能夠使用它來構建一個富工具函式庫,這個庫能夠工作在 functor,monad 以及其他任何代數型別上。

對於一個工廠函式來說,新增 .constructor.of() 是非常容易的:

const createUser = ({
  userName = 'Anonymous',
  avatar = 'anon.png'
} = {}) => ({
  userName,
  avatar,
  constructor: createUser
});
createUser.of = createUser;
// 測試 .of 和 .constructor:
const empty = ({ constructor } = {}) => constructor.of ?
  constructor.of() :
  undefined
;
const foo = createUser({ userName: 'Empty', avatar: 'me.png' });
console.log(
  empty(foo), // { avatar: "anon.png", userName: "Anonymous" }
  foo.constructor === createUser.of, // true
  createUser.of === createUser       // true
);複製程式碼

你甚至可以通過 Object.create() 方法來讓 .constructor 不可列舉(譯註:這樣 Object.keys() 等方法就無法拿到 .constructor 屬性):

const createUser = ({
  userName = 'Anonymous',
  avatar = 'anon.png'
} = {}) => Object.assign(
  Object.create({
    constructor: createUser
  }), {
    userName,
    avatar
  }
);複製程式碼

從類切到工廠將是一次巨大的變遷

工廠函式通過下面這些方式提高了程式碼的靈活性:

  • 將物件例項化細節從呼叫程式碼處解耦。
  • 允許你返回任意型別,例如,使用一個物件池控制垃圾收集器。
  • 不要提供任何的型別保證,這樣,呼叫者也不會嘗試使用 instanceof 或者其他不可靠的型別檢測手段,這些手段往往會在跨執行上下文呼叫或是當你切換到一個抽象工廠時破壞了原有的程式碼。
  • 由於工廠函式不提供任何型別保證,工廠就能動態地切換到抽象工廠的實現。例如,一個媒體播放器工廠變為了一個抽象工廠,該工廠提供一個 .play() 方法來滿足不同的媒體型別。
  • 使用工廠函式將更利於函式組合。

儘管多數目標能夠通過類完成,但是使用工廠函式,將會讓一切變得更加輕鬆。使用工廠函式,將更少地遇到 bug,更少地陷入複雜性的泥潭,以及更少的程式碼。

基於以上原因,更加推崇將 class 重構為工廠函式,但也要注意,重構會是個複雜並且有可能產生錯誤的過程。在每一個面嚮物件語言中,從類到工廠函式的重構都是一個普遍的需求。關於此,你可以在 Martin Fowler、Kent Beck、John Brant、William Opdyke 和 Don Roberts 的這篇文章中知道更多:Refactoring: Improving the Design of Existing Code

由於 new 改變了一個函式呼叫的行為,從類到工廠函式進行的重構將是一個潛在的巨大改變。換言之,強制呼叫者使用 new 將不可避免地將呼叫者限制到建構函式的實現中,因此,new 將潛在地引起巨大的呼叫相關的 API 的實現改變。

我們已經見識過了,下面這些隱式行為會讓從類到工廠的轉變成為一個巨大的改變:

  • 工廠函式建立的例項不再具有 [[Prototype]] 連結,那麼該例項所有呼叫 instanceof 進行型別檢測的程式碼都需要修改。
  • 工廠函式建立的例項不再具有 .constructor 屬性,所有用到該例項 .constructor 屬性的程式碼都需要修改。

這兩個問題可以通過在工廠函式建立物件的過程中繫結這兩個屬性來補救。

你也要留心 this 可能會繫結到工廠函式的呼叫環境,這在使用 new 時是不需要考慮的(譯註:new 會將 this 預設繫結到新建立的物件上)。如果你想要將抽象工廠原型儲存為工廠函式的靜態屬性,這會讓問題變得更加棘手。

這是也是另一個需要留意的問題。所有的 class 呼叫都必須使用 new。省略了 new 的話,將會丟擲如下錯誤:

class Foo {};
// TypeError: Class constructor Foo cannot be invoked without 'new'
const Bar = Foo();複製程式碼

在 ES6 及以上的版本,更常使用箭頭函式來建立工廠,但是在 JavaScript 中,由於箭頭函式不會擁有自己的 this 繫結,用 new 來呼叫一個箭頭函式將會丟擲錯誤:

const foo = () => ({});
// TypeError: foo is not a constructor
const bar = new foo();複製程式碼

所以,你無法在 ES6 環境下去將類重構為一個箭頭函式工廠。但這無關緊要,徹頭徹尾的失敗是件好事兒,這會讓你斷了使用 new 的念想。

但是,如果你將箭頭函式編譯為標準函式來允許對標準函式使用 neW,就會錯上加錯。在構建應用程式時,程式碼工作良好,但是應用切到生產環境時,也許會導致錯誤,從而影響了使用者體驗,甚至讓整個應用崩潰。

一個編輯器預設配置的變化就能破壞你的應用,甚至是你都沒有改變任何你自己撰寫的程式碼。再嘮叨一句:

警告:class 到箭頭函式的工廠的重構可能能在某一編譯器下工作,但是如果工廠被編譯為了一個原生箭頭函式,你的應用將因為不能對該箭頭函式使用 new 而崩潰。

程式碼要求使用 new 違反了開閉原則

開閉原則指的是,我們的 API 應當對擴充套件開放,而對修改封閉。由於對某個類常見的擴充套件是將它變為一個靈活性更高的工廠函式,但是這個重構如上文所說是一個巨大的改變,因此 new 關鍵字是對擴充套件封閉而對修改開放的,這與開閉原則相悖。

如果你的 class API 是公開的,或者如果你和一個大型團隊一起服務於一個大型專案,重構很可能破壞一些你無法意識到的程式碼。更好的做法是淘汰掉整個類(譯註:也要淘汰類的相關操作,如 newinstanceof 等),並將其替代為工廠函式。

該過程將一個小的,興許能夠靜默解決的技術問題變為了極大的人的問題,新的重構將要求開發者對此具有足夠的意識,受教育程度,以及願意入夥重構,因此,這樣的重構會是一個十分繁重的任務。

我已經見到過了 new 多次引起了非常令人頭痛的問題,但這很容易避免:

使用工廠函式替代類。

類關鍵字以及繼承

class 關鍵字被認為是為 JavaScript 中的物件模式建立提供了更棒的語法,但在某些方面,它仍有不足:

友好的語法

class 的初衷是要提供一個友好的語法來在 JavaScript 中模擬其他語言中的 class。但我們需要問問自己,究竟在 JavaScript 中是否真的需要來模擬其他語言中的 class

JavaScript 的工廠函式提供了一個更加友好的語法,開箱即用,非常簡單。通常,一個物件字面量就足夠完成物件建立了。如果你需要建立多個例項,工廠函式會是接下來的選擇。

在 Java 和 C++ 中,相較於類,工廠函式更加複雜,但由於其提供的高度靈活性,工廠仍然值得建立。在 JavaScript 中,相較於類,工廠則更加簡單,但是卻更加強大。

下面的程式碼使用類來建立物件:

class User {
  constructor ({userName, avatar}) {
    this.userName = userName;
    this.avatar = avatar;
  }
}
const currentUser = new User({
  userName: 'Foo',
  avatar: 'foo.png'
});複製程式碼

同樣的功能,我們替換為工廠函式試試:

const createUser = ({ userName, avatar }) => ({
  userName,
  avatar
});
const currentUser = createUser({
  userName: 'Foo',
  avatar: 'foo.png'
});複製程式碼

如果熟悉 JavaScript 以及箭頭函式,那麼能夠感受到工廠函式更簡潔的語法及因此帶來的程式碼可讀性的提高。或許你還傾向於 new,但下面這篇文章闡述了應當避免使用的 new 的原因:Familiarity bias may be holding you back

還有別的工廠優於類的論證嗎?

效能及記憶體佔用

委託原型好處寥寥。

class 語法稍優於 ES5 的建構函式,其主要目的在於為物件建立委託原型鏈,但是委託原型實在是好處寥寥。原因主要歸結於效能。

class 提供了兩個效能優化方式:屬性檢索優化以及存在委託原型上的屬性會共享記憶體。

大多數現代裝置的 RAM 都不小,任何型別的閉包作用域或者屬性檢索都能達到成百上千的 ops。所以是否使用 class 造成的效能差異在現代裝置中幾乎可以忽略不計了。

當然,也有例外。RxJS 使用了 class 例項,是因為它們確實比閉包效能好些,但是 RxJS 作為一個工具庫,有可能工作在操作頻繁的上下文中,因此它需要限制其渲染迴圈在 16 毫秒內完成,這無可厚非。

ThreeJS 也使用了類,但你知道的,ThreeJS 是一個 3d 渲染庫,常用於開發遊戲引擎,對效能極度苛求,每 16 毫秒的渲染迴圈就要操作上千個物件。

上面兩個例子想說明的是,作為對效能有要求的庫,它們使用 class 是合情合理的。

在一般的應用開發中,我們應當避擴音前優化,只有在效能需要提升或者遭遇瓶頸時才考慮去優化它。對於大多數應用來說,效能優化的點在於網路的請求和響應,過渡動畫,靜態資源的快取策略等等。

諸如使用 class 這樣的微型優化對效能的優化是有限的,除非你真正發現了效能問題,並找準了瓶頸發生的位置。

取而代之的,你更應當關注和優化程式碼的可維護性和靈活性。

型別檢測

JavaScript 中的類是動態的,instanceof 的型別檢測不會真正地跨執行上下文工作,所以基於 class 的型別檢測不值得考慮。型別檢測可能導致 bug,你的應用程式也不需要那麼嚴格,造成複雜性的提高。

使用 extends 進行類繼承

類繼承會造成的這些問題想必你已經聽過多次了:

  • 緊耦合: 在物件導向程式設計中,類繼承會造成最緊的耦合。
  • 層級不靈活: 隨著開發時間的增長,所有的類層級最終都不適應於新的用例,但緊耦合又限制了程式碼重構的可能性。
  • 猩猩/香蕉 問題: 繼承的強制性。“你只想要一個香蕉,但是你最終得到的卻是一個拿著香蕉的猩猩以及整個叢林 ” 這句話來自 Joe Armstrong 在 Coders at Work 中提到的
  • 程式碼重複: 由於不靈活的層級及 猩猩/香蕉 問題,程式碼重用往往只能靠複製/貼上,這違反了 DRY(Don't Repeat Yourself)原則,反而一開始就違背了繼承的初衷。

extends 的唯一目的是建立一個單一祖先的 class 分類法。一些機智的 hacker 讀了本文會說:“我不認同你的看法,類也是可組合的 ”。對此,我的回答是 “但是你脫離了 extend,使用物件組合來替代類繼承,在 JavaScript 中是更加簡單,安全的方式”

如果你足夠仔細的話,類也是 OK 的

我說了很多工廠替代掉類的好處,但你仍堅持使用類的話,不妨再看看我下面的一些建議,它們幫助你更安全地使用類:

  • 避免使用 instanceof。由於 JavaScript 是動態語言並且擁有多個執行上下文,instanceof 總是難以反映期望的型別檢測結果。如果之後你要切換到抽象工廠,這也會造成問題。
  • 避免使用 extends。不要多次繼承一個單一層級。“應當優先考慮物件組合而不是類繼承” 這句話源自 Design Patterns: Elements of Reusable Object-Oriented Software
  • 避免匯出你的類。使用 class 會讓應用獲得一定程度的效能提升,但是匯出一個工廠來建立例項是為了不鼓勵使用者來繼承你撰寫好的類,也避免他們使用 new 來例項化物件。
  • 避免使用 new。儘量不直接使用 new,也不要強制你的呼叫者使用它,取而代之的是,你可以匯出一個工廠供呼叫者使用。

下面這些情況你可以使用類:

  • 你正使用某個框架建立 UI 元件,例如你正使用 React 或者 Angular 撰寫元件。這些框架會將你的元件類包裹為工廠函式,並負責元件的例項化,所以也避免了使用者去使用 new
  • 你從不會繼承你的類或者元件。嘗試使用物件組合、函式組合、高階函式、高階元件或者模組,相較於類繼承,它們更利於程式碼複用。
  • 你需要優化效能。只要記住你使用了類之後應當暴露工廠而不是類給使用者,讓使用者避免使用 newextend

在大多數情況下,工廠函式將更好地服務於你。

在 JavaScript 中,工廠比類或者建構函式更加簡單。我們在撰寫應用時,應當先從簡單的模式開始,直到需要時,才漸進到更復雜的模式。

下一篇: 使用函式完成的可組合型別 >

接下來

想學習更多 JavaScript 函數語言程式設計嗎?

跟著 Eric Elliott 學 Javacript,機不可失時不再來!

[譯]為什麼在使用了類之後會使得組合變得愈發困難(軟體編寫)(第九部分)

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

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


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章