我读过的 Monad 最好的介绍之部分内容——F# 演绎版

本文在原文《Translation from Haskell to JavaScript of selected portions of the best introduction to Monads I’ve ever read》的基础上用 F# 代码再次“演绎”并拓展


首先的例子是一个正弦函数和一个立方函数:

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

它们都输入、输出一个数,这使它们都具备可组合性(composable):一个函数的输出可以作为下一个函数的输入:

let sineCubed = sine (cube x)

F# 语言能够以比多层嵌套更优雅、可读性更强的 pipe 操作符表示这种组合性

let sineCubed = x |> cube |> sine

推广到更通常的情况是:有两个函数 f 和 g,经过 f(g(x)) 计算返回结果。现用一个函数来封装这个组合操作(composition)

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

let sineOfCube =
  compose sine cube

let y = sineOfCube 3.

接着,让这些函数被调用时打印一句日志以记录自己曾被调用,以此可以调试这些函数:

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

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

但是注意,printfn 既不是函数的参数,也不是函数的返回值,它有副作用。在一个严格的、仅允许纯函数存在的系统里,不可以这样做。若要获得日志信息,必须把它作为函数的返回值的一部分。可是函数一般只能返回一个呀。那就把返回值的类型设计成能容纳多个值的容器,这在 F# 语言里可以是元组:(结果值,一些调试信息):

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

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

但如此一来,这些函数无法再按前面的方法组合起来:

let sineOfCube =
  compose sine cube 3.
compose sine cube 3.
-------------^^^^
stdin(8,16): error FS0001: 需要一个支持运算符 “*” 的类型,但提供的是元组类型

因为在以下两个方面计算遭到了破坏:cube 返回的是一个元组,而 sine 必须传入一个数字,这必然导致 sine 失败。再则,cube 调用中的调试信息也无法传递下去而丢失。

让我们搞个新函数 debuggable,不仅能传入 x ,并通过原先这两个函数,不仅仍能正确地计算出结果,还能各自返回一个表示“自己得到了调用”的字符串。

原来的简单组合显然不再起作用,新函数需要写一些胶水代码,能够解开每个函数的返回值,并将它们重新缝合回去:

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.")

这样,我们将两个输入为数字、返回为数字+字符串序对的函数组合在了一起,组合的结果依然是一个具有与 cube 和 sine 同样类型签名的函数:
number -> number * string

这意味着这个新的组合函数还可以和其他 debuggable 函数进一步组合,所有的 debuggable 函数及其组合都会拥有这样的签名。

原来的函数有更简单的签名 number -> number,参数和返回值的类型具备对称性(symmetry),这才使得它们可组合。同样,与其为 debuggable 函数另外定制一个专用的组合逻辑,不如直接简单地把它们的签名也转化(convert) 成对称的形式:
number*string -> number*string

如此一来,我们就可以继续使用之前定义的 compose 函数把它们粘合在一起了。当然,靠重写 cube 和 sine 那样的手动转换(conversion),使其接受 number * string 类型的参数也是可以的,但这种做法显然无法扩展(scale)到其他函数上,因为这可能需要重写所有的函数,添加相同的样板(boilerplate)代码

更佳做法是:让每个函数仍旧仅做自己原先的工作,即维持其原来的签名,另外提供一个工具函数 bind,其工作就是接受一个形如 number -> number * string 签名的函数 f,自动转换成一个形如 number*string -> number*string 签名的函数:

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

经过 bind 转化后函数拥有可组合的签名,更容易组合出想要的结果:

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

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

但这样一来,所有函数的接收参数都变成了 number * string,而我们还是只想传一个 number 参数。所以除了转化函数 bind 之外,还需要一个“包裹”函数 number -> number*string,其作用就是接受一个值 number,并包裹于一个基本容器(basic container) 中,这个基本容器就是那些 debuggable 函数可以消费(consume) 的类型 number * string

以 debuggable 函数为例,给这个 number 配对一个空字符串,然后一起“包裹”起来,作为整个组合函数的初始输入值:

let wrap x = (x, "")

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

于是,

sineOfCube (3., "")

就可以写作:

sineOfCube (wrap 3.)

或者:

(compose sineOfCube wrap)3.

wrap 函数,有的语言也写作 unit 或 Return,能把任何函数转化成 debuggable 函数,只要它的返回值类型是 debuggable 函数可接受的输入类型即可:

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

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

进一步,这种把一个简单函数变成一个 debuggable 函数的转换函数,可以抽象为名为 lift 的函数,其签名的意思是:接受一个形如 number -> number 的函数作为参数,并返回一个形如 number -> number*string 的函数:

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)

