[譯] 函式式 Mixin(軟體編寫)(第七部分)

吳曉軍發表於2017-06-21

[譯] 函式式 Mixin(軟體編寫)(第七部分)

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

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

函式式 Mixins 是通過管道(pipeline)連線起來的、可組合的工廠函式。每一個工廠函式就類似於流水線上的工人,負責為原始物件新增一個額外的屬性或者行為。函式式 Mixin 不依賴一個基礎工廠函式或者建構函式,我們僅僅需要向 Mixin 管道入口塞入任意一個物件,在管道出口就能獲得該物件的增強版本。

函式式 Mixin 有這麼一些特點:

  • 可以實現資料私有(通過閉包)。
  • 可以繼承私有狀態。
  • 可以實現多繼承。
  • 不存在菱形問題,在 JavaScript 實現的函式式 Mixin 中,有這麼一個原則 -- 後進有效(last in wins)。
  • 不需要基類。

動機

現如今的軟體開發都是在做組合工作:我們將大型的、複雜的問題,劃分成多個小的、簡單的問題,對各個小問題的解決最終就構成了我們的應用。

組合有下面這兩個基本元素:

  • 函式
  • 資料結構

這些基本元素組成了應用結構。通常,複合物件(composite objects)是通過類繼承(某個類從父類繼承了許多功能,再通過擴充套件或者過載來增強自身)產生的。類繼承的問題在於,它描述的是一個 is-a 的思考,例如,“一個管理員也是一個員工”,這種思考方式會造成很多的設計問題:

  • 緊耦合問題:由於子類依賴於父類的實現,在物件導向設計中,類繼承無法避免的產生了最緊耦合。
  • 基類的脆弱問題:由於緊耦合的存在,對基類的更改可能會破壞大量的子類-甚至潛在改變由第三方管理的程式碼。作者可能在不知情的狀態下破壞了程式碼。
  • 不夠靈活的繼承層次問題:由於各個類都是由一個祖先分類演化開來,久而久之,對於新的用例,我們將難以確定其類別。(譯註:比如綠色卡車這個類應當繼承自卡車類,還是繼承自綠色類?)
  • 不得已的複製問題:由於不夠靈活的繼承層次,新的用例通常都是通過複製實現的,而不是擴充套件,這就造成了相似類之間可能存在歧義。一旦出現了複製問題,那麼新的類該從哪個類繼承,為什麼要從這個類繼承,都變得模稜兩可了。
  • 猩猩和香蕉問題:“物件導向的問題在於解決問題時不得不構建一整個隱性環境。這好比你只想要一隻香蕉,但最終拿到的確是拿著猩猩的香蕉和整個叢林。” ~ Joe Armstrong 在其著作 Coders at Work 中這樣描述物件導向。

在 “認為一個管理員是一個員工”(is-a) 的思維模式下,你如何通過類繼承實現這麼一個場景:僱傭一個外部顧問來臨時執行一些管理性質的工作。如果你提前就知道這個場景面臨的種種需求,也許類繼承可以工作良好,但至少我個人從未見過誰能對此瞭若指掌。隨著應用規模的膨脹,更有效的功能擴充套件方式也漸漸出現。

Mixin 橫空出世,提供了類繼承所不能及的靈活性。

什麼是 Mixin ?

“優先考慮物件組合而不是類繼承” 這句話出自 “四人幫(the Gang of Four,GoF)” 的著作 Design Patterns: Elements of Reusable Object Oriented Software

Mixin 是一個物件組合的形式,某個元件特性將被混入(mixin)到複合物件中,這樣,每個 Mixin 的特性也能變成這個複合物件的特性。

“mixins” 這個術語在物件導向程式設計中是來自於出售自助口味冰淇淋的甜品店。在這樣的冰淇淋店中,你買不到一個多種口味的冰淇淋,你只能買到一個原味冰淇淋,然後根據自己的口味,新增其他風味的醬料。

物件的 Mixin 過程與之類似:一開始,你只有一個空物件,通過不斷混入新的特性來擴充套件這個物件。由於 JavaScript 支援動態物件擴充套件(譯註:obj.newProp = xxx),並且物件不依賴於類,因此,在 JavaScript 中進行 Mixin 將無比簡單,這也讓 Mixin 成為了 JavaScript 最常用的繼承方式。下面這個例子展示了我們如何獲得一個多味冰淇淋:

const chocolate = {
  hasChocolate: () => true
};

const caramelSwirl = {
  hasCaramelSwirl: () => true
};

const pecans = {
  hasPecans: () => true
};

const iceCream = Object.assign({}, chocolate, caramelSwirl, pecans);

/*
// 如果你所採用的環境支援解構賦值,也可以這麼做:
const iceCream = {...chocolate, ...caramelSwirl, ...pecans};
*/

