State, Reader 和 Writer Monad

最近又在學(xué)習(xí) Monad,這里重新學(xué)習(xí)一下 State,以及學(xué)習(xí)功能更受限的 Reader,Writer,不考慮 Monad Transformer 的話題。
State
關(guān)于 State Monad 的引入已經(jīng)提了很多次了,最典型的例子是使用種子的隨機(jī)數(shù)生成器,其相關(guān)操作均滿足seed => (returnValue, newSeed)
(seed 即 state)的形式,而將其定義為 Monad,能將種子或狀態(tài)的傳遞過程隱藏起來,避免類似這樣的難看代碼:
動(dòng)機(jī)
觀察函數(shù) next3Int,可以發(fā)現(xiàn)其形式似乎是遞歸的,考慮把第一層以外的 let 抽象為(不使用閉包的)函數(shù),可以得到:
觀察這里的函數(shù)f
,可以發(fā)現(xiàn),其接受了第一個(gè) nextInt 的返回值x1
后,獲得了另一個(gè)形式和 nextInt 一致的函數(shù)f x1
,并且整個(gè)函數(shù)的返回結(jié)果也為f x1
應(yīng)用 s0 的結(jié)果…這里是一個(gè)這樣的邏輯:nextInt -> f -> next3Int
,定義type State s a = s -> (a, s)
,令Int = a, (Int, Int, Int) = b, String = s
, 有 State s a -> (a -> State s b) -> State s b
,這就是 State 的組合方式,有趣的地方是,這玩意函數(shù)體和上面的 next3Int 的形式完全一樣:
這個(gè)bind
的邏輯可以這樣描述:現(xiàn)在有一個(gè)初始狀態(tài),將其應(yīng)用到第一個(gè) State,得到結(jié)果和新的狀態(tài),將結(jié)果應(yīng)用給函數(shù) f,得到新的 State(這允許函數(shù) f 能看到這個(gè)結(jié)果),再將新的狀態(tài)應(yīng)用到這個(gè)新的 State。
next3Int
可以使用 bind
去描述,在某些語言里寫慣了 flatMap 的話這個(gè)還是蠻容易接受的:
定義
將上面這個(gè)形式去抽象,就得到了 Haskell 中State
的定義,其中(State s)
將成為 Monad 的實(shí)例,>>=
即為bind
。
操作
但上面的方法還不足以讓 State 能滿足生產(chǎn)實(shí)踐,還缺少兩個(gè)重要的原語——獲取狀態(tài)和設(shè)置狀態(tài),使用這兩個(gè)原語,可以定義更多操作。
用例
State 可以用來模擬全局變量,下面是一個(gè)非常簡單的交互式控制臺(tái)程序,其維護(hù)兩個(gè)計(jì)數(shù)器,提供 inc 和 show 命令;這里的 State Global (IO ()) 可以看作是Global -> (Global, IO ())
,區(qū)別在于用戶不需要顯式地通過遞歸去把結(jié)果的 Global 去返回了:
Reader
動(dòng)機(jī)
State 雖好,但給了使用者太多的自由度——用戶既能讀也能寫,這既增加了對其的理解和維護(hù)負(fù)擔(dān)(想想 Scala 的 var 和 val,js 的 let 和 const),也可能導(dǎo)致更多邏輯上的 bug。
Reader 和 Writer 可以認(rèn)為是 State 增加了相應(yīng)約束——Reader 只允許讀取,Writer 只允許寫入。Reader 非常適合用來保存配置信息,或者進(jìn)行依賴注入。
定義
Reader 的定義去參考 State 的話是容易想到的——State 允許狀態(tài)改變,但 Reader 不允許狀態(tài)改變,從 State 的上下文來看,這就是說對s -> (a, s)
,返回值元組第二個(gè)參數(shù)即新的狀態(tài)必定是和原狀態(tài)一致,這樣我們大可以省略掉第二個(gè)元素;而這就是 Reader 的定義:
操作和用例
Reader 的原語只有一個(gè),稱為ask
,即獲取當(dāng)前配置:
但 Reader 所保存的值通常并非是原子的,很可能是一個(gè)記錄,這時(shí)候提供從記錄中獲取部分字段的方法也是比較有意義的,這里有一個(gè)新方法稱為asks
去解決該問題,下面是定義和用例:
但上面仍有一個(gè)問題——這里每次都是把整個(gè)配置全都傳遞給各個(gè)函數(shù),對特定函數(shù),其中可能有很多不需要使用的配置;這會(huì)影響程序的耦合性(最小知道原則?。?,這時(shí)候我們可以把 Reader 所保存的信息也給修改;該操作稱為 withReader,下面是定義和用例:
這個(gè)定義有點(diǎn)反直覺,但我們手動(dòng)把 newtype 解包裝的話查看簽名(r -> r') -> (r' -> a) -> (r -> a)
,就會(huì)發(fā)現(xiàn)這里只有一種組合方式——(r'->a).(r->r')=(r->a)
,至于為何如此,寫一個(gè)用例就能感覺到了:
withReader 也可以用來臨時(shí)地去修改 r 的值(作用范圍顯然僅限于第二個(gè)參數(shù)這個(gè) Reader 中)的同時(shí)保持 r 的類型不變,這個(gè)操作也叫 local,其函數(shù)體和 withReader 一致,但作用域更狹窄,因此應(yīng)盡量使用它:
Writer
Writer 限制無法讀,只能寫(實(shí)際上是“拼接”),從type State s a = s -> (a, s)
出發(fā),就是說這里的函數(shù)參數(shù) s 是無法獲取到的,因此得到這樣的定義,w 為寫出的內(nèi)容,a 為副產(chǎn)品,w 的維護(hù)將被隱藏:
為什么連函數(shù)都丟掉了,直接是(w, a)
呢?因?yàn)?Haskell 是惰性求值的,用 Scala 的話來說,這里是=> (=> w, => a)
(是這樣嗎?),沒有什么問題。
下面是 Writer 的 Monad 實(shí)例的定義,這里展示了 Writer 和 State,Reader 一個(gè)非常不同的區(qū)別——Writer 的類型參數(shù) w 必須是幺半群 Monoid(即(集合,集合上元素的二元運(yùn)算,單位元)這樣一個(gè)三元組,典型例子如(字符串,字符串拼接,空字符串),(自然數(shù),加法,零)),不然 pure 沒法定義了;這顯然也影響了 Writer Monad 的運(yùn)算的定義——去使用該二運(yùn)運(yùn)算去“拼接”每一次運(yùn)算的 w 作為最終結(jié)果:
好奇有沒有不使用幺半群的解決方案,這樣或許會(huì)得到一個(gè)不同的 Writer Monad 實(shí)現(xiàn)?
Writer 的原語稱為 tell(和 Reader 的 ask 對應(yīng)),它的實(shí)現(xiàn)是顯然的:
基于 Writer 的 w 是幺半群這個(gè)特性,很容易想到,它可以用來進(jìn)行累加和累乘,Control.Monad.Writer
包下為這兩種情形定義了相應(yīng)的幺半群 Sum 和 Product: