有人可以在 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
-> Result< int, _>),或非容器到容器的操作(如 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
}
而且它绝对允许跳过我本来需要的分层结果匹配链。
但是,昨天我发了一个无关的问题:
和用户@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
。就是这样!
但这还不是全部。除了 Result
和 Option
之外,这也可以与其他东西一起使用。你知道 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
代表 Result
,Some
代表 Option
,以及 Promise
实际使用的黑魔法,我不记得了。
但我们也可以概括这一点!为什么?因为它也有一个模式:它是一个函数,它接受一个值和 returns 具有该值的“包装器”(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
所以真的,为了让我们的 function-chaining 代码在不同的上下文中工作,我们需要做的就是提供 >>=
(通常称为“绑定”)和 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
不同之处在于对 >>=
的调用从右到左“翻转”并使用特殊关键字 <-
(是的,那是关键字,而不是运算符)。看起来很干净,不是吗?
但是 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
。如果您查看这些方法的实现(您在问题中引用了它们),您会发现它们 完全匹配 我在这个答案中的实现。
不同之处在于 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
。我不打算在这里展开,这是对整个其他 post 的讨论。只是想快点提一下。
首先,我读过:
https://fsharpforfunandprofit.com/posts/elevated-world/
和
https://ericlippert.com/2013/02/21/monads-part-one/
我觉得我掌握了所有的部分,但还没有将它们连接在一起的部分,所以我有几个问题可能可以一起回答。
此外,F# 是我第一次遇到单子/计算表达式。我来自 C 语言背景,没有其他函数式语言和这些概念的经验。
我想澄清一下术语:据我了解,monad 是模型,计算表达式是该模型的 F# 实现。对吗?
为此,我似乎明白有一些底层功能(绑定、映射等)在您声明表达式时以这种方式调用,但需要完全不同的语法(let!,yield!等)使用时。但是,您仍然可以根据需要使用原始术语(Option.map,等等)。这看起来很令人困惑,所以我很好奇我是否做对了,如果是这样,为什么同一件事有两种语法?
就实际用途而言,在我看来是这样的:
- 您描述了一个模型,在该模型中,您将数据包装在您设计的任何容器中,并提供函数(如绑定和映射),以便能够将容器链接到容器操作(如 Result
-> Result< int, _>),或非容器到容器的操作(如 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
}
而且它绝对允许跳过我本来需要的分层结果匹配链。
但是,昨天我发了一个无关的问题:
和用户@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
。就是这样!
但这还不是全部。除了 Result
和 Option
之外,这也可以与其他东西一起使用。你知道 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
代表 Result
,Some
代表 Option
,以及 Promise
实际使用的黑魔法,我不记得了。
但我们也可以概括这一点!为什么?因为它也有一个模式:它是一个函数,它接受一个值和 returns 具有该值的“包装器”(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
所以真的,为了让我们的 function-chaining 代码在不同的上下文中工作,我们需要做的就是提供 >>=
(通常称为“绑定”)和 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
不同之处在于对 >>=
的调用从右到左“翻转”并使用特殊关键字 <-
(是的,那是关键字,而不是运算符)。看起来很干净,不是吗?
但是 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
。如果您查看这些方法的实现(您在问题中引用了它们),您会发现它们 完全匹配 我在这个答案中的实现。
不同之处在于 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
。我不打算在这里展开,这是对整个其他 post 的讨论。只是想快点提一下。