以上第一个例子中,为了给计算结果添加附加信息并一起输出,需要将原来的简单函数的返回类型复合成更复杂的结构的类型(如 number -> number*string )。

在第二个例子中,这个简单函数(round)并没有输出更高复合类型的需求(decimal -> decimal)。但可能会发生调用流程后面的函数却是 debuggable 之类的函数,它们可接受的输入参数却是更高复合类型。所以需要把简单函数的输出类型加以提升(lift)。提升的方法既可以先计算出仍是简单类型的结果,然后靠 wrap 包裹,比如 wrap (f x) ;也可以靠转换函数 lift 把简单函数也变成 debuggable 函数那样的签名形式。
这就是 lift 函数存在的意义所在。

至此,我们找到了三个能用来粘合 debuggable 函数的重要抽象:

lift, 可以将简单函数转化为 debuggable 函数

bind, 可以把 debuggable 函数转化为可组合的形式

wrap, 可以把一个简单值放到一个容器中,以转化成 debuggable 函数所需的形式

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

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

这些抽象(lift、bind 和 wrap)定义了一个 Monad,在 Haskell 标准库中,它被称为 Writer Monad


为了更清楚地理解这个模式(pattern) 的通用性,再举个例子 List Monad

一个可能常见的问题是:需要判断一个函数的参数是一个元素还是多个元素的数组,目的通常为是否需要在函数体里用一个 for 循环去操作各个元素。虽然,这通常是一个可能做了很多次的样板代码,但却会给如何组合这些函数带来很大的影响。例如,假设某个函数的功能是接受一个文件夹 Folder 作为参数,并将其属下的所有子 Folder 作为一个数组形式返回(不是遍历属下所有层级的文件夹)。该函数的签名为 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 []

现在想要找到 parent 里的 grandchildrenOf,即它的 children 的 children。直觉上,下面是一个好的定义:

let grandchildrenOf = compose childrenOf childrenOf

然而 childrenOf 函数的输入输出并不对称,所以不能简单地把它们 compose 起来。应该手写成这样:

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

grandchildrenOf parent

只要简单地把所有找到的文件夹串接(concat) 起来,就能把原来分属于不同 children 下面的 grandchildren 变成一个扁平的(flat) 的数组。但这样一来,仅仅为了处理数组,而不是在解决这个问题本身,就不得不用上很多样板代码,这并不很方便。最好是只需组合两个列表处理(list-handling) 函数就可以完成此事。

回顾之前的例子,需要两个步骤来解决这个问题:提供一个 bind 函数,把 childrenOf 转化成可组合的形式,然后写一个 wrap 函数把初始输入值 parent 转化成可被接受的形式。

这个问题的核心就在于:目前的函数接受一个 node 而返回了一组 node,所以这个转换应该能把单个元素转化成数组的形式,反之亦然。这个元素是 Folder 或者别的什么在此并不重要,只要把这个具体的类型写成泛型即可。wrap 接受一个 item 并返回一个以这个 item 为元素的数组 [],而 bind 接受一个一对多函数并返回一个多对多函数:

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 []

现在我们可以如愿地组合 childrenOf 了:

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

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

至此,我们实现了 Haskell 里的 List Monad,它可以 compose 那些一对多的函数。

所以,Monad 是一个设计模式(design pattern),它表明:

可以给某类函数 F 应用如下两个函数,使得 F 们可组合(composable)

bind 函数,可以转化 F 函数,使其输入和输出的类型是一致的容器;

wrap 函数,可以把一个值包装到一个容器中,使其可被该可组合函数(composable function)所接受。

换言之,bind 可以把 F 类函数应用于 wrap 给出的容器上。

这个说法略过了 Monad 的数学基础,所以并非严谨的定义,但却是一个非常有用的设计模式,因为它可以帮你避免意外的复杂性(accidental complexity),从根本上显著提高代码的可读性。


下面再用 F# 演绎下 Monad 在出错处理方面的运用。

一个函数的计算过程中有可能会出错。许多语言采用的处理异常方式是 try ... catch... 的形式,将流程从 try 块跳转到 catch 块。

但是,纯函数的系统不允许这样的方式,那样会有如下缺点:

违反了引用透明原则。因为那样的跳转会导致函数的出口既有可能在 try 块中,也有可能在 catch 块中),所以不能确保单一的、可预测的返回值;

破坏线性逻辑关系。因为用于处理异常的代码 catch 块与正常的函数运算代码 try 块,它们之间的关系不是正常的函数调用关系,也打破了为正常情况而设的线性函数调用链;

