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

Tho*_*mas 6 monads f#

首先,我读过:

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

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

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

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

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

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

就实际用途而言,它在我看来如下所示:

  • 您描述了一个模型,在该模型中您将数据包装在您设计的任何容器中并提供函数(如绑定和映射),以便能够将容器链接到容器操作(如 Result<int, _> -> Result<int, _>) ,或非容器到容器的操作(如 int -> Result<int, _>)等。这是正确的吗?
  • 然后在该上下文中构建一个使用该模型的表达式以构建操作链。这是一个正确的假设,还是我错过了大局?

我经常使用 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
Run Code Online (Sandbox Code Playgroud)

没有真正了解如何使用 Return / ReturnFrom,并以这种方式成功使用它:

ResultBuilder() {
    let! r1 = checkForEmptyGrid gridManager
    let! r2 = checkValidation r1
    let! r3 = checkMargin instrument marginAllowed lastTrade r2
    return r3
}
Run Code Online (Sandbox Code Playgroud)

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

但是,昨天我发布了一个不相关的问题:尝试扩展结果类型.. 失败,在 F# 中

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

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

我开始明白 Map 将采用一个非包装(在 Result 内部)函数并将结果放在包装/结果格式中,而 Bind 将附加/绑定已经在 Result 模型中的函数。

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

Fyo*_*kin 15

好的,让我们再试一次。什么可能出错?:-)


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

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

要查看模式,让我们以您的示例为例:

  • checkForEmptyGrid
  • checkValidation
  • checkMargin

首先,这些功能中的每一个都可能失败。为了表示我们让它们返回一个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
Run Code Online (Sandbox Code Playgroud)

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

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

let callNext result nextFunc =
    match result with
    | Error err -> Error err
    | Ok r -> nextFunc r
Run Code Online (Sandbox Code Playgroud)

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

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
            )
        )
    )
Run Code Online (Sandbox Code Playgroud)

不错哦!那是多么短啊!它较短的原因是我们的代码现在从不处理Errorcase。那个工作被外包给了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
            )
        )
    )
Run Code Online (Sandbox Code Playgroud)

括号少了一点,但还是有点难看。如果我们创建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
Run Code Online (Sandbox Code Playgroud)

不错哦!现在所有的函数都不必放在它们自己的括号中——这是因为运算符语法允许这样做。

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

let checkStuff gridManager instrument marginAllowed lastTrade =
    checkForEmptyGrid gridManager >>= fun r1 ->
    checkValidation r1 >>= fun r2 ->
    checkMargin instrument marginAllowed lastTrade r2 >>= fun r3 ->
    Ok r3
Run Code Online (Sandbox Code Playgroud)

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

你去吧。你现在就可以停下来享受>>=运营商(顺便说一下,它被称为“绑定”;-)

那是你的单子。


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

上面的代码适用于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
Run Code Online (Sandbox Code Playgroud)

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

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

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

看到不同?又到了最后。

所以事实证明,在你盯着这个看了一会儿之后,这种“将下一个函数粘到上一个结果上”的模式扩展到了很多有用的东西。除了这个小小的不便:最后我们必须使用不同的方法来构造“最终返回值” - Okfor ResultSomeforOption以及Promise实际使用的任何黑魔法,我不记得了。

但我们也可以概括一下!为什么?因为它也有一个规律:那就是需要一个值,并返回“包装”(函数ResultOptionPromise与里面的值,或其他):

let mkValue v = Ok v  // For Result
let mkValue v = Some v  // For Option
let mkValue v = new Promise(v)  // For Promise
Run Code Online (Sandbox Code Playgroud)

所以说真的,为了让我们的函数链代码在不同的上下文中工作,我们需要做的就是提供>>=(通常称为“绑定”)和mkValue(通常称为“返回”,或者在更现代的 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
Run Code Online (Sandbox Code Playgroud)

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

但是 F# 没有使用这种风格,它有自己的风格。部分原因是缺少类型类(因此您每次都必须提供特定的计算构建器),部分原因是我认为它只是试图保持语言的总体美感。我不是 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
}
Run Code Online (Sandbox Code Playgroud)

脱糖过程也比仅仅插入对>>=. 相反, everylet!被调用result.Bind和 every return-by替换result.Return。如果您查看这些方法的实现(您在问题中引用了它们),您会发现它们与我在此答案中的实现完全匹配。

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

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


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

let map fn result = result >>= \r -> mkValue (fn r)
Run Code Online (Sandbox Code Playgroud)

但通常map被视为自己的东西,而不是作为bind小弟。为什么?因为它实际上适用于比bind. 不能是 monad 的东西仍然可以有map。我不打算在这里展开讨论,这是另一篇文章的讨论。只是想快点提一下。