一。純函數
純函數是說沒有副作用的函數(a function that has no side effects),有幾個好處:
-
引用透明(referential transparency)
-
可推理(reason about your code)
P.S. 關於引用透明,見 [基礎語法_Haskell 筆記 1](/articles/基礎語法-haskell 筆記 1/#articleHeader1)
零副作用(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](/articles/從惰性 io 說起-haskell 筆記 6/#articleHeader3) 的意思),以此類推:
// 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](/articles/深入 typeclass-haskell 筆記 4/#articleHeader5):
-- 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,[作用於函數的映射操作實際上就是函數組合](/articles/functor 與 applicative-haskell 筆記 7/#articleHeader3):
-- 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.
暫無評論,快來發表你的看法吧