我讀過的 Monad 最好的介紹之部分內(nèi)容——F# 演繹版

本文在原文《Translation from Haskell to JavaScript of selected portions of the best introduction to Monads I’ve ever read》的基礎(chǔ)上用 F# 代碼再次“演繹”并拓展


首先的例子是一個(gè)正弦函數(shù)和一個(gè)立方函數(shù):

let sine x = Math.Sin x
let cube x = x*x*x

它們都輸入梢什、輸出一個(gè)數(shù)融痛,這使它們都具備可組合性(composable):一個(gè)函數(shù)的輸出可以作為下一個(gè)函數(shù)的輸入:

let sineCubed = sine (cube x)

F# 語言能夠以比多層嵌套更優(yōu)雅、可讀性更強(qiáng)的 pipe 操作符表示這種組合性

let sineCubed = x |> cube |> sine

推廣到更通常的情況是:有兩個(gè)函數(shù) f 和 g虐沥,經(jīng)過 f(g(x)) 計(jì)算返回結(jié)果。現(xiàn)用一個(gè)函數(shù)來封裝這個(gè)組合操作(composition)

let compose f g x =
  x |> g |> f

let sineOfCube =
  compose sine cube

let y = sineOfCube 3.

接著泽艘,讓這些函數(shù)被調(diào)用時(shí)打印一句日志以記錄自己曾被調(diào)用欲险,以此可以調(diào)試這些函數(shù):

let sine x =
  printfn "Sine was called."
  Math.Sin x

let cube x =
  printfn "Cube was called."
  x*x*x

但是注意,printfn 既不是函數(shù)的參數(shù)匹涮,也不是函數(shù)的返回值天试,它有副作用。在一個(gè)嚴(yán)格的然低、僅允許純函數(shù)存在的系統(tǒng)里喜每,不可以這樣做。若要獲得日志信息雳攘,必須把它作為函數(shù)的返回值的一部分带兜。可是函數(shù)一般只能返回一個(gè)呀吨灭。那就把返回值的類型設(shè)計(jì)成能容納多個(gè)值的容器刚照,這在 F# 語言里可以是元組:(結(jié)果值,一些調(diào)試信息):

let sine x =
  (Math.Sin x, "Sine was called.")

let cube x =
  (x*x*x, "Cube was called.")

但如此一來喧兄,這些函數(shù)無法再按前面的方法組合起來:

let sineOfCube =
  compose sine cube 3.
compose sine cube 3.
-------------^^^^
stdin(8,16): error FS0001: 需要一個(gè)支持運(yùn)算符 “*” 的類型无畔,但提供的是元組類型

因?yàn)樵谝韵聝蓚€(gè)方面計(jì)算遭到了破壞:cube 返回的是一個(gè)元組,而 sine 必須傳入一個(gè)數(shù)字吠冤,這必然導(dǎo)致 sine 失敗浑彰。再則,cube 調(diào)用中的調(diào)試信息也無法傳遞下去而丟失拯辙。

讓我們搞個(gè)新函數(shù) debuggable闸昨,不僅能傳入 x ,并通過原先這兩個(gè)函數(shù),不僅仍能正確地計(jì)算出結(jié)果饵较,還能各自返回一個(gè)表示“自己得到了調(diào)用”的字符串拍嵌。

原來的簡(jiǎn)單組合顯然不再起作用,新函數(shù)需要寫一些膠水代碼循诉,能夠解開每個(gè)函數(shù)的返回值横辆,并將它們重新縫合回去:

let composeDebuggable f g x =
  let y, s = g x
  let z, t = f y
  z, s + " " + t
composeDebuggable sine cube 3.

val it : float * string = (0.9563759284, "Cube was called. Sine was called.")

這樣,我們將兩個(gè)輸入為數(shù)字茄猫、返回為數(shù)字+字符串序?qū)Φ暮瘮?shù)組合在了一起狈蚤,組合的結(jié)果依然是一個(gè)具有與 cube 和 sine 同樣類型簽名的函數(shù):
number -> number * string

這意味著這個(gè)新的組合函數(shù)還可以和其他 debuggable 函數(shù)進(jìn)一步組合,所有的 debuggable 函數(shù)及其組合都會(huì)擁有這樣的簽名划纽。

原來的函數(shù)有更簡(jiǎn)單的簽名 number -> number脆侮,參數(shù)和返回值的類型具備對(duì)稱性(symmetry),這才使得它們可組合勇劣。同樣靖避,與其為 debuggable 函數(shù)另外定制一個(gè)專用的組合邏輯,不如直接簡(jiǎn)單地把它們的簽名也轉(zhuǎn)化(convert) 成對(duì)稱的形式:
number*string -> number*string

如此一來比默,我們就可以繼續(xù)使用之前定義的 compose 函數(shù)把它們粘合在一起了幻捏。當(dāng)然,靠重寫 cube 和 sine 那樣的手動(dòng)轉(zhuǎn)換(conversion)命咐,使其接受 number * string 類型的參數(shù)也是可以的篡九,但這種做法顯然無法擴(kuò)展(scale)到其他函數(shù)上,因?yàn)檫@可能需要重寫所有的函數(shù)醋奠,添加相同的樣板(boilerplate)代碼榛臼。

更佳做法是:讓每個(gè)函數(shù)仍舊僅做自己原先的工作,即維持其原來的簽名窜司,另外提供一個(gè)工具函數(shù) bind沛善,其工作就是接受一個(gè)形如 number -> number * string 簽名的函數(shù) f,自動(dòng)轉(zhuǎn)換成一個(gè)形如 number*string -> number*string 簽名的函數(shù):

