函數語言程式設計中如何處理副作用?
一.純函式
純函式是說沒有副作用的函式(a function that has no side effects),有幾個好處:
-
引用透明(referential transparency)
-
可推理(reason about your code)
P.S.關於引用透明,見ofollow,noindex" target="_blank">基礎語法_Haskell筆記1
零副作用(side effects)是關鍵,但有些副作用是不可避免且至關重要的 ,例如:
-
輸出:顯示到Console、傳送給印表機、寫入資料庫等
-
輸入:從輸入裝置取得使用者輸入、從網路請求資訊等
那麼,推崇純函式的函數語言程式設計如何應對這些場景?有2種解法:
-
依賴注入
-
Effect Functor
二.依賴注入
We take any impurities in our code, and shove them into function parameters. Then we can treat them as some other function’s responsibility.
簡言之,把不純的部分剔出去作為引數
例如:
// logSomething :: String -> String function logSomething(something) { const dt = new Date().toISOString(); console.log(`${dt}: ${something}`); return something; }
logSomething
函式有兩個不純的因素,Date
與console
是偷偷取的外部狀態,所以對於同樣的輸入(something
),並不一定輸出相同結果(log行為及輸出內容都不確定)。所以,為了滿足相同輸入總是對應相同輸出,採用這種欺騙性的手段
:
// logSomething: Date -> (String -> *) -> String -> * function logSomething(d, log, something) { const dt = d.toISOString(); return log(`${dt}: ${something}`); }
如此這般,就能做到相同輸入對應相同輸出了:
const something = "Curiouser and curiouser!" const d = new Date(); const log = console.log.bind(console); logSomething(d, log, something);
看起來這可真蠢,獨善其身似乎沒什麼意義 。實際上,我們做了幾件事情:
-
把不純的部分剝離出來
-
把它們推開,遠離核心程式碼(拿到了
logSomething
之外) -
讓
logSomething
變純了(行為可預測)
意義在於控制不確定性(unpredictability) :
-
縮小範圍:把不確定性移到了更小的函式(
log
)裡 -
集中管理:如果反覆縮小範圍,並把不確定性推啊推推到邊緣(如應用入口),就能讓不確定性遠離核心程式碼,從而保證核心程式碼的行為可預測
So we end up with a thin shell of impure code that wraps around a well-tested, predictable core.
P.S.這樣做也有利於測試,只要把這層不純的薄殼換掉就能讓核心程式碼在模擬的測試環境中跑起來,而不需要模擬全套執行環境
但這種引數化的依賴注入方式並非完美,其缺點在於:
-
方法簽名長:例如
app(document, console, fetch, store, config, ga, (new Date()), Math.random)
-
傳參鏈路長:例如React裡從頂層元件一路接力傳遞
props
給某個葉子元件
長方法簽名的好處在於標清楚了將要進行的呼叫依賴哪些不純的東西,但逐層傳遞引數確實比較麻煩
三.惰性函式(Lazy Function)
另一種控制副作用的思路是,把產生副作用的部分保護起來 (放到地鐵站防爆球裡),帶著這層防護殼參與運算,直到需要結果時才打開殼取值
例如:
// fZero :: () -> Number function fZero() { console.log('發射核彈'); // Code to launch nuclear missiles goes here return 0; }
顯然,fZero
不是個純函式,存在極大的副作用(會發射核彈)。安全起見,把這個危險操作包進防爆球:
// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() { return fZero; }
接下來就可以隨意操作這個球而不會引發核彈了:
const zeroFunc = returnZeroFunc(); roll(zeroFunc); knock(zeroFunc);
但returnZeroFunc
仍然不是純函式(依賴外部的fZero
),不妨把依賴收進來:
// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() { function fZero() { console.log('Launching nuclear missiles'); // Code to launch nuclear missiles goes here return 0; } return fZero; }
不直接返回結果,而是返回一個能夠返回預期結果的函式(有點thunk的意思),以此類推:
// fIncrement :: (() -> Number) -> (() -> Number) function fIncrement(f) { return () => f() + 1; } const fOne= fIncrement(fZero); const fTwo= fIncrement(fOne); const fThree = fIncrement(fTwo); // And so on…
我們定義了一些特殊的數值,對這些數值進行任何操作(傳遞、計算等等)都是安全無副作用的:
// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number) function fMultiply(a, b) { return () => a() * b(); } // fPow :: (() -> Number) -> (() -> Number) -> (() -> Number) function fPow(a, b) { return () => Math.pow(a(), b()); } // fSqrt :: (() -> Number) -> (() -> Number) function fSqrt(x) { return () => Math.sqrt(x()); } const fFour = fPow(fTwo, fTwo); const fEight = fMultiply(fFour, fTwo); const fTwentySeven = fPow(fThree, fThree); const fNine = fSqrt(fTwentySeven); // No console log or thermonuclear war. Jolly good show!
這些操作相當於公式變換,只有最終代數計算時才會真正產生副作用。就像是把副作用沉澱出來,而依賴注入的方案是讓副作用漂起來 ,兩種方式都能夠達到分離副作用,控制不確定性的目的
但是,由於數值的定義變了(從數值變成了返回數值的函式),我們不得不重新定義加、減、乘、除……等一整套基於數值的算術運算,這可真蠢,有更好的辦法嗎?
四.Effect Functor
至此,我們把數值對映成返回數值的函式,並把數值運算對映成能夠操作這種特殊數值的函式。等一下,對映、防爆球、包裝、操作包起來的東西……想到了什麼?
沒錯,是Functor:
-- Haskell class Functor f where fmap :: (a -> b) -> f a -> f b
fmap定義的行為恰恰是對容器裡的內容(值)做對映,完了再裝進容器
這不就是惰性函式方案中迫切想要的東西嗎?
試著用JS實現,先造個容器型別(Effect
):
// Effect :: Function -> Effect function Effect(f) { return { get: () => f } }
有了容器就可以進行裝箱/拆箱操作:
// 含有副作用的方法 function fZero() { console.log('some side effects...'); return 0; } // 裝箱,把fZero包成Effect const eZero = Effect(fZero); // 拆箱,從Effect中取出fZero eZero.get(); -- 對應Haskell中的 -- 裝箱 let justZero = Just (\x -> 0) -- 拆箱 let (Just fZero) = justZero in fZero
接下來實現fmap
:
// fmap :: ((a -> b), Effect a) -> Effect b function fmap(g, ef) { let f = ef.get(); return Effect(x => g(f(x))); } // test let eOne = fmap(x => x + 1, Effect(fZero)); eOne.get()(); // 1
或者更地道(函式簽名一致)的curried 版本:
const curry = f => arg => f.length > 1 ? curry(f.bind(null, arg)) : f(arg); // fmap :: (a -> b) -> Effect a -> Effect b fmap = curry(fmap); // test let eOne = fmap(x => x + 1)(Effect(fZero)); eOne.get()(); // 1
讓Effect跑起來的get()()
看著有些囉嗦,簡化一下,同時把fmap
也收進來,讓這一切更符合JS的味道:
// Effect :: Function -> Effect function Effect(f) { return { get: () => f, run: x => f(x), map(g) { return Effect(x => g(f(x))); } } }
試玩一下:
const increment = x => x + 1; const double = x => x * 2; const cube = x => x ** 3; // (0 + 1) * 2 ^ 3 const calculations = Effect(fZero) .map(increment) .map(double) .map(cube) calculations.run(); // 8
這一系列map
運算都是不含副作用的,直到最後run()
才會引發fZero
的副作用,這正是惰性函式方案的意義:讓副作用像沙子一樣沉澱到最後,保證上層的水純淨透明
P.S.上面實現的Effect
其實相當於函式Functor,作用於函式的對映操作實際上就是函式組合
:
-- Haskell instance Functor ((->) r) where fmap = (.) (.):: (b -> c) -> (a -> b) -> a -> c (.) f g = \x -> f (g x) // 即 map(g) { return Effect(x => g(f(x))); }
所以關鍵點在於函式組合(compose
):
// 特殊值 const fZero = x => 0; // 普通函式 const double = x => x + x; // 無法直接double fZero // 引入Functor fmap概念 const compose = (f, g) => x => g(f(x)); // 不改變double,實現double fZero compose(fZero, double)(); // (0 + 1) * 2 ^ 3 // compose(compose(compose(fZero, increment), double), cube)();
五.總結
無論依賴注入還是Effect Functor方案,處理副作用的原則都是將其帶來的不確定性限制在一定範圍內,讓其它部分得以保持純的特性
如果把四處混雜著副作用的應用看作一杯混著沙子的水,兩種方案的區別在於讓水變清的思路 不同:
-
依賴注入:讓沙子漂起來浮在最上層,形成一層不純的薄殼,保持下方的水純淨
-
Effect Functor:把沙子澱到杯底,讓上方的水澄清透明
誠然,副作用還在,並沒有被消除。但通過類似的方式能夠讓大部分程式碼保持純的特性,享受純函式帶來的確定性好處(think less ):
You can be confident that the only thing affecting their behaviour are the inputs passed to it. And this narrows down the number of things you need to consider. In other words, it allows you to think less.