【问题标题】:Is it possible to use continuations to make foldRight tail recursive?是否可以使用延续来使 foldRight 尾递归?
【发布时间】:2012-01-22 21:08:36
【问题描述】:

以下blog article 展示了如何在 F# 中使用延续传递样式使 foldBack 成为尾递归。

在 Scala 中,这意味着:

def foldBack[T,U](l: List[T], acc: U)(f: (T, U) => U): U = {
  l match {
    case x :: xs => f(x, foldBack(xs, acc)(f))
    case Nil => acc
  }
} 

可以通过这样做使尾递归:

def foldCont[T,U](list: List[T], acc: U)(f: (T, U) => U): U = {
  @annotation.tailrec
  def loop(l: List[T], k: (U) => U): U = {
    l match {
      case x :: xs => loop(xs, (racc => k(f(x, racc))))
      case Nil => k(acc)
    }
  }
  loop(list, u => u)
} 

不幸的是,对于长列表,我仍然会出现堆栈溢出。循环是尾递归和优化的,但我猜堆栈累积只是移动到继续调用中。

为什么这不是 F# 的问题?有什么办法可以用 Scala 解决这个问题?

编辑:这里有一些显示堆栈深度的代码:

def showDepth(s: Any) {
  println(s.toString + ": " + (new Exception).getStackTrace.size)
}

def foldCont[T,U](list: List[T], acc: U)(f: (T, U) => U): U = {
  @annotation.tailrec
  def loop(l: List[T], k: (U) => U): U = {
    showDepth("loop")
    l match {
      case x :: xs => loop(xs, (racc => { showDepth("k"); k(f(x, racc)) }))
      case Nil => k(acc)
    }
  }
  loop(list, u => u)
} 

foldCont(List.fill(10)(1), 0)(_ + _)

打印出来:

loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
loop: 50
k: 51
k: 52
k: 53
k: 54
k: 55
k: 56
k: 57
k: 58
k: 59
k: 60
res2: Int = 10

【问题讨论】:

  • 这没有意义。你有一个简单的测试用例吗?
  • @DanielC.Sobral,查看我添加的代码并打印出来。

标签: scala f# fold tail-call-optimization


【解决方案1】:

这个问题我迟到了,但我想展示如何在不使用完整蹦床的情况下编写尾递归 FoldRight;通过累积一个延续列表(而不是让它们在完成时相互调用,这会导致堆栈溢出)并在最后折叠它们,有点像保留一个堆栈,但在堆上:

object FoldRight {

  def apply[A, B](list: Seq[A])(init: B)(f: (A, B) => B): B = {
    @scala.annotation.tailrec
    def step(current: Seq[A], conts: List[B => B]): B = current match {
      case Seq(last) => conts.foldLeft(f(last, init)) { (acc, next) => next(acc) }
      case Seq(x, xs @ _*) => step(xs, { acc: B => f(x, acc) } +: conts)
      case Nil => init
    }
    step(list, Nil)
  }

}

最后发生的折叠本身就是尾递归的。试试看in ScalaFiddle

在性能方面,它的表现略逊于尾调用版本。

[info] Benchmark            (length)  Mode  Cnt   Score    Error  Units
[info] FoldRight.conts           100  avgt   30   0.003 ±  0.001  ms/op
[info] FoldRight.conts         10000  avgt   30   0.197 ±  0.004  ms/op
[info] FoldRight.conts       1000000  avgt   30  77.292 ±  9.327  ms/op
[info] FoldRight.standard        100  avgt   30   0.002 ±  0.001  ms/op
[info] FoldRight.standard      10000  avgt   30   0.154 ±  0.036  ms/op
[info] FoldRight.standard    1000000  avgt   30  18.796 ±  0.551  ms/op
[info] FoldRight.tailCalls       100  avgt   30   0.002 ±  0.001  ms/op
[info] FoldRight.tailCalls     10000  avgt   30   0.176 ±  0.004  ms/op
[info] FoldRight.tailCalls   1000000  avgt   30  33.525 ±  1.041  ms/op

