我如何实现不在 ZIO 中使用可能非常大量的堆空间的循环

How can I implement loops that don't use potentially very large amounts of heapspace in ZIO

我知道 ZIO 维护自己的堆栈,即 zio.internal.FiberContext#stack,它保护递归函数,如

def getNameFromUser(askForName: UIO[String]): UIO[String] =
  for {
    resp <- askForName
    name <- if (resp.isEmpty) getNameFromUser(askForName) else ZIO.succeed(resp)
  } yield name

堆栈溢出。但是,它们仍然消耗 ZIO 解释器堆栈中的 space,这可能导致 OutOfMemoryError 用于非常深的递归。您将如何重写上面的 getNameFromUser 函数,即使 askForName 影响 returns 空字符串很长时间也不会破坏堆?

您正在递归函数中使用循环。基本上,每次您调用 getNameFromUser 时,您都在将对象分配给堆,堆永远无法释放这些对象,因为您在 t1 上创建的对象需要在 t2 中创建的对象来解析,但来自 t2 的对象需要t3 上的对象无限解析。

您应该使用 ZIO 组合器而不是循环,就像 forever 或您可以在 Schedule

上找到的任何其他组合器
 import zio.Schedule

 val getNameFromUser: RIO[Console, String] = for {
  _    <- putStrLn("Waht is your name")
  name <- zio.console.getStrLn
 } yield name

 val runUntilNotEmpty = Schedule.doWhile[String](_.isEmpty)

 rt.unsafeRun(getNameFromUser.repeat(runUntilNotEmpty))

[编辑] 添加一个不同的示例,因为您真正需要的是:

import zio._
import zio.console._
import scala.io.StdIn

object ConsoleEx extends App {

  val getNameFromUser = for {
    _    <- putStrLn("What is your name?")
    name <- getStrLn
    _    <- putStr(s"Hello, $name")
  } yield ()

  override def run(args: List[String]) =
    getNameFromUser.fold(t => {println(t); 1}, _ => 0)

}

但是请注意,如果您在 build.sbt 中包含 fork in run := true,那么您还需要按照 in the sbt docs

的说明添加 run / connectInput := true

重写上述函数的推荐方法是使用适当的 Schedule, as suggested by toxicafunk,结果是

def getNameFromUserSchedule(askForName: UIO[String]): UIO[String] =
  askForName.repeat(Schedule.doWhile(_.isEmpty))

这既简洁又可读,并且仅消耗恒定数量的 ZIO 堆栈帧。

但是,您不必使用Schedule来制作

def getNameFromUser(askForName: UIO[String]): UIO[String] =
  for {
    resp <- askForName
    name <- if (resp.isEmpty) getNameFromUser(askForName) else ZIO.succeed(resp)
  } yield name

消耗恒定数量的 ZIO 堆栈帧。也可以这样做:

def getNameFromUser(askForName: UIO[String]): UIO[String] =
  askForName.flatMap { resp =>
    if (resp.isEmpty) getNameFromUser(askForName) else ZIO.succeed(resp)
  }

这个函数在脱糖后的形式看起来几乎像原始函数,即

def getNameFromUser(askForName: UIO[String]): UIO[String] =
  askForName.flatMap { resp =>
    if (resp.isEmpty) getNameFromUser(askForName) else ZIO.succeed(resp)
  }.map(identity)

唯一的区别是最后的 map(identity)。解释从该函数生成的 ZIO 值时,解释器必须将 identity 压入堆栈,计算 flatMap,然后应用 identity。然而,为了计算 flatMap,相同的过程可能会重复,迫使解释器在我们有循环迭代时将尽可能多的 identities 压入堆栈。这有点烦人,但解释器无法知道,它压入堆栈的函数实际上是身份。您可以通过使用 better-monadic-for 编译器插件,在不删除漂亮的 for 语法的情况下消除它们,该插件能够在为推导式脱糖时优化掉最终的 map(identity)

没有map(identity),解释器会执行askForName,然后使用闭包

resp =>
    if (resp.isEmpty) getNameFromUser(askForName) else ZIO.succeed(resp)

获取下一个ZIO值进行解释。此过程可能会重复任意次数,但解释器堆栈的大小将保持不变。

综上所述,这里简要讨论 ZIO 解释器何时使用其内部堆栈:

  1. 计算链接时 flatMaps,如 io0.flatMap(f1).flatMap(f2).flatMap(f3)。为了评估这样的表达式,解释器会将 f3 压入堆栈,然后查看 io0.flatMap(f1).flatMap(f2)。然后它将 f2 放入堆栈并查看 io0.flatMap(f1)。最后 f1 会被压栈,io0 会被计算(解释器中有一个优化可能会在这里走捷径,但这与讨论无关)。在 io0r0 的评估之后,f1 从堆栈中弹出,并应用于 r0 的结果,给我们一个新的 ZIO 值,io1 = f1(r0).现在 io1 被评估为 r1 并且 f2 从堆栈中弹出,以获得下一个 ZIO 值 io2 = f2(r1)。最后,io2被求值为r2f3出栈得到io3 = f3(r2)io3被解释为r3,最终结果的表达。因此,如果您有一个算法,通过将 flatMaps 链接在一起来工作,您应该期望 ZIO 堆栈的最大深度至少是 flatMaps.
  2. 链的长度
  3. 计算链式折叠时,例如 io.foldM(h1, f1).foldM(h2, f2).foldM(h3, f3),或链式折叠和链式折叠的混合 flatMaps。如果没有错误,folds 的行为类似于 flatMaps,因此关于 ZIO 堆栈的分析非常相似。您应该期望 ZIO 堆栈的最大深度至少是您的链的长度。
  4. 在应用上述规则时,请记住,有许多组合器直接或间接地在 flatMapfoldCauseM 之上实现:
    • mapaszipzipWith<**>foldLeftforeachflatMap
    • 之上实现
    • foldfoldMcatchSomecatchAllmapError 是在 foldCauseM
    • 之上实现的

最后但并非最不重要的一点:您不必太担心 ZIO 内部堆栈的大小,除非

  • 您正在实施一种算法,其中对于中等大小甚至恒定大小的输入数据,迭代次数可能会变得任意大
  • 您正在遍历非常大的数据结构,内存无法容纳
  • 用户可以毫不费力地直接影响堆栈深度(例如,这意味着无需通过网络向您发送大量数据)