如何推断 Scala Cats/fs2 中的堆栈安全性?

How to reason about stack safety in Scala Cats / fs2?

这是 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
}

我之前的回答 提供了一些可能有用的背景信息。基本思想是某些效果类型具有直接支持堆栈安全递归的 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

请注意,没有明确的递归。


你第二个问题的答案取决于其他方法的样子,但在你的具体例子中,>> 只会导致更多的 flatMap 层,所以它应该是很好。

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

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