組合軟體:7. 函數式 Mixins

原文鏈接: medium.com

Mixins

(混入)是對象組合的一種形式,這裡組件的特性被混合進一個複合對象中,這樣每個mixin的屬性就變成了該複合對象的屬性。

OOP中的“mixins”一詞來自混合冰淇淋店。在這種店中,並非是把一大堆不同口味的冰淇淋放在不同的預混桶中,而是用原味冰淇淋以及一堆可以混在一起的不同配料為每個客戶創建定製的口味。

對象 mixins 也差不多:你可以從一個空對象開始,混入一些特性來擴展它。因為JavaScript支持動態對象擴展和沒有類的對象,所以在JavaScript中使用對象 mixins 是非常簡單的 - 所以它也成了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

什麼是函數式繼承?

函數式繼承是把一個增強函數應用到一個對象實例上來繼承特性的過程。該函數提供一個閉包作用域,從而可以讓一些數據保持私有。增強函數使用動態對象擴展,用新屬性和方法來擴展對象實例。

我們來看看一個來自發明這個術語的Douglas Crockford的例子:

// 基對象工廠
function base(spec) {
var that = {}; // 創建一個空對象
that.name = spec.name; // 給它添加一個 "name" 屬性
return that; // 返回該對象
}
// 構造一個子對象,繼承自 "base"
function child(spec) {
// 通過 "base" 構造器創建對象
var that = base(spec);
that.sayHello = function() { // 放大該對象
return 'Hello, I\'m ' + that.name;
};
return that; // 返回它
}
// 用法
var result = child({ name: 'a functional object' });
console.log(result.sayHello()); // "Hello, I'm a functional object"

因為child()是與base()緊耦合的,所以當你添加grandchild()、greatGrandchild()等等時,也就選擇了類繼承中大部分常見的問題。

什麼是函數式Mixin?

函數式mixins是一些可以組合的函數,它們將新屬性或行為與給定對象的屬性混合起來。函數式mixins不依賴於或不需要基對象工廠或構造器:只需將任意對象傳遞到一個mixin中,該對象就會被擴展。

我們來看一個例子:

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()時,我們需要把要擴展的對象傳遞進來。函數式mixins是專為函數組合而設計的。下面我們來創建一些要組合的東西:

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

});
const quacker = quacking('Quack!')({});
console.log( quacker.quack() ); // 'Quack!'

組合函數式 Mixins

函數式mixins可以用簡單的函數組合來組合:

const createDuck = quack => quacking(quack)(flying({}));
const duck = createDuck('Quack!');
console.log(duck.fly().quack());

不過,這看起來有點難讀。調試或調整組合的順序也可能有點棘手。

當然,這是標準的函數組合,而從前幾篇文章我們已經知道用compose()或pipe()來組合會更優雅一些。如果我們用pipe()來反轉函數組合的順序,那麼組合讀起來就會跟Object.assign({},...)或{... object, ... spread}一樣 - 從而保持了相同的位次順序。在屬性衝突的情況下,最後一個混入的對象勝出。

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// OR...
// import pipe from `lodash/fp/flow`;
const createDuck = quack => pipe(
flying,
quacking(quack)
)({});
const duck = createDuck('Quack!');
console.log(duck.fly().quack());

何時用函數式 Mixins

應該始終使用最簡單的抽象來解決正在處理的問題。首先從純函數開始。如果需要一個具有持久狀態的對象,就試試工廠函數。如果需要創建更復雜的對象,就試試函數式mixins。

以下是適用函數式mixins的一些不錯的場景:

  • 應用程序狀態管理,例如Redux store。
  • 某些橫切關注點和服務,例如集中式的日誌管理器。
  • 具有生命週期鉤子的UI組件。
  • 可組合的函數式數據類型,例如JavaScript Array類型實現Semigroup、Functor、Foldable。

一些代數結構可以用其他代數結構派生出來,這意味著某些衍生物可以被組合成一個新的數據類型而不需要定製。

注意事項

大多數問題都可以用純函數優雅地解決。不過函數式mixins並非如此。像類繼承一樣,函數式mixins也有它自己的問題。事實上,用函數式mixins完全有可能忠實複製類繼承的所有特性和問題。

不過,可以用以下建議來規避:

  • 使用最簡單的實用實現。從左邊開始,只需要根據需要轉到右邊:純函數 > 工廠 > 函數式mixins > 類
  • 避免在對象、mixins或數據類型之間創建 is-a關係
  • 避免mixins之間的隱式依賴關係 - 只要有可能,函數式mixins就應該是自包含的,並且不瞭解其他mixins
  • “函數式mixins”並不意味著“函數式編程”

類繼承從來就不是JavaScript中最好的方法,但是這種選擇有時是由你不能控制的庫或框架造成的。在這種情況下,使用class有時是實用的,因為這些庫或者框架提供瞭如下功能:

  1. 不需要你擴展你自己的類(即不要求你建立多層次的類層次結構)
  2. 不需要你直接使用new關鍵字 - 換句話說,框架會為你處理實例化

