【问题标题】:How to reason about stack safety in Scala Cats / fs2?如何推理 Scala Cats / fs2 中的堆栈安全性?
【发布时间】:2020-03-23 02:01:50
【问题描述】:

这是fs2 文档中的一段代码。函数go 是递归的。问题是我们如何知道它是否是堆栈安全的以及如何推断任何函数是否是堆栈安全的?

import fs2._
// import fs2._

def tk[F[_],O](n: Long): Pipe[F,O,O] = {
  def go(s: Stream[F,O], n: Long): Pull[F,O,Unit] = {
    s.pull.uncons.flatMap {
      case Some((hd,tl)) =>
        hd.size match {
          case m if m <= n => Pull.output(hd) >> go(tl, n - m)
          case m => Pull.output(hd.take(n.toInt)) >> Pull.done
        }
      case None => Pull.done
    }
  }
  in => go(in,n).stream
}
// tk: [F[_], O](n: Long)fs2.Pipe[F,O,O]

Stream(1,2,3,4).through(tk(2)).toList
// res33: List[Int] = List(1, 2)

如果我们从另一个方法调用go,它是否也是堆栈安全的?

def tk[F[_],O](n: Long): Pipe[F,O,O] = {
  def go(s: Stream[F,O], n: Long): Pull[F,O,Unit] = {
    s.pull.uncons.flatMap {
      case Some((hd,tl)) =>
        hd.size match {
          case m if m <= n => otherMethod(...)
          case m => Pull.output(hd.take(n.toInt)) >> Pull.done
        }
      case None => Pull.done
    }
  }

  def otherMethod(...) = {
    Pull.output(hd) >> go(tl, n - m)
  }

  in => go(in,n).stream
}

【问题讨论】:

  • 不,不完全是。虽然如果这是尾递归的情况,请告诉我,但似乎不是。据我所知,猫会做一些叫做蹦床的魔法来确保堆栈安全。不幸的是,我无法判断一个函数何时被蹦床,何时不是。
  • 您可以重写go 以使用例如Monad[F] typeclass - tailRecM 方法允许您显式执行蹦床以保证该函数是堆栈安全的。我可能是错的,但没有它,您将依靠 F 本身的堆栈安全(例如,如果它在内部实现蹦床),但您永远不知道谁将定义您的 F,所以您不应该这样做。如果您不能保证 F 是堆栈安全的,请使用提供 tailRecM 的类型类,因为它在法律上是堆栈安全的。
  • 很容易让编译器用@tailrec 注释来证明它的尾部rec 函数。对于其他情况,Scala AFAIK 没有正式的保证。即使函数本身是安全的,它调用的其他函数也可能不是 :/.

标签: scala functional-programming tail-recursion scala-cats fs2


【解决方案1】:

我之前的回答here 提供了一些可能有用的背景信息。基本思想是某些效果类型具有直接支持堆栈安全递归的flatMap 实现——您可以显式或通过递归任意深度嵌套flatMap 调用,并且不会溢出堆栈。

对于某些效果类型,由于效果的语义,flatMap 不可能是堆栈安全的。在其他情况下,可以编写堆栈安全的flatMap,但实施者可能出于性能或其他考虑而决定不这样做。

不幸的是,没有标准(甚至传统)方法可以知道给定类型的 flatMap 是否是堆栈安全的。 Cats 确实包含一个 tailRecM 操作,它应该为任何合法的单子效果类型提供堆栈安全的单子递归,有时查看已知合法的 tailRecM 实现可以提供一些关于 flatMap 是否是堆栈的提示 -安全的。对于Pull,它看起来像this

def tailRecM[A, B](a: A)(f: A => Pull[F, O, Either[A, B]]) =
  f(a).flatMap {
    case Left(a)  => tailRecM(a)(f)
    case Right(b) => Pull.pure(b)
  }

这个tailRecM 只是通过flatMap 递归,我们知道PullMonad 实例is lawful,这很好地证明PullflatMap 是堆栈安全的.这里的一个复杂因素是Pull 的实例对F 有一个ApplicativeError 约束,而PullflatMap 没有,但在这种情况下不会改变任何东西。

所以这里的tk 实现是堆栈安全的,因为Pull 上的flatMap 是堆栈安全的,我们通过查看它的tailRecM 实现知道这一点。 (如果我们再深入一点,我们可以发现flatMap 是堆栈安全的,因为Pull 本质上是FreeC 的包装器,即trampolined。)

tailRecM 重写tk 可能并不难,尽管我们必须添加否则不必要的ApplicativeError 约束。我猜文档的作者为了清楚起见选择不这样做,因为他们知道PullflatMap 很好。


更新:这是一个相当机械的tailRecM 翻译:

import cats.ApplicativeError
import fs2._

def tk[F[_], O](n: Long)(implicit F: ApplicativeError[F, Throwable]): Pipe[F, O, O] =
  in => Pull.syncInstance[F, O].tailRecM((in, n)) {
    case (s, n) => s.pull.uncons.flatMap {
      case Some((hd, tl)) =>
        hd.size match {
          case m if m <= n => Pull.output(hd).as(Left((tl, n - m)))
          case m => Pull.output(hd.take(n.toInt)).as(Right(()))
        }
      case None => Pull.pure(Right(()))
    }
  }.stream

请注意,没有显式递归。


第二个问题的答案取决于其他方法的外观,但在您的具体示例中,&gt;&gt; 只会导致更多的flatMap 层,所以应该没问题。

为了更笼统地解决您的问题,整个主题在 Scala 中是一团乱麻。您不必像我们上面所做的那样深入研究实现,只是为了知道一个类型是否支持堆栈安全的单子递归。更好的文档约定在这里会有所帮助,但不幸的是,我们在这方面做得并不好。您始终可以使用 tailRecM 来“安全”(无论如何,当 F[_] 是通用的时,您会想要这样做),但即便如此,您仍然相信 Monad 实现是合法的。

总结:这是一个糟糕的情况,在敏感的情况下,你绝对应该编写自己的测试来验证这样的实现是堆栈安全的。

【讨论】:

  • 感谢您的解释。关于当我们从另一个方法调用go 时的问题,是什么使它堆栈不安全?如果我们在调用Pull.output(hd) &gt;&gt; go(tl, n - m)之前做一些非递归计算可以吗?
  • 是的,应该没问题(当然,假设计算本身不会溢出堆栈)。
  • 例如,哪种效果类型对于单子递归来说不是堆栈安全的?延续类型?
  • @bob 是的,虽然 Cats 的 ContTflatMap 实际上是堆栈安全的(通过基础类型的 Defer 约束)。我在考虑更多类似List 的东西,其中通过flatMap 递归不是堆栈安全的(但它确实有一个合法的tailRecM)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-10-21
  • 2020-12-04
  • 2019-09-20
  • 1970-01-01
  • 1970-01-01
  • 2013-02-10
相关资源
最近更新 更多