有人可以在 F# 中阐明 monads/计算表达式及其语法吗

Can someone clarify monads / computation expressions and their syntax, in F#

首先,我读过:

https://fsharpforfunandprofit.com/posts/elevated-world/

https://ericlippert.com/2013/02/21/monads-part-one/

我觉得我掌握了所有的部分,但还没有将它们连接在一起的部分,所以我有几个问题可能可以一起回答。

此外,F# 是我第一次遇到单子/计算表达式。我来自 C 语言背景,没有其他函数式语言和这些概念的经验。

我想澄清一下术语:据我了解,monad 是模型,计算表达式是该模型的 F# 实现。对吗?

为此,我似乎明白有一些底层功能(绑定、映射等)在您声明表达式时以这种方式调用,但需要完全不同的语法(let!,yield!等)使用时。但是,您仍然可以根据需要使用原始术语(Option.map,等等)。这看起来很令人困惑,所以我很好奇我是否做对了,如果是这样,为什么同一件事有两种语法?

就实际用途而言,在我看来是这样的:

我经常使用 Result、Option 等,但我试图对底层机制有一个很好的了解。

作为实验,我从网上拿了这个:

type ResultBuilder () =
    member this.Bind(x, f) =
        match x with
        | Ok x    -> f x
        | Error e -> Error e
    member this.Return     x = Ok x
    member this.ReturnFrom x = x

没有真正理解 Return / ReturnFrom 是如何使用的,并且成功地使用了它:

ResultBuilder() {
    let! r1 = checkForEmptyGrid gridManager
    let! r2 = checkValidation r1
    let! r3 = checkMargin instrument marginAllowed lastTrade r2
    return r3
}

而且它绝对允许跳过我本来需要的分层结果匹配链。

但是,昨天我发了一个无关的问题:

和用户@Guran 指出 Result.map 可以达到同样的效果。

所以,我去了 https://blog.jonathanchannon.com/2020-06-28-understanding-fsharp-map-and-bind/,拿了代码并用它做了一个 Jupyter notebook 来玩它。

我开始明白 Map 将采用非包装(在 Result 内部)函数并将结果放入 wrapped/Result 格式,而 Bind 将 attach/bind 函数,这些函数已经在 Result 模型中。

但不知何故,尽管顶部的两个链接深入探讨了主题,但我似乎看不到全局,也无法可视化包装/解包操作的不同操作及其结果在自定义模型中。

好的,让我们再试一次。会出什么问题? :-)


编程或多或少是关于捕捉模式。好吧,至少它的有趣部分无论如何。以 GoF“设计模式”为例。是的,我知道,不好的例子 :-/

Monad 是这个特定模式的名称。这种模式变得非常有用,以至于单子获得了一种神圣的品质,现在每个人都对它们感到敬畏。但实际上,这只是一种模式。

要查看模式,让我们举个例子:

  • checkForEmptyGrid
  • checkValidation
  • checkMargin

首先,这些功能中的每一个都可能失败。为了表达我们让他们 return 一个 Result<r, err> 可以是成功也可以是失败。到目前为止,一切都很好。现在让我们尝试编写程序:

let checkStuff gridManager instrument marginAllowed lastTrade =
    let r1 = checkForEmptyGrid gridManager
    match r1 with
    | Error err -> Error err
    | Ok r -> 
        let r2 = checkValidation r
        match r2 with
        | Error err -> Error err
        | Ok r ->
            let r3 = checkMargin instrument marginAllowed lastTrade r
            match r3 with
            | Error err -> Error err
            | Ok r -> Ok r

看到规律了吗?看到那三个几乎相同的嵌套块了吗?在每一步我们都或多或少地做同样的事情:我们正在查看之前的结果,如果是错误,return,如果不是,我们调用下一个函数。

所以让我们尝试提取该模式以供重复使用。毕竟,这就是我们作为程序员所做的,不是吗?

let callNext result nextFunc =
    match result with
    | Error err -> Error err
    | Ok r -> nextFunc r

很简单吧?现在我们可以使用这个新函数重写原始代码:

let checkStuff gridManager instrument marginAllowed lastTrade =
    callNext (checkForEmptyGrid gridManager) (fun r1 ->
        callNext (checkValidation r1) (fun r2 ->
            callNext (checkMargin instrument marginAllowed lastTrade r2) (fun r3 ->
                Ok r3
            )
        )
    )

哦,太好了!那是多么短啊!它更短的原因是我们的代码现在从不处理Error 案例。该工作外包给 callNext.

现在让它更漂亮一点。首先,如果我们翻转 callNext 的参数,我们可以使用管道:

let callNext nextFunc result =
    ...

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager |> callNext (fun r1 ->
        checkValidation r1 |> callNext (fun r2 ->
            checkMargin instrument marginAllowed lastTrade r2 |> callNext (fun r3 ->
                Ok r3
            )
        )
    )

少了一点parens,但还是有点难看。如果我们让 callNext 成为一个运算符呢?看看能不能有所收获:

let (>>=) result nextFunc =
    ...

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager >>= fun r1 ->
        checkValidation r1 >>= fun r2 ->
            checkMargin instrument marginAllowed lastTrade r2 >>= fun r3 ->
                Ok r3

