3

我正在尝试正确理解副作用在使用单子样式遍历 F# 列表时的工作原理,遵循 Scott 的指南here

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

我明白了一般的想法 - 拆分头部和尾部,将 func 应用于头部。如果它返回 Ok 然后通过尾部递归,做同样的事情。如果在任何时候返回错误,则将其短路并返回。

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

我也可以按照逻辑:

  • 列表最后一项的结果(在我们使用折返时首先处理)将作为累加器传递给下一项。

  • 如果是 Error 并且下一项是 Ok,则丢弃下一项。

  • 如果下一项是错误,它将替换任何先前的结果并成为累加器。

  • 这意味着当您从右到左递归整个列表并在开始时结束时,您要么以正确的顺序对所有结果进行确定,要么获得最近的错误(这将是第一个如果我们从左到右发生)。

让我感到困惑的是,既然我们从列表的末尾开始,处理每个项目的所有副作用都会发生,即使我们只取回创建的最后一个错误?

这似乎在此处得到确认,因为打印输出以 [5] 开头,然后是 [4,5],然后是 [3,4,5] 等。

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

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

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

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

  • Scott 的初始实现和 AsyncSeq 遍历确实像我想的那样从左到右进行,因此只处理直到遇到错误

  • 它们通过将头部添加到已处理的尾部而不是将每个处理结果添加到前一个(这是内置的 F# fold 所做的)来保持内容有序。

  • foldback 会让事情井井有条,但确实会执行每个案例(异步序列可能需要很长时间)

4

2 回答 2

2

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

让我们看一下traverseChoieAsync您链接到的代码中的函数,并逐步阅读它。我还将重写它以使用Result代替,因为这两种类型在功能上基本相同,但在 DU 中具有不同的名称,如果调用 DU 案例而不是代替,Choice则更容易判断发生了什么和。这是原始代码:OkErrorChoice1Of2Choice2Of2

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。请注意,这是一个简单的重命名,不需要更改任何逻辑:

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!在这个函数内部意味着在异步上下文中“解包”(本质上是“等待”)。

let! s = s

这需要s参数(类型AsyncSeq<'a>)并将其解包,将结果绑定到本地名称s,该名称今后将隐藏原始参数。当您等待 a 的结果时AsyncSeq,您得到的只是第一个元素,而其余元素仍被包裹在需要进一步等待的异步中。您可以通过查看match表达式的结果或查看AsyncSeq类型的定义来了解这一点:

type AsyncSeq<'T> = Async<AsyncSeqInner<'T>>

and AsyncSeqInner<'T> =
    | Nil
    | Cons of 'T * AsyncSeq<'T>

因此,当您执行let! x = swhen sis of type时AsyncSeq<'T>,值x将是Nil(当序列运行到其末尾时)或者它将是Cons(head, tail)where headis of type'Ttailis of type AsyncSeq<'T>

因此,在这一let! s = s行之后,我们的本地名称s现在引用了一个AsyncSeqInner类型,它包含序列的头项(或者Nil如果序列为空),并且序列的其余部分仍然包装在 an 中AsyncSeq,因此尚未对其进行评估 (而且,至关重要的是,它的副作用还没有发生)。

match s with
| Nil -> return Ok (Nil |> async.Return)

这一行发生了很多事情,因此需要进行一些解包,但要点是,如果输入序列sNil它为头部,即已经到达结尾,那么这不是错误,我们返回一个空序列.

现在来解包。外部return位于async关键字中,因此它采用Result(其值为Ok something)并将其转换为Async<Result<something>>. 记住函数的返回类型被声明为Async<Result<AsyncSeq>>,innersomething显然是一个AsyncSeq类型。那么这是怎么回事Nil |> async.Return呢?嗯,async不是 F# 关键字,它是AsyncBuilder. 在一个计算表达式里面foo { ... }return x被翻译成foo.Return(x)。所以调用async.Return x和写是一样的async { return x },除了它避免将一个计算表达式嵌套在另一个计算表达式中,这在精神上尝试和解析会有点讨厌(而且我不是 100% 确定 F# 编译器在语法上允许它)。所以Nil |> async.Returnisasync.Return Nil这意味着它产生一个值Async<x>wherex是 value 的类型Nil。正如我们刚刚看到的,这Nil是一个类型的值AsyncSeqInner,所以Nil |> async.Return产生一个Async<AsyncSeqInner>。另一个名称Async<AsyncSeqInner>AsyncSeq。所以这整个表达式产生了一个Async<Result<AsyncSeq>>意思是“我们在这里完成了,序列中没有更多的项目,并且没有错误”。

呸。现在为下一行:

  | Cons(a,tl) ->

很简单:如果AsyncSeqnamed中的下一个项目s是 a Cons,我们解构它,以便现在调用实际项目a,并调用尾部(另一个AsyncSeqtl

    let! b = f a

这会调用f我们刚刚得到的值s,然后解开的返回值Async部分f,所以b现在是 a Result<'b, 'e>

    match b with
    | Ok b -> 

更多的阴影名称。在的这个分支中matchb现在将值命名为 type'b而不是 a 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我们已经看到的东西。这是一个函数,它接受一个尾部(我们目前不知道或不在乎那个尾部里面是什么,除非它的类型签名Cons必须是 an AsyncSeq)并将其转换为一个AsyncSeq后面b跟有尾部的. 即,这就像在一个列表b :: tl:它贴b在.AsyncSeq

从最内在的表达中迈出的一步是:

Result.map

请记住,map可以通过两种方式来考虑该函数:一种是“获取一个函数并针对该包装“内部”的任何内容运行它”。另一种是“取一个作用于其上'T的函数,使其成为一个作用于其上的函数Wrapper<'T>”。(如果你还没有清楚这两个概念,https ://sidburn.github.io/blog/2016/03/27/understanding-map 是一篇很好的文章,可以帮助你理解这个概念)。因此,它所做的是将 typeAsyncSeq -> AsyncSeq的函数转换为 type 的函数Result<AsyncSeq> -> Result<AsyncSeq>。或者,您可以将其视为采用 a并针对该结果进行Result<tail>调用,然后将该函数的结果重新包装在一个 new 中。重要的:fun tail -> ...tailResultResult.mapChoice.mapl在原文中)我们知道如果tail是一个Error值(或者如果在原文中Choice是 a ),则不会调用Choice2Of2该函数。因此,如果产生一个以值开头的结果,它将产生一个值为 an 的地方,因此尾部的值将被丢弃。请记住这一点,以备后用。traverseResultAsyncError<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>>

  1. 等待外线async
  2. 如果结果是Error,则返回Error
  3. 如果结果为Ok tail,则生成Ok (Cons (b, tail)).

下一行:

traverseResultAsync f tl

我可能应该从这个开始,因为它实际上会运行,然后它的值将被传递到Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>我们刚刚分析的函数中。

所以整个事情要做的就是说“好吧,我们把AsyncSeq我们交给的第一部分,传递给f,并f产生一个Ok我们正在调用的值的结果b。所以现在我们需要处理剩下的部分序列类似地,然后,如果序列的其余部分产生Ok结果,我们将粘贴b在它的前面并返回一个Ok包含内容的序列b :: tail。但是如果序列的其余部分产生一个Error,我们将丢弃该值的b,只是Error原封不动地返回。”

return!

这只是获取我们刚刚得到的结果(anError或 an Ok (b :: tail),已经包含在 an 中Async)并原封不动地返回它。但请注意,对的调用traverseResultAsync不是递归的,因为它的值必须首先传递到Async.map (...)表达式中。

现在我们还有一点traverseResultAsync要看。还记得我说过“以后记住这一点”吗?嗯,那个时候到了。

    | Error e -> 
      return Error e }

在这里,我们回到了match b with表达式。如果bError结果,则不再进行递归调用,并且整体traverseResultAsync返回值为 的Async<Result>位置。如果我们当前嵌套在递归的深处(即,我们在表达式中),那么我们的返回值将是,这意味着正如我们所记住的,“外部”调用的结果也将是,丢弃“之前”可能发生的任何其他结果。ResultErrorreturn! traverseResultAsync ...ErrorErrorOk

结论

所以所有这些的效果是:

  1. 逐步执行,依次AsyncSeq调用每个项目。f
  2. 一次f返回,停止单步执行Error,丢弃任何以前的Ok结果,并将其Error作为整个结果返回。
  3. 如果f从不返回Error而是Ok b每次都返回,则返回一个Ok包含AsyncSeq所有这些b值中的一个的结果,按照它们的原始顺序。

为什么它们按原来的顺序排列?因为Ok案例中的逻辑是:

  1. 如果序列为空,则返回一个空序列。
  2. 分为头尾。
  3. b从中获取价值f head
  4. 处理尾巴。
  5. 将值粘贴b在尾部处理结果的前面

因此,如果我们从 (conceptually) 开始[a1; a2; a3],实际上看起来Cons (a1, Cons (a2, Cons (a3, Nil)))我们最终会Cons (b1, Cons (b2, Cons (b3, Nil)))转换为概念 sequence [b1; b2; b3]

于 2019-06-19T15:32:40.123 回答
2

有关解释,请参见上面@rmunn 的最佳答案。我只是想为将来阅读此内容的任何人发布一个小助手,它允许您将 AsyncSeq 遍历与 Results 一起使用,而不是使用它编写的旧 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)

这里还有一个非异步映射的版本:

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)
于 2019-06-19T16:54:53.240 回答