了解遍历遍历的副作用

时间:2019-06-19 13:27:06

标签: functional-programming f# monads traversal fsharpx

按照斯科特指南here

,我正在尝试正确理解使用Monadic风格的F#遍历列表时副作用的工作方式。

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

我有一个大致的想法-将头和尾分开,将func应用于头部。如果返回OK,则通过尾部递归,执行相同的操作。如果在任何时候返回错误,则将其短路并返回。

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

我也可以遵循以下逻辑:

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

  • 如果这是错误并且下一项是OK,则下一项被丢弃。

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

  • 这意味着,当您从右到左遍历整个列表并在开始时结束时,您要么以正确的顺序获得了所有结果的OK,要么获得了最新的Error(如果我们从左到右走,那将是第一个发生的事情。

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

here似乎已得到确认,因为打印输出以[5],[4,5],[3,4,5]等开头。

让我感到困惑的是,当我使用FSharpx库中的AsyncSeq.traverseChoiceAsync(我包装来处理Result而不是Choice)时,这不是我看到的。我看到副作用从左到右发生,停止在我要发生的第一个错误上。

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

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

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

  • Scott的初始实现和AsyncSeq遍历 do 都按照我的想法从左到右进行,因此只进行处理直到遇到错误为止

  • 它们通过将头部放在处理过的尾巴之前而不是将每个处理过的结果放在前一个(这是内置F#折叠的作用)之前,使它们的内容保持顺序。

  • foldback可以使事情井井有条,但是确实会执行每种情况(使用异步seq可能要花很长时间)

2 个答案:

答案 0 :(得分:2)

这很简单:traverseChoiceAsync没有使用foldBack。是的,使用foldBack将首先处理最后一个项目,因此当您到达第一个项目并发现其结果为Error时,您将触发每个项目的副作用。我认为,这正是为什么在FSharpx中写traverseChoiceAsync的人选择不使用foldBack的原因,因为他们想确保副作用会被有序地触发,并在第一个{{1} }(或者,对于该函数的Error版本,第一个Choice —但我会假装该函数是使用Choice2Of2类型编写的) )

让我们看一下链接到的代码中的Result函数,并逐步阅读它。我也将其重写为使用traverseChoieAsync而不是Result,因为这两种类型在功能上基本相同,但在DU中具有不同的名称,这将使事情更容易知道是否将DU案例称为ChoiceOk而不是ErrorChoice1Of2。这是原始代码:

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

现在让我们逐步完成它。整个功能都包装在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 { }意味着在异步上下文中(实际上是“ await”)“解包”。

let!

这将使用let! s = s 参数(类型为s)并将其解包,将结果绑定到本地名称AsyncSeq<'a>,此后此名称将覆盖原始参数。当您等待s的结果时,您只会得到 first 元素,而其余元素仍包裹在需要进一步等待的异步中。您可以通过查看AsyncSeq表达式的结果或通过查看match类型的定义来看到这一点:

AsyncSeq

因此,当type AsyncSeq<'T> = Async<AsyncSeqInner<'T>> and AsyncSeqInner<'T> = | Nil | Cons of 'T * AsyncSeq<'T> 的类型为let! x = s时执行s时,AsyncSeq<'T>的值将为x(当序列运行到其结尾))将为Nil,其中Cons(head, tail)的类型为head,而'T的类型为tail

因此,在此AsyncSeq<'T>行之后,我们的 local 名称let! s = s现在是指s类型,其中包含序列的开头项(或{ {1}}(如果序列为空),并且序列的其余部分仍被{em>包裹在AsyncSeqInner中,因此尚待评估(而且,其副作用是尚未发生)。

Nil

此行中发生了很多事情,因此需要进行一些拆包,但要点是,如果输入序列AsyncSeqmatch s with | Nil -> return Ok (Nil |> async.Return) 作为其开头,即到达末尾,那不是错误,我们返回一个空序列。

现在要打开包装。外部sNil关键字中,因此它采用return(其值为async)并将其转换为Result。请记住,函数的返回类型声明为Ok something,内部Async<Result<something>>显然是Async<Result<AsyncSeq>>类型。那something到底是怎么回事?好吧,AsyncSeq不是F#关键字,它是Nil |> async.Return实例的名称。在计算表达式async中,AsyncBuilder被转换为foo { ... }。因此,调用return x与编写foo.Return(x)一样,只是它避免在另一个计算表达式中嵌套一个计算表达式,这在精神上试图进行解析有点麻烦。 (而且我不确定100%F#编译器在语法上允许它)。因此async.Return xasync { return x },这意味着它会产生一个值Nil |> async.Return,其中async.Return Nil是值Async<x>的类型。正如我们刚刚看到的,此x是类型Nil的值,因此Nil产生一个AsyncSeqInnerNil |> async.Return的另一个名称是Async<AsyncSeqInner>。因此,整个表达式产生一个Async<AsyncSeqInner>,其含义是“我们已经完成了,序列中没有更多的项目,也没有错误”。

Ph。现在进入下一行:

AsyncSeq

简单:如果Async<Result<AsyncSeq>>中名为 | Cons(a,tl) -> 的下一个项目是AsyncSeq,我们就对其进行解构,以使实际的 item 现在称为{{ 1}},而尾巴(另一个s)称为Cons

a

这将对我们刚从AsyncSeq中得到的值调用tl,然后解包 let! b = f a 返回值的f部分,以便{{1 }}现在是s

Async

更多阴影名称。在f this 分支中,b现在命名为Result<'b, 'e>类型的值,而不是 match b with | Ok b ->

match

哦,男孩。这太多了,无法立即解决。让我们这样写,就好像b运算符在不同的行上排列在一起,然后我们将一次遍历每个步骤。 (请注意,我在这周围加上了一对括号,只是为了阐明将整个表达式的 final结果传递给'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。即,就像列表中的Cons:它将AsyncSeq粘贴到AsyncSeq front 上。

从最里面的表达式走出来的第一步是:

b

请记住,可以通过两种方式来考虑函数b :: tl:一种是“采用一个函数,然后针对该包装内部的任何内容运行它”。另一个是“将可在b上运行的函数设为可在AsyncSeq上运行的函数”。 (如果您还不清楚这两个概念,那么https://sidburn.github.io/blog/2016/03/27/understanding-map是一篇很好的文章,可以帮助您理解这个概念)。因此,这是在使用Result.map 类型的函数并将其转换为map类型的函数。或者,您可以考虑将它取一个'T并针对该Wrapper<'T>调用AsyncSeq -> AsyncSeq,然后将该函数的结果重新包装到新的Result<AsyncSeq> -> Result<AsyncSeq>中。 重要提示:由于此操作使用的是Result<tail>(原为fun tail -> ...),所以我们知道tail是一个Result值(还是{ {1}}原为Result.map,该函数将不被调用。因此,如果Choice.mapl产生一个以tail值开头的结果,它将产生一个Error,其中Choice的值为Choice2Of2,依此类推尾巴的值将被丢弃。请记住这一点,以便以后使用。

好的,下一步。

traverseResultAsync

在这里,我们有一个由内部表达式产生的Error函数,并将其转换为<Async<Result<foo>>>函数。我们刚刚讨论了这一点,因此我们无需再研究Result<foo>的工作方式。请记住,我们建立的Error函数的效果为:

  1. 等待外面的Async.map
  2. 如果结果为Result<AsyncSeq> -> Result<AsyncSeq>,请返回Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
  3. 如果结果为map,请产生Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>

下一行:

async

我可能应该从此开始,因为它实际上会先运行 ,然后将其值传递到我们刚刚分析过的Error函数中。

所以整个事情要做的是说:“好吧,我们拿走了交到的Error的第一部分,并将其传递给Ok tailOk (Cons (b, tail))产生了一个traverseResultAsync f tl 的值叫Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>,所以现在我们需要类似地处理序列的 rest ,然后再处理 if 序列的其余部分产生一个AsyncSeq结果,我们将f粘贴在其前面,并返回一个内容为f的{​​{1}}序列。该序列产生一个Ok,我们将丢弃 b的值,然后将其Ok保持不变。”

b

这只是获取我们刚得到的结果(Okb :: tail,已经包装在Error中)并返回原样。但是请注意,对b的调用是 NOT 尾部递归的,因为必须先将其值传递到Error表达式中。

现在我们还要看return! 。还记得我说的“以后记着”吗?好,那时候到了。

Error

在这里,我们回到了Ok (b :: tail)表达式中。如果AsynctraverseResultAsync的结果,则不会进行进一步的递归调用,整个Async.map (...)返回一个traverseResultAsync,其中{{1 }}的值为 | Error e -> return Error e } 。而且,如果当前我们嵌套在递归内部(即,我们在match b with表达式中),那么我们的返回值将是b,这意味着“外部”调用的结果,如我们谨记,也将Error丢弃可能在“之前”发生的任何其他traverseResultAsync结果。

结论

所有这些的效果是:

  1. 逐步执行Async<Result>,依次对每个项目调用Result
  2. 第一次 Error返回return! traverseResultAsync ...,停止单步执行,丢弃所有先前的Error结果,并将该Error作为整个结果。
  3. 如果Ok从不返回AsyncSeq,而是每次都返回f,则返回一个f结果,其中包含所有{{1}中的Error }值,按其原始顺序。

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

  1. 如果序列为空,则返回一个空序列。
  2. 分为头和尾。
  3. Error获取价值f
  4. 处理尾巴。
  5. 处理尾部结果的 front 中的
  6. Stick值Error

因此,如果我们以(概念上)Ok b(实际上看起来像Ok)开头,我们将以AsyncSeq结尾,这将转换为概念序列b。 / p>

答案 1 :(得分: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)