let bind f =
  fun tuple ->
    let x, s = tuple
    let y, t = f x
    y, s + " " + t

經(jīng)過 bind 轉(zhuǎn)化后函數(shù)擁有可組合的簽名例证,更容易組合出想要的結(jié)果:

let sineOfCube =
  compose (bind sine) (bind cube)
sineOfCube (3., "")

val it : float * string = (0.9563759284, " Cube was called. Sine was called.")

但這樣一來,所有函數(shù)的接收參數(shù)都變成了 number * string迷捧,而我們還是只想傳一個(gè) number 參數(shù)织咧。所以除了轉(zhuǎn)化函數(shù) bind 之外,還需要一個(gè)“包裹”函數(shù) number -> number*string漠秋,其作用就是接受一個(gè)值 number笙蒙,并包裹于一個(gè)基本容器(basic container) 中,這個(gè)基本容器就是那些 debuggable 函數(shù)可以消費(fèi)(consume) 的類型 number * string庆锦。

以 debuggable 函數(shù)為例捅位,給這個(gè) number 配對(duì)一個(gè)空字符串,然后一起“包裹”起來,作為整個(gè)組合函數(shù)的初始輸入值:

let wrap x = (x, "")

val wrap : x:'a -> 'a * string

于是艇搀,

sineOfCube (3., "")

就可以寫作:

sineOfCube (wrap 3.)

或者:

(compose sineOfCube wrap)3.

wrap 函數(shù)尿扯,有的語言也寫作 unit 或 Return,能把任何函數(shù)轉(zhuǎn)化成 debuggable 函數(shù)焰雕,只要它的返回值類型是 debuggable 函數(shù)可接受的輸入類型即可:

let round (x: decimal) = Math.Round x
let roundDebug x = wrap (round x)

val round : x:decimal -> decimal
val roundDebug : x:decimal -> decimal * string

進(jìn)一步衷笋,這種把一個(gè)簡(jiǎn)單函數(shù)變成一個(gè) debuggable 函數(shù)的轉(zhuǎn)換函數(shù),可以抽象為名為 lift 的函數(shù)矩屁,其簽名的意思是:接受一個(gè)形如 number -> number 的函數(shù)作為參數(shù)辟宗,并返回一個(gè)形如 number -> number*string 的函數(shù):

type F = decimal -> decimal
type Lift = F -> decimal -> decimal*string
let lift: Lift = fun f x -> wrap (f x)
// or, more simply:
let lift f = compose wrap f

let round (x: decimal) = Math.Round x
let roundDebug = lift round

val round : x:decimal -> decimal
val roundDebug : (decimal -> decimal * string)

以上第一個(gè)例子中,為了給計(jì)算結(jié)果添加附加信息并一起輸出吝秕,需要將原來的簡(jiǎn)單函數(shù)的返回類型復(fù)合成更復(fù)雜的結(jié)構(gòu)的類型(如 number -> number*string )泊脐。

在第二個(gè)例子中,這個(gè)簡(jiǎn)單函數(shù)(round)并沒有輸出更高復(fù)合類型的需求(decimal -> decimal)烁峭。但可能會(huì)發(fā)生調(diào)用流程后面的函數(shù)卻是 debuggable 之類的函數(shù)容客,它們可接受的輸入?yún)?shù)卻是更高復(fù)合類型。所以需要把簡(jiǎn)單函數(shù)的輸出類型加以提升(lift)则剃。提升的方法既可以先計(jì)算出仍是簡(jiǎn)單類型的結(jié)果耘柱,然后靠 wrap 包裹,比如 wrap (f x) 棍现;也可以靠轉(zhuǎn)換函數(shù) lift 把簡(jiǎn)單函數(shù)也變成 debuggable 函數(shù)那樣的簽名形式调煎。
這就是 lift 函數(shù)存在的意義所在。

至此己肮,我們找到了三個(gè)能用來粘合 debuggable 函數(shù)的重要抽象:

lift, 可以將簡(jiǎn)單函數(shù)轉(zhuǎn)化為 debuggable 函數(shù)

bind, 可以把 debuggable 函數(shù)轉(zhuǎn)化為可組合的形式

wrap, 可以把一個(gè)簡(jiǎn)單值放到一個(gè)容器中士袄,以轉(zhuǎn)化成 debuggable 函數(shù)所需的形式

let f = compose (bind roundDebug) (bind sine)
f (wrap 27.)

val it : float * string = (1.0, " Sine was called. ")

這些抽象(lift、bind 和 wrap)定義了一個(gè) Monad谎僻,在 Haskell 標(biāo)準(zhǔn)庫(kù)中娄柳,它被稱為 Writer Monad


為了更清楚地理解這個(gè)模式(pattern) 的通用性艘绍,再舉個(gè)例子 List Monad赤拒。

一個(gè)可能常見的問題是:需要判斷一個(gè)函數(shù)的參數(shù)是一個(gè)元素還是多個(gè)元素的數(shù)組,目的通常為是否需要在函數(shù)體里用一個(gè) for 循環(huán)去操作各個(gè)元素诱鞠。雖然挎挖,這通常是一個(gè)可能做了很多次的樣板代碼,但卻會(huì)給如何組合這些函數(shù)帶來很大的影響航夺。例如蕉朵,假設(shè)某個(gè)函數(shù)的功能是接受一個(gè)文件夾 Folder 作為參數(shù),并將其屬下的所有子 Folder 作為一個(gè)數(shù)組形式返回(不是遍歷屬下所有層級(jí)的文件夾)阳掐。該函數(shù)的簽名為 Folder -> Folder array

