好的,让我们再试一次。会出什么问题? :-)
编程或多或少是关于捕捉模式。好吧,至少它的有趣部分无论如何。以 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
不同之处在于对>>= 的调用从右到左“翻转”并使用特殊关键字<-(是的,这是一个关键字,而不是一个运算符)。看起来很干净,不是吗?
但是 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
}
而脱糖过程也比仅仅插入对>>= 的调用更复杂一些。相反,每个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。我不打算在这里展开这个,这是一个完整的其他帖子的讨论。只是想快速提一下。