违反局域性的原则,会有副作用。由于 try 块和 catch 块是不同的作用域。若要将 try 块里的值带入 catch 块,无法采用函数之间 参数->返回值 那样的传递方式,这势必要求存在超越这两个作用域块的公共变量,try 块里还要给这个公共变量赋值,也就是有副作用;

如果异常处理过程中再次出现异常,会出现复杂的、更加混乱的、地狱般的嵌套异常处理块。

因此,我们更希望将出错信息仅作为返回值输出,让函数的外部去判断出错信息,从而决定该如何处理这个出错。那样,无论函数运算是否出错或发生异常,函数之间始终保持原有的顺序关系。

乍一看,这类似于前面第一个例子:让函数把正常的计算结果和额外的(出错)信息打包在一起,共同作为其返回值。但不同的是:这次返回值的类型是正常结果与错误信息的二选一,而不是同时具有。这样的类型就是拥有 Right 和 Left 分支的结构体,称之为 Either ,Right 指代正常的分支,Left 指代出现异常的分支,两者绝不会同时出现。

F# 的预定义 Option 类型其实是一种特殊的 Either,也是表示两个值中的一个:Some result 或 None,分别代表计算值有了正常的结果 result,或者因异常而无结果。

其它有的语言把表示这种 或有或无 的类型称作 Maybe

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

以下引用《Computation expressions: Introduction 》里安全除法的例子。该例子是想更优雅地处理除法出错的情况。
那篇文章是主讲 F# 的 Computation expressions 技术的。但这里我们不采用那门技术,而只采用“传统”的写法,只为了把重点突出在 Monad 的应用上。

这个例子里,我们要除以一系列的数,即一个接一个地将这些数作为除数,但是这些数其中可能有 0。如何处理?抛出一个异常会使代码丑陋,把 NULL、undefined、empty 等当作结果值中的一个另类值更是一个糟糕的主意( Tony Hoare 托尼·霍尔所称的“数十亿美元的错误 the billion-dollar mistake ”)。使用 option 类型应该是一个不错的方法。

先定义一个帮助函数,实现其中一个简单除法功能并返回一个 int option。每个除法过程后判定是否成功,只有在成功(返回 Some)的时候才会继续下一个除法过程。然后将这些除法过程链接起来。帮助函数如下:

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

注意第一个参数为除数,第二个是被除数。故可以将除法表达式(12/3)写成 12 |> divideBy 3 的形式,这会更容易将整个除法过程串联起来:

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

调用此函数:

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 // 因为有一个除数为 0

以上例子中,返回结果必须为 int option,不能返回 int。

然而,这个例子中的代码依然丑陋。

现在定义两个新的函数 bind 和 wrap 如下。函数 f 经过 bind 后具有了对称的输入输出类型:

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

重写这个系列除法的工作流函数,隐藏了之前的判断分支逻辑:

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)

如果采用中缀运算符,会使这个流程的写法呈线性流,避免多层嵌套,更加优雅,就像 F# 的原生管道运算符 |> 那样。
>>= 是将 bind 写成中缀操作符的标准方式。不过注意:由于参数传递的顺序要求,中缀运算符(>>=)的入参要调换一下位置:

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

看来,>>= 是比函数管道符更高级形式的函数中间链,它不仅能承上启下地把上一个函数的返回值输入给下一个函数,还有对值进行封装和解封的作用。


前面我们用了 compose 函数来复合两个函数:

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

如果要复合更多的函数呢?由于 F# 语言不允许函数有可变数目的参数,对于每 N 个要被复合的函数,就要分别单独定义一个 composeN,不仅麻烦,还没有通用性。
如果还是用只能两个参数的函数,势必要靠括弧多层嵌套,很不优雅:
(compose (compose f1 f2) f3)

虽然中缀操作符 |> 能够解决这个问题,但下面我们换一种写法,不仅是为了演示 F# 的表现能力,更为了用于其它一些场景。在下面这段代码里,定义了一个 Composition 类,通过它所含的方法 chain 将一个计算函数推入类中,推入的同时与前面已复合好的的函数复合。随着这个 chain 的不断被调用,越来越多的函数被推入并复合,直到最后 fold 方法被调用,全部函数复合完毕,可以得到调用并返回结果。

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

上面的代码里,我们对方法 chain 和 fold 的调用做了个包装,只是为了适应中缀操作符对入参次序的要求,避免如下的啰嗦或嵌套的代码,并没有功能上的意义:

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 将函数 f1,f2 ,... 推入和复合的过程中,这些函数都没有真正得到调用和计算,因此,作为这些函数的复合的结果,Composition 也只不过是一个尚未计算的函数,相当于惰性(Lazy) 的值;如果这些函数都是纯函数,那么 promotion 也是个纯函数:

val promotion : init:float -> float

要想计算整个复合函数,只要将初始值 init 传入给 promotion 即可:

promotion 100. // 原价 100

这个 init 可以是从外部介质读入进来的,promotion 的计算结果可以输出到外部介质,所以,与外部介质的交互这个“副作用”仅限于在 promotion 即整个函数复合过程的首尾两头,独立于 promotion,不会影响到 promotion 的纯函数性,这就很好地隔离了纯与不纯的代码。至此,我们实现了 IO Monad

但是,计算函数可能会发生异常,比如价格输入错误,无法再按原先的设计计算出优惠价格。此时不仅要返回出错函数的内部发出的错误信息,还要跳过后续的函数,终止进一步的计算,并且将先前没有出错的函数所计算好的结果返回,而不是像上述 divideByWorkflow 例子那样,不管不顾地返回整个计算结果为 error 或 None。

你肯定会想到:让计算函数的返回类型是 Tuplenumber * string),以表示一个计算结果和关于可能的异常信息。这样类型的数据在函数链中传递,每一个计算函数都要判断传入的参数里是否含有异常的信息,以此决定自己的行为。
但那就意味着每个计算函数不能只顾关注自身功能,还要考虑并非由自己产生的异常,加重负担、叠加噪音、产生干扰。

下面我们换一种决定分支行为的方式:给计算函数提供两个函数 resolve 和 reject,分别在计算正常或者异常时被调用,且仅在计算函数内部、仅根据其内部(而不是外部其它地方)的错误与否来决定调用哪个。调用时将正常的计算结果或异常信息分别作为参数传给 resolve 和 reject。由于 resolve 和 reject 函数是作为参数传进来的,这意味着可以向其中传入另一个函数来指示下一步该做什么,实际上是由函数的调用者而非函数自身来决定应该具体如何处理计算结果。

在每个计算函数内部,不再需要输入和返回的参数是同时含有正常结果与错误的值对的 Tuple 复合类型(float * string),或者将两者包装出一个 Either 类型(Result of float | Err of string),当然也用不着 if err 之类判断前面函数传来的异常信息。

同样,调用者也不用到处对计算函数返回的值使用 if ... then ... else 结构去处理了,想怎么处理,就将对应的处理函数作为参数传入计算函数中即可。

函数的返回值不再通过函数自身直接返回,而是进一步调用下一个函数,不仅将返回值作为下一个函数的参数传递出去,也把本函数结束后的控制流交给下一个函数,这样的编程风格就是 continuation passing style(CPS),resolve 和 reject 就是所谓的下一个函数continuation

为避免分散注意力,我们在此不对 CPS 作深入讲解。

let f1 x (resolve, reject) = // 打折
  if x > 0. then
    let x' = x * 0.8
    resolve x'
  else
    reject $"打折时 {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 $"满减时 {x} is error"

由于函数发生异常的原因有可能是在计算过程中,没有计算很可能就无法暴露异常,因此前面那种纯粹的函数复合、在最后没有调用 promotion 之前全程都没有计算那样的方式,对于暴露异常就不灵了。

下面的代码就要改动一下:每次 chain 到一个新的函数时,要让该函数进行运算。当计算正常时,计算结果被 resolve 函数传递出来,再次推入到容器中,作为下一个要被 chain 的函数的输入参数,就跟 |> 运算符的作用一样。

当发生异常时,错误信息被 reject 函数传递出来,连同前面函数的计算结果推入到容器中,但不能再输入给后续要 chain 的函数,只是通过容器简单地原样传递至最后。

异常时这样的传递逻辑保持了与正常时的相同,即始终完成整个传递过程,而不是中途提前结束,或者像 try ... catch ... 那样不按原来顺序,而是跳转到另一个位置。于是容器之外只需关注把所有计算函数按正常时的顺序 bind 到容器,而无需对可能发生的传递链的提前结束或跳转做出专门的安排。捕捉异常和获得计算结果都是在函数调用链的最后。

注意在下面的代码里,我们利用活动模式和模式匹配,进一步减少了判断异常与执行分支之间的耦合:

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) // 不再计算,直接传递
    | Right -> f prev (resolve, reject) // 继续计算

  member _.fold = prev, err

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

let finalize (this: Context) =
  this.fold

为了与前面陈述的概念对应起来,我们在对方法 chain 和 fold 的调用作包装的同时,把部分标识符换了起名。

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