type Folder(pathIn: string) =
  let foldersName: string [] =
    IO.Directory.GetDirectories(pathIn)
  member thisFldr.subFolders =
    Array.map
      (fun fn -> Folder(fn)) foldersName
  member this.Name = pathIn

let childrenOf (folder: Folder) = folder.subFolders

let parent = Folder(".")
childrenOf parent

val childrenOf : folder:Folder -> Folder []

現(xiàn)在想要找到 parent 里的 grandchildrenOf始衅,即它的 children 的 children冷蚂。直覺上,下面是一個(gè)好的定義:

let grandchildrenOf = compose childrenOf childrenOf

然而 childrenOf 函數(shù)的輸入輸出并不對(duì)稱汛闸,所以不能簡(jiǎn)單地把它們 compose 起來蝙茶。應(yīng)該手寫成這樣:

let grandchildrenOf node = [|
  for child1 in childrenOf node do
    for child2 in childrenOf child1 do
      yield child2
  |]

grandchildrenOf parent

只要簡(jiǎn)單地把所有找到的文件夾串接(concat) 起來,就能把原來分屬于不同 children 下面的 grandchildren 變成一個(gè)扁平的(flat) 的數(shù)組蛉拙。但這樣一來尸闸,僅僅為了處理數(shù)組,而不是在解決這個(gè)問題本身孕锄,就不得不用上很多樣板代碼吮廉,這并不很方便。最好是只需組合兩個(gè)列表處理(list-handling) 函數(shù)就可以完成此事畸肆。

回顧之前的例子宦芦,需要兩個(gè)步驟來解決這個(gè)問題:提供一個(gè) bind 函數(shù),把 childrenOf 轉(zhuǎn)化成可組合的形式轴脐,然后寫一個(gè) wrap 函數(shù)把初始輸入值 parent 轉(zhuǎn)化成可被接受的形式调卑。

這個(gè)問題的核心就在于:目前的函數(shù)接受一個(gè) node 而返回了一組 node,所以這個(gè)轉(zhuǎn)換應(yīng)該能把單個(gè)元素轉(zhuǎn)化成數(shù)組的形式大咱,反之亦然恬涧。這個(gè)元素是 Folder 或者別的什么在此并不重要,只要把這個(gè)具體的類型寫成泛型即可碴巾。wrap 接受一個(gè) item 并返回一個(gè)以這個(gè) item 為元素的數(shù)組 []溯捆,而 bind 接受一個(gè)一對(duì)多函數(shù)并返回一個(gè)多對(duì)多函數(shù):

let wrap item = [|item|]

let bind f =
  fun arr -> [|
    for item1 in Array.map f arr do
      for item2 in item1 -> item2
  |]

val wrap : item:'a -> 'a []
val bind : f:('a -> 'c []) -> arr:'a [] -> 'c []

現(xiàn)在我們可以如愿地組合 childrenOf 了:

let parent = wrap (Folder("."))
let grandchildrenOf = compose (bind childrenOf) (bind childrenOf)

for child in grandchildrenOf parent do
  printfn "%s" child.Name

至此,我們實(shí)現(xiàn)了 Haskell 里的 List Monad厦瓢,它可以 compose 那些一對(duì)多的函數(shù)提揍。

所以,Monad 是一個(gè)設(shè)計(jì)模式(design pattern)煮仇,它表明:

可以給某類函數(shù) F 應(yīng)用如下兩個(gè)函數(shù)劳跃,使得 F 們可組合(composable)

bind 函數(shù),可以轉(zhuǎn)化 F 函數(shù)浙垫,使其輸入和輸出的類型是一致的容器刨仑;

wrap 函數(shù),可以把一個(gè)值包裝到一個(gè)容器中夹姥,使其可被該可組合函數(shù)(composable function)所接受杉武。

換言之,bind 可以把 F 類函數(shù)應(yīng)用于 wrap 給出的容器上佃声。

這個(gè)說法略過了 Monad 的數(shù)學(xué)基礎(chǔ)艺智,所以并非嚴(yán)謹(jǐn)?shù)亩x倘要,但卻是一個(gè)非常有用的設(shè)計(jì)模式圾亏,因?yàn)樗梢詭湍惚苊?em>意外的復(fù)雜性(accidental complexity)十拣,從根本上顯著提高代碼的可讀性。


下面再用 F# 演繹下 Monad 在出錯(cuò)處理方面的運(yùn)用志鹃。

一個(gè)函數(shù)的計(jì)算過程中有可能會(huì)出錯(cuò)夭问。許多語言采用的處理異常方式是 try ... catch... 的形式,將流程從 try 塊跳轉(zhuǎn)到 catch 塊曹铃。

但是缰趋,純函數(shù)的系統(tǒng)不允許這樣的方式,那樣會(huì)有如下缺點(diǎn):

違反了引用透明原則陕见。因?yàn)槟菢拥奶D(zhuǎn)會(huì)導(dǎo)致函數(shù)的出口既有可能在 try 塊中秘血,也有可能在 catch 塊中),所以不能確保單一的评甜、可預(yù)測(cè)的返回值灰粮;

破壞線性邏輯關(guān)系。因?yàn)橛糜谔幚懋惓5拇a catch 塊與正常的函數(shù)運(yùn)算代碼 try 塊忍坷,它們之間的關(guān)系不是正常的函數(shù)調(diào)用關(guān)系粘舟,也打破了為正常情況而設(shè)的線性函數(shù)調(diào)用鏈;

