【问题标题】:Can someone clarify monads / computation expressions and their syntax, in F#有人可以在 F# 中阐明单子/计算表达式及其语法吗
【发布时间】:2021-03-07 16:33:12
【问题描述】:

首先,我读过:

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

和

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

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

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

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

为此,我似乎理解在声明表达式时会以这种方式调用一些底层功能(bind、map 等),但需要完全不同的语法(let!、yield! ​​等) ) 使用时。但是,您仍然可以根据需要使用原始术语(Option.map 等)。这似乎很令人困惑,所以我很好奇我是否正确,如果正确,为什么同一事物有两种语法?

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

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

我经常使用 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
}

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

但是,昨天我发了一个不相关的问题:trying to extend the result type.. unsuccesfully, in F#

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

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

我了解到 Map 将采用非包装(在 Result 内部)函数并将结果以包装/Result 格式放置,Bind 将附加/绑定已经在 Result 模型中的函数。

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

【问题讨论】:

  • 您的问题有点难以回答。网上有很多教程和解释,但这已经足够抽象了,获得直觉的唯一方法就是用它来做事。这已经变得如此糟糕,以至于“解释 monads”在 FP 社区中已经成为一种常备的笑话。 “我们边做边学。没有其他办法”
  • 至少,我知道我不是唯一一个遇到困难的人,我可以得到安慰:D 但我认为问题的一部分根本不是抽象的:为什么让!/使用!绑定/映射术语似乎重叠,看起来我们可以访问这两个集合。是不是 bind/map 是 monad 术语,然后 F# 表达式决定有自己的命名法?
  • @FyodorSoikin :说不可能,然后就去做了! (如果我可以添加,那就太好了):)
  • @scriptum applicative (monoidal) functors 组合/连接从一开始就给出的几个(即两个,因此可能更多)计算;当一个依赖于另一个计算的纯值时,monads 能够连接两个(因此可能更多)计算。
  • @scriptum 你在那里做得很好,只是有点遗漏,我以前也经常这样做,后来不得不纠正自己,直到我不再这样做了。 :) 就在我写给你的回复时,我的脑海中终于出现了一些东西:两者都是 app.f。和 monads "join",只是不同。 monad 中的仿函数 nesting 正是对应于对值的依赖,而在 app.f 中没有两个仿函数的嵌套。 :)

标签: f# monads


【解决方案1】:

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


编程或多或少是关于捕捉模式。好吧,至少它的有趣部分无论如何。以 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

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

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

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

括号少了一些,但还是有点难看。如果我们将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 之外,这也可以与其他东西一起使用。你知道 JavaScript Promises 吗?这些也有效!

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

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

所以事实证明,在你凝视了一会儿之后,这种“将下一个函数粘合到上一个结果”的模式延伸到了很多有用的东西。除了这一点不便:最后,我们必须使用不同的方法来构造“最终返回值”——Ok 用于Result,Some 用于Option,以及实际上的任何黑魔法Promises用过,不记得了。

但我们也可以概括这一点!为什么?因为它也有一个模式:它是一个接受一个值并返回包含该值的“包装器”(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

真的,为了让我们的函数链代码在不同的上下文中工作,我们需要做的就是提供>>=(通常称为“bind”)和mkValue(通常称为“return ”,或者在更现代的 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

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

但是 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
}

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

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

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


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

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

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

【讨论】:

  • 对新手有帮助的一件事是用两种形式编写他们的代码。使用|&gt; Result.bind (fun x -&gt; ... 语法和等效的let! x = ...。我仍然经常发现自己构建了一个 3 或 4 深度的厄运阶梯,然后才发现我应该只使用计算表达式。
  • 很好的解释。但是投入了这么多工作,为什么不也摆脱 &gt;&gt;= fun rx -&gt; foo rx 模式而只支持 &gt;&gt;= foo 呢?
  • 因为那时我无法将它连接到特殊语法
  • @FyodorSoikin,所以你提出了一个不可能的问题,并在网上做出了最好的答案:) 它确实有助于将所有点点滴滴结合在一起,而不必熟悉其他函数式语言(即其他答案失败的地方)。 Guran 在 cmets 中提出改进问题;由于我认为您的答案可能是确定的答案之一,因此可能大量简化问题会帮助其他人找到答案。你怎么看?
  • @tranquillity,我喜欢这个;这是了解内部工作原理的好方法,而不会被 F# 语法所掩盖
猜你喜欢
  • 2021-07-09
  • 1970-01-01
  • 2020-04-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-05-27
  • 1970-01-01
相关资源
最近更新 更多