val promotion : init:float -> float * string


对以上几个例子的总结,我们发现它们都有:

一个类型构造器,把一系列值所同属于的类型 T 作为元素,构造出来的容器 M 就是 T 的包装类型。比如上面例子里出现的 number * stringOptionFolder array ,就是把 number、string、Folder 等类型作为元素加以包装得到的。

一个包裹函数,能够把一个属于 T 类型的初始值装进 M 类的容器中:warp x : T -> M<T>

一个转换函数 bind,接收一包装类型 M 的实例 MT(里面包裹着 T 类型的值),和一个函数 f ,然后能把 MT 里的值 t 提取出来(解包 unwrap),让 f 去运算和变换,得到新的 U 类型的值 u 再包裹到仍是 M 类型、却是其新的实例 MU 里去:init: M<T> 执行 f: (T-> M<U>) 生成 M<U>。于是原来那个只能接受 t 并返回 u 的函数 f 变身成了能反映 M 这个容器变化前后( MT 到 MU )之映射关系的“函数”。这个“函数”是 f 经过 bind 转化后获得的,它接受和输出的参数都是个容器(包装类型 M ),都能与其它 fmap(如 bind g)可组合。很多文章或语言把这个“函数”写作 fmap:

let fmap = bind f

val fmap : (‘Context -> 'Context)

于是它们都是个 Monad

F# 的 type class 语法能将类型构造器包裹函数合二为一。比如:Context 将(比如是)float 和 string 两个类型构造出容纳了 (float, string) 的 Context 类;并在 Context(init, "") 开始调用时将 init 和 "" 两个初始值装入。

在“函数是一等公民”的理念中,函数本来就是个值。所以在 Composition 的例子中,把 f 和 g 函数都看作是,则 Composition 就与 Context 同样是个包装类型。即函数类型的容器。

列表、数组等其实也是一种包装类型,分别是由列表构造器、数组构造器将元素的类型构造得到的容器。

结合总结到的这几个概念,我们可以对前面 List Monad 进一步加深理解。

想要把一个元素值 item 放进容器(这里即为数组),包裹函数就是:

let wrap item = [| item |]

而为了能从数组里提取元素 item 并传给函数 f 以执行,就要“循环”遍历数组:
for item in array -> f item

再重新包裹回数组:

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

childrenOf 函数就是这样一个 f 函数:它接收的参数的类型 T 是个非包装的类型 Folder,返回的类型 M 是以 Folder 为元素的包装容器————Folder 的数组:

val childrenOf : folder:Folder -> Folder []

我们尝试调用下面这个 fmap “函数” children。这里 parent 由于是个非包装的类型,因此需要包裹起来:

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

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

childrenOf 经过 bind 后,能接受一组 folders,然后将其中的每个值 item(好比提取出来)运用 childrenOf 求出该值的一组 children,即每个 folder 对应一个 children 数组,得到的是一个数组,里面的元素仍是一个个数组。这样的结果表现形式可不好,也与前面要求的“ bind 的传入与返回值的类型必须是同样的包装类型 M ”不符,在本例就是要求必须仍是 [| Folder; Folder |] 而不能是 [| [| Folder; Folder |] |],因此要将它们转成简单的一阶数组,即扁平化

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 |]

继续把 children 传给 bind childrenOf,就能得到 children 的 children,即 grandchildren:

let grandchildren = bind childrenOf children

运用管道后就是这样:

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

于是我们都得到了与前面例子里完全相同的代码形式。

上面我们是以数组举例,但对于列表、序列也同样适用。

对数组、列表运用 bind f,通过函数 f 对其中的元素进行了变换(transform),得到了新的数组、列表。这不就是函数式语言中普遍具备的 map 函数嘛,如 F# 语言的 List.mapSeq.mapArray.map。可以认为:fmap 就是将数组、列表的 map 概念推广到其它包装类型、定义出那些类型的 map 函数。或者,正因为数组、列表也是包装类型,map 函数就是它们的 fmap,于是数组、列表属于典型的 Monad。

前面多次提到:反映容器之变化的映射“函数” fmap,其参数和返回值的类型都应是一个包装类型。但是,下面这个 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

简单地理解为:一个类型可以看作为其自身的包装类型。

一个类型自身不变还要这样“绕弯子”貌似增加了复杂性,但其意义在:可以“侵入”附加功能,如打印某些过程中的信息,而在函数复合时又隐藏了这些附加逻辑,突出了主干逻辑,还不用改变复合的方式,代码变得简练和优雅。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容