違反局域性的原則佩研,會(huì)有副作用柑肴。由于 try 塊和 catch 塊是不同的作用域。若要將 try 塊里的值帶入 catch 塊旬薯,無法采用函數(shù)之間 參數(shù)->返回值 那樣的傳遞方式晰骑,這勢(shì)必要求存在超越這兩個(gè)作用域塊的公共變量,try 塊里還要給這個(gè)公共變量賦值袍暴,也就是有副作用些侍;

如果異常處理過程中再次出現(xiàn)異常,會(huì)出現(xiàn)復(fù)雜的政模、更加混亂的岗宣、地獄般的嵌套異常處理塊。

因此淋样,我們更希望將出錯(cuò)信息僅作為返回值輸出耗式,讓函數(shù)的外部去判斷出錯(cuò)信息,從而決定該如何處理這個(gè)出錯(cuò)趁猴。那樣刊咳,無論函數(shù)運(yùn)算是否出錯(cuò)或發(fā)生異常,函數(shù)之間始終保持原有的順序關(guān)系儡司。

乍一看娱挨,這類似于前面第一個(gè)例子:讓函數(shù)把正常的計(jì)算結(jié)果和額外的(出錯(cuò))信息打包在一起,共同作為其返回值捕犬。但不同的是:這次返回值的類型是正常結(jié)果與錯(cuò)誤信息的二選一跷坝,而不是同時(shí)具有酵镜。這樣的類型就是擁有 Right 和 Left 分支的結(jié)構(gòu)體,稱之為 Either 柴钻,Right 指代正常的分支淮韭,Left 指代出現(xiàn)異常的分支,兩者絕不會(huì)同時(shí)出現(xiàn)贴届。

F# 的預(yù)定義 Option 類型其實(shí)是一種特殊的 Either靠粪,也是表示兩個(gè)值中的一個(gè):Some result 或 None,分別代表計(jì)算值有了正常的結(jié)果 result毫蚓,或者因異常而無結(jié)果占键。

其它有的語言把表示這種 或有或無 的類型稱作 Maybe

type Maybe<T> = Just<T> | Nothing

以下引用《Computation expressions: Introduction 》里安全除法的例子。該例子是想更優(yōu)雅地處理除法出錯(cuò)的情況元潘。
那篇文章是主講 F# 的 Computation expressions 技術(shù)的捞慌。但這里我們不采用那門技術(shù),而只采用“傳統(tǒng)”的寫法柬批,只為了把重點(diǎn)突出在 Monad 的應(yīng)用上啸澡。

這個(gè)例子里,我們要除以一系列的數(shù)氮帐,即一個(gè)接一個(gè)地將這些數(shù)作為除數(shù)嗅虏,但是這些數(shù)其中可能有 0。如何處理上沐?拋出一個(gè)異常會(huì)使代碼丑陋皮服,把 NULL、undefined参咙、empty 等當(dāng)作結(jié)果值中的一個(gè)另類值更是一個(gè)糟糕的主意( Tony Hoare 托尼·霍爾所稱的“數(shù)十億美元的錯(cuò)誤 the billion-dollar mistake ”)龄广。使用 option 類型應(yīng)該是一個(gè)不錯(cuò)的方法。

先定義一個(gè)幫助函數(shù)蕴侧,實(shí)現(xiàn)其中一個(gè)簡(jiǎn)單除法功能并返回一個(gè) int option择同。每個(gè)除法過程后判定是否成功,只有在成功(返回 Some)的時(shí)候才會(huì)繼續(xù)下一個(gè)除法過程净宵。然后將這些除法過程鏈接起來敲才。幫助函數(shù)如下:

let divideBy bottom top =
  if bottom = 0 then None else Some(top/bottom)

注意第一個(gè)參數(shù)為除數(shù),第二個(gè)是被除數(shù)择葡。故可以將除法表達(dá)式(12/3)寫成 12 |> divideBy 3 的形式紧武,這會(huì)更容易將整個(gè)除法過程串聯(lián)起來:

let divideByWorkflow init x y z =
  let a = init |> divideBy x
  match a with
  | None -> None  // give up
  | Some a' ->    // keep going
    let b = a' |> divideBy y
    match b with
    | None -> None  // give up
    | Some b' ->    // keep going
      let c = b' |> divideBy z
      match c with
      | None -> None  // give up
      | Some c' ->    // keep going
        Some c'  //Return

調(diào)用此函數(shù):

let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1

val good : int option = Some 2
val bad : int option = None // 因?yàn)橛幸粋€(gè)除數(shù)為 0

以上例子中,返回結(jié)果必須為 int option敏储,不能返回 int阻星。

然而,這個(gè)例子中的代碼依然丑陋已添。

現(xiàn)在定義兩個(gè)新的函數(shù) bind 和 wrap 如下妥箕。函數(shù) f 經(jīng)過 bind 后具有了對(duì)稱的輸入輸出類型:

let bind f =
  fun maybe ->
    match maybe with
    | None -> None
    | Some a -> f a

let wrap x =
  Some x

val bind : f:('a -> 'b option) -> maybe:'a option -> 'b option
val wrap : x:'a -> 'a option

重寫這個(gè)系列除法的工作流函數(shù)番舆,隱藏了之前的判斷分支邏輯:

let divideByWorkflow init x y z =
  let a = wrap init
  let b = a |> bind (divideBy x)
  let c = b |> bind (divideBy y)
  let d = c |> bind (divideBy z)
  d

可以同樣地 compose 起來:

let divideByWorkflow init x y z =
  (compose
    (compose
      (bind (divideBy x))
      (bind (divideBy y))
    )
    (bind (divideBy z))
  )
  (wrap init)

如果采用中綴運(yùn)算符,會(huì)使這個(gè)流程的寫法呈線性流矾踱,避免多層嵌套,更加優(yōu)雅疏哗,就像 F# 的原生管道運(yùn)算符 |> 那樣呛讲。
>>= 是將 bind 寫成中綴操作符的標(biāo)準(zhǔn)方式。不過注意:由于參數(shù)傳遞的順序要求返奉,中綴運(yùn)算符(>>=)的入?yún)⒁{(diào)換一下位置:

let (>>=) x f =
  match x with
  | None -> None
  | Some x' -> f x'

let divideByWorkflow init x y z =
  divideBy x init >>= divideBy y >>= divideBy z

val ( >>= ) : x:'a option -> f:('a -> 'b option) -> 'b option

看來贝搁,>>= 是比函數(shù)管道符更高級(jí)形式的函數(shù)中間鏈,它不僅能承上啟下地把上一個(gè)函數(shù)的返回值輸入給下一個(gè)函數(shù)芽偏,還有對(duì)值進(jìn)行封裝和解封的作用雷逆。


前面我們用了 compose 函數(shù)來復(fù)合兩個(gè)函數(shù):

let compose f g x =
  x |> g |> f

如果要復(fù)合更多的函數(shù)呢?由于 F# 語言不允許函數(shù)有可變數(shù)目的參數(shù)污尉,對(duì)于每 N 個(gè)要被復(fù)合的函數(shù)膀哲,就要分別單獨(dú)定義一個(gè) composeN,不僅麻煩被碗,還沒有通用性某宪。
如果還是用只能兩個(gè)參數(shù)的函數(shù),勢(shì)必要靠括弧多層嵌套锐朴,很不優(yōu)雅:
(compose (compose f1 f2) f3)

雖然中綴操作符 |> 能夠解決這個(gè)問題兴喂,但下面我們換一種寫法,不僅是為了演示 F# 的表現(xiàn)能力焚志,更為了用于其它一些場(chǎng)景衣迷。在下面這段代碼里,定義了一個(gè) Composition 類酱酬,通過它所含的方法 chain 將一個(gè)計(jì)算函數(shù)推入類中壶谒,推入的同時(shí)與前面已復(fù)合好的的函數(shù)復(fù)合。隨著這個(gè) chain 的不斷被調(diào)用膳沽,越來越多的函數(shù)被推入并復(fù)合佃迄,直到最后 fold 方法被調(diào)用,全部函數(shù)復(fù)合完畢贵少,可以得到調(diào)用并返回結(jié)果呵俏。

type Composition(g) =
  member _.chain f = Composition(compose f g)
  member _.fold x = g x

let chain f (this: Composition) =
  this.chain f

let fold x (this: Composition) =
  this.fold x

let f1 x = // 打折
  x * 0.8

let f2 x = // 滿減
  match x with
  | _ when x>=200. -> x-100.
  | _ when x>=100. -> x-50.
  | _ -> x

let promotion init = // 促銷
  Composition(fun x -> x)
  |> chain f1
  |> chain f2
  |> fold init

上面的代碼里,我們對(duì)方法 chain 和 fold 的調(diào)用做了個(gè)包裝滔灶,只是為了適應(yīng)中綴操作符對(duì)入?yún)⒋涡虻囊笃账椋苊馊缦碌膯禄蚯短椎拇a,并沒有功能上的意義:

let promotion init = // 促銷
  let a = Composition(fun x -> x)
  let b = a.chain f1
  let c = b.chain f2
  c.fold init

或者:

let promotion init = // 促銷
  let a =
    (
      (
        Composition(fun x -> x)
      ).chain f1
    ).chain f2
  a.fold init

可見录平,在 Composition 將函數(shù) f1麻车,f2 缀皱,... 推入和復(fù)合的過程中,這些函數(shù)都沒有真正得到調(diào)用和計(jì)算动猬,因此啤斗,作為這些函數(shù)的復(fù)合的結(jié)果,Composition 也只不過是一個(gè)尚未計(jì)算的函數(shù)赁咙,相當(dāng)于惰性(Lazy) 的值钮莲;如果這些函數(shù)都是純函數(shù),那么 promotion 也是個(gè)純函數(shù):

val promotion : init:float -> float

要想計(jì)算整個(gè)復(fù)合函數(shù)彼水,只要將初始值 init 傳入給 promotion 即可:

promotion 100. // 原價(jià) 100

這個(gè) init 可以是從外部介質(zhì)讀入進(jìn)來的崔拥,promotion 的計(jì)算結(jié)果可以輸出到外部介質(zhì),所以凤覆,與外部介質(zhì)的交互這個(gè)“副作用”僅限于在 promotion 即整個(gè)函數(shù)復(fù)合過程的首尾兩頭链瓦,獨(dú)立于 promotion,不會(huì)影響到 promotion 的純函數(shù)性盯桦,這就很好地隔離了純與不純的代碼慈俯。至此,我們實(shí)現(xiàn)了 IO Monad拥峦。

