[譯]爲何在使用了類以後會使得組合變得愈發困難(軟件編寫)(第九部分)

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)。後續還有更多精彩內容,敬請期待!
< 上一篇 | << 返回第一篇javascript

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

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

ES6 擁有了一個便捷的 class 語法,這也讓你難免懷疑爲何咱們還須要工廠函數。兩者最顯著的區別是構造函數以及 class 要使用 new 關鍵字。但 new 究竟作了什麼?react

  • 建立了一個新的對象,而且將構造函數中的 this 綁定到了該對象。
  • 若是你沒有顯式地在構造函數中返回其餘對象,那麼構造函數將隱式地返回 this
  • 將對象的 [[Prototype]] (一個內部引用) 屬性設置爲 Constructor.prototype,從而有 Object.getPrototypeOf(instance) === Constructor.prototype
  • 聲明構造函數引用,令 instance.constructor === Constructor

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

委託原型

最終,你可能須要將類重構爲工廠函數,可是若是你要求調用者使用 new 關鍵字,那麼重構將會以各類你沒法預見到的方式打破原有的客戶端代碼。首先,不一樣於類和構造函數,工廠函數不會自動地構造一條委託原型鏈。ios

[[Prototype]] 連接是服務於原型委託的,若是你有數以百萬計的對象,它將能幫你節約內存,亦或當你須要在程序中在 16 毫秒內的渲染循環中訪問一個對象成千上萬的屬性時,它可以帶來一些微小的性能提高。git

若是你並不須要內存或者性能上的微型優化,[[Prototype]] 連接就弊大於利了。在 JavaScript 中,原型鏈增強了 instanceof 運算符,但不幸的是,因爲如下兩個緣由,instanceof 並不可靠:github

在 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前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索