太好了!现在所有的函数都不必在它们自己的括号中 - 这是因为运算符语法允许它。

等等,我们可以做得更好!将所有缩进向左移动:

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager >>= fun r1 ->
    checkValidation r1 >>= fun r2 ->
    checkMargin instrument marginAllowed lastTrade r2 >>= fun r3 ->
    Ok r3

看:现在 几乎 看起来我们正在将每次调用的结果“分配”给“变量”,这不是很好吗?

好了。您现在可以停下来享受 >>= 运算符(顺便说一下,它被称为“绑定”;-)

那是适合你的 monad。


但是等等!我们是程序员,不是吗?概括一切!

上面的代码适用于 Result<_,_>,但实际上,Result 本身(几乎)在代码中无处可见。它也可能与 Option 一起工作。看!

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

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager >>= fun r1 ->
    checkValidation r1 >>= fun r2 ->
    checkMargin instrument marginAllowed lastTrade r2 >>= fun r3 ->
    Some r3

你能看出 checkStuff 的区别吗?区别只是最后的 Some 取代了之前的 Ok 。就是这样!

但这还不是全部。除了 ResultOption 之外,这也可以与其他东西一起使用。你知道 JavaScript Promise 吗?这些也有用!

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager >>= fun r1 ->
    checkValidation r1 >>= fun r2 ->
    checkMargin instrument marginAllowed lastTrade r2 >>= fun r3 ->
    new Promise(r3)

看出区别了吗?又到最后了

事实证明,在你盯着这个看了一会儿之后,这种“将下一个函数粘合到上一个结果”的模式扩展到了很多有用的东西。除了这个一点点不便:最后我们不得不使用不同的方法来构造“最终 return 值” - Ok 代表 ResultSome 代表 Option,以及 Promise 实际使用的黑魔法,我不记得了。

但我们也可以概括这一点!为什么?因为它也有一个模式:它是一个函数,它接受一个值和 returns 具有该值的“包装器”(ResultOptionPromise 或其他)里面:

let mkValue v = Ok v  // For Result
let mkValue v = Some v  // For Option
let mkValue v = new Promise(v)  // For Promise

所以真的,为了让我们的 function-chaining 代码在不同的上下文中工作,我们需要做的就是提供 >>= (通常称为“绑定”)和 mkValue(通常称为“return”,或更现代的 Haskell - “纯”,出于复杂的数学原因)。

这就是 monad:它是针对特定上下文的这两件事的实现。为什么?为了以这种方便的形式写下链接计算,而不是在这个答案的最顶部写下厄运之梯。


等等,我们还没有完成!

事实证明,monad 非常有用,以至于函数式语言决定为它们实际提供特殊语法会非常好。语法并不神奇,它只是在最后对一些 bindreturn 调用进行了脱糖,但它使程序看起来更好一点。

最干净的(在我看来)这项工作是在 Haskell(及其朋友 PureScript)中完成的。它被称为“do notation”,下面是上面的代码在其中的样子:

checkStuff gridManager instrument marginAllowed lastTrade = do
    r1 <- checkForEmptyGrid gridManager
    r2 <- checkValidation r1
    r3 <- checkMargin instrument marginAllowed lastTrade r2
    return r3

不同之处在于对 >>= 的调用从右到左“翻转”并使用特殊关键字 <-(是的,那是关键字,而不是运算符)。看起来很干净,不是吗?

但是 F# 不使用那种风格,它有自己的风格。部分原因是缺少类型 类(因此您必须提供特定的computation builder every time),我认为部分原因是它只是试图保持语言的一般美感。我不是 F# 设计师,所以我不能说出确切的原因,但不管它们是什么,等效的语法是这样的:

let checkStuff gridManager instrument marginAllowed lastTrade = result {
    let! r1 = checkForEmptyGrid gridManager
    let! r2 = checkValidation r1
    let! r3 = checkMargin instrument marginAllowed lastTrade r2
    return r3
}

而且脱糖过程也比插入对 >>= 的调用要复杂一些。相反,每个 let! 都被调用 result.Bind 和每个 return 替换为 result.Return。如果您查看这些方法的实现(您在问题中引用了它们),您会发现它们 完全匹配 我在这个答案中的实现。

不同之处在于 BindReturn 不是运算符形式,它们是 ResultBuilder 上的方法,而不是独立函数。这在 F# 中是必需的,因为它没有通用的全局重载机制(例如 Haskell 中的类型 类)。但其他方面的想法是一样的。

此外,F# 计算表达式实际上试图不仅仅是 monad 的实现。他们还有所有其他东西 - foryieldjoinwhere,你甚至可以添加自己的关键字(有一些限制)等。我我不完全相信这是最好的设计选择,但是嘿!他们工作得很好,所以我要抱怨谁?


最后,关于 map 的主题。 Map可以看作只是bind的一个特例。你可以这样实现它:

let map fn result = result >>= \r -> mkValue (fn r)

但通常把map当成自己的东西,而不是bind的小弟。为什么?因为它实际上适用于比 bind 更多的东西。不能是 monad 的东西仍然可以有 map。我不打算在这里展开,这是对整个其他 post 的讨论。只是想快点提一下。