首先,我读过:
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、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
}
并且它绝对允许跳过我本来需要的分层结果匹配链。
但是,昨天我发布了一个不相关的问题:尝试扩展结果类型.. 失败,在 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 是这个特定模式的名称。这种模式变得非常有用,以至于单子获得了一种神圣的品质,现在每个人都对它们感到敬畏。但实际上,这只是一种模式。
要查看模式,让我们以您的示例为例:
checkForEmptyGridcheckValidationcheckMargin首先,这些功能中的每一个都可能失败。为了表示我们让它们返回一个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
看到图案了吗?看到那三个几乎相同的嵌套块了吗?在每一步我们或多或少地做同样的事情:我们正在查看前一个结果,如果它是一个错误,则返回它,如果不是,我们调用下一个函数。
因此,让我们尝试提取该模式以供重用。毕竟,这就是我们作为程序员所做的,不是吗?
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
            )
        )
    )
不错哦!那是多么短啊!它较短的原因是我们的代码现在从不处理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
            )
        )
    )
括号少了一点,但还是有点难看。如果我们创建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
看:现在看起来我们几乎把每次调用的结果“分配”给一个“变量”,不是很好吗?
你去吧。你现在就可以停下来享受>>=运营商(顺便说一下,它被称为“绑定”;-)
那是你的单子。
可是等等!我们是程序员,不是吗?概括一切!
上面的代码适用于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之前的那个。就是这样!
但这还不是全部。这也可以与其他东西一起使用,除了Result和Option。你知道 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)
看到不同?又到了最后。
所以事实证明,在你盯着这个看了一会儿之后,这种“将下一个函数粘到上一个结果上”的模式扩展到了很多有用的东西。除了这个小小的不便:最后我们必须使用不同的方法来构造“最终返回值” - Okfor Result、SomeforOption以及Promise实际使用的任何黑魔法,我不记得了。
但我们也可以概括一下!为什么?因为它也有一个规律:那就是需要一个值,并返回“包装”(函数Result,Option,Promise与里面的值,或其他):
let mkValue v = Ok v  // For Result
let mkValue v = Some v  // For Option
let mkValue v = new Promise(v)  // For Promise
所以说真的,为了让我们的函数链代码在不同的上下文中工作,我们需要做的就是提供>>=(通常称为“绑定”)和mkValue(通常称为“返回”,或者在更现代的 Haskell 中)的合适定义- “纯”,出于复杂的数学原因)。
这就是 monad 的含义:它是针对特定上下文的这两件事的实现。为什么?为了以这种方便的形式写下链接计算,而不是在这个答案的最顶部写下厄运之梯。
但是等等,我们还没有完成!
如此有用的 monad 被证明是函数式语言决定为它们实际提供特殊语法会非常好。语法并不神奇,它只是对某些人进行了脱糖bind并return最终调用,但它使程序看起来更好一些。
最干净的(在我看来)这项工作是在 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# 没有使用这种风格,它有自己的风格。部分原因是缺少类型类(因此您每次都必须提供特定的计算构建器),部分原因是我认为它只是试图保持语言的总体美感。我不是 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
}
脱糖过程也比仅仅插入对>>=. 相反, everylet!被调用result.Bind和 every return-by替换result.Return。如果您查看这些方法的实现(您在问题中引用了它们),您会发现它们与我在此答案中的实现完全匹配。
不同之处在于Bind和Return不是运算符形式,它们是 上的方法ResultBuilder,而不是独立函数。这在 F# 中是必需的,因为它没有通用的全局重载机制(例如 Haskell 中的类型类)。但除此之外,想法是一样的。
此外,F# 计算表达式实际上试图不仅仅是 monad 的实现。他们还有所有其他的东西 - for、yield、join、where,你甚至可以添加你自己的关键字(有一些限制)等等。我不完全相信这是最好的设计选择,但是嘿!他们工作得很好,那么我该抱怨谁呢?
最后,关于map. Map 可以看作是 的一个特例bind。你可以像这样实现它:
let map fn result = result >>= \r -> mkValue (fn r)
但通常map被视为自己的东西,而不是作为bind小弟。为什么?因为它实际上适用于比bind. 不能是 monad 的东西仍然可以有map。我不打算在这里展开讨论,这是另一篇文章的讨论。只是想快点提一下。