console.log(`
  hasChocolate: ${ iceCream.hasChocolate() }
  hasCaramelSwirl: ${ iceCream.hasCaramelSwirl() }
  hasPecans: ${ iceCream.hasPecans() }
`);複製程式碼

程式輸出如下:

hasChocolate: true
hasCaramelSwirl: true
hasPecans: true複製程式碼

什麼是函式式繼承 ?

使用函式式繼承(Functional Inheritance)來增加物件特性的方式是,將一個增強函式(augmenting function)直接應用到物件例項上。函式能通過閉包來實現資料私有,增強函式使用動態物件擴充套件來為物件增加新的屬性或者方法。

讓我們看一下 Douglas Crackford 給出的函式式繼承的例子:

// 基礎物件工廠
function base(spec) {
    var that = {}; // 建立一個空物件
    that.name = spec.name; // 為物件增加一個 “name” 屬性
    return that; // 生產完畢,返回該物件
}

// 構造一個子物件,該物件產生(繼承)自基礎物件工廠
function child(spec) {
    // 通過 “基礎” 建構函式來建立物件
    var that = base(spec);
    // 通過增強函式來動態擴充套件物件
    that.sayHello = function() {
        return 'Hello, I\'m ' + that.name;
    };
    return that; // 返回該物件
}

// Usage
var result = child({ name: 'a functional object' });
console.log(result.sayHello()); // "Hello, I'm a functional object"複製程式碼

由於 child() 緊耦合於 base(),當我們建立更多的子孫物件 grandchild()greateGrandChild() 時,就不得不面臨類繼承所面臨的問題。

什麼是函式式 Mixin ?

使用函式式 Mixin 擴充套件物件依賴於一些可組合的函式,這些函式能夠將新的特性混入到指定物件上。新的屬性或者行為來自於指定的物件。函式式的 Mixin 不依賴於基礎物件構造工廠,傳遞任意一個物件,經過混入,就能得的擴充套件後的物件。

我們看到下面的一個例子,flying() 將能夠為物件新增飛行的能力:

// flying 是一個可組合的函式
const flying = o => {
  let isFlying = false;

  return Object.assign({}, o, {
    fly () {
      isFlying = true;
      return this;
    },

    isFlying: () => isFlying,

    land () {
      isFlying = false;
      return this;
    }
  });
};

const bird = flying({});
console.log( bird.isFlying() ); // false
console.log( bird.fly().isFlying() ); // true複製程式碼

注意到,當我們呼叫 flying() 方法時,我們需要將待擴充套件的物件傳入,函式式 Mixin 是服務於函式組合的。我們再建立一個喊叫 Mixin,當我們傳遞一個喊叫函式 quackquacking() 這個 Mixin 就能為物件新增喊叫的能力:

const quacking = quack => o => Object.assign({}, o, {
  quack: () => quack
});

const quacker = quacking('Quack!')({});
console.log( quacker.quack() ); // 'Quack!'複製程式碼

對函式式 Mixin 進行組合

函式式 Mixin 可以通過一個簡單的組合函式進行組合。現在,物件具備了飛行和喊叫的能力:

const createDuck = quack => quacking(quack)(flying({}));

const duck = createDuck('Quack!');

console.log(duck.fly().quack());複製程式碼

這段程式碼可能不是那麼易讀,並且,也不容易 debug 或者改變組合順序。

這是一個標準的函式組合方式,在前面的章節中,我們知道,更優雅的組合方式是 composing() 或者 pipe()。如果我們使用 pipe() 方法來反轉函式的組合順序,那麼組合能夠被讀成 Object.assign({}, ...) 或者 {...object, ...spread},這保證了 mixin 的順序是按照宣告順序的。如果出現了屬性衝突,那麼按照後進有效的原則處理。

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// 如果不想用自定義的 `pipe()`
// 可以 import pipe from `lodash/fp/flow`

const createDuck = quack => pipe(
  flying,
  quacking(quack)
)({});

const duck = createDuck('Quack!');

console.log(duck.fly().quack());複製程式碼

什麼時候使用函式式 Mixin ?

你應該儘可能使用最簡單的抽象來解決問題。首先被你考慮的應該是最簡單的純函式。如果物件需要維持一個持續的狀態,那麼考慮使用工廠函式。如果需要構建更加複雜的物件,再考慮使用函式式 Mixin。

下面列舉了一些函式式 Mixin 的適用場景:

  • 應用狀態管理,例如 Redux store。
  • 特定的橫切關注點或者服務(cross-cutting concerns and services),例如一個集中的日誌管理。
  • 具有生命週期鉤子的 UI 元件。
  • 可組合的資料型別,例如,JavaScript 的 Array 型別通過 Mixin 實現 SemigroupFunctorFoldable 等。

一些代數結構可能派生於另一些代數結構,這意味著某個特定的派生能夠組合成新的資料型別,而不需要重新自定義實現。

