了解单子遍历的副作用
Understanding side effects with monadic traversal
我正在尝试按照 Scott 的指南 here
正确理解使用 monadic 样式在 F# 中遍历列表时副作用的工作原理
我有一个项目的 AsyncSeq,以及一个可以 return 结果 <'a,'b> 的副作用函数(它将项目保存到磁盘)。
我明白了总体思路——拆分头部和尾部,将函数应用于头部。如果它 returns Ok 然后通过尾递归,做同样的事情。如果在任何时候出现错误 return,则将其短路并 return。
我也明白为什么 Scott 的最终解决方案使用 foldBack 而不是 fold - 它使输出列表与输入的顺序保持一致,因为每个已处理的项目都放在前一个项目之前。
我也可以按照这个逻辑:
列表最后一项的结果(首先处理,因为我们使用折返)将作为累加器传递给下一项。
如果是Error而下一项是Ok,则丢弃下一项。
如果下一项是错误,它将替换之前的所有结果并成为累加器。
这意味着当您从右到左递归遍历整个列表并在开头结束时,您要么以正确的顺序获得所有结果的 Ok,要么获得最多最近的错误(如果我们从左到右,这将是第一个发生的错误)。
让我困惑的是,因为我们是从列表的末尾开始的,所以处理每一项的所有副作用都会发生,即使我们只得到最后一个错误那是创建的?
这似乎得到了证实 因为打印输出以 [5] 开头,然后是 [4,5],然后是 [3,4,5] 等等
让我感到困惑的是,这 不是 当我使用 FSharpx 库中的 AsyncSeq.traverseChoiceAsync 时我看到的情况(我将其包装为处理结果选择)。我看到副作用从左到右发生,在第一个错误处停止,这就是我想要发生的。
它看起来也像 Scott 的非尾递归版本(它不使用 foldBack 并且只是在列表上递归)从左到右? AsyncSeq 版本也是如此。这可以解释为什么我看到它在第一个错误时短路但肯定如果它完成 Ok 那么输出项目将被反转,这就是我们通常使用 foldback 的原因?
我觉得我误解或误读了一些明显的东西!有人可以向我解释一下吗? :)
编辑:
rmunn 在下面对 AsyncSeq 遍历给出了非常全面的解释。 TLDR 是
Scott 的初始实现和 AsyncSeq 遍历两者 do 正如我所想的那样从左到右,因此只处理直到遇到错误
他们通过将头部添加到处理后的尾部而不是将每个处理结果添加到前一个(这是内置 F# 折叠所做的)来保持内容的顺序。
foldback 会让事情井井有条,但确实会执行每个案例(使用异步序列可能永远需要)
很简单:traverseChoiceAsync
没有使用 foldBack
。是的,使用 foldBack
时,最后一个项目将首先处理,因此当您到达第一个项目并发现其结果是 Error
时,您已经触发了每个项目的副作用。我认为,这就是为什么在 FSharpx 中编写 traverseChoiceAsync
的人选择不使用 foldBack
的原因,因为他们想确保副作用会按顺序触发,并在第一个 Error
处停止] (或者,在函数的 Choice
版本的情况下,第一个 Choice2Of2
— 但我会假装从这一点开始,该函数被编写为使用 Result
类型.)
让我们看看您链接到的代码中的 traverseChoieAsync
函数,并通读它 step-by-step。我还将重写它以使用 Result
而不是 Choice
,因为这两种类型在功能上基本相同,但在 DU 中具有不同的名称,并且会更容易分辨发生了什么如果 DU 案例被称为 Ok
和 Error
而不是 Choice1Of2
和 Choice2Of2
。这是原始代码:
let rec traverseChoiceAsync (f:'a -> Async<Choice<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Choice<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Choice1Of2 (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Choice1Of2 b ->
return! traverseChoiceAsync f tl |> Async.map (Choice.mapl (fun tl -> Cons(b, tl) |> async.Return))
| Choice2Of2 e ->
return Choice2Of2 e }
这里是使用 Result
重写的原始代码。请注意,这是一个简单的重命名,需要更改none的逻辑:
let rec traverseResultAsync (f:'a -> Async<Result<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Result<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Ok (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Ok b ->
return! traverseChoiceAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
| Error e ->
return Error e }
现在让我们逐步了解它。整个函数包含在 async { }
块中,因此此函数中的 let!
在异步上下文中表示 "unwrap"(本质上是 "await")。
let! s = s
这接受 s
参数(AsyncSeq<'a>
类型)并将其解包,将结果绑定到本地名称 s
,此后将隐藏原始参数。当您等待 AsyncSeq
的结果时,您得到的只是 first 元素,而其余元素仍包含在需要进一步等待的异步中。您可以通过查看 match
表达式的结果或查看 AsyncSeq
类型的定义来了解这一点:
type AsyncSeq<'T> = Async<AsyncSeqInner<'T>>
and AsyncSeqInner<'T> =
| Nil
| Cons of 'T * AsyncSeq<'T>
因此,当 s
的类型为 AsyncSeq<'T>
时,当您执行 let! x = s
时,x
的值将是 Nil
(当序列具有运行 它的结尾)或者它将是 Cons(head, tail)
其中 head
是类型 'T
并且 tail
是类型 AsyncSeq<'T>
.
所以在let! s = s
行之后,我们的local名称s
现在指的是一个AsyncSeqInner
类型,它包含序列(如果序列为空,则为 Nil
),而序列的其余部分 仍被 包装在 AsyncSeq
中,因此尚未对其进行评估(而且,至关重要的是,它的副作用还没有发生)。
match s with
| Nil -> return Ok (Nil |> async.Return)
这一行发生了很多事情,所以它需要一些解包,但要点是如果输入序列 s
以 Nil
作为它的头部,即已经达到它结束了,那不是错误,我们 return 一个空序列。
现在打开包装。外层 return
位于 async
关键字中,因此它采用 Result
(其值为 Ok something
)并将其转换为 Async<Result<something>>
。记住函数的 return 类型声明为 Async<Result<AsyncSeq>>
,内部 something
显然是 AsyncSeq
类型。那么 Nil |> async.Return
是怎么回事?好吧,async
不是 F# 关键字,它是 AsyncBuilder
实例的名称。在计算表达式 foo { ... }
中,return x
被翻译成 foo.Return(x)
。所以调用 async.Return x
和写 async { return x }
是一样的,只是它避免了在 另一个 计算表达式中嵌套一个计算表达式,这会有点讨厌尝试并在心理上进行解析(我不是 100% 确定 F# 编译器在语法上允许它)。所以 Nil |> async.Return
是 async.Return Nil
这意味着它产生一个 Async<x>
的值,其中 x
是值 Nil
的类型。正如我们刚刚看到的,这个 Nil
是一个 AsyncSeqInner
类型的值,所以 Nil |> async.Return
产生一个 Async<AsyncSeqInner>
。 Async<AsyncSeqInner>
的另一个名字是 AsyncSeq
。所以整个表达式产生一个 Async<Result<AsyncSeq>>
,其含义为 "We're done here, there are no more items in the sequence, and there was no error".
呸。现在是下一行:
| Cons(a,tl) ->
简单:如果名为 s
的 AsyncSeq
中的下一个项目是 Cons
,我们将其解构,以便实际的 项目 现在称为 a
,尾部(另一个 AsyncSeq
)称为 tl
。
let! b = f a
这会调用 f
我们刚从 s
中得到的值,然后展开 f
的 return 值的 Async
部分, 所以 b
现在是 Result<'b, 'e>
.
match b with
| Ok b ->
更多隐藏的名字。在 match
的 this 分支中,b
现在命名一个 'b
类型的值,而不是 Result<'b, 'e>
.
return! traverseResultAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
哎哟小子。一次处理太多了。让我们这样写,就好像 |>
运算符排在不同的行上一样,然后我们将逐个执行每个步骤。 (请注意,我在这周围多加了一对括号,只是为了说明这是将传递给 return!
关键字的整个表达式的 最终结果 ) .
return! (
traverseResultAsync f tl
|> Async.map (
Result.map (
fun tl -> Cons(b, tl) |> async.Return)))
我将从内到外解决这个问题。内线是:
fun tl -> Cons(b, tl) |> async.Return
async.Return
我们已经看到了。这是一个带尾巴的函数(我们目前不知道,也不关心尾巴里面有什么,除了 nCons
的类型签名的必然性,它必须是一个 AsyncSeq
) 并将它变成一个 AsyncSeq
,即 b
后跟尾巴。也就是说,这就像列表中的 b :: tl
:它将 b
粘在 AsyncSeq
的 前面 上。
从最里面的表达式中走出一步是:
Result.map
记住函数 map
可以用两种方式来思考:一种是 "take a function and run it against whatever is " 在“这个包装器”中。另一个是"take a function that operates on 'T
and make it into a function that operates on Wrapper<'T>
"。 (如果您还不清楚这两个概念,https://sidburn.github.io/blog/2016/03/27/understanding-map 是一篇很好的文章,可以帮助理解这个概念)。所以它所做的是将类型为 AsyncSeq -> AsyncSeq
的函数转换为类型为 Result<AsyncSeq> -> Result<AsyncSeq>
的函数。或者,您可以将其视为采用 Result<tail>
并针对该 tail
结果调用 fun tail -> ...
,然后 re-wrapping 新 Result
中该函数的结果. 重要提示: 因为这是使用 Result.map
(原来的 Choice.mapl
)我们知道如果 tail
是一个 Error
值(或如果 Choice
原来是 Choice2Of2
),该函数将 不会被调用 。因此,如果 traverseResultAsync
产生一个以 Error
值开头的结果,它将产生一个 <Async<Result<foo>>>
,其中 Result<foo>
的值是一个 Error
,因此尾部的值将被丢弃。请记住这一点以备后用。
好的,下一步。
Async.map
在这里,我们有一个由内部表达式生成的 Result<AsyncSeq> -> Result<AsyncSeq>
函数,并将其转换为 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数。我们刚刚谈到了这一点,所以我们不需要再讨论 map
是如何工作的。请记住,我们构建的 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数的 effect 如下:
- 等待外部
async
。
- 如果结果是
Error
,return即Error
.
- 如果结果是
Ok tail
,产生一个Ok (Cons (b, tail))
。
下一行:
traverseResultAsync f tl
我可能应该从这个开始,因为这实际上会 运行 首先 ,然后它的值将被传递到 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数我们刚刚分析过。
所以这整件事要做的就是说 "Okay, we took the first part of the AsyncSeq
we were handed, and passed it to f
, and f
produced an Ok
result with a value we're calling b
. So now we need to process the rest of the sequence similarly, and then, if the rest of the sequence produces an Ok
result, we'll stick b
on the front of it and return an Ok
sequence with contents b :: tail
. BUT if the rest of the sequence produces an Error
, we'll throw away the value of b
and just return that Error
unchanged."
return!
这只是采用我们刚刚得到的结果(Error
或 Ok (b :: tail)
,已经包含在 Async
中)并且 return 保持不变。但请注意,对 traverseResultAsync
的调用是 NOT tail-recursive,因为它的值必须先传递到 Async.map (...)
表达式中。
现在我们还有一点 traverseResultAsync
要看。还记得我说过 "Keep that in mind for later" 吗?嗯,那个时候到了。
| Error e ->
return Error e }
这里我们回到 match b with
表达式。如果 b
是 Error
结果,则 不再进行递归调用 ,并且整个 traverseResultAsync
return 是一个 Async<Result>
其中 Result
值为 Error
。如果我们当前嵌套在递归的深处(即,我们在 return! traverseResultAsync ...
表达式中),那么我们的 return 值将是 Error
,这意味着 "outer" 调用,正如我们牢记的那样,也将是 Error
,丢弃可能发生的任何其他 Ok
结果 "before".
结论
所有这些的效果是:
- 单步执行
AsyncSeq
,依次对每个项目调用 f
。
- 第第次
f
return秒Error
,停止步进,丢弃之前的任何Ok
结果, return Error
作为整个事件的结果。
- 如果
f
从不returns Error
而是每次returns Ok b
,return一个Ok
结果包含所有这些 b
值的 AsyncSeq
,按其原始顺序排列。
为什么它们是原来的顺序?因为Ok
情况下的逻辑是:
- 如果序列为空,return 一个空序列。
- 分为头部和尾部。
- 从
f head
获取值 b
。
- 处理尾部。
- 在front尾部处理的结果中粘贴值
b
。
因此,如果我们从(概念上)[a1; a2; a3]
开始,它实际上看起来像 Cons (a1, Cons (a2, Cons (a3, Nil)))
,我们将以 Cons (b1, Cons (b2, Cons (b3, Nil)))
结束,它转化为概念序列 [b1; b2; b3]
.
请参阅上面@rmunn 的精彩回答以获取解释。我只是想 post 为将来阅读本文的任何人提供一个小帮手,它允许您将 AsyncSeq 遍历与结果一起使用,而不是使用它编写的旧 Choice 类型:
let traverseResultAsyncM (mapping : 'a -> Async<Result<'b,'c>>) source =
let mapping' =
mapping
>> Async.map (function
| Ok x -> Choice1Of2 x
| Error e -> Choice2Of2 e)
AsyncSeq.traverseChoiceAsync mapping' source
|> Async.map (function
| Choice1Of2 x -> Ok x
| Choice2Of2 e -> Error e)
这里还有一个 non-async 映射的版本:
let traverseResultM (mapping : 'a -> Result<'b,'c>) source =
let mapping' x = async {
return
mapping x
|> function
| Ok x -> Choice1Of2 x
| Error e -> Choice2Of2 e
}
AsyncSeq.traverseChoiceAsync mapping' source
|> Async.map (function
| Choice1Of2 x -> Ok x
| Choice2Of2 e -> Error e)
我正在尝试按照 Scott 的指南 here
正确理解使用 monadic 样式在 F# 中遍历列表时副作用的工作原理我有一个项目的 AsyncSeq,以及一个可以 return 结果 <'a,'b> 的副作用函数(它将项目保存到磁盘)。
我明白了总体思路——拆分头部和尾部,将函数应用于头部。如果它 returns Ok 然后通过尾递归,做同样的事情。如果在任何时候出现错误 return,则将其短路并 return。
我也明白为什么 Scott 的最终解决方案使用 foldBack 而不是 fold - 它使输出列表与输入的顺序保持一致,因为每个已处理的项目都放在前一个项目之前。
我也可以按照这个逻辑:
列表最后一项的结果(首先处理,因为我们使用折返)将作为累加器传递给下一项。
如果是Error而下一项是Ok,则丢弃下一项。
如果下一项是错误,它将替换之前的所有结果并成为累加器。
这意味着当您从右到左递归遍历整个列表并在开头结束时,您要么以正确的顺序获得所有结果的 Ok,要么获得最多最近的错误(如果我们从左到右,这将是第一个发生的错误)。
让我困惑的是,因为我们是从列表的末尾开始的,所以处理每一项的所有副作用都会发生,即使我们只得到最后一个错误那是创建的?
这似乎得到了证实
让我感到困惑的是,这 不是 当我使用 FSharpx 库中的 AsyncSeq.traverseChoiceAsync 时我看到的情况(我将其包装为处理结果选择)。我看到副作用从左到右发生,在第一个错误处停止,这就是我想要发生的。
它看起来也像 Scott 的非尾递归版本(它不使用 foldBack 并且只是在列表上递归)从左到右? AsyncSeq 版本也是如此。这可以解释为什么我看到它在第一个错误时短路但肯定如果它完成 Ok 那么输出项目将被反转,这就是我们通常使用 foldback 的原因?
我觉得我误解或误读了一些明显的东西!有人可以向我解释一下吗? :)
编辑: rmunn 在下面对 AsyncSeq 遍历给出了非常全面的解释。 TLDR 是
Scott 的初始实现和 AsyncSeq 遍历两者 do 正如我所想的那样从左到右,因此只处理直到遇到错误
他们通过将头部添加到处理后的尾部而不是将每个处理结果添加到前一个(这是内置 F# 折叠所做的)来保持内容的顺序。
foldback 会让事情井井有条,但确实会执行每个案例(使用异步序列可能永远需要)
很简单:traverseChoiceAsync
没有使用 foldBack
。是的,使用 foldBack
时,最后一个项目将首先处理,因此当您到达第一个项目并发现其结果是 Error
时,您已经触发了每个项目的副作用。我认为,这就是为什么在 FSharpx 中编写 traverseChoiceAsync
的人选择不使用 foldBack
的原因,因为他们想确保副作用会按顺序触发,并在第一个 Error
处停止] (或者,在函数的 Choice
版本的情况下,第一个 Choice2Of2
— 但我会假装从这一点开始,该函数被编写为使用 Result
类型.)
让我们看看您链接到的代码中的 traverseChoieAsync
函数,并通读它 step-by-step。我还将重写它以使用 Result
而不是 Choice
,因为这两种类型在功能上基本相同,但在 DU 中具有不同的名称,并且会更容易分辨发生了什么如果 DU 案例被称为 Ok
和 Error
而不是 Choice1Of2
和 Choice2Of2
。这是原始代码:
let rec traverseChoiceAsync (f:'a -> Async<Choice<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Choice<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Choice1Of2 (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Choice1Of2 b ->
return! traverseChoiceAsync f tl |> Async.map (Choice.mapl (fun tl -> Cons(b, tl) |> async.Return))
| Choice2Of2 e ->
return Choice2Of2 e }
这里是使用 Result
重写的原始代码。请注意,这是一个简单的重命名,需要更改none的逻辑:
let rec traverseResultAsync (f:'a -> Async<Result<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Result<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Ok (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Ok b ->
return! traverseChoiceAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
| Error e ->
return Error e }
现在让我们逐步了解它。整个函数包含在 async { }
块中,因此此函数中的 let!
在异步上下文中表示 "unwrap"(本质上是 "await")。
let! s = s
这接受 s
参数(AsyncSeq<'a>
类型)并将其解包,将结果绑定到本地名称 s
,此后将隐藏原始参数。当您等待 AsyncSeq
的结果时,您得到的只是 first 元素,而其余元素仍包含在需要进一步等待的异步中。您可以通过查看 match
表达式的结果或查看 AsyncSeq
类型的定义来了解这一点:
type AsyncSeq<'T> = Async<AsyncSeqInner<'T>>
and AsyncSeqInner<'T> =
| Nil
| Cons of 'T * AsyncSeq<'T>
因此,当 s
的类型为 AsyncSeq<'T>
时,当您执行 let! x = s
时,x
的值将是 Nil
(当序列具有运行 它的结尾)或者它将是 Cons(head, tail)
其中 head
是类型 'T
并且 tail
是类型 AsyncSeq<'T>
.
所以在let! s = s
行之后,我们的local名称s
现在指的是一个AsyncSeqInner
类型,它包含序列(如果序列为空,则为 Nil
),而序列的其余部分 仍被 包装在 AsyncSeq
中,因此尚未对其进行评估(而且,至关重要的是,它的副作用还没有发生)。
match s with
| Nil -> return Ok (Nil |> async.Return)
这一行发生了很多事情,所以它需要一些解包,但要点是如果输入序列 s
以 Nil
作为它的头部,即已经达到它结束了,那不是错误,我们 return 一个空序列。
现在打开包装。外层 return
位于 async
关键字中,因此它采用 Result
(其值为 Ok something
)并将其转换为 Async<Result<something>>
。记住函数的 return 类型声明为 Async<Result<AsyncSeq>>
,内部 something
显然是 AsyncSeq
类型。那么 Nil |> async.Return
是怎么回事?好吧,async
不是 F# 关键字,它是 AsyncBuilder
实例的名称。在计算表达式 foo { ... }
中,return x
被翻译成 foo.Return(x)
。所以调用 async.Return x
和写 async { return x }
是一样的,只是它避免了在 另一个 计算表达式中嵌套一个计算表达式,这会有点讨厌尝试并在心理上进行解析(我不是 100% 确定 F# 编译器在语法上允许它)。所以 Nil |> async.Return
是 async.Return Nil
这意味着它产生一个 Async<x>
的值,其中 x
是值 Nil
的类型。正如我们刚刚看到的,这个 Nil
是一个 AsyncSeqInner
类型的值,所以 Nil |> async.Return
产生一个 Async<AsyncSeqInner>
。 Async<AsyncSeqInner>
的另一个名字是 AsyncSeq
。所以整个表达式产生一个 Async<Result<AsyncSeq>>
,其含义为 "We're done here, there are no more items in the sequence, and there was no error".
呸。现在是下一行:
| Cons(a,tl) ->
简单:如果名为 s
的 AsyncSeq
中的下一个项目是 Cons
,我们将其解构,以便实际的 项目 现在称为 a
,尾部(另一个 AsyncSeq
)称为 tl
。
let! b = f a
这会调用 f
我们刚从 s
中得到的值,然后展开 f
的 return 值的 Async
部分, 所以 b
现在是 Result<'b, 'e>
.
match b with
| Ok b ->
更多隐藏的名字。在 match
的 this 分支中,b
现在命名一个 'b
类型的值,而不是 Result<'b, 'e>
.
return! traverseResultAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
哎哟小子。一次处理太多了。让我们这样写,就好像 |>
运算符排在不同的行上一样,然后我们将逐个执行每个步骤。 (请注意,我在这周围多加了一对括号,只是为了说明这是将传递给 return!
关键字的整个表达式的 最终结果 ) .
return! (
traverseResultAsync f tl
|> Async.map (
Result.map (
fun tl -> Cons(b, tl) |> async.Return)))
我将从内到外解决这个问题。内线是:
fun tl -> Cons(b, tl) |> async.Return
async.Return
我们已经看到了。这是一个带尾巴的函数(我们目前不知道,也不关心尾巴里面有什么,除了 nCons
的类型签名的必然性,它必须是一个 AsyncSeq
) 并将它变成一个 AsyncSeq
,即 b
后跟尾巴。也就是说,这就像列表中的 b :: tl
:它将 b
粘在 AsyncSeq
的 前面 上。
从最里面的表达式中走出一步是:
Result.map
记住函数 map
可以用两种方式来思考:一种是 "take a function and run it against whatever is " 在“这个包装器”中。另一个是"take a function that operates on 'T
and make it into a function that operates on Wrapper<'T>
"。 (如果您还不清楚这两个概念,https://sidburn.github.io/blog/2016/03/27/understanding-map 是一篇很好的文章,可以帮助理解这个概念)。所以它所做的是将类型为 AsyncSeq -> AsyncSeq
的函数转换为类型为 Result<AsyncSeq> -> Result<AsyncSeq>
的函数。或者,您可以将其视为采用 Result<tail>
并针对该 tail
结果调用 fun tail -> ...
,然后 re-wrapping 新 Result
中该函数的结果. 重要提示: 因为这是使用 Result.map
(原来的 Choice.mapl
)我们知道如果 tail
是一个 Error
值(或如果 Choice
原来是 Choice2Of2
),该函数将 不会被调用 。因此,如果 traverseResultAsync
产生一个以 Error
值开头的结果,它将产生一个 <Async<Result<foo>>>
,其中 Result<foo>
的值是一个 Error
,因此尾部的值将被丢弃。请记住这一点以备后用。
好的,下一步。
Async.map
在这里,我们有一个由内部表达式生成的 Result<AsyncSeq> -> Result<AsyncSeq>
函数,并将其转换为 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数。我们刚刚谈到了这一点,所以我们不需要再讨论 map
是如何工作的。请记住,我们构建的 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数的 effect 如下:
- 等待外部
async
。 - 如果结果是
Error
,return即Error
. - 如果结果是
Ok tail
,产生一个Ok (Cons (b, tail))
。
下一行:
traverseResultAsync f tl
我可能应该从这个开始,因为这实际上会 运行 首先 ,然后它的值将被传递到 Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
函数我们刚刚分析过。
所以这整件事要做的就是说 "Okay, we took the first part of the AsyncSeq
we were handed, and passed it to f
, and f
produced an Ok
result with a value we're calling b
. So now we need to process the rest of the sequence similarly, and then, if the rest of the sequence produces an Ok
result, we'll stick b
on the front of it and return an Ok
sequence with contents b :: tail
. BUT if the rest of the sequence produces an Error
, we'll throw away the value of b
and just return that Error
unchanged."
return!
这只是采用我们刚刚得到的结果(Error
或 Ok (b :: tail)
,已经包含在 Async
中)并且 return 保持不变。但请注意,对 traverseResultAsync
的调用是 NOT tail-recursive,因为它的值必须先传递到 Async.map (...)
表达式中。
现在我们还有一点 traverseResultAsync
要看。还记得我说过 "Keep that in mind for later" 吗?嗯,那个时候到了。
| Error e ->
return Error e }
这里我们回到 match b with
表达式。如果 b
是 Error
结果,则 不再进行递归调用 ,并且整个 traverseResultAsync
return 是一个 Async<Result>
其中 Result
值为 Error
。如果我们当前嵌套在递归的深处(即,我们在 return! traverseResultAsync ...
表达式中),那么我们的 return 值将是 Error
,这意味着 "outer" 调用,正如我们牢记的那样,也将是 Error
,丢弃可能发生的任何其他 Ok
结果 "before".
结论
所有这些的效果是:
- 单步执行
AsyncSeq
,依次对每个项目调用f
。 - 第第次
f
return秒Error
,停止步进,丢弃之前的任何Ok
结果, returnError
作为整个事件的结果。 - 如果
f
从不returnsError
而是每次returnsOk b
,return一个Ok
结果包含所有这些b
值的AsyncSeq
,按其原始顺序排列。
为什么它们是原来的顺序?因为Ok
情况下的逻辑是:
- 如果序列为空,return 一个空序列。
- 分为头部和尾部。
- 从
f head
获取值b
。 - 处理尾部。
- 在front尾部处理的结果中粘贴值
b
。
因此,如果我们从(概念上)[a1; a2; a3]
开始,它实际上看起来像 Cons (a1, Cons (a2, Cons (a3, Nil)))
,我们将以 Cons (b1, Cons (b2, Cons (b3, Nil)))
结束,它转化为概念序列 [b1; b2; b3]
.
请参阅上面@rmunn 的精彩回答以获取解释。我只是想 post 为将来阅读本文的任何人提供一个小帮手,它允许您将 AsyncSeq 遍历与结果一起使用,而不是使用它编写的旧 Choice 类型:
let traverseResultAsyncM (mapping : 'a -> Async<Result<'b,'c>>) source =
let mapping' =
mapping
>> Async.map (function
| Ok x -> Choice1Of2 x
| Error e -> Choice2Of2 e)
AsyncSeq.traverseChoiceAsync mapping' source
|> Async.map (function
| Choice1Of2 x -> Ok x
| Choice2Of2 e -> Error e)
这里还有一个 non-async 映射的版本:
let traverseResultM (mapping : 'a -> Result<'b,'c>) source =
let mapping' x = async {
return
mapping x
|> function
| Ok x -> Choice1Of2 x
| Error e -> Choice2Of2 e
}
AsyncSeq.traverseChoiceAsync mapping' source
|> Async.map (function
| Choice1Of2 x -> Ok x
| Choice2Of2 e -> Error e)