【讨论】:

    【解决方案2】:

    乔恩,n.m.,谢谢你的回答。根据您的 cmets,我想我会尝试使用蹦床。一些研究表明 Scala 在TailCalls 中有对蹦床的库支持。这是我经过一番摆弄后得出的结论:

    def foldContTC[T,U](list: List[T], acc: U)(f: (T, U) => U): U = {
      import scala.util.control.TailCalls._
      @annotation.tailrec
      def loop(l: List[T], k: (U) => TailRec[U]): TailRec[U] = {
        l match {
          case x :: xs => loop(xs, (racc => tailcall(k(f(x, racc)))))
          case Nil => k(acc)
        }
      }
      loop(list, u => done(u)).result
    } 
    

    我很想看看这与没有蹦床的解决方案以及默认的foldLeftfoldRight 实现相比如何。这是基准代码和一些结果:

    val size = 1000
    val list = List.fill(size)(1)
    val warm = 10
    val n = 1000
    bench("foldContTC", warm, lots(n, foldContTC(list, 0)(_ + _)))
    bench("foldCont", warm, lots(n, foldCont(list, 0)(_ + _)))
    bench("foldRight", warm, lots(n, list.foldRight(0)(_ + _)))
    bench("foldLeft", warm, lots(n, list.foldLeft(0)(_ + _)))
    bench("foldLeft.reverse", warm, lots(n, list.reverse.foldLeft(0)(_ + _)))
    

    时间安排是:

    foldContTC: warming...
    Elapsed: 0.094
    foldCont: warming...
    Elapsed: 0.060
    foldRight: warming...
    Elapsed: 0.160
    foldLeft: warming...
    Elapsed: 0.076
    foldLeft.reverse: warming...
    Elapsed: 0.155
    

    基于此,似乎蹦床实际上产生了相当不错的性能。我怀疑装箱/拆箱上面的惩罚相对来说还不错。

    编辑: 正如 Jon 的 cmets 所建议的那样,这里是 1M 项的时间,这些时间证实了列表越大性能会下降。我还发现库 List.foldLeft 实现没有被覆盖,所以我用下面的 foldLeft2 计时:

    def foldLeft2[T,U](list: List[T], acc: U)(f: (T, U) => U): U = {
      list match {
        case x :: xs => foldLeft2(xs, f(x, acc))(f)
        case Nil => acc
      }
    } 
    
    val size = 1000000
    val list = List.fill(size)(1)
    val warm = 10
    val n = 2
    bench("foldContTC", warm, lots(n, foldContTC(list, 0)(_ + _)))
    bench("foldLeft", warm, lots(n, list.foldLeft(0)(_ + _)))
    bench("foldLeft2", warm, lots(n, foldLeft2(list, 0)(_ + _)))
    bench("foldLeft.reverse", warm, lots(n, list.reverse.foldLeft(0)(_ + _)))
    bench("foldLeft2.reverse", warm, lots(n, foldLeft2(list.reverse, 0)(_ + _)))
    

    产量:

    foldContTC: warming...
    Elapsed: 0.801
    foldLeft: warming...
    Elapsed: 0.156
    foldLeft2: warming...
    Elapsed: 0.054
    foldLeft.reverse: warming...
    Elapsed: 0.808
    foldLeft2.reverse: warming...
    Elapsed: 0.221
    

    所以 foldLeft2.reverse 是赢家...

    【讨论】:

    • “相当不错的表现”。确实。我会称之为可疑的好表现!也许蹦床实现足够聪明,可以意识到它不必因为您的列表太短而启动?使用 100 万个元素的列表可以获得哪些性能测量结果?
    • 随着时间的关闭,缓存和 GC 问题也将发挥作用,例如使用分代 GC 和缓存效率,一遍又一遍地反转相同的 1k 元素列表很便宜,但 1M 元素列表可能会在 Nursery 或线程本地区域中幸存下来,这将产生开销并降低缓存效率。
    【解决方案3】:

    为什么这不是 F# 的问题?

    F# 优化了所有尾调用。

    有没有什么方法可以用 Scala 解决这个问题?

    您可以使用蹦床等其他技术来实现 TCO,但您会失去互操作性,因为它会改变调用约定并且速度会慢约 10 倍。这是我不使用 Scala 的三个原因之一。

    编辑

    您的基准测试结果表明 Scala 的蹦床比我上次测试它们时快很多。此外,使用 F# 添加等效的基准测试并用于较大的列表也很有趣(因为在较小的列表上进行 CPS 毫无意义!)。

    对于我使用 1.67GHz N570 Intel Atom 的上网本上的 1,000 元素列表中的 1,000 倍,我得到:

    List.fold     0.022s
    List.rev+fold 0.116s
    List.foldBack 0.047s
    foldContTC    0.334s
    

    对于 1x 1,000,000 元素列表,我得到:

    List.fold     0.024s
    List.rev+fold 0.188s
    List.foldBack 0.054s
    foldContTC    0.570s
    

    在用优化的尾递归函数替换 OCaml 的非尾递归列表函数的上下文中,您可能还对 caml-list 上关于此的旧讨论感兴趣。

    【讨论】:

    • 您不使用 Scala 的另外两个原因是什么?
    • @StephenSwensen:缺少值类型和类型推断。请注意,缺少尾调用和值类型是 JVM 而不是 Scala 的问题。这也是我选择在 LLVM 而不是 JVM 上开发 HLVM 的原因。 Geoff Reedy 将 Scala 移植到 LLVM 的项目有可能解决这两个问题,这绝对很棒。
    【解决方案4】:

    问题在于延续函数(racc => k(f(x, racc))) 本身。它应该针对整个业务进行优化,但不是。

    Scala 不能对任意尾调用进行尾调用优化,只能对那些它可以转换为循环的尾调用进行优化(即当函数调用自身时,而不是其他函数时)。

    【讨论】:

    • 这就是我的猜测。有什么可以做的吗?喜欢使用蹦床之类的东西吗?
    • 蹦床可能会有所帮助,但我认为在这种特殊情况下leftFold 会以更少的痛苦解决问题。如果您出于某种原因绝对必须具有foldRight 语义,则可以反转列表并在结果上调用foldLeft
    • 原来在这种情况下真的没有那么痛苦,看我自己的回答。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-03-20
    • 2017-03-25
    • 2015-08-10
    • 1970-01-01
    • 2012-04-12
    • 2019-07-08
    • 1970-01-01
    相关资源
    最近更新 更多