注意了

大多數問題通過純函式就解決了,但函式式 Mixin 卻並非如此。類似於類繼承,函式式 Mixin 也有其自身的一些問題,甚至於,它可能重現類繼承所面臨的問題。

你可以採納下面這些建議來規避這個問題:

  • 在必須的情況下,按照從左到右的順序考慮實現方式:純函式 > 工廠函式 > 函式式 Mixin > 類。
  • 避免使用 “is-a” 關係來組織物件、Mixin 以及資料型別。
  • 避免 Mixin 間的隱式依賴,無論如何,函式式 Mixin 都不應該自我維護狀態,也不需要其他的 Mixin。(譯註:後文會解釋什麼叫做隱式依賴)。
  • “函式式 Mixin” 不意味著 “函數語言程式設計”。

類繼承幾乎(甚至可以說是從來)不是 JavaScript 中擴充套件功能的最佳途徑,但不一定所有人都這麼想,因此你無法控制一些第三方庫或者框架去使用類和類繼承。在這種情況下,對於使用了 class 關鍵字的庫或者框架來說,需要做到:

  1. 不要求你(指使用這些庫或框架的開發者)使用它們的類來擴充套件自己的類(不要求你去構建一個多層次的類層級)。
  2. 不要求你直接使用 new 關鍵字,換言之,由框架去負責物件例項化過程。

Angular 2+ 和 React 都滿足了這些要求,所以只要你不擴充套件自己的類,你就大可放心的使用它們。React 允許你不使用類來構建元件,但是你的元件可能因此喪失掉一些 React 中一些基類所提供的優化措施,並且,你的元件可能也無法像文件範例中描述的那樣去工作。即便如此,在使用 React 的任何時候,你都應當優先考慮使用函式形式來構建元件。

類的效能

在一些瀏覽器中,類可能帶來了某些 JavaScript 引擎的優化。但是,在絕大多數場景中,這些優化不會對你的應用效能產生明顯的提高。實際上,多年以來,人們都不需要擔心使用 class 帶來的效能差異。無論你怎麼構建物件,物件的建立和屬性訪問已經夠快了(每秒上百萬的 ops)。

當然,這倒不是說 RxJS、Lodash 的作者們可以不去看看使用 class 能為建立物件帶來多大的效能提升。而是說除非你在減少使用 class 的過程中遭遇了嚴重的效能瓶頸,否則你的優化都更應當著眼於構建整潔、靈活的程式碼,而不是去擔心不用類丟掉的效能。

隱式依賴

你可能對怎麼建立函式式 Mixin,並讓他們協同工作饒有興趣。想象你現在要為你的應用構建一個配置管理器,這個管理器能為應用生成配置,並且,當程式碼試圖訪問不存在的配置時,還能進行警告。

可能你會這樣實現:

// 日誌 Mxin
const withLogging = logger => o => Object.assign({}, o, {
  log (text) {
    logger(text)
  }
});

// 在配置 Mixin 中,沒有顯式地依賴日誌 Mixin:withLogging
const withConfig = config => (o = {
  log: (text = '') => console.log(text)
}) => Object.assign({}, o, {
  get (key) {
    return config[key] == undefined ?

      // vvv 這裡出現了隱式依賴 vvv
      this.log(`Missing config key: ${ key }`) :
      // ^^^ 這裡出現了隱式依賴 ^^^

      config[key]
    ;
  }
});

// 由於依賴隱藏,另一個模組需要引入 withLogging 及 withConfig
const createConfig = ({ initialConfig, logger }) =>
  pipe(
    withLogging(logger),
    withConfig(initialConfig)
  )({})
;

// elsewhere...
const initialConfig = {
  host: 'localhost'
};

const logger = console.log.bind(console);

const config = createConfig({initialConfig, logger});

console.log(config.get('host')); // 'localhost'
config.get('notThere'); // 'Missing config key: notThere'複製程式碼

譯註:在這種實現中,withConfig 這個 Mixin 在為物件 o 新增功能時,依賴了物件 olog 方法,因此,需要保證 o 具備 log 方法。

也可能你會這樣實現:

import withLogging from './with-logging';

const addConfig = config => o => Object.assign({}, o, {
  get (key) {
    return config[key] == undefined ?
      this.log(`Missing config key: ${ key }`) :
      config[key]
    ;
  }
});

const withConfig = ({ initialConfig, logger }) => o =>
  pipe(

    // vvv 在此組合顯式依賴 vvv
    withLogging(logger),
    // ^^^ 在此組合顯式依賴 ^^^

    addConfig(initialConfig)
  )(o)
;

// 配置工廠現在只需要知道 withConfig
const createConfig = ({ initialConfig, logger }) =>
  withConfig({ initialConfig, logger })({})
;