但是肥卡,計(jì)算函數(shù)可能會(huì)發(fā)生異常,比如價(jià)格輸入錯(cuò)誤事镣,無法再按原先的設(shè)計(jì)計(jì)算出優(yōu)惠價(jià)格步鉴。此時(shí)不僅要返回出錯(cuò)函數(shù)的內(nèi)部發(fā)出的錯(cuò)誤信息,還要跳過后續(xù)的函數(shù)璃哟,終止進(jìn)一步的計(jì)算氛琢,并且將先前沒有出錯(cuò)的函數(shù)所計(jì)算好的結(jié)果返回,而不是像上述 divideByWorkflow 例子那樣随闪,不管不顧地返回整個(gè)計(jì)算結(jié)果為 error 或 None阳似。

你肯定會(huì)想到:讓計(jì)算函數(shù)的返回類型是 Tuplenumber * string),以表示一個(gè)計(jì)算結(jié)果和關(guān)于可能的異常信息铐伴。這樣類型的數(shù)據(jù)在函數(shù)鏈中傳遞撮奏,每一個(gè)計(jì)算函數(shù)都要判斷傳入的參數(shù)里是否含有異常的信息,以此決定自己的行為当宴。
但那就意味著每個(gè)計(jì)算函數(shù)不能只顧關(guān)注自身功能畜吊,還要考慮并非由自己產(chǎn)生的異常,加重負(fù)擔(dān)户矢、疊加噪音玲献、產(chǎn)生干擾。

下面我們換一種決定分支行為的方式:給計(jì)算函數(shù)提供兩個(gè)函數(shù) resolve 和 reject,分別在計(jì)算正嘲颇辏或者異常時(shí)被調(diào)用瓢娜,且僅在計(jì)算函數(shù)內(nèi)部、僅根據(jù)其內(nèi)部(而不是外部其它地方)的錯(cuò)誤與否來決定調(diào)用哪個(gè)礼预。調(diào)用時(shí)將正常的計(jì)算結(jié)果或異常信息分別作為參數(shù)傳給 resolve 和 reject眠砾。由于 resolve 和 reject 函數(shù)是作為參數(shù)傳進(jìn)來的,這意味著可以向其中傳入另一個(gè)函數(shù)來指示下一步該做什么托酸,實(shí)際上是由函數(shù)的調(diào)用者而非函數(shù)自身來決定應(yīng)該具體如何處理計(jì)算結(jié)果褒颈。

在每個(gè)計(jì)算函數(shù)內(nèi)部,不再需要輸入和返回的參數(shù)是同時(shí)含有正常結(jié)果與錯(cuò)誤的值對(duì)的 Tuple 復(fù)合類型(float * string)获高,或者將兩者包裝出一個(gè) Either 類型(Result of float | Err of string),當(dāng)然也用不著 if err 之類判斷前面函數(shù)傳來的異常信息吻育。

同樣念秧,調(diào)用者也不用到處對(duì)計(jì)算函數(shù)返回的值使用 if ... then ... else 結(jié)構(gòu)去處理了,想怎么處理布疼,就將對(duì)應(yīng)的處理函數(shù)作為參數(shù)傳入計(jì)算函數(shù)中即可摊趾。

函數(shù)的返回值不再通過函數(shù)自身直接返回,而是進(jìn)一步調(diào)用下一個(gè)函數(shù)游两,不僅將返回值作為下一個(gè)函數(shù)的參數(shù)傳遞出去砾层,也把本函數(shù)結(jié)束后的控制流交給下一個(gè)函數(shù),這樣的編程風(fēng)格就是 continuation passing style(CPS)贱案,resolve 和 reject 就是所謂的下一個(gè)函數(shù)continuation肛炮。

為避免分散注意力,我們?cè)诖瞬粚?duì) CPS 作深入講解宝踪。

let f1 x (resolve, reject) = // 打折
  if x > 0. then
    let x' = x * 0.8
    resolve x'
  else
    reject $"打折時(shí) {x} is error"

let f2 x (resolve, reject) = // 滿減
  if x > 50. then
    let x' =
      match x with
      | _ when x>=200. -> x-100.
      | _ when x>=100. -> x-50.
      | _ -> x
    resolve x'
  else
    reject $"滿減時(shí) {x} is error"

由于函數(shù)發(fā)生異常的原因有可能是在計(jì)算過程中侨糟,沒有計(jì)算很可能就無法暴露異常,因此前面那種純粹的函數(shù)復(fù)合瘩燥、在最后沒有調(diào)用 promotion 之前全程都沒有計(jì)算那樣的方式秕重,對(duì)于暴露異常就不靈了。

下面的代碼就要改動(dòng)一下:每次 chain 到一個(gè)新的函數(shù)時(shí)厉膀,要讓該函數(shù)進(jìn)行運(yùn)算溶耘。當(dāng)計(jì)算正常時(shí),計(jì)算結(jié)果被 resolve 函數(shù)傳遞出來服鹅,再次推入到容器中凳兵,作為下一個(gè)要被 chain 的函數(shù)的輸入?yún)?shù),就跟 |> 運(yùn)算符的作用一樣企软。

當(dāng)發(fā)生異常時(shí)留荔,錯(cuò)誤信息被 reject 函數(shù)傳遞出來,連同前面函數(shù)的計(jì)算結(jié)果推入到容器中,但不能再輸入給后續(xù)要 chain 的函數(shù)聚蝶,只是通過容器簡(jiǎn)單地原樣傳遞至最后杰妓。

