了解单子遍历的副作用

Understanding side effects with monadic traversal

我正在尝试按照 Scott 的指南 here

正确理解使用 monadic 样式在 F# 中遍历列表时副作用的工作原理

我有一个项目的 AsyncSeq,以及一个可以 return 结果 <'a,'b> 的副作用函数(它将项目保存到磁盘)。

我明白了总体思路——拆分头部和尾部,将函数应用于头部。如果它 returns Ok 然后通过尾递归,做同样的事情。如果在任何时候出现错误 return,则将其短路并 return。

我也明白为什么 Scott 的最终解决方案使用 foldBack 而不是 fold - 它使输出列表与输入的顺序保持一致,因为每个已处理的项目都放在前一个项目之前。

我也可以按照这个逻辑:

让我困惑的是,因为我们是从列表的末尾开始的,所以处理每一项的所有副作用都会发生,即使我们只得到最后一个错误那是创建的?

这似乎得到了证实 因为打印输出以 [5] 开头,然后是 [4,5],然后是 [3,4,5] 等等

让我感到困惑的是,这 不是 当我使用 FSharpx 库中的 AsyncSeq.traverseChoiceAsync 时我看到的情况(我将其包装为处理结果选择)。我看到副作用从左到右发生,在第一个错误处停止,这就是我想要发生的。

它看起来也像 Scott 的非尾递归版本(它不使用 foldBack 并且只是在列表上递归)从左到右? AsyncSeq 版本也是如此。这可以解释为什么我看到它在第一个错误时短路但肯定如果它完成 Ok 那么输出项目将被反转,这就是我们通常使用 foldback 的原因?

我觉得我误解或误读了一些明显的东西!有人可以向我解释一下吗? :)

编辑: rmunn 在下面对 AsyncSeq 遍历给出了非常全面的解释。 TLDR 是

很简单:traverseChoiceAsync 没有使用 foldBack。是的,使用 foldBack 时,最后一个项目将首先处理,因此当您到达第一个项目并发现其结果是 Error 时,您已经触发了每个项目的副作用。我认为,这就是为什么在 FSharpx 中编写 traverseChoiceAsync 的人选择不使用 foldBack 的原因,因为他们想确保副作用会按顺序触发,并在第一个 Error 处停止] (或者,在函数的 Choice 版本的情况下,第一个 Choice2Of2 — 但我会假装从这一点开始,该函数被编写为使用 Result 类型.)

让我们看看您链接到的代码中的 traverseChoieAsync 函数,并通读它 step-by-step。我还将重写它以使用 Result 而不是 Choice,因为这两种类型在功能上基本相同,但在 DU 中具有不同的名称,并且会更容易分辨发生了什么如果 DU 案例被称为 OkError 而不是 Choice1Of2Choice2Of2。这是原始代码:

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)

这一行发生了很多事情,所以它需要一些解包,但要点是如果输入序列 sNil 作为它的头部,即已经达到它结束了,那不是错误,我们 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.Returnasync.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) ->

简单:如果名为 sAsyncSeq 中的下一个项目是 Cons,我们将其解构,以便实际的 项目 现在称为 a,尾部(另一个 AsyncSeq)称为 tl

    let! b = f a

这会调用 f 我们刚从 s 中得到的值,然后展开 f 的 return 值的 Async 部分, 所以 b 现在是 Result<'b, 'e>.

    match b with
    | Ok b -> 

更多隐藏的名字。在 matchthis 分支中,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 如下:

  1. 等待外部async
  2. 如果结果是Error,return即Error.
  3. 如果结果是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!

这只是采用我们刚刚得到的结果(ErrorOk (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 表达式。如果 bError 结果,则 不再进行递归调用 ,并且整个 traverseResultAsync return 是一个 Async<Result> 其中 Result 值为 Error。如果我们当前嵌套在递归的深处(即,我们在 return! traverseResultAsync ... 表达式中),那么我们的 return 值将是 Error,这意味着 "outer" 调用,正如我们牢记的那样,也将是 Error,丢弃可能发生的任何其他 Ok 结果 "before".

结论

所有这些的效果是:

  1. 单步执行 AsyncSeq,依次对每个项目调用 f
  2. freturn秒Error,停止步进,丢弃之前的任何Ok结果, return Error 作为整个事件的结果。
  3. 如果f从不returns Error而是每次returns Ok b,return一个Ok结果包含所有这些 b 值的 AsyncSeq,按其原始顺序排列。

为什么它们是原来的顺序?因为Ok情况下的逻辑是:

  1. 如果序列为空,return 一个空序列。
  2. 分为头部和尾部。
  3. f head 获取值 b
  4. 处理尾部。
  5. 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)