const initialConfig = {
  host: 'localhost'
};

const logger = console.log.bind(console);

const config = createConfig({initialConfig, logger});

console.log(config.get('host')); // 'localhost'
config.get('notThere'); // 'Missing config key: notThere'複製程式碼

譯註:在這個實現中,withConfig 顯式依賴了 withLogging,因此,不用保證 o 具有 log 方法,withLogging 能夠為 o 提供 log 能力。

選擇哪種實現,是取決於多個方面的。使用提升後的資料型別來使得函式式 Mixin 工作是可行的,但如果是這樣的話,在函式簽名和 API 文件中,API 約定需要設計的足夠清晰。

這也就是為什麼在隱式依賴的版本中,會為 o 設定預設值。由於 JavaScript 缺乏型別宣告的能力,我們只能通過預設值來保障型別正確:

const withConfig = config => (o = {
  log: (text = '') => console.log(text)
}) => Object.assign({}, o, {
  // ...
})複製程式碼

如果你使用 TypeScript 或者 Flow,更好的方式是為物件需求宣告一個顯式介面。

函式式 Mixin 與 函數語言程式設計

貫穿函式式 Mixin 的“函式式”不意味著這種 Mixin 具備“函數語言程式設計”提倡的函式純度。實際上函式式 Mixin 通常都是物件導向風格的,並且充斥著副作用。許多函式式 Mixin 都會改變你傳入的物件,這個你務必注意。

話說回來,一些開發者可能更偏愛函數語言程式設計風格,因此,也就不會為傳入物件維護一個引用標識。在撰寫 Mixin 時,你要假定使用這些 Mixin 的程式碼風格不只是函式式的,也可能是物件導向的,甚至是各種風格雜糅在一起的。

這意味著如果你需要返回物件例項,那麼就返回 this 而不是閉包中的物件例項引用。在函式式編碼風格下,閉包中的物件例項引用可能反映的不是用一個物件。譯註:在下面這段程式碼中,fly() 返回了 this 而不是閉包中儲存的 o

const flying = o => {
  let isFlying = false;

  return Object.assign({}, o, {
    fly () {
      isFlying = true;
      return this;
    },

    isFlying: () => isFlying,

    land () {
      isFlying = false;
      return this;
    }
  });
};複製程式碼

另外,你得知道物件的擴充套件是通過 Object.assign() 或者 {...object, ...spread} 實現的,這意味著如果你的物件有不可列舉的屬性,它們將不會出現在最終的物件上:

const a = Object.defineProperty({}, 'a', {
  enumerable: false,
  value: 'a'
});

const b = {
  b: 'b'
};

console.log({...a, ...b}); // { b: 'b' }複製程式碼

如果你正使用函式式 Mixin,而沒有使用函數語言程式設計,那麼就別指望這些 Mixin 是純的。相反,你得認為待擴充套件的基礎物件可能是可變的,Mixin 也是充斥著副作用的,也沒有引用透明的保障,亦即,對由函式式 Mixin 組合成的工廠進行快取,通常是不安全的。

總結

函式式 Mixin 是一系列可組合的工廠函式,這些工廠函式能為物件增添屬性或者行為,這些函式就好比流水線的各個站點一樣。相較於類繼承 “is-a” 的思考模式,函式式 Mixin 幫助物件從多個源獲得特性,其所表達的是 has-auses-a、或者說 can-do 的思考模式。

需要注意的是,“函式式 Mixin” 沒有向你暗示“函數語言程式設計”,其僅僅描述了 -- “使用函式實現的 Mixin”。當然了,函式式 Mixin 也可以使用函數語言程式設計的風格來撰寫,這樣能幫助我們避免副作用並且保證引用透明。但對於第三方庫所提供的函式式 Mixin,就可能充斥著副作用和不確定性了。

  • 不同於簡單物件 Mixin,函式式 Mixin 可以通過閉包來實現真正的資料私有,以及對私有資料的繼承。
  • 不同於單一祖先的類繼承,函式式 Mixin 能夠支援多祖先,在這種情形下,它就像是裝飾器(decorators)、特徵(traits)、或者多繼承(multiple inheritance)。
  • 不同於 C++ 中的多繼承,使用 JavaScript 實現的函式式 Mixin 在面臨多繼承問題時,基本不會存在菱形問題,當屬性或者方法衝突時,認為最後進入的 Mixin 為勝出者,將採納他提供的特性。
  • 不同於類的裝飾器、特徵、或者多繼承,函式式 Mixin 不需要基類。

最後,你還要切記,不要把事情搞複雜,函式式 Mixin 不是必需的,對於某個問題,你的解決思路應當是:

純函式 > 工廠函式 > 函式式 Mixin > 類

未完待續……

接下來

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

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

[譯] 函式式 Mixin(軟體編寫)(第七部分)

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

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


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

相關文章