異常時(shí)這樣的傳遞邏輯保持了與正常時(shí)的相同,即始終完成整個(gè)傳遞過程碘勉,而不是中途提前結(jié)束巷挥,或者像 try ... catch ... 那樣不按原來順序,而是跳轉(zhuǎn)到另一個(gè)位置验靡。于是容器之外只需關(guān)注把所有計(jì)算函數(shù)按正常時(shí)的順序 bind 到容器倍宾,而無需對(duì)可能發(fā)生的傳遞鏈的提前結(jié)束或跳轉(zhuǎn)做出專門的安排。捕捉異常和獲得計(jì)算結(jié)果都是在函數(shù)調(diào)用鏈的最后胜嗓。

注意在下面的代碼里高职,我們利用活動(dòng)模式和模式匹配,進(jìn)一步減少了判斷異常與執(zhí)行分支之間的耦合:

type Context(prev, err) =
  let (| Left | Right |) (prev, err) =
    if err = "" then Right else Left

  let resolve result = Context(result, err)
  let reject err' = Context(prev, err')

  member _.chain f =
    match (prev, err) with
    | Left -> Context(prev, err) // 不再計(jì)算辞州,直接傳遞
    | Right -> f prev (resolve, reject) // 繼續(xù)計(jì)算

  member _.fold = prev, err

let bind f (this: Context) =
  this.chain f

let finalize (this: Context) =
  this.fold

為了與前面陳述的概念對(duì)應(yīng)起來怔锌,我們?cè)趯?duì)方法 chain 和 fold 的調(diào)用作包裝的同時(shí),把部分標(biāo)識(shí)符換了起名变过。

let promotion init = // 促銷
  Context(init, "")
  |> bind f1
  |> bind f2
  |> finalize

val promotion : init:float -> float * string


對(duì)以上幾個(gè)例子的總結(jié)埃元,我們發(fā)現(xiàn)它們都有:

一個(gè)類型構(gòu)造器,把一系列值所同屬于的類型 T 作為元素媚狰,構(gòu)造出來的容器 M 就是 T 的包裝類型岛杀。比如上面例子里出現(xiàn)的 number * stringOption崭孤、Folder array 类嗤,就是把 number、string辨宠、Folder 等類型作為元素加以包裝得到的土浸。

一個(gè)包裹函數(shù),能夠把一個(gè)屬于 T 類型的初始值裝進(jìn) M 類的容器中:warp x : T -> M<T>彭羹。

一個(gè)轉(zhuǎn)換函數(shù) bind黄伊,接收一包裝類型 M 的實(shí)例 MT(里面包裹著 T 類型的值),和一個(gè)函數(shù) f 派殷,然后能把 MT 里的值 t 提取出來(解包 unwrap)还最,讓 f 去運(yùn)算和變換,得到新的 U 類型的值 u 再包裹到仍是 M 類型毡惜、卻是其新的實(shí)例 MU 里去:init: M<T> 執(zhí)行 f: (T-> M<U>) 生成 M<U>拓轻。于是原來那個(gè)只能接受 t 并返回 u 的函數(shù) f 變身成了能反映 M 這個(gè)容器變化前后( MT 到 MU )之映射關(guān)系的“函數(shù)”。這個(gè)“函數(shù)”是 f 經(jīng)過 bind 轉(zhuǎn)化后獲得的经伙,它接受和輸出的參數(shù)都是個(gè)容器(包裝類型 M )扶叉,都能與其它 fmap(如 bind g)可組合勿锅。很多文章或語言把這個(gè)“函數(shù)”寫作 fmap:

let fmap = bind f

val fmap : (‘Context -> 'Context)

于是它們都是個(gè) Monad

F# 的 type class 語法能將類型構(gòu)造器包裹函數(shù)合二為一枣氧。比如:Context 將(比如是)float 和 string 兩個(gè)類型構(gòu)造出容納了 (float, string) 的 Context 類溢十;并在 Context(init, "") 開始調(diào)用時(shí)將 init 和 "" 兩個(gè)初始值裝入。

在“函數(shù)是一等公民”的理念中达吞,函數(shù)本來就是個(gè)值张弛。所以在 Composition 的例子中,把 f 和 g 函數(shù)都看作是酪劫,則 Composition 就與 Context 同樣是個(gè)包裝類型吞鸭。即函數(shù)類型的容器。

列表覆糟、數(shù)組等其實(shí)也是一種包裝類型刻剥,分別是由列表構(gòu)造器、數(shù)組構(gòu)造器將元素的類型構(gòu)造得到的容器滩字。

結(jié)合總結(jié)到的這幾個(gè)概念造虏,我們可以對(duì)前面 List Monad 進(jìn)一步加深理解。

想要把一個(gè)元素值 item 放進(jìn)容器(這里即為數(shù)組)踢械,包裹函數(shù)就是:

let wrap item = [| item |]

而為了能從數(shù)組里提取元素 item 并傳給函數(shù) f 以執(zhí)行酗电,就要“循環(huán)”遍歷數(shù)組:
for item in array -> f item

再重新包裹回?cái)?shù)組:

let bind f =
  fun m -> [| for item in m -> f item |]

childrenOf 函數(shù)就是這樣一個(gè) f 函數(shù):它接收的參數(shù)的類型 T 是個(gè)非包裝的類型 Folder魄藕,返回的類型 M 是以 Folder 為元素的包裝容器————Folder 的數(shù)組:

val childrenOf : folder:Folder -> Folder []

我們嘗試調(diào)用下面這個(gè) fmap “函數(shù)” children内列。這里 parent 由于是個(gè)非包裝的類型,因此需要包裹起來:

let parent = Folder(".")
let children = bind childrenOf (wrap parent)

val children : Folder [] [] = [| [| Folder; Folder |] |]

childrenOf 經(jīng)過 bind 后背率,能接受一組 folders话瞧,然后將其中的每個(gè)值 item(好比提取出來)運(yùn)用 childrenOf 求出該值的一組 children,即每個(gè) folder 對(duì)應(yīng)一個(gè) children 數(shù)組寝姿,得到的是一個(gè)數(shù)組交排,里面的元素仍是一個(gè)個(gè)數(shù)組。這樣的結(jié)果表現(xiàn)形式可不好饵筑,也與前面要求的“ bind 的傳入與返回值的類型必須是同樣的包裝類型 M ”不符埃篓,在本例就是要求必須仍是 [| Folder; Folder |] 而不能是 [| [| Folder; Folder |] |],因此要將它們轉(zhuǎn)成簡(jiǎn)單的一階數(shù)組根资,即扁平化

let bind f =
  fun m -> [| for item in m -> f item |] |> Array.concat

或者

let bind f =
  fun m -> [|
    for item1 in
      [| for item in m -> f item |] do
        for item2 in item1 -> item2
  |]

val bind : f:('a -> 'c[]) -> m:'a[] -> 'c []
val children : Folder [] = [| Folder; Folder |]

繼續(xù)把 children 傳給 bind childrenOf架专,就能得到 children 的 children,即 grandchildren:

let grandchildren = bind childrenOf children

運(yùn)用管道后就是這樣:

let grandchildren =
  (wrap parent)
  |> (bind childrenOf)
  |> (bind childrenOf)

于是我們都得到了與前面例子里完全相同的代碼形式玄帕。

上面我們是以數(shù)組舉例部脚,但對(duì)于列表、序列也同樣適用裤纹。

對(duì)數(shù)組委刘、列表運(yùn)用 bind f,通過函數(shù) f 對(duì)其中的元素進(jìn)行了變換(transform),得到了新的數(shù)組锡移、列表呕童。這不就是函數(shù)式語言中普遍具備的 map 函數(shù)嘛,如 F# 語言的 List.map罩抗、Seq.map拉庵、Array.map√椎伲可以認(rèn)為:fmap 就是將數(shù)組钞支、列表的 map 概念推廣到其它包裝類型、定義出那些類型的 map 函數(shù)操刀∷感或者,正因?yàn)閿?shù)組骨坑、列表也是包裝類型撼嗓,map 函數(shù)就是它們的 fmap,于是數(shù)組欢唾、列表屬于典型的 Monad且警。

前面多次提到:反映容器之變化的映射“函數(shù)” fmap,其參數(shù)和返回值的類型都應(yīng)是一個(gè)包裝類型礁遣。但是斑芜,下面這個(gè) logging 例子中沒有包裝類型,輸入類型與輸出類型相同祟霍,類型沒有被改變杏头,但符合 Monad 的特征:

let bind m f  =
  printfn "expression here is %A" m
  f m
let wrap x = x

let identity =
  let x = 1
  let y = 2
  wrap x + y

val bind : m:'a -> f:('a -> 'b) -> 'b
val wrap : x:'a -> 'a

簡(jiǎn)單地理解為:一個(gè)類型可以看作為其自身的包裝類型。

一個(gè)類型自身不變還要這樣“繞彎子”貌似增加了復(fù)雜性沸呐,但其意義在:可以“侵入”附加功能醇王,如打印某些過程中的信息,而在函數(shù)復(fù)合時(shí)又隱藏了這些附加邏輯崭添,突出了主干邏輯寓娩,還不用改變復(fù)合的方式,代碼變得簡(jiǎn)練和優(yōu)雅呼渣。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末棘伴,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子徙邻,更是在濱河造成了極大的恐慌排嫌,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件缰犁,死亡現(xiàn)場(chǎng)離奇詭異淳地,居然都是意外死亡怖糊,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門颇象,熙熙樓的掌柜王于貴愁眉苦臉地迎上來伍伤,“玉大人,你說我怎么就攤上這事遣钳∪呕辏” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵蕴茴,是天一觀的道長(zhǎng)劝评。 經(jīng)常有香客問我,道長(zhǎng)倦淀,這世上最難降的妖魔是什么蒋畜? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任咕幻,我火速辦了婚禮仅讽,結(jié)果婚禮上赏表,老公的妹妹穿的比我還像新娘境氢。我一直安慰自己,他們只是感情好搏屑,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布低缩。 她就那樣靜靜地躺著堪旧,像睡著了一般糠雨。 火紅的嫁衣襯著肌膚如雪才睹。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天见秤,我揣著相機(jī)與錄音砂竖,去河邊找鬼真椿。 笑死鹃答,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的突硝。 我是一名探鬼主播测摔,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼解恰!你這毒婦竟也來了锋八?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤护盈,失蹤者是張志新(化名)和其女友劉穎挟纱,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體腐宋,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡紊服,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年檀轨,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片欺嗤。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡参萄,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出煎饼,到底是詐尸還是另有隱情讹挎,我是刑警寧澤,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布吆玖,位于F島的核電站筒溃,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏沾乘。R本人自食惡果不足惜铡羡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望意鲸。 院中可真熱鬧烦周,春花似錦、人聲如沸怎顾。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽槐雾。三九已至夭委,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間募强,已是汗流浹背株灸。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留擎值,地道東北人慌烧。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像鸠儿,于是被迫代替她去往敵國(guó)和親屹蚊。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容