Angular 2+和React都滿足了這些需求,所以只要你不擴展自己的類,就可以安全地使用它們提供的類。如果你願意的話,React也允許避免使用類,但是你的組件可能無法利用React基類中內置的優化,並且你的組件會看起來不像文檔示例中的組件。無論如何,只要有可能就應該總是用函數形式的React組件。

類的性能

在某些瀏覽器中,可能會提供僅對類的JavaScript引擎優化。不過,在大多數情況下,這些優化不會對應用程序的性能產生重大影響。事實上,可能很多年都不用擔心class的性能差異。不管你如何創建對象,對象創建和屬性訪問總是非常快(數百萬次操作/秒)。

也就是說,通用目的實用程序庫(比如RxJS、Lodash等)的作者應該研究用class來創建對象實例的可能的性能優勢。除非你已經測量出用class帶來可證明以及顯著的性能降低的重大瓶頸,否則你應為乾淨、靈活的代碼而優化,而不用擔心性能。

隱式依賴

你可能會試圖創建旨在一起工作的函數式mixins。想象一下,你想為你的應用程序創建一個配置管理器,當你嘗試訪問不存在的配置屬性時,該配置管理器會記錄警告。

可以像這樣創建它:

// 在它自己的模塊中...
const withLogging = logger => o => Object.assign({}, o, {
log (text) {
logger(text)
}
});
// in a different module with no explicit mention of

// withLogging -- we just assume it's there...
const withConfig = config => (o = {
log: (text = '') => console.log(text)
}) => Object.assign({}, o, {
get (key) {
return config[key] == undefined ?
// vvv implicit dependency here... oops! vvv
this.log(`Missing config key: ${ key }`) :
// ^^^ implicit dependency here... oops! ^^^
config[key]
;
}
});
// in yet another module that imports withLogging and
// 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'

不過,也可以像這樣創建它:

// import withLogging() explicitly in withConfig module
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 compose explicit dependency in here vvv
withLogging(logger),
// ^^^ compose explicit dependency in here ^^^
addConfig(initialConfig)

)(o)
;
// The factory only needs to know about withConfig now...
const createConfig = ({ initialConfig, logger }) =>
withConfig({ initialConfig, logger })({})
;
// elsewhere, in a different module...
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'

正確的選擇取決於很多因素。要求提升的數據類型用於函數式mixin是有效的,但如果是這種情況,應在函數簽名和API文檔中明確說明API約定。

這就是為什麼隱式版本在簽名中o有一個默認值的原因。由於JavaScript缺少類型註解功能,我們可以通過提供默認值來偽造該功能:

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

如果你在用TypeScript或Flow,那麼可能為對象需求聲明一個顯式接口會更好一些。

函數式Mixins和函數式編程

函數式mixins語境中的“函數式”並不總是具備“函數式編程”一樣的純度含義。函數式mixins通常會被以OOP風格使用,充斥著副作用。許多函數式mixins會改變傳遞給它們的對象參數。請務必注意。

同樣的道理,有些開發人員雖說喜歡函數式編程風格,但是不會對傳入的對象維護同一性引用。在編寫函數式mixins時,應該假定使用這些mixins的代碼會隨機混合兩種風格。

就是說,如果你需要返回對象實例,那就始終返回this值,而不是對閉包中的實例對象的引用 -- 在函數式代碼中,二者引用的可能不是同一對象。此外,總是假定對象實例會通過用Object.assign()或{... object,... spread}語法進行賦值而複製。也就是說,如果你設置有非可枚舉屬性,這些屬性可能不會在最終對象上出現:

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

同樣,如果你正在用不是在你的函數式代碼中創建的函數式mixins,就不要假定代碼是純的。應該假定基礎對象可能被改變,可能存在副作用,且不保證引用透明,也就是說要緩存(memoize)由函數式mixins組成的工廠通常是不安全的。

總結

函數式mixins是一些可組合的工廠函數,這些函數可以像在流水線上的站點一樣,給對象添加屬性和行為。它們是一種從多個來源的特性(has-a,uses-a,can-do)中組合行為,而不是繼承給定類的所有功能(is-a)的好方法。

請注意,“函數式mixins”並不意味著“函數式編程” - 它僅僅是指“使用函數的mixins”。函數式mixinx可以用函數式編程風格來編寫,以避免副作用,保持引用透明,但是這並非是有保證的。在第三方mixins中可能存在副作用和非確定性。

  • 與簡單對象mixins不同,函數式mixins支持真正的數據私有(封裝),包括繼承私有數據的能力。
  • 與單祖先類繼承不同,函數式mixins還支持從多祖先繼承的能力,類似於類裝飾器、traits或多重繼承。
  • 與C ++中的多重繼承不同,JavaScript中的鑽石問題(即菱形繼承)很少出現,因為在出​​現衝突時有一個簡單的規則:最後添加的mixin獲勝。
  • 不同於類裝飾器、traits或多重繼承,它不需要基類。

從最簡單的實現開始,只按需轉到更復雜的實現:

純函數>工廠函數>函數式mixins>類

組合軟件:7. 函數式 Mixins


分享到:


相關文章: