【问题标题】:Accidental recursion, blowing up the stack with Seq.append, without using `rec`意外递归,使用 Seq.append 炸毁堆栈,而不使用 `rec`
【发布时间】:2018-06-07 19:09:25
【问题描述】:

我的代码正在等待炸毁潜伏的东西。使用 F# 4.1 Result 与此类似:

module Result =
    let unwindSeq (sourceSeq: #seq<Result<_, _>>) =
        sourceSeq
        |> Seq.fold (fun state res -> 
            match state with
            | Error e -> Error e
            | Ok innerResult ->
                match res with
                | Ok suc -> 
                    Seq.singleton suc
                    |> Seq.append innerResult
                    |> Ok
                | Error e -> Error e) (Ok Seq.empty)

这里明显的瓶颈是Seq.singleton 添加到Seq.append。我知道这很慢(而且写得不好),但为什么它必须炸毁堆栈?我不认为 Seq.append 本质上是递归的......

// blows up stack, StackOverflowException
Seq.init 1000000 Result.Ok
|> Result.unwindSeq
|> printfn "%A" 

顺便说一句,为了展开一系列Result,我使用简单的try-catch-reraise 修复了这个函数,但感觉也低于标准。关于如何在不强制评估序列或炸毁堆栈的情况下更惯用地执行此操作的任何想法?

不那么完美的展开(它也强制结果失败类型),但至少没有对序列进行预评估:

let unwindSeqWith throwArgument (sourceSeq: #seq<Result<_, 'a -> 'b>>) =
    try 
        sourceSeq
        |> Seq.map (throwOrReturnWith throwArgument)
        |> Ok
    with
    | e -> 
        (fun _ -> raise e)
        |> Error

【问题讨论】:

  • 您是在 FSI 还是在调试版本中运行它?如果是这样,尾调用优化可能会被禁用。并不是说您在此代码示例中执行了任何尾递归操作,但可能需要检查。
  • @Aaron 好点。在主项目中,无论调试还是发布,它都会爆炸,上面的示例我只尝试了调试设置。让我检查一下。
  • Seq.append 不是递归的,但Seq.fold 是。
  • @Fyodor Soikin 我认为这取决于 F# 的版本。查看 GitHub 上的源码,Seq.fold 目前使用了 for 循环和可变累加器,但它曾经是尾递归的:github.com/fsharp/fsharp/blob/master/src/fsharp/FSharp.Core/…
  • @Aaron,我暂时离开了,但刚刚测试了一个发布版本:SOE 发生得更快,这是意料之中的,除了它是一样的。 @fyodor 我只检查了最近的 Seq 库实现,因为我使用了最新的 FSharp.Core,事实上,我不认为 Seq.foldSeq.append 是递归定义的。

标签: recursion f# stack-overflow


【解决方案1】:

我相信按照您建议的方式折叠Results 序列的惯用方式是:

let unwindSeq<'a,'b> =
    Seq.fold<Result<'a,'b>, Result<'a seq, 'b>> 
        (fun acc cur -> acc |> Result.bind (fun a -> cur |> Result.bind (Seq.singleton >> Seq.append a >> Ok))) 
        (Ok Seq.empty)

并不是说这会比您当前的实现更快,它只是利用Result.bind 来完成大部分工作。我相信堆栈溢出是因为 F# 库中某处的递归函数,可能在 Seq 模块中。我对此的最好证据是,首先将序列具体化为List 似乎可以使其工作,如下例所示:

let results = 
    Seq.init 2000000 (fun i -> if i <= 1000000 then Result.Ok i else Error "too big") 
    |> Seq.toList

results
|> unwindSeq
|> printfn "%A"

但是,如果序列太大而无法在内存中实现,这可能不适用于您的生产场景。

【讨论】:

  • 是的,这确实是一种更惯用的写法,只是创建一个泛型值会改变Result.unwindSeq 的类型。重复至少一个参数可以正确解析泛型(我经常想知道为什么存在这种类型推断限制,但这是另一个讨论)。然而,它也遇到了同样的 SOE 异常,尽管它出现的速度似乎有点慢(但这只是一种感觉,因为我无法捕捉到 SOE,所以我不能准确计时)。
  • 关于List,我实际上需要一个不消耗序列的实现。允许成功访问成员,直到找到不成功的匹配,这可能会也可能不会。结果,我使用Seq.fold 的原始实现已经是错误的方法。但这里真正令人沮丧的是Seq.singleton &gt;&gt; Seq.append(或相同的管道版本)的奇迹般的缓慢。
  • 我有reported this here,因为我认为这是一个错误。用seq { yield! innerResult, yield suc} 替换Seq.singleton &gt;&gt; Seq.append 可以缓解问题,不再有SOE。
  • 我最后的评论是错误的,事实证明我犯了一个错误,正如 Github 讨论所示。正如您还发现的那样,任何一种解决方案都会引发 SOE。正如 Don Syme 所提到的,它不是一个“隐藏的”递归函数,它只是野兽的本性:添加到序列之前需要嵌套,因为序列是懒惰地评估的。 first 实现列表需要额外的迭代。更好的解决方案是将state 更改为列表,然后更改为该列表上的cons,然后更改为List.revList.toSeq 是 O(1),所以没关系。整体性能要好得多,不再有 SOE。
猜你喜欢
  • 1970-01-01
  • 2015-03-03
  • 2014-12-10
  • 2016-01-03
  • 2014-09-02
  • 1970-01-01
  • 2018-03-09
  • 2018-11-08
  • 2018-10-03
相关资源
最近更新 更多