Scala:构建可与外部交互的 monadic 流式处理指令集

外部作用和 IO

之前的 State Monad 已经预示了我们可以使用 Scala 语言自由地建模不同领域的问题。但美中不足的是,我们在之前都有意地避免了与外界交互的过程,而这是不现实的,一个自动化的程序总是要通过 IO 手段进行输入输出。本章要做的最后一件事,就是为代数式接口赋予与外界交互的语义。

我们还是期望构建一个 monadic trait,它能够表达包含了任意外部作用 F[_] 的指令流,具体的执行却是由另一个解释器负责的。这么做的核心目的是将 声明执行 相分离,想象只需要一份指令流,它可以任意地在同步和异步环境中切换,而代价仅仅是更换它的解释器!先是一个简易的实现,然后逐渐将它改造成支持流式处理的高级 API。

那么,包含了外部作用 F[_] 的指令流还具备可推导性,从而具备可组合性吗?答案是可以。这只需要我们保证指令流的引用透明性,更详细的方案是,将外部作用 F[_] 限制在指令流内部,使其不被外界观察。这里有必要重新阐述关于引用透明和副作用的概念,见本篇的第二个章节。我们还会利用 Scala 的类型系统构建一个特殊类型,一旦用户尝试将指令流内部的状态发布出去,那么编译器将会阻止这一行为。

递归是函数式编程中的一个重要环节,尽管我们在工作中很少会大量地使用递归。其一大原因是:过深的递归会引发致命的栈溢出错误。尽管 Scala 可以替我们负责一部分尾递归优化,但是大部分时候还是要我们亲力亲为。在本章我们会介绍使用 trampoline 技巧消除伴随递归的栈累积问题,这样就可以放心地通过递归模拟不断循环的指令流了。

此为 Functional Programming in Scala ( first-edition ) 的最后一部分笔记,同时笔者对原书中的部分例子做了一小点改进,使其在 Scala 3 环境下也可以运行。关于 Monad,以及作用 (effect) 等相关概念,可以参阅上一篇:解构函数式编程的通用模式 | The Law Paves All, the Functor Expresses All 𓀀 - 掘金 (juejin.cn)

分解作用

从最简单的例子开始引出我们要探讨的话题。比较两个玩家 p1p2 的分数,然后将获胜者信息输出到控制台。下面是一段带有 IO 外部作用的实现,见 contest

scala 复制代码
case class Player(name: String, score: Int)

def contest(p1: Player, p2: Player): Unit = 

  if p1.score > p2.score then
    println(s"${p1.name} win!")

  else if p1.score < p2.score then
    println(s"${p2.name} win!")

  else
    println("it's a draw.")

为了展示获胜者的信息,contest 函数进行了 IO 调用,同时与判断胜负的逻辑耦合在一起。按照 最小职责原则 ,我们可以将与 IO 无关的判断逻辑拆分到一个纯函数 winner 当中:

scala 复制代码
def contest(p1: Player, p2: Player): Unit =
  winner(p1, p2) match
    case Some(p) => println(s"${p.name} win!")
    case None => println("it's a draw.")

def winner(p1: Player, p2: Player): Option[Player] =
  if p1.score > p2.score then Some(p1)
  else if p1.score < p2.score then Some(p2)
  else None

还可以进一步分解这个代码。contest 函数仍然有两个职责,一个是计算需要展示的信息,然后打印那个消息到控制台。

scala 复制代码
case class Player(name: String, score: Int)

def contest(p1: Player, p2: Player): Unit =
  println(winnerMsg(winner(p1, p2)))

def winnerMsg(p: Option[Player]): String = p match
  case Some(p) =>  s"${p.name}"
  case None => "it's a draw"

def winner(p1: Player, p2: Player): Option[Player] =
  if p1.score > p2.score then Some(p1)
  else if p1.score < p2.score then Some(p2)
  else None

存在外部作用的函数调用 println 此刻在最外层,而其内部调用了一个纯函数的表达式。这是个简单的例子,但同样的道理可以应用到更复杂的程序当中,我们可以归纳:每个有外部作用的函数里都有一个纯函数等待抽离。假定有一个非纯函数 f: A => B,事实上总是可以将其拆解成两个步骤:

  1. 纯函数 A => DD 表示 description,也就是对 f 的流程描述。
  2. 非纯函数 D => B,对上述描述的解释器。解释器会涉及诸多外部作用,比如文件 IO,网络 IO,数据库访问,提交任务到线程池等等。

遵循这个思路,我们可以不断地将外部作用逐步推到最外层,而这些非纯的函数为 声明式的 shell。不过随着程序的执行,我们总是会到达这层边界。此时应该怎么办?

IO 类型

现在引入一个新的数据类型 IO描述 一个即将要发生的外部作用:

scala 复制代码
trait IO:
  def run: Unit
  
def printLine(msg: String): IO = new IO:
  override def run: Unit = println(msg)

// contest 变成了一个纯函数。
def contest(p1: Player, p2: Player): IO =
  printLine(winnerMsg(winner(p1, p2)))

contest 现在只负责将各个部分构建在一起,然后封装到一个 IO 实例中就返回。winner 负责计算谁是胜者,winnerMsg 负责处理信息,printLine 表示消息应当打印到控制台。而解释 IO 并运行它是 run 的职责。

处理输入效果

我们现在对 "描述一段计算" (description) 应该有个基本的概念了。只不过,这个简易的 IO 特质只能实现 output 的效果,我们目前没办法表达像 readLine 那样等待外部输入并返回结果的 IO 计算。比如想要编写一个程序,它首先是提示用户以华氏度输入温度,随后它将转换成摄氏度然后返回。这个需求很容易用一般代码实现:

scala 复制代码
def fahrenheitToCelsius(f: Double): Double = (f - 32) * 5.0/9.0

def converter(): Unit =
  println("Enter a temperature in Fahrenheit")
  val input = readLine.toDouble
  println(fahrenheitToCelsius(input))

然而,用 IO 特质重构这段代码的过程并不会顺利。主要的问题是,如果将 readLine 封装到 IO 内部,我们就无法取出其捕获的 String 类型结果!

scala 复制代码
def converter(): IO =
  val s1: IO = printLine("Enter a temperature in Fahrenheit")
  val s2: IO = IO {readLine}
  val s1: IO = printLine(fahrenheitToCelsius(???))  // 没有输入

看来直接返回 Unit 的做法是有点武断了。现在为 IO 添加一个类型参数 A 来表示 IO 的输出:

scala 复制代码
trait IO[A]:
  self=>
  def run: A
  def flatMap[B](f: A => IO[B]) : IO[B] = f(self.run)
  def map[B](f: A => B) : IO[B] = new IO[B]:
    override def run: B = f(self.run)

object IO:
  def apply[A](a: => A): IO[A] = new IO[A]:
    override def run: A = a

def fahrenheitToCelsius(f: Double): Double = (f - 32) * 5.0/9.0
def ReadLine: IO[String] = IO {readLine}
def PrintLine(x: Any): IO[Unit] = IO {println(x)}

IO 现在可以返回一个有意义的值。另一方面,将 flatMapmap 定义为 IO 的内部方法,把 IO 提升为一个 monadic trait,这样就可以使用 Scala 提供的 for 推导式了。

scala 复制代码
def convert: IO[Unit] = for {
  _ <- PrintLine("Enter a temperature in Fahrenheit")
  input <- ReadLine
  _ <- PrintLine(fahrenheitToCelsius(input.toDouble))
} yield ()

类似于之前例子的 contest 函数,convert现在也不存在外部作用了,它现在是一个存在 IO 作用的引用透明描述 (description),只有 convert.run 才是真正执行并产生作用的 执行器 (executor) 。我们还可以再定义一些其他 monadic 的组合子。比如:

scala 复制代码
trait IO[A]:
  //...
  def doWhile(cond: A => IO[Boolean]) : IO[Unit] = for {
    maybeOk <- cond(self.run)
    _ <- if maybeOk then doWhile(cond) else IO(())
  } yield ()

  // 不是尾递归
  def forever: IO[A] =
    self flatMap { _ => forever }

// ...
// 不断接收输入直到 eof 为止
ReadLine.doWhile(a => IO(!(a equals "eof"))).run

// 不断打印 a (但是会栈溢出)
PrintLine("a").forever.run

当然,我们并不用按照这种形式编写 Scala 代码,但是它已经展示出了函数式编程的表达性完全不会受到任何限制。更有趣的是,我们现在似乎正在构建一个 DSL 以及关联的解释器!现在将基于这个 IO trait 开始拓展功能,以此来表达期望的计算,包括和外部环境交互的部分。

很显然,使用 IO Monad 来表达计算有诸多优点。比如:

  1. IO 计算是一个普通的值。这意味着任意一段 IO 的描述可以被到处传递,或是复用,或是动态创建。
  2. 声明与执行相分离。IO[A] 本身代表了与外界交互的抽象,交互的对象可以是文件,数据库,或者是网络。比如,完全可以改写 ReadLine,使其从某个 *.txt 当中获取输入。但当我们专注于描述计算过程的时候,并不需要关心这些细节。

一切看起来十分美好。只是目前的 IO Monad 潜藏着一个严重的问题:栈溢出错误,比如前文的所定义的 forever 方法。

将一个控制流转换为数据构造子【重要】

归根结底,当前的 forever 方法会在 flatMap 返回之前再一次调用自身,它自身并不是一个尾调用。

我们已经习惯了使用函数调用让程序控制流程,现在不妨将控制流程定义为数据类型 。比如将 flatMap 调用替换成返回 FlatMap 实例,然后将解释 IO 作用的过程分离到另一个尾递归的 run 函数当中。它看起来应当是这样的:

scala 复制代码
trait IO[A]:
  self=>
  def flatMap[B](f: A => IO[B]) = FlatMap(self, f)
  def map[B](f: A => B) = flatMap(f andThen {Return(_)})
  def forever: IO[A] =
    self flatMap { _ => forever }
end IO

case class Suspend[A](resume: () => A) extends IO[A]
case class Return[A](a: A) extends IO[A]
case class FlatMap[A, B](sub: IO[A], f: A => IO[B]) extends IO[B]

@tailrec // 确保 run 是尾递归的
def run[A](io: IO[A]): A = io match
  case Return(a) => a
  case Suspend(r1) => r1()
  case FlatMap(last, f) => last match
    case Return(a) => run(f(a))
    case Suspend(r2) => run(f(r2()))
	// 1
    case FlatMap(cur, g) => run(cur flatMap(a => g(a) flatMap f))
	// 2
    // case FlatMap(cur, g) => run((cur flatMap g).flatMap(f))

def printLine(x: Any) = Suspend{() => println(x)}

@main
def main(): Unit =
  run(printLine("Hello").forever)

此时的 forever 实际上只返回一个表示递归的描述。从结构上看,forever 仍然不是尾递归的,但现在它在返回一个 FlatMap 对象之后立刻停止。

不妨将 foreverrun 函数放到一起去看。如果 debug 主函数,我们可以发现这两者会有序交替运行,达到 "边解释边执行" 的效果。形象点说,run 解释完当前的 printLine 命令之后跳转到 forever ,取出下一条 println 指令后继续执行,如此往复。

run 函数既不会尝试一次性 执行 所有指令,forever 也不会尝试一次性 生成 所有指令,整个过程显而易见是栈安全的。在一些资料中,像 run 这样以解释边执行方式消除栈累积的函数称之为 trampoline (意为 "蹦床") 函数。

注意两个代码块。注释 1 与注释 2 两个代码行可以由 Monad 的结合律证明等价。但由于其结合顺序有所不同,这导致程序最终呈现的效果完全不同:

注释 1 的 cur flatMap(a => g(a) flatMap f) 等价于构造了 FlatMap(cur, a => g(a) flatMap f)。这样,run 函数下一次就可以递归地展开并检查 cur 的结构。然而,注释 2 的分支 case FlatMap(cur, g) => FlatMap(FlatMap(cur, g), f)会导致 run 函数不断地对 FlatMap(cur, g) 进行装箱拆箱操作,这显然是一个死循环。

关于 Scala 与 Java 的尾递归优化问题

Scala 编译器会自发地尝试使用 while 循环代替可被优化的尾递归逻辑,该行为与是否添加 @tailrec 注解无关。@tailrec 只是显式地通知编译器当代码无法进行尾递归优化时拒绝编译,从而避免将非尾递归函数引入到生产环境。

另一点,@tailrec 注解必须用于 final, private 等修饰的不可被重写的方法,或者是定义在包变量 ( 包含 Scala 3 中在源文件顶级声明的函数 ),单例对象的静态方法。

如果使用 Java 来表示上述的 forever 组合子及其 run 函数的等价逻辑,写起来应该是这样的:

java 复制代码
public class Node {
    public Callable<Node> next;
    //下面这个控制块会反复执行,但是不会栈溢出
    {System.out.println("hello world");}
    public Node(Callable<Node> getNext) {this.next = getNext;}
    static Node forever() {return new Node(Node::forever);}
    public static void main(String[] args) throws Exception {run(Node::forever);}
    public static Node run(Callable<Node> node) throws Exception {
        Callable<Node> nxt = node;
        while(true){
            Node tmp = nxt.call();
            nxt = tmp.next;
        }
    }
    public static void naiveTailrec(){naiveTailrec();}
}

但和 Scala 不同的是,Java 不支持尾递归优化 。比如上述的 naiveTailrec 方法是一个朴素的尾递归,但是运行它会抛出 SOF 异常。原因是,javac 不会自发地将尾递归函数替换为等价 while 循环,或者是令 JVM 在运行时动态地弹出栈帧。这是因为在 JDK 类中,有许多安全敏感的方法依赖于计算 JDK 库代码和调用代码之间的栈帧数量来确定谁在调用它们。见:recursion - Tail Call Optimisation in Java - Stack Overflow

其他支持尾递归优化的 JVM 语言如 Groovy (trampoline 方法 ),Kotlin 在底层也是通过 while 循环替代的思路实现的。

更加通用的 Tailrec 类型

上述 Suspend 类型未必一定会存在外部作用。事实上我们构建的 API 是一个可以实现 trampolining 计算的通用数据结构,哪怕这个计算实际上不涉及任何外部 IO。在 Scala 中,( 尤其是大量地 ) 组合函数理论上存在栈溢出的风险,这很容易模拟:

scala 复制代码
val g = List.fill(100_000)(identity).foldLeft(identity)(_ compose _)
println(g(42))

我们现在可以用构造好的 IO Monad 来优化任意可能栈溢出的递归调用:

scala 复制代码
val Id: (Int => Tailrec[Int])= x => Return x}
val g = List.fill(100_000)(Id).foldLeft(Id){
  (a, b) => x => Suspend{() => ()}.flatMap(_ => a(x).flatMap(b))
}

val o = run(g(42))

很显然,在刚才的例子中并没有涉及 IO。我们真正需要的只是一个可以被尾调用的 Monad!现在将 IO 修改成 Tailrec (尾递归)这个名字会更加合适, Tailrec[A] 为任何 A => B 增加 trampolining 功能以消除栈溢出。

scala 复制代码
trait Tailrec[A]:
  self=>
  def flatMap[B](f: A => Tailrec[B]) = FlatMap(self, f)
  def map[B](f: A => B) = flatMap(f andThen {Return(_)})
  def forever: Tailrec[A] = self flatMap { _ => forever }
end Tailrec

case class Suspend[A](resume: () => A) extends Tailrec[A]
case class Return[A](a: A) extends Tailrec[A]
case class FlatMap[A, B](current: Tailrec[A], f: A => Tailrec[B]) extends Tailrec[B]

resume 还可以表示成什么呢?之前章节中曾实现的异步 Par[A] 类型表示它行不行?当然可以。可以再构建出一个 Async 类型,它代表了可组合的异步流程:

scala 复制代码
import Par.*
trait Async[A]:
  self =>
  def flatMap[B](f: A => Async[B]) = FlatMap(self, f)
  def map[B](f: A => B) = flatMap(f andThen {Return(_)})
  def forever: Async[A] = self flatMap { _ => forever }
end Async

// 描述器
@tailrec
def step[A](async: Async[A]) : Async[A] = async match
  case FlatMap(FlatMap(x, f), g) => step(FlatMap(x, a => f(a).flatMap(g)))
  case FlatMap(Return(x), f) => step(f(x))
  case _ => async

// the end of step
def run[A](async: Async[A]): Par[A] = step(async) match
  case Return(a) => Par.unit(a)
  case Suspend(resume) => resume
  case FlatMap(current, f) => current match
    case Suspend(g) =>
      g.flatMap(a => run(f(a)))
    case _ => sys.error("'step' eliminates other cases.")

case class Suspend[A](resume: Par[A]) extends Async[A]
case class Return[A](a: A) extends Async[A]
case class FlatMap[A, B](current: Async[A], f: A => Async[B]) extends Async[B]

有关于 Par 的声明可以直接参考本文的附录。

为了更容易观察,这里将原本的递归逻辑拆解成了一个纯尾递归函数 step 和外部的驱动函数 run,这是尾递归优化的常用手段。run 方法首先会进入到 step 方法内,并递归地尝试拆解嵌套 FlatMap 来步进到下一个状态。等到不能继续的时候,step 将最终的 async 对象抛回给 run 方法执行。step 方法被 @tailrec 修饰并被编译器优化,因此 run 的调用成本只有 2 个栈帧,它是栈安全的。

Free Monad

很明显,Async[A]Tailrec[A] 并没有什么本质区别,只不过 Suspend 现在接受一个 Par[A],之前是 Function0[A]。可以从这个角度出发,引申出一个更加抽象的代数结构 Free:

scala 复制代码
enum Free[F0[_], A0]:
  import Par.*
  case Return[F[_], A](a : A) extends Free[F, A]
  case Suspend[F[_], A](s: F[A]) extends Free[F, A]
  case FlatMap[F[_], A, B](a: Free[F, A], f: A => Free[F, B]) extends Free[F, B]

  //[X] ==> () => X  等价于 Function0.
  type Tailrec[A] = Free[[X]=>>()=>X, A]
  type Async[A] = Free[Par, A]

这里的两个 type 别名所表达的已经很明显了:TailrecAsync 都只是 Free 的一种特殊情况。

Free 显然也是一个 monadic trait。下面针对 Free 给出必要的实现 (见 freeMonad 方法),以及针对 Function0[A] 类型所实现的 runTrampoline 方法。

scala 复制代码
enum Free[F0[_], A0]:
  import Par.*
  case Return[F[_], A](a : A) extends Free[F, A]
  case Suspend[F[_], A](s: F[A]) extends Free[F, A]
  case FlatMap[F[_], A, B](fa: Free[F, A], f: A => Free[F, B]) extends Free[F, B]
  // ...
  // 这样 Free trait 本身就是 monadic 的了。
  def flatMap[B](f: A0 => Free[F0, B]): Free[F0, B] = FlatMap(this, f)
  def map[B](f: A0 => B): Free[F0, B] = flatMap(a => unit(f(a)))
  def unit[A](a: A): Free[F0, A] = Return(a)

  def freeMonad[F[_]] = new Monad[[X] =>> Free[F, X]]:
    override def unit[A](a: A): Free[F, A] = Return(a)
    override def flatMap[A, B](fa: Free[F, A])(f: A => Free[F, B]): Free[F, B] = FlatMap(fa, f)
    override def map[A, B](fa: Free[F, A])(f: A => B): Free[F, B] = flatMap(fa)(a => unit(f(a)))

  @tailrec
  final def runTrampoline[A](free : Free[Function0, A]) : A = free match
    case Return(a) => a
    case Suspend(s1) => s1()
    case FlatMap(last, f1) => last match
      case Return(b) => runTrampoline(f1(b))
      case Suspend(s2) => runTrampoline(f1(s2()))
      case FlatMap(cur, f2) =>
        runTrampoline(FlatMap(cur, a => FlatMap(f2(a), f1)))

一个自然的想法是基于 runTrampoline 的签名延申出更加泛化的 Free::run 方法 。这样的话返回的就不是 A,而是代表了其他作用的 F[A]。只需要参考前文的 Async 是怎么实现的:

scala 复制代码
enum Free[F0[_], A0]:
  //.... 
  @tailrec
  final def step: Free[F0, A0] = this match
    case FlatMap(FlatMap(fx, f), g) => FlatMap(fx, a => FlatMap(f(a), g)).step
    case FlatMap(Return(x), f) => f(x).step
    case _ => this

  final def run(using M: Monad[F0]): F0[A0] = step match
    case Return(a) => M.unit(a)
    case Suspend(fa) => fa
    case FlatMap(Suspend(fa), f) => M.flatMap(fa)(a => f(a).run)
    case _ => sys.error("All the cases are eliminated by 'step', never reach this case.")

显然,Free[F, A] 代表着一个递归的结构,包含了 0 层或者是多层 F 包裹的 A。在用户真正得到结果之前,解释器必须处理完所有的 F。换句话说,Free 是一棵叶子类型为 A 的语法树,而每个分支由 F 进行描述。run 方法事实上描述了指令流是应当如何被执行的

这个 Free::run 方法适用于任何 F[_],只要外界能够提供一个 Monad[F] 驱动其运行。而 Free 级别的抽象无法 run 是栈安全的,我们马上就会谈到这个问题。

Free 将一串具有 F[_] 作用的指令以数据结构形式存储了起来。它总结了任意指令流的三种基本状态:

  1. Return:终止状态,表示计算已经完成,并且有一个值 A 可以使用。
  2. Suspend:暂停状态,表示计算正在等待一个外部作用 F[A] 完成,一旦操作结束,它将返回一个 A
  3. FlatMap:连续状态,表示计算正在进行中。它表示一个当前的状态 fa 和函数 f,这个函数描述了如何用当前计算的结果进行下一步的计算。

如此层级的抽象能为我们带来什么好处呢?为了进一步对 Free 的作用有更清晰的认识,下面看另一个例子:

支持控制台 IO 的 Monad

我们构造一个 Console[A] 类型,它抽象了一个产生 A 的控制台交互:输入 ReadLine 和输出 PrintLineConsole[A] 定义了两种可用的转换,即我们之前提到的 Par[A] 类型 ( 提交到一个线程池执行,等价于异步操作 ) 和 Function0[A] 类型 ( 仅在主线程执行,等价于同步操作 )。

scala 复制代码
package monadsIO
import Par.Par
trait Console[A] { 
  def toPar: Par[A]  // 将 Console[A] 解释为 Par[A]
  def toThunk: () => A // 将 Console[A] 解释为 Function0[A]
}

case object ReadLine extends Console[Option[String]]:
  override def toPar: Par[Option[String]] = Par.lazyUnit(run())
  override def toThunk: () => Option[String] = () => run()

  import scala.io.StdIn

  // 将控制台输入的部分抽取出来。
  private def run(): Option[String] =
    try Some(StdIn.readLine())
    catch
      case _: Exception => None

case class PrintLine(line: String) extends Console[Unit]:
  override def toPar: Par[Unit] = Par.lazyUnit(println(line))
  override def toThunk: () => Unit = () => println(line)

另一方面将 Console 集成到 Free 内部,这样就可以复用 Free 的 API 描述控制台交互流程了:

scala 复制代码
object Console:
  import Free.*
  type ConsoleIO[A] = Free[Console, A]

  def readLine(): ConsoleIO[Option[String]] = Suspend(ReadLine)
  def printLine(line: String): ConsoleIO[Unit] = Suspend(PrintLine(line))

//...
import Console.*
val process = for {
  _ <- printLine("now interact with the console")
  r <- readLine()
} yield r

想要将这个 process 跑起来,还差一个 Monad[Console]。但是,Console[A] 只是抽象接口,我们必须得将其转换到 Par[A] 或者是 Function0[A]。现在构造一个将任意 F[_] 转换为 G[_] 的特质 Translate,它可以被表示为 F[_] to G[_]。同时,存在一种 F[_] to F[_] 的自身映射,我们在此命名为 self[F]

scala 复制代码
trait Translate[F[_], G[_]]:
  def apply[A](fa: F[A]): G[A]

// 这样就可以应用 Scala 的中缀语法: F[_] to G[_]
infix type to[F[_], G[_]] = Translate[F,G]

val console2function0: Console to Function0 = new:
  override def apply[A](ca: Console[A]): () => A = ca.toThunk
  
val console2Par: Console to Par = new:
  override def apply[A](ca: Console[A]): Par[A] = ca.toPar

def self[F[_]] = new (F to F):
  override def apply[A](f: F[A]): F[A] = f

考虑到转换的情况之后,我们可以对 Free::run 做更进一步泛化,这里的实现都是基于类型推导的。

scala 复制代码
enum Free[F0[_], A0]:  
  //....
  final def run(using M: Monad[F0]): F0[A0] = run(self[F0])
  final def run[G[_]](t: F0 to G)(using M: Monad[G]): G[A0] = step match
    case Return(a) => M.unit(a)
    case Suspend(fa) => t(fa)
    case FlatMap(Suspend(fa), f) => M.flatMap(t(fa))(a => f(a).run(t))
    case _ => sys.error("All the cases are eliminated by 'step'.")

同时构建一些适配器,将 Console[A] 转换为 Par[A] 或者是 Function0[A]

scala 复制代码
val console2function0: Console to Function0 = new:
  override def apply[A](ca: Console[A]): () => A = ca.toThunk

val console2Par: Console to Par = new:
  override def apply[A](ca: Console[A]): Par[A] = ca.toPar

given func0M: Monad[Function0] with
  override def unit[A](a: A): () => A = () => a
  override def flatMap[A, B](fa: () => A)(f: A => () => B): () => B = () => f(fa()).apply()

given parM: Monad[Par] with
  override def unit[A](a: A): Par[A] = Par.lazyUnit(a)
  override def flatMap[A, B](fa: Par[A])(f: A => Par[B]): Par[B] =
    Par.fork{fa.flatMap(f)}

现在可以编写一个与控制台交互的指令流并测试一下效果了。见 process 的声明:

scala 复制代码
import Console.*
val process = for {
  _ <- printLine("now interact with the console")
  r <- readLine()
} yield r

val description1: () => Option[String] = process.run(console2function0)
val r1: Option[String] = description1()

val description2: Par[Option[String]] = process.run(console2Par)
val r2: Option[String] = description2.run(ForkJoinPool()).get()

对于同一个顺序指令流 process,我们可以有两种解释方式,见 description1description2Free[F[_], A] 将计算的声明和执行相互分离。

Free::run 的栈安全问题

看起来 Free::run 是一个通用的解释器。但我们在前文提到 Free 本身无法保证 run 方法是栈安全的。具体来说,这取决于外部提供的 IO Monad ,但很不幸的是 Func0MParM 都无法满足要求。

为了证明这个致命而隐晦的 bug 的确存在,现在通过 .forever 构建出一个无限循环的指令流。运行这段 loop_process 就可以观察到栈溢出错误。这里以 Function0[A] 作为解释器的情况进行说明,当然同样的问题也会出现在 Par[A] 解释器上。

scala 复制代码
val loop_process: Free[Console, Unit] = printLine("beware of StackOverFlow!").forever

// SOF
loop_process.run(console2function0)(using func0M)

再来单独观察下方 func0M 提供的 flatMap 组合子,它以内联形式组合函数。

scala 复制代码
override def flatMap[A, B](fa: () => A)(f: A => () => B): () => B = () => f(fa()).apply()

首先,f(fa()).apply() 并不处于尾调用的位置,另一个问题是,程序为了装配出 () => B 的结果并返回,它必须立刻调用 f。这就导致了栈帧在 flatMap 内部开始累积,直至程序栈溢出崩溃。

回想一下之前是如何处理 IO::forever 的栈溢出问题的!只需将下一条指令 f 存储在一个数据结构上,比如 Free[F[_], A],这样就可以推迟其执行的时机了。

首先要做的是构建关于 Free 的 Monad。由于 Free[F[_], A] 存在两个类型参数,这里得先借助类型 Lambda 固定其中的类型参数 F

scala 复制代码
given freeM[F[_]]: Monad[[X] =>> Free[F, X]] with
  override def unit[A](a: A): Free[F, A] = Return(a)
  override def flatMap[A, B](fa: Free[F, A])(f: A => Free[F, B]): Free[F, B] = Free.FlatMap(fa, f)

freeM 只返回一个 FlatMap 容器,其内部保存指向下一条指令的函数 f 的引用,而转换操作 (fg) 则被推迟到解释时再执行。考虑到繁杂的转换过程对用户而言是噪音,妥善的做法是将其封装到名为 translate 函数内部:

scala 复制代码
def translate[F[_], G[_], A](f: Free[F, A])(fg: F to G): Free[G, A] = {
  type FreeG[X] = Free[G, X]
  val t = new(F to FreeG) {
    def apply[T](a: F[T]): Free[G, T] =
      Free.Suspend {fg(a)}
  }
  f.run(t)(using freeM[G])
}

因此,当 IO Monad 无法给出栈安全的实现时,用户就得另行提供栈安全的专用解释器驱动 Free 运行,而不是直接调用 Free::run。比如,前文实现过的 runTrampoline 就是一个 Function0[A] 专用的尾递归解释器:

scala 复制代码
Free.runTrampoline {translate(loop_process)(console2function0)}

可以通过类似的手段改进 Par 解释器,本篇不再赘述。

为什么 Free 不足以支撑流式 IO

当前的 Free 还并不是最终实现,它们不太兼容流式 IO。所谓流式 I/O,指一次只处理一小部分数据,这样可以减少内存的使用,提高程序的效率。在流式 I/O 中,数据是逐个被处理的,而不是一次性地被加载到内存中。但目前为止,Free 的设计并没有考虑这一点。

假如需要编写一个程序读取 fahrenheit.txt,它的每一行存储了华氏度,我们需要逐行转化为摄氏度之后输出到 celsius.txt 当中。基于目前的 Free API,其实现可以如下表示:

scala 复制代码
import Free.*
trait Files[A]
case class ReadLines(f: String) extends Files[List[String]]
case class WriteLines(f: String, lines: List[String]) extends Files[Unit]

def fahrenheitToCelsius(f: Double): Double = (f - 32) * 5.0/9.0

val process: Free[Files, Unit] = for {
  lns <- Suspend {ReadLines("fahrenheit.txt")}
  cs = lns.map {s => fahrenheitToCelsius(s.toDouble).toString}
  _ <- Suspend{WriteLines("celsius.txt", cs)}
} yield ()

这样的程序是可以运行的,只不过这意味着我们要率先一次性将 fahrenheit.txt 的内存全部读入内存里,在文件很大时可能会引发 OOM 问题。要避免这个问题,我们就得编写 "边读编写" 的流水线代码。这可能得暴露更多底层的文件 API 进行 IO 处理:

scala 复制代码
  import Free.{Suspend, Return}
  trait Files[A]
  trait HandleR // 负责读的实现
  trait HandleW // 负责写的实现
  case class OpenRead(f: String) extends Files[HandleR]
  case class OpenWrite(f: String) extends Files[HandleW]

  case class ReadLine(h: HandleR) extends Files[Option[String]]
  case class WriteLine(h: HandleW, line: String) extends Files[Unit]
  case class Halt() extends Files[Unit]

  def fahrenheitToCelsius(f: Double): Double = (f - 32) * 5.0/9.0
  def loop(r: HandleR, w: HandleW): Free[Files, Unit] = for {
    ln <- Suspend(ReadLine(r))
    _ <- ln match {
      // Halt() 意味着程序应该截止了。
      case None => Return {Halt()}
      case Some(s) => Suspend {
        WriteLine(
          w, fahrenheitToCelsius(s.toDouble).toString
        )
      }.flatMap(_ => loop(r, w))
    }
  } yield ()

  def convert = for{
    r <- Suspend{ OpenRead("fahrenheit.txt")}
    w <- Suspend{ OpenWrite("celsius.txt")}
    _ <- loop(r, w)
  } yield ()

编写大段的循环无可厚非,但是这样的逻辑显然不容易组合与拓展。比如我们想再添加一个功能:"忽略掉文件中以 # 开头的注释行"。很大可能要大幅修改 WriteLine/ ReadLine 或构造其匿名子类,从而添加 "读到特定行则跳过" 或是 "接收到特定行则忽略写" 等逻辑。或许有很多奇思妙想,但都不如使用一个 filter 组合子那样来得直观。

还有其他的情况我们没有考虑。比如,如何保证文件总是被安全的关闭,无论读写过程是否被异常打断?或者就算捕捉到异常,用户可不可以传入钩子函数自行清理呢?我们大概率会不断地将新的需求缝合到某个巨大的 for 循环内部,看吧,想想日后要维护这样的代码就很痛苦。

在函数式 API 的设计当中,重点是 可组合的 抽象。我们后续会尝试构建一个 Process,以编写可读性更高,更灵活的流式 IO 接口,包括自动化的资源清理,直观易用的组合子,乃至优雅地处理多输入源或者多输出源的情况。

关于引用透明性的顾虑

IO[A] 描述了一个关于 A 的 IO 作用。Free[F[_], A] 则更抽象,它直接使用 F 描述任何可能的作用。前文曾解释过在函数式编程中作用意味着什么,简单来说作用就是 A 的某项额外能力或上下文。尽管这些作用的最终效果都是返回 A 值,但是不同的作用语义也各不相同。如 List[A] 表示一系列 A 的值,Option[A] 则可以表示可能不存在的 A

与之前接触过的所有纯代数结构有所不同,我们现在允许 F[_] 与外界进行交互,比如 Free[Console, A] 描述的一些控制台 IO 操作就是可以被用户直接观察到的。不过,我们仍然希望像 IO { ... } 这样的代码块是引用透明的,即所有出现 IO[A] 的地方都可以被它最终返回的值 A 等价代替。这是我们保证 API 是可组合的前提。

我们应该有一个大概的答案了,最好是将 IO 与外部交互的部分以局部作用 ( local effect ) 的形式限制在指令流内部,这对于任何 Monad API 都是如此。但是像控制台 IO 这样的操作几乎是肯定暴露到外界的!那么我们所构造的 IO 还可以称之引用透明的吗?

关于这个问题的答案事实上是很主观的,具体取决于我们 (或者程序) 是否在意这些 "意外" 的作用发生了。如果答案是肯定的,那么这个暴露作用显然属于不应该发生的副作用。到目前为止,笔者都是用 "外部作用" 表示控制台 IO 这样的操作,而不是直接称之副作用,这是有原因的。

我们会在下一个章节详细地解答这些疑虑。

附:关于 Par 类型的源代码

本章提到的 Par[A] 类型依赖 java.util.concurrent.Future 类,它代表了一个返回 A 的异步操作。它是在 Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn) 这篇实践中构建出来的。这里截取了一部分必要的定义。

scala 复制代码
package monadsIO
import java.util.concurrent.{ExecutorService, Future, TimeUnit}

object Par :

  type Par[A] = ExecutorService => Future[A]
  def unit[A](a : A) : Par[A] = _ => UnitFuture(a)
  def lazyUnit[A](a : A) : Par[A] = fork(unit(a))
  def asyncFunc[A,B](f : A=> B) : A => Par[B] = a => lazyUnit(f(a))

  // UnitFuture 兼容一个已经完成的 Future 常量。
  private case class UnitFuture[A](get : A) extends Future[A] :
    override def cancel(mayInterruptIfRunning: Boolean): Boolean = false
    override def isCancelled: Boolean = false
    override def isDone: Boolean = true
    override def get(timeout: Long, unit: TimeUnit): A = get

  def fork[A]( a  : => Par[A]) : Par[A] =
    exe => exe.submit(()=> {
      a(exe).get
    })

  def delay[A]( a  : => Par[A]) : Par[A] =
    exe => a(exe)

  extension [A](ths : Par[A])
    infix def eqs(that : Par[A]): ExecutorService => Boolean = (exe : ExecutorService) => ths(exe).get == that(exe).get
    infix def map[B](f : A => B): Par[B] = Par.map$(ths)(f)
    infix def flatMap[B](f : A => Par[B]) : Par[B] = Par.flatMap$(ths)(f)
    infix def run(exe : ExecutorService) : Future[A] = ths(exe)

  private[this] def map$[A,B](pa : Par[A])(f : A => B) : Par[B] = map2(pa,unit(()))((a,_) => f(a))
  def map2[A,B,C](a : Par[A],b : Par[B])( f : (A,B) => C) : Par[C] =
    (exe : ExecutorService) =>
      val af: Future[A] = a(exe)
      val bf: Future[B] = b(exe)
      UnitFuture(f(af.get,bf.get))

  private[this] def flatMap$[A,B](a : Par[A])(f : A => Par[B]) : Par[B] = exe =>
    val n_ : A = a(exe).get
    Par.run$(exe)(f(n_))

  private[this] def run$[A](s : ExecutorService)(a : Par[A]) : Future[A] = a(s)

附:StackSafe.IO

下面是栈安全特质 IO[A] 的完整定义,它代表了一个返回 A 的同步操作。我们在后面的代码演示中还会复用这段代码。

scala 复制代码
object StackSafe:

  trait IO[A]:
    self =>
    def flatMap[B](f: A => IO[B]) = FlatMap(self, f)
    def map[B](f: A => B) = flatMap(f andThen {Return(_)})
    def forever: IO[A] = self flatMap { _ => forever }
  end IO

  case class Suspend[A](resume: () => A) extends IO[A]
  case class Return[A](a: A) extends IO[A]
  case class FlatMap[A, B](sub: IO[A], f: A => IO[B]) extends IO[B]

  @tailrec // 确保 run 是尾递归的
  def run[A](io: IO[A]): A = io match
    case Return(a) => a
    case Suspend(r1) => r1()
    case FlatMap(last, f) => last match
      case Return(a) => run(f(a))
      case Suspend(r2) => run(f(r2()))
      // 1,  ok
      case FlatMap(cur, g) => run(cur flatMap (a => g(a) flatMap f))
      // 2, don't write like this
      // case FlatMap(cur, g) => run((cur flatMap g).flatMap(f))

局部作用和可变状态

以往我们或许会存在一些误解,即 "函数式的 API 不应该与外部环境交互,否则它们将失去可推导或者可组合的性质"。但是 IO 以及 Free 的实现似乎打破了这一点。本章会进一步延申关于引用透明的概念,即假定一些应用被限制在表达式内部,并且能够保证程序的其余部分是感知不到的

我们先来看一段朴素的快排代码:

scala 复制代码
object Mutable {
  def quicksort(xs: List[Int]): List[Int] = if (xs.isEmpty) xs else {
    val arr = xs.toArray
    def swap(x: Int, y: Int) = {
      val tmp = arr(x)
      arr(x) = arr(y)
      arr(y) = tmp
    }
    def partition(l: Int, r: Int, pivot: Int) = {
      val pivotVal = arr(pivot)
      swap(pivot, r)
      var j = l
      for (i <- l until r) if (arr(i) < pivotVal) {
        swap(i, j)
        j += 1
      }
      swap(j, r)
      j
    }
    def qs(l: Int, r: Int): Unit = if (l < r) {
      val pi = partition(l, r, l + (r - l) / 2)
      qs(l, pi - 1)
      qs(pi + 1, r)
    }
    qs(0, arr.length - 1)
    arr.toList
  }
}

quicksort 内部有三个子函数:swap (交换元素),partition (基于 pivot 的比较),qs (递归分治)。很显然,quicksort 内部是不纯粹的,因为函数内部存在一个 xs 的拷贝数组 arr,它被上述的三个子函数执行就地修改。然而,在它被排序完成并发布 (return) 之前,其引用一直被封锁在 quicksort 内部。

因此在外部看来,这个 quicksort 仍然可以被认为是引用透明的,外部感知不到在排序的过程中发生了哪些外部作用。并且对于相同的列表,无论调用多少次 quicksort,外界获取到的 值排序 总是不变的。

限制产生副作用的数据类型

类似 quicksort 的算法,都需要在原有数据进行修改才能获得最佳的效率。不过,我们仍然可以通过局部化的方式安全地修改数据,只要上下文不曾观察到这个外部作用的发生,那么权当无事发生。API 仍然可以对外提供纯粹,可组合的实现,用户可以在程序中毫无顾忌地调用其方法。

相反,倘若 quicksort 对传入的 xs 进行原地修改,那么外部所有的调用者都会感知到其中的副作用了。我们并不希望意外的引用泄露,但是 Scala 编译器目前是无法提供任何帮助的。在这个章节,我们将尝试通过 Scala 的类型系统来强制局限一个变量的作用域

可以构建一个局部语言来表达状态,比如之前我们用 State[S, A],即 S => (A, S),接受一个状态 S,然后输出一个结果 A 以及下一个状态。但现在状态的变化是内化的,这意味着我们并不会产生一个新的状态。但保留 S 类型仍然是必要的,因为它可以用来识别不同的 State 指令流,这一点会在后文继续提及。

另一方面,我们期望新的类型将由 Scala 的类型系统提供两点保证,违反任意一条将编译不通过:

  1. 一旦某个过程持有一个可变对象的引用,则它对外部不可见。
  2. 可变对象在被创建的范围之外也是不可见的。

quicksort 的例子满足第一条,因为 arr 对外部是不可见的,而第二条的要求则更细,即不能把可变状态的引用发布到可变范围之外的地方,我们将这种内化影响的数据结构称之 StateTransition ( 后文简称 ST ):

scala 复制代码
trait StateTransition[S, A]:
  self =>
  // 可以被定义为 Single Abstract Method (SAM)
  protected def run(s: S): (A, S)
  def map[B](f: A => B): StateTransition[S, B] = new StateTransition[S, B]:
    override def run(s: S): (B, S) =
      val (a, s1) = self.run(s)
      (f(a), s1)

  def flatMap[B](f: A => StateTransition[S, B]): StateTransition[S, B] = new StateTransition[S, B]:
    override def run(s: S): (B, S) =
      val (a, s1) = self.run(s)
      f(a).run(s1)

// 只有 StateTransition 伴生对象可以读取并执行 StateTransition 的 run 方法。
object StateTransition:
  def apply[S, A](a: => A): StateTransition[S, A] =
    lazy val cache = a
    new StateTransition[S, A]:
      override protected def run(s: S): (A, S) = (cache, s)

现在可以参考 State Monad 那样构建一段 ST 指令流 demo 了:

scala 复制代码
for {
  v1 <- StateTransition(1)
  v2 <- StateTransition(2)
} yield v1 + v2

只是,目前的 ST 所能做的就是声明一些普通的字面量,然后收集操作这些字面量的结果。这些字面量显然是不可变的。比如 v1 <- StateTransition(1) 这段提取式,尽管 v1 可能会参与到其他的计算中,但是在这个局部的 for 表达式内,v1 只可能在提取式左侧出现一次。我们下面看看如何在 ST 内部构建封闭的可变变量以及可变数组。

可变引用的代数表达

首先是被封闭在 ST 内部的可变引用 StateTransitionRef[S, A] ( 后简称为 STRef ),它本质上是一个可变变量的 cell 包装器。关于可变的内存单元有三个原语操作:创建,读,写。这些操作都是纯的,因为它们最终总是返回一个 ST,这样 cell 变量就可以在 ST 指令流内部被捕获。

注意,STRefsealed 关键字修饰,这意味着我们只能通过调用伴生对象的 apply() 方法去创建它。

scala 复制代码
// 一种可变引用的代数表达
// Scala3 的特质允许携带参数列表 (相当于属性列表)
sealed trait StateTransitionRef[S, A](protected var cell: A):
  def read: StateTransition[S, A] = StateTransition(cell)
  def write(a: A): StateTransition[S, Unit] = new StateTransition[S, Unit]:
    override def run(s: S): (Unit, S) =
      cell = a
      ((), s)

object StateTransitionRef:
  def apply[S, A](a: A): StateTransition[S, StateTransitionRef[S, A]] = StateTransition{
    new StateTransitionRef[S, A](a){}
  }

你已经注意到了,在创建 STRef 的过程中,我们还是没有使用类型 S。暂且可以理解成它是识别某段唯一 ST 流程的标记,事实上也的确如此。至少现在我们可以在 ST 指令创建并变量了:

scala 复制代码
val process = for {
  r1 <- StateTransitionRef(10)
  x <- r1.read
  _ <- r1.write(20)
} yield x

尝试在外部修改引用

下一步要做的事情就是想办法让 ST 流程运行起来。这实现起来并不难,更重要的是避免 STRef 发布到外界。我们得先分辨哪些行为是安全的,哪些则是不安全的。对于一段 ST 指令流:

  1. 返回 ST[S, STRef[S, V]] 是不安全的,这意味着外部可以获取当前 STSTRef 并修改引用。
  2. 返回 ST[S, V] 是安全的,这意味着发布了一个不变的字面量 V

假设我们编写一段返回 ST[S, STRef[S, V]]ST 指令,那么 Scala 编译器应当阻止这个行为。从更抽象的层面来说,编译器应该阻止发布任何 ST[S, T]。只要 T 涉及到了 S,就这意味着 T 是属于某个 ST 的内部状态。为了安全地运行 ST,我们创建了一个新的 trait RunnableST

scala 复制代码
trait RunnableStateTransition[A]:
  def apply[S]: StateTransition[S, A]

object StateTransition:
  //..
  def run[A](runST: RunnableStateTransition[A]): A = runST.apply[Unit].run(())._1

RunnableSTapply 和它返回的 ST 绑定了同一个 S 令牌。然而这里做出的限制是:构建 RunnableST 实例的时候不允许从外部确定 S 类型。这样做的直接结果是用户没法在外部让 STRef 在不同的 ST 指令流之间流通,见下方的代码。一旦我们这么做,Scala 编译器将报出编译错误,因为它无法证明某个 ST 流的 STRef 和另一段 ST 指令流持有的 S 类型是同一类型。

scala 复制代码
val ref: StateTransitionRef[?, Int] = StateTransition.run {
  new RunnableStateTransition[StateTransitionRef[?, Int]]:
    override def apply[S] = for {
      r1 <- StateTransitionRef(1)
    } yield r1
  }

new RunnableStateTransition[Int] {
  override def apply[S]: StateTransition[S, Int] = for {
    x <- ref.read
  } yield x
}

当然,这个错误是我们所期望发生的,Scala 编译器现在相当于阻止了用户暴露内部可变引用的行为。现在你应该理解为什么前文说 S 是标识并区分出不同 ST 流程的令牌了。至于 S 的具体形式我们不需要关心,因为 ST 的计算和 S 本身没有关系。因此 ST::run 函数中只是传入了一个 Unit 字面量 () 来驱动 ST 运行。

引用安全的可变数组

如果已经理解了 STRefRunnableST,那么再构建一个 STArray 也不是一件难事了。类似的,可变数组有三个基本的原语:分配,读取和修改。

scala 复制代码
sealed abstract class StateTransitionArray[S, A: Manifest]:
  protected def value: Array[A]
  def size[S]: StateTransition[S, Int] = StateTransition(value.length)
  def write[S](index: Int, a: A): StateTransition[S, Unit] = new StateTransition[S, Unit]:
    override def run(s: S): (Unit, S) =
      value(index) = a
      ((), s)
  def read[S](index: Int): StateTransition[S, A] = StateTransition(value(index))
  def freeze[S]: StateTransition[S, List[A]] = StateTransition(value.toList)

  def fillWithMap[S](xs: Map[Int, A]): StateTransition[S, Unit] =
    xs.map{ case (i, a) => this.write[S](i, a)}.foldRight(StateTransition[S, Unit](())){
      (st, unit) => unit.flatMap(_ => st)
    }

object StateTransitionArray:
  def apply[S, A: Manifest](n: Int, v: A): StateTransition[S, StateTransitionArray[S, A]] =
  StateTransition[S, StateTransitionArray[S, A]]{
    new StateTransitionArray[S, A]:
      lazy final val value = Array.fill(n)(v)
  }

  def lift[S, A: Manifest](xs: List[A]): StateTransition[S, StateTransitionArray[S, A]] =
    StateTransition {
      new StateTransitionArray[S, A] {
        lazy val value = xs.toArray
      }
    }

  def swap(i: Int, j: Int): StateTransition[S, Unit] = for {
    x <- read(i)
    y <- read(j)
    _ <- write(i, y)
    _ <- write(j, x)
  } yield ()

受限于 Java,Scala 同样不能直接创建一个 A 类型的数组,但幸运的是只需引入一个 Manifest[A] 的上下文界定就可以搞定这个问题。

纯函数的 in-place 快排

quicksort 的内部组件可以全部被改写为 ST。如你所见,qspartition,以及 STArray 内部的 swap 方法都是纯函数。由 Scala 的类型系统保证其内部没有任何不安全的引用发布。

这里仅作为演示,不需要详细地解读下面的每一行代码,只要理解它所达到的效果就足够了。

scala 复制代码
object Immutable {
  def noop[S] = StateTransition[S,Unit](())

  def partition[S](a: StateTransitionArray[S,Int], l: Int, r: Int, pivot: Int): StateTransition[S,Int] = for {
    vp <- a.read(pivot)
    _ <- a.swap(pivot, r)
    j <- StateTransitionRef(l)
    _ <- (l until r).foldLeft(noop[S])((s, i) => for {
      _ <- s
      vi <- a.read(i)
      _  <- if (vi < vp) (for {
        vj <- j.read
        _  <- a.swap(i, vj)
        _  <- j.write(vj + 1)
      } yield ()) else noop[S]
    } yield ())
    x <- j.read
    _ <- a.swap(x, r)
  } yield x

  def qs[S](a: StateTransitionArray[S,Int], l: Int, r: Int): StateTransition[S, Unit] = if (l < r) for {
    pi <- partition(a, l, r, l + (r - l) / 2)
    _ <- qs(a, l, pi - 1)
    _ <- qs(a, pi + 1, r)
  } yield () else noop[S]

  def quicksort(xs: List[Int]): List[Int] =
    if (xs.isEmpty) xs else StateTransition.run(new RunnableStateTransition[List[Int]] {
      def apply[S] = for {
        arr    <- StateTransitionArray.lift(xs)
        size   <- arr.size
        _      <- qs(arr, 0, size - 1)
        sorted <- arr.freeze
      } yield sorted
    })
}

纯粹性是相对于上下文的

我们已经意识到,只有将可变数据被局限在一定的范围内,且不会泄露引用,这个外部作用才不可见。然而,作用可不可见也要分谁的视角。比如:

scala 复制代码
case class Foo(s: String)
val b1 = Foo("1") == Foo("1") // 值相等
val b2 = Foo("1") eq Foo("1") // 引用不同

如果将 Foo("1") 看作表达式,它肯定是引用透明的,且两个 Foo("1") 值相等显然成立。但如果我们严格地使用 eq 测试引用相同性,不同的 Foo("1") 显然是不同的,因为每个 Foo("1") 都是不同的对象。

还有前文的 quicksort。它并不是一个稳定的排序算法 (尤其是随机选取枢轴量的快排)。每次对同一个序列调用 quicksort 得到的值排序结果无疑是相同的,而深究到引用的层次,这个结论就未必成立了。幸运的是,大部分需要进行排序的程序反而不太纠结引用相同性的问题。由此可见,只要应用 quicksort 的整个上下文都没有使用 eq 观察引用,自然就可以认为 quicksort 是纯粹的。

一个更加抽象的引用透明的定义

如果程序 p 中的每个表达式 expr 都可以被其结果替换,且对 p 不构成任何影响,我们可以称引用是透明的。

另一个与引用透明相类似但不完全等价的概念是纯函数。所有的纯函数都是引用透明的,但我们现在已经知道了引用透明的函数却未必是都是纯函数 ( 也就是说,纯函数的要求更加严格 )。然而,在确保函数的内部状态不会泄露的情况下,满足引用透明性质的函数和纯函数在效果上是一致的:它们都可以简单地用最终的返回值进行代数推导 ( 或者称等量代换 )。

关于副作用

当我们说某个函数调用的副作用时,通常是指它除了 return 值与外部交互之外,还额外修改了上下文的其他状态。尽管《副作用》这个词本身是中性的,比如控制台 IO 这样的外部作用显然也是副作用的一种,但是从情感上来说:"一个副作用发生了",这往往是我们反而不希望发生的,因为其他观测或跟踪那个外部状态的调用极有可能会受到影响,从而破坏整体的引用透明性,或者是程序的语义。

具体怎么样算 "不影响程序的语义" 呢?再看一个例子:

scala 复制代码
def timesTwo(x: Int) =
  if(x < 0) println("A negative num")
  x*2

timesTwo 显然是一个非纯粹的函数,因为它可能调用 println 向控制台输出内容。那么这意味着 timeTwo(x) 不是引用透明的了吗?倒也未必。这其实是一个价值取向,准确的说,这取决于我们的代码 ( 或者称上下文 ) 是否需要跟踪控制台的输出行为。

在代码段中安插一些 println 语句来临时打印一些调试信息,这是测试环节中简易的 debug 的手段之一。回味一下,当我们选择这么做的时候,其实已经假定了额外的 IO 行为不会影响程序的语义。同样的道理,我们此时仍然可以称 timeTwo 满足引用透明性。但是,如果程序的行为依赖控制台打印的内容,比如 UNIX 命令行工具,那么这样的 IO 作用就不可忽视了。

从内存的角度来看,我们所认为的一些 "纯粹" 的操作事实上都是不纯的。因为总是要将值写入到内存的某个地方,然后在某个时刻再将它们丢弃掉。比如,Foo("1") 的每次出现,JVM 都在堆内存中构造了一个新的对象。但显然在与 JVM 交互的过程中,跟踪内存并不总是要做的事情。真正应该关心的是 追踪那些影响程序正确性 的副作用。

现在我们应该对 作用,副作用 ( 外部作用 ),引用透明 这些抽象的 FP 概念有一个更深的理解了。最好是通过约束局部作用以满足引用透明性,从而在不破坏 API 组合性的前提下允许其安全地和外界交互。这种开发思想还会结合到后续的开发当中。

流式处理与增量 IO

我们在之前已经介绍了 IO trait 的局限性。目前来看,想要对输入流进行一些复杂的处理,用户就得编写大段的 for 循环代码,这样的代码不具备组合性,更不要提用有限的原语集构成通用的表达了。用户一定希望像操作普通列表那样操作文件流,而不希望对底层 API 有过多的涉猎!除此之外,想要安全地调用接口,我们还得设计其他的内容,这包含了异常捕获,资源自动释放等机制,毕竟我们要交互的 Sink 都是需要及时被关闭的资源。

流转换器

先从平日中最直观的数据结构引入流转换器的概念,比如 LazyList[A] ( Scala 2.13 之前为 Stream[A])。实际上,这些例子可以是行流,HTTP 请求流,UI 界面的事件流。

构建一个 Process[I, O] 代数类型来表示一种流的转换形式,它具备输入和输出,可以将一个 I 流转换为 O 流。但 Process 并不是一个简单的 LazyList[I] => LazyList[O],而是一个状态机:

scala 复制代码
sealed trait Process[I, O]:
  def apply(lazyList: LazyList[I]): LazyList[O] = this match
    case Emit(head, tail) => head #:: tail(lazyList)
    case Halt() => LazyList()
    case Await(recv) => lazyList match
      case h #:: t => recv(Some(h))(t)  // 得到下一个状态,并执行
      case nil => recv(None)(nil)

case class Emit[I, O](head: O, tail: Process[I, O] = Halt[I, 0]()) extends Process[I, O]
case class Await[I, O](recv: Option[I] => Process[I, O]) extends Process[I, O]
case class Halt[I, O]() extends Process[I, O]

Process 有三种状态:

  1. Emit(head, tail)head: O 元素发射 (emit) 到输出流,tail: Processp[I, O] 代表了后续一系列的状态。默认情况下 tail 的值为 Halt(),表示发射当前元素之后没有其他的 IO。
  2. Await(recv) 尝试从输入流中得到下一个有效值并传入到 recv 函数中。在 recv 被触发之后,切换到其他状态。
  3. Halt() 表示暂时没有任何元素从输入流中读取,或者是送给输出流。

给定一个 p: Process[I, O] 和一个 in: LazyList[I],那么 p(in) 将会得到一个 out: LazyList[O]

在 Scala 中,任何实现 apply 方法的类的实例 o 都会变成 "可调用对象"。因此,这里的 p(in) 相当于 p.apply(in)。可以类比 Python 语言的 __call__

构建转换流

可以将任何一个 f: I => O 提升为 Process[I, O]。先考虑一种仅转换单值的 Await,如果它接受了一个有意义的值,则应用 f 之后发射 ( emit,或称 '递送" ) 给输出流。

scala 复制代码
def liftOne[I, O](f: I => O): Process[I, O] = Await {
  case Some(i) => Emit(f(i))
  case None => Halt()
}

val s = liftOne((x: Int) => 2 * x)(LazyList(1, 2, 3))
// 只处理第一个元素 1,因此得到的是 List(2)。
println(s.toList)

liftOne 在处理第一个值之后就会立刻停止,因为我们定义了 Emit 的下一个状态默认是 Halt。为了处理整个流,我们得递归这个过程,令其持续地接受输入。见 repeatlift 组合子的实现:

scala 复制代码
sealed trait Process[I, O]:

  // 为了方便观察,这里将 p(x) 展开为 p.apply(x)
  def apply(lazyList: LazyList[I]): LazyList[O] = this match
    case Emit(head, tail) => head #:: tail.apply(lazyList)
    case Halt() => LazyList()
    case Await(recv) => lazyList match
      case h #:: t => recv(Some(h)).apply(t)  // 得到下一个状态,并执行
      case nil => recv(None).apply(nil)

  def repeat: Process[I, O] =
    def go(p: Process[I, O]): Process[I, O] = p match
      case Emit(head, tail) => Emit(head, go(tail))
      case Halt() => go(this)
      case Await(recv) => Await {
        case None => recv(None)
        case i => go(recv(i))
      }
    go(this)

end Process

// 定义一些外部函数,也可以定义在 object Process 中。

def liftOne[I, O](f: I => O): Process[I, O] = Await {
  case Some(i) => Emit(f(i))
  case None => Halt()
}
def lift[I, O](f: I => O): Process[I, O] = liftOne(f).repeat

如果某个 Process prepeat 组合子修饰,这意味着 p 在到达 Halt 状态之后会立刻重启自身;如果 p 的状态是 Await(recv) 且外部传入了有意义的值 i,则在应用 i 后继续递归这个过程 ------ 这个逻辑被包装在了另一个 Await 中等待延迟执行。这样 p.repeatapply 就构成了交替执行的协程,这种技巧在之前的 IO Monad 章节中已经出现过了。

非等待 (Await) 的 Process 不能直接使用 repeat。诸如 Emit(1).repeat 会导致程序构建 Process 的过程中出现 SOF 错误,而 Halt().repeat 则会使程序陷入忙等。Process 是流转换器,即使我们要得到一个无限流,那也应该是从另一个无限流当中得来的。

scala 复制代码
// 先构建一个 LazyUnit((), (), ...) 流,
// 再通过 (_:Unit) => 1 变换成 LazyUnit(1, 1, ...)
lift((_:Unit) => 1)(LazyList.continually(()))

更多例子

另一个实用的是过滤流 filter。它的实现非常直观,如果满足则递送到输出,否则忽略这个输入并重启。

scala 复制代码
def filter[I](p: I => Boolean): Process[I, I] = Await[I, I] {
  case Some(v) if p(v) => Emit(v)
  case _ => Halt()
}.repeat

val even = filter[Int](i => i % 2 == 0)
val evens = even.apply(LazyList(1, 2, 3, 4)).toList

还有一个是对数值流累计求和,这里以 Double 类型为例子。

scala 复制代码
def sum: Process[Double, Double] =
  def go(d: Double): Process[Double, Double] = Await {
    case Some(acc) => Emit(acc+ d, go(acc + d))
    case None => Halt()
  }
  go(0.0d)

还有很多实用的逻辑,比如 takedroptakeWhiledropWhile 等,它们的用法与 List / LazyList 的同名 API 完全相同,因此用户可以按照熟悉地方式转换流。

scala 复制代码
def take[I](n: Int): Process[I, I] =
  def go(c: Int): Process[I, I] = Await {
    case Some(v) if c != 0 => Emit(v, go(c - 1))
    case _ => Halt()
  }
  go(n)

def drop[I](n: Int): Process[I, I] =
  def go(c: Int): Process[I, I] = Await {
    case Some(_) if c != 0 => go(c - 1)
    case Some(v) => Emit(v)
    case _ => Halt()
  }
  go(n)

def takeWhile[I](p: I => Boolean): Process[I, I] =
  def go(p: I => Boolean): Process[I, I] = Await {
    case Some(v) if p(v) => Emit(v, go(p))
    case _ => Halt()
  }
  go(p)

def dropWhile[I](p: I => Boolean): Process[I, I] =
  def go(p: I => Boolean): Process[I, I] = Await {
    case Some(v) if p(v) => go(p)
    case Some(v) => Emit(v, go(_ => false))
    case _ => Halt()
  }
  go(p)

下面是实现 count,用于计算元素的个数,以及使用 mean 求平均值:

scala 复制代码
def count[I]: Process[I, Int] =
  def go(c: Int): Process[I, Int] = Await {
    case Some(_) => Emit(c + 1, go(c + 1))
    case _ => Halt()
  }
  go(0)

def mean: Process[Double, Double] =
  def go(c: Int, acc: Double): Process[Double, Double] = Await {
    case Some(v) => Emit((acc + v) / (c + 1), go(c + 1, acc + v))
    case _ => Halt()
  }
  go(0, 0.0d)

当应用 count(LazyList("a", "b", "c")) 时,我们可能只想得到一个表示序列的最终长度值。可以令 Process 在首次检测到无输入值时发射计数值然后停止。它的实现可以是这样:

scala 复制代码
def countLast[I]: Process[I, Int] =
  def go(c: Int, escape: Boolean = false): Process[I, Int] = Await {
    case Some(_) => go(c + 1)
    case None => if !escape then Emit(c, go(c, true)) else Halt()
  }
  go(0)

val nums = countLast.apply(List("a", "b", "c")).head
println(nums)  // 3

显然,sumcountmean 都具有相同的模式,每个模式都有自己的内部状态。我们可以将其提取出一个公用的 loop:

scala 复制代码
def loop[S, I, O](z: S)(f: (S, I) => (S, O)): Process[I, O] =
  def go(s: S): Process[I, O] = Await {
    case Some(v) =>
      val (s1, v1) = f(s, v)
      Emit(v1, go(s1))
    case _ => Halt()
  }
  go(z)

val _sum_ = loop[Int, Int, Int](0)((acc, v) => (v + acc, v + acc))(List(1, 2, 3))
println(_sum_)  // List(1, 3, 6) 跟踪累计和

val _count_ = loop[Int, Any, Int](0)((acc, _) => (acc + 1, acc + 1))(List("groovy", "scala", "java"))
println(_count_) // List(1, 2, 3) 构建 index 

从语义上讲,mean 似乎是可以通过组合 countsum 来实现的。在此之前,我们可以先探讨一下 Process 之间可以被如何组合。

组合流与追加处理

在我们的构想中,Process 应当是可组合的。基础的操作是管道操作,这里使用 |> 符号来表示。若有管道 p1 |> p2,则输入 I 会经由 p1 转换为 O,随后再作为输入传递给 p2,最终递送出 O2。由于 p1p2 都是有状态的流转换器,因此它们在产生输出之后都会各自切换到下一个状态。

scala 复制代码
//  当前状态:I ==> |p1| ==> O ==> |p2| ==> O2
//                  ↓              ↓
//  下个状态:       t1             t2
def |>[O2](p2: Process[O, O2]): Process[I, O2] = p2 match
  case Halt() => Halt()   // 如果 p2 的状态是 Halt(),那么整体 Halt()。
  case Emit(h2, t2) => Emit(h2, this |> t2)
  case Await(f) => this match
    case Emit(h1, t1) => t1 |> f(Some(h1))
    case Await(g) => Await((i: Option[I]) => g(i) |> p2)
    case Halt() => Halt() |> f(None)

由于 p1 |> p2 管道的最终状态实际上取决于 p2 递送完值之后的状态,因此这里不妨先对 p2 进行模式匹配:

p2 为 Halt 时,无论 p1 的输出 O 是什么,都没有地方可以接受输出,管道的下个状态一定是 Halt。

p2 为 Emit 时,则 h2 作为这条管道的输出,t2 就是这条管道的下一个状态。

p2 为 Await 时,意味着 p2 的状态还没有确定。这个时候应当检查之前 p1 的状态:

  1. 如果 p1 为 Emit,则发射 h1 后切换到 t1。另一方面,h1 被发射给 p2,此时 p2 被解析为 f(Some(h1))
  2. 如果 p1 也为 Await,那么依序将 p1p2 合并为一个 Await。在新的延迟操作中,p1 被解析为 g(i)i 代表之后向这条管道的输入。
  3. 如果 p1 为 Halt,则显然 p2 不会接收到有意义的值,这里使用 f(None) 来驱动 p2 切换到下一个状态。

有了 |> 我们可以很容易地实现 map 组合子:

scala 复制代码
def map[O2](f: O => O2): Process[I, O2] = this |> lift(f)

很显然,Process 也是一个函子。这里如果忽略输入类型 I,那么 Process[_, O] 代表了一系列 O 的值。

可以 append (++) 一个流到另一个。比如 x ++ y 表示执行完 x 之后将余下的输入继续执行 y。因此,x 必须是一个有限流,否则 y 永远不会被应用。

scala 复制代码
sealed trait Process[I, O]:
  //....
  def ++(p: Process[I, O]): Process[I, O] = this match
    case Emit(head, tail) => Emit(head, tail ++ p)
    case Await(recv) => Await {recv andThen(_ ++ p)}
    case Halt() => p

为了避免混淆,这里通过举例来区分 ++|> 的作用:

scala 复制代码
val process1 = liftOne[Int, Int](_ + 1) ++ liftOne[Int, Int](_ + 2)
println(process1(LazyList(1, 2)).toList)

val process2 = lift[Int, Int](_ + 1) |> lift[Int, Int](_ + 2)
println(process2(LazyList(1, 2)).toList)

对于第一个例子 procees1liftOne(_ + 1) 对流内的第一个元素应用加一,随后切换到 liftOne(_ + 1),对流的第二个元素应用加二。这样最终的结果就是:List(2, 4)

对于第二个例子 process2lift(_ + 1) 在对流内的每一个元素应用加一之后发射给 lift(_ + 2),然后再应用加二。这样最终的结果就是 List(4, 5)

构建 Process Monad

可以基于 ++ 组合子进一步定义出 flatMap

scala 复制代码
sealed trait Process[I, O]:
  // ...
  def flatMap[O2](f: O => Process[I, O2]): Process[I, O2] = this match
    case Emit(head, tail) => f(head) ++ (tail flatMap f)
    case Await(recv) => Await {recv andThen (_ flatMap f)}
    case Halt() => Halt()

Process 现在可以进一步被视作一个 Monad 单子。unit 的定义是,发射一个给定的输入后就停下来。

scala 复制代码
def processMonad[I] = new Monad[[O] =>> Process[I, O]] {
  override def unit[A](a: A): Process[I, A] = Emit(a)
  override def flatMap[A, B](pa: Process[I, A])(f: A => Process[I, B]): Process[I, B] = pa flatMap f
}

processMonad 预示着 Process 适用于前文介绍过的所有 monadic 操作。比如我们这里实现 zip

scala 复制代码
def zip[A,B,C](p1: Process[A,B], p2: Process[A,C]): Process[A,(B,C)] =
  (p1, p2) match {
    case (Halt(), _) => Halt()
    case (_, Halt()) => Halt()
    case (Emit(b, t1), Emit(c, t2)) => Emit((b,c), zip(t1, t2))
    case (Await(recv1), _) =>
      Await((oa: Option[A]) => zip(recv1(oa), feed(oa)(p2)))
    case (_, Await(recv2)) =>
      Await((oa: Option[A]) => zip(feed(oa)(p1), recv2(oa)))
  }

def feed[A,B](oa: Option[A])(p: Process[A,B]): Process[A,B] =
  p match {
    case Halt() => p
    case Emit(h,t) => Emit(h, feed(oa)(t))
    case Await(recv) => recv(oa)
  }

现在可以通过 zip sumcount 两个流来实现求均值 mean 了:

scala 复制代码
val attachIndex = zip(count, lift(identity))
// List((1,java), (2,groovy), (3,scala))
println(attachIndex(LazyList("java", "groovy", "scala")).toList) 

val mean = zip(count, sum) |> lift((cnt, s) => s / cnt)
println(mean(LazyList(2.0, 4, 6)).toList)  // List(2.0, 3.0, 4.0)

处理文件

有了前文的铺垫,处理文件将不再是一个难事,我们唯一要做的就是将之前的 LazyList 替换成 f.getLines 文件流。processFile 是一个文件读写的驱动器,它接受一个文件句柄 f 和转换流 p,并返回一个 IO 作用。

scala 复制代码
import StackSafe.{Suspend, run}
def processFile[A, B](f: java.io.File, p: Process[String, A], zero: B)(g: (B, A) => B): IO[B] = Suspend[B] {
  ()=>
  val s = io.Source.fromFile(f)
  @tailrec
  def go(ss: Iterator[String], cur: Process[String, A], acc: B): B =
    cur match
      case Emit(head, tail) => go(ss, tail, g(acc, head))
      case Await(recv) =>
        val next = if(ss.hasNext) recv(Some(ss.next())) else recv(None)
        go(ss, next, acc)
      case Halt() => acc
  try go(s.getLines, p, zero) finally s.close()
}

我们同时参考了 List API 中的折叠方法 fold 并预留了 zero: Bg: (B, A) => B 两个参数,这样用户可以按照自己预期的方式来收集结果。现在可以很容易地处理前文提出的需求了:下面的代码演示了用 Process 读取文件,过滤注释行,将华氏度有效值转换为摄氏度,最后以字符串形式收集结果。

scala 复制代码
def toCelsius(fahrenheit: Double): Double = (5.0 / 9.0) * (fahrenheit - 32.0)

// 忽略 '#' 注释行, 忽略空行,提取数值行
val p = filter[String](! _.startsWith("#")) |>
  filter[String](_.trim.nonEmpty) |>
  lift[String, Double](v => toCelsius(v.toDouble))

val file = java.io.File("src\\main\\scala\\monadsIO\\temperature.txt")

// 将最后的结果拼接为字符串
val result = run(processFile(file, p, "")((s1, s2) => s1 + "|" + s2))
println(result)

如果有多个结果,那么 processFile 则使用 | 符号切割出来,当然也可以选择使用一个 List 收集它们。看,相比于 IO 的实现,使用流式 API 表达起来清晰明了,更重要的是这些 API 是可组合的。在原语组合子构造完毕后,只需要使用 |>liftfilter 就能够搞定大部分的操作。

可拓展的处理类型

LazyList[A] 毕竟只是数据流 A 的一种可能形式。进一步将外界的输入流抽象为附带作用的 F[A]

scala 复制代码
trait Process[F[_], O]

// 新的 Process 协议
object Process:
  case class Await[F[_], A, O](req: F[A], recv: Either[Throwable, A] => Process[F, O]) extends Process[F, O]
  case class Emit[F[_], O](head: O, tail: Process[F, O]) extends Process[F, O]
  case class Halt[F[_], O](err: Throwable) extends Process[F, O]

  case object End extends Exception
  case object Kill extends Exception

还有一个重要的拓展内容,那就是如何保证处理过程中资源是安全的,也就是文件句柄或者是数据库连接应当被正确的关闭。不过在此之前,我们首先得能区分出流是正常关闭还是异常中断的。

现在 Halt 携带了一个 err 参数:它有可能是 End 表示输入耗尽,也有可能是 Kill 表示强制中断,还有可能是其他的运行时异常。无论如何,占用的资源都应该在流关闭之前被释放。除此之外,Await 将接受一个 Either[Throwable, A] 输入。一旦执行 req 时发生错误,那么 recv 就可以通过 err 了解到流关闭的原因,然后自行决定该怎么做。

Process 的操作显然是与 F[_] 是无关的,我们可以像处理 LazyList[A] 那样处理任何流。也就是说,在之前章节定义的 ++map 或者 filter 在这里同样适用。下面是可捕获异常的 ++ 实现,它依赖另一个组合子 onHalt

scala 复制代码
trait Process[F[_], O]:

  def onHalt(f: Throwable => Process[F, O]): Process[F, O] = this match
    case Await(req, recv) => Await(req, recv andThen (_.onHalt(f)))
    case Emit(head, tail) => Emit(head, tail.onHalt(f))
    case Halt(err) => Try(f(err))

  def ++(p: => Process[F, O]): Process[F, O] = this.onHalt {
    case End => p
    case err => Halt(err)
  }

 def repeat: Process[F, O] = this ++ this.repeat

end Process

def Try[F[_], O](p: => Process[F, O]): Process[F, O] =
  try p catch {case e: Throwable => Halt(e)}

onHalt 是一个基础的组合子,它预留了一个回调函数 f,决定了当前的 Process 在进入 Halt 状态之后应该做什么。

另一方面,帮助函数 Try 可以确保 Process 求值的安全性,将捕获到的任何异常转换成 Halt,对于资源安全来说非常重要。原则上,运行时异常最好都由我们捕获。好在只有关键的组合子可能会出现异常,只要我们能保障它们是异常安全的,就可以保证资源安全。

++ 组合子所表达的意图简洁明了:如果当前的 Process 正常结束,就继续执行下一个 p,否则就异常中断。由此还引申出 repeat 组合子,其重复过程就是一个 Process 不断衔接自身的过程。

来源

在之前 processFile 的实现中,我们明确外界的输入是一个 String 类型,即 io.Source.fromFile 返回的字符串流。实际上,任何外界的请求都可以抽象成通过执行或 flatMap 对应的 IO 行为得到。比如下面就是 Await 的一个特例:

scala 复制代码
// 在前文中,IO[A] 被解释为 FunctionO[A], 即 () => A。
import StackSafe.IO
case class IOAwait[A, O](req: IO[A], recv: Either[Throwable, A] => Process[IO, O]) extends Process[IO, O]

下面的 runLog 用于接收一个 IO Process,并在处理完这个流之后返回一个 IndexSeq[O],且自身也表现为一个 IO。

出于简单起见,我们在这里暂且将 IO 行为理解为调用 () => A,这是在 StackSafe.IO 中定义的,相当于同步 IO。实际上,这里的 IO 行为可以是异步的,比如将取值的过程打包到一个 Runnable 内部并发送到某个线程池,就像 Par[A] 所做的那样。

其中,IO 作用被限制到了 performIO 内部:

scala 复制代码
// 方便以柯里化的方式初始化 Await
def await[F[_], A, O](req: F[A])(recv: Either[Throwable, A] => Process[F, O]) = Await(req, recv)

// 将 IO 限制在此处
def performIO[A](io: IO[A]): A = run(io)

// 表示一个被延迟的 IO 操作。
def IO[A](a: => A): IO[A] = Suspend {()=>a}

def runLog[O](src: => Process[IO, O], semaphoreRef: Semaphore): IO[IndexedSeq[O]] = IO {
  semaphoreRef.acquire(1)
  def go(cur: Process[IO, O], acc: IndexedSeq[O]): IndexedSeq[O] = {
    cur match
      case Await(req, recv) =>
        val next = try recv(Right(performIO(req)))
        catch {
          case e: Throwable => recv(Left(e))
        }
        go(next, acc)
      case Emit(h_o, tail_p) => go(tail_p, acc :+ h_o)
      case Halt(End) => acc
      case Halt(e) =>
        System.err.println(e.getClass)
        acc
  }

  try go(src, IndexedSeq()) finally semaphoreRef.release()
}

runLog 的定义中,src 进入到 Halt 状态之后会停止。如果模式匹配出的 eEnd 类型,则可以判定程序正常结束,否则将其视作异常中断。关于如何处理捕获到的异常,用户可以在 case Halt(e) => ... 这条分支上做更多的拓展,比如将错误信息写入到日志等等,也可以抛出异常,直到 runLog 最外部的 try ... catch 段去处理。

runLog 总是会返回所有累积的正确结果,并在最后关闭一些外部资源。在这里,外部资源是用一个信号量 Semaphore 对象来模拟的。下面是一个通过 runLog 实现顺序读取文件的例子:

scala 复制代码
import StackSafe.{IO, run}
val semaphore = new Semaphore(3);

val p = await(IO(new BufferedReader(new FileReader("src\\main\\scala\\monadsIO\\temperature.txt")))){
  case Right(b) =>
    def next: Process[IO, String] = await(IO(b.readLine())) {
      case Left(e) => await(IO(b.close())){_ => Halt(e)}
      case Right(line) =>
        if line eq null then Halt(End)
        else Emit(line, next)
    }
    next
  case Left(e) => Halt(e)
}

val list = StackSafe.run(runLog(p, semaphore))
println(list)
println(semaphore.availablePermits() == 3)

有一个瑕疵是,runLogsrc 也会隐含地持有一个文件资源 ( 在刚才的例子里是 BufferReader ),然而该资源的释放是由外部 p 的逻辑来保证的。我们还是希望有一种约束性更强的通用组合子保证重要的 IO 资源能够被 及时关闭

资源安全的通用实现

它应该何时被 "及时" 关闭呢?比如说,一个大文件的所有行 lines: Process[IO, String],它应该是在程序的最后关闭吗?不。更确切的时机是 lines 返回文件的最后一行,即没有更多的行之后就关闭。我们得出第一条规则:

一个生产者在没有更多值之后应当立即释放资源。

另一个问题是,消费者有可能会在消费过程中提早结束。比如 runLog{ lines("abba.txt") |> take(5)},但是消费者从文件中读取的内容不够 5 个。由此引申出第二条规则:

任何消费过程 p 输出值的过程 d 必须确保在自身停止之前执行 p 的清理行为。

这听起来比较绕。通俗点说,我们得确保 Await 的 recv 函数在接受到 Left(err) 时马上执行清理行为,因为无论 err 表示了何种原因,都意味着这条流要停止了。

首先引入一个新的组合子 onComplete,并假设若 p1 onComplete p2,则它可以确保 p2 总会在 p1 停止之后执行。这实现起来并不难,因为已经有了 onHalt 的实现,现在只需要传递一个回调函数。如果 p1 运行时发生了错误 err,那么就将其留到最后的清理阶段。asFinalizer 是另一个单独的帮助函数,它会确保 p2 忽略掉 Kill 信号并继续运行一些重要的资源释放操作,尽管消费者希望提前结束。

scala 复制代码
trait Process[F[_], O]:
  // ...
  def onComplete(p: Process[F, O]): Process[F, O] = this.onHalt {
    case End => p.asFinalizer
    case err => p.asFinalizer ++ Halt(err)
  }

  def asFinalizer: Process[F, O] = this match
    case Await(req, recv) =>
      await(req) {
        case Left(Kill) => this.asFinalizer
        case x => recv(x)
      }
    case Emit(head, tail) => Emit(head, tail.asFinalizer)
    case Halt(err) => Halt(err)

进一步可以实现资源安全的 resource 组合子:

scala 复制代码
def resource[R, O](acquire: IO[R])(use: R => Process[IO, O])(release: R => Process[IO, O]): Process[IO, O] =
  await[R, O](acquire) {
    case Right(r) => use(r).onComplete(release(r))
    case Left(err) => Halt(err)
  }

需要有一种手段将普通的 IO[A] 类型提升为 Process[IO, A],这样就可以将关闭某个 src 资源的 IO 指令 IO{src.close()} 转换为 Process 了。我们为此分别实现构建了 evaleval_ 函数,其中 eval_ 更特殊一些,它不对外传递后续的值。drain 方法会忽略当前 Process 的后续输出,直到递送 Halt(e) 为止。

scala 复制代码
def eval[A](ioa: IO[A]): Process[IO, A] = await(ioa) {
  case Left(err) => Halt(err)
  case Right(a) => Emit(a, Halt(End))
}

def eval_[A, B](ioa: IO[A]): Process[IO, B] = eval(ioa).drain

trait Process[F[_], O]:
  // ...
  final def drain[O2]: Process[F, O2] = this match
    case Halt(e) => Halt(e)
    case Emit(_, t) => t.drain
    case Await(req, recv) => Await(req, recv andThen (_.drain))

现在 lines 的实现如下,resources 保证它也是资源安全的:

scala 复制代码
def lines(filename: String): Process[IO, String] = resource{IO{io.Source.fromFile(filename)}}{
  src =>
    lazy val iter = src.getLines
    def step: Option[String] = if iter.hasNext then Some(iter.next()) else None
    def lines: Process[IO, String] = {
      eval(IO{step}) flatMap {
        case None => Halt(End)
        case Some(line) => Emit(line, lines)
      }
    }
    lines
  }{src => eval_{IO{src.close()}}}

单一输入过程

对于任意 IProcess[F[_], I] 并不限制它的来源 F。譬如 Option[I]List[I] 都可以作为 I 的流。考虑一种特殊的 Process,它只接受纯 I 类型的输入,又称 单一输入过程 。想在现有 Process 的定义下构建这样一个过程会有点绕,因为不能直接将元素类型 I 视作 F[_]

回想我们在 Monad 章节曾构造的 Id[A]

scala 复制代码
// 一个实际指向 A 的高阶类型别名 Id[A]。
type Id[A] = A

Id[A] 倒是可以适配 F[_] 的形状。只是没法直接创建关于 Id 的实例,因为它只是一个类型参数的别名。在 Scala 2 中,可以通借助 类型投影 机制构造一个辅助接口,见下方新的 Id[I] 的定义:

scala 复制代码
case class Id[I](){
  sealed trait f[X]
  val get = new f[I]{}
}

def Get[I] = Id[I]().get

调用 Get[I] 方法会返回一个 Id[I]#f[X],但类型 X 实际上已经和 I 绑定了。因此,类型 Is[I]#f 指代 I 本身。当然 Get[I] 返回的实例没有实际用途,因为我们不再从某个外部作用 F[_] 中获取值了。它仅仅是作为一个 "I 类型的单一输入过程" 的标签。

虽然看起来很别扭,但是总算能构造出处理单一输入过程的 Process 了。它被命名为 Process1[I, O]

scala 复制代码
type Process1[I,O] = Process[Is[I]#f, O]

当然,我们本质上做的事情还是类型 Lambda。在 Scala 3 中,可以直接如此定义 Process1 别名,以替代 Id[I]#f 这样的写法,两者的思想其实是一样的。

scala 复制代码
type Process1[I, O] = Process[[_] =>> I, O]

现在可以为 Process1 构建一系列配套的实用函数了:

scala 复制代码
def await1[I, O](
                recv: I => Process1[I, O],
                fallback: => Process1[I, O] = halt1[I, O]): Process1[I, O] =
Await[[_]=>>I, I, O](Object().asInstanceOf[I], (e: Either[Throwable, I]) => e match {
  case Left(End) => fallback
  case Left(err) => Halt(err)
  case Right(i) => Try(recv(i))
})

def emit1[I, O](h: O, tl: Process1[I, O] = halt1[I, O]): Process1[I, O] =
  Emit(h, tl)

def halt1[I, O]: Process1[I, O] = Halt[[_] =>> I, O](End)

对于 await1 函数所构造的 Await,其首个参数 req 是没有意义的,前文已经提到过,Process1 不需要从 req 当中获取元素。Object().asInstanceOf[I] 是一个虚构出来的 I 实例,它只是一个占位符,用于固定 [_] =>> II

Object().asInstanceOf[I] 是一个非法的类型转换,坦率地说这样做是有风险的。但我们的代码可以确保它不会在其他场合中被调用,所以程序在运行时不会抛出 ClassCastException。当然,可读性更好且更安全的设计是另行构建一个专门适配 Process1 的,不携带多余 req 参数的 Await1 类型,代价则是维护更多重复的代码。

新的 |> 组合子接受一个 Process1[O, O2],因为当前 Process 的单一输出 O 被直接递送给 p2,不需要经过任何的外部作用 F[_]。类似地, filter 组合子返回一个 Process1[I, I],因为过滤操作也是内化的。

scala 复制代码
trait Process[F[_], O]:
  // ...
  def filter(f: O => Boolean): Process[F, O] =  this |> filter_(f)
  
  // 有时会使用 'pipe' 作为 |> 符号的别名
  def pipe[O2](p2: Process1[O, O2]): Process[F, O2] = this |> p2
  def |>[O2](p2: Process1[O, O2]): Process[F, O2] = p2 match {
    case Halt(e) => this.kill onHalt {
      e2 => 
        println(s"e1: ${e}, e2: ${e2}")
        Halt(e) ++ Halt(e2)
    }
    case Emit(h, t) => Emit(h, this |> t)
    case Await(_: O, recv) =>
      this match {
      case Halt(err) =>
        Halt(err).pipe{recv.asInstanceOf[Either[Throwable, O] => Process1[O, O2]](Left(err)).kill}
      case Emit(h, t) =>
        t |> Try(recv.asInstanceOf[Either[Throwable, O] => Process1[O, O2]](Right(h)))
      case Await(req0, recv0) =>
        await(req0)(recv0 andThen (_ |> p2))
    }
  }

  @annotation.tailrec
  final def kill[O2]: Process[F, O2] = this match {
    case Await(_, recv) => recv(Left(Kill)).drain.onHalt {
      // 将 kill 转换为 End 保证程序正常退出。
      case Kill => Halt(End)
      case e => Halt(e)
    }
    case Halt(e) => Halt(e)
    case Emit(_, t) => t.kill
  }

end Process

def filter_[I](f: I => Boolean): Process1[I, I] =
  await1[I, I](i => if (f(i)) emit1(i) else halt1).repeat

|> 方法块内多次出现了 asInstanceOf[Either[Throwable, O] => Process1[O, O2]] 这样显式的类型转换,它们是必要的。

如果 p2 被匹配为 Await,则完整类型应该是:Await[[_] =>> O, O, O2],且 [_] =>> OO 都指向同一个 O,但这是我们基于语义 (或者称逻辑) 推断的结果。而在编译器视角,Await 定义中的流和元素是两个类型:FA。因此,在 Scala 3 的版本中,不进行类型转换的 Try(recv(Right(h))) 是无法通过编译的。

当已知 p1 ( 也就是 this ) 为 Halt(err) 时,就没有必要再执行 p2 了。这个时候只需向 p2 传递 p1err 并令其尽快切换到 Halt,这可以通过调用 kill 帮助函数来实现。

多个输入流

想象现在有两个记录华氏温度的文件 f1f2,我们想读取这两个文件记录的所有有效数值,并将它们转换成摄氏度之后汇集到一个文件。这个场景同样可以用到泛化的 Process 类型。

根据这个需求,我们具化一个新类型 Tee ( "T" 的读音,其字母形状看起来就像是两个流交汇)。还是需要适配 F 类型参数,只不过这一次要更加抽象一点。

scala 复制代码
type Tee[I1, I2, O] = Process[[X] =>> Either[I1 => X, I2 => X], O]

Either[_, _] 的大小长短正好能容纳两个流的类型 I1I2。其中 I1 => XI2 => X 限制了两个流最终的输出 X 必须是相同的,这样才能汇聚在一起。至于是 I1 => XMap[I2, X] 还是其他的形式也是无所谓的,只要观察向 awaitL / awaitR 传入的 req 就知道怎么回事了,它们只是用于装载两个类型参数的容器。紧接着可以分别定义出选择接收 I1awaitL 以及选择接收 I2awaitR,递送 haltT 和停止 emitT

scala 复制代码
def awaitL[I1, I2, O](recv: I1 => Tee[I1, I2, O], fallback: => Tee[I1, I2, O] = haltT[I1, I2, O]): Tee[I1, I2, O]     await[[X] =>> Either[I1 => X, I2 => X], I1, O](
    Left[I1 => I1, I2 => I1](_ => Object().asInstanceOf[I1])
  ){
    case Left(End) => fallback
    case Left(err) => Halt(err)
    case Right(a) => Try(recv(a))  // 此时的 Right 指代 I1
  }

def awaitR[I1, I2, O](recv: I2 => Tee[I1, I2, O], fallback: => Tee[I1, I2, O] = haltT[I1, I2, O]): Tee[I1, I2, O] =
  await[[X] =>> Either[I1 => X, I2 => X], I2, O](
    Right[I1 => I2, I2 => I2](_ => Object().asInstanceOf[I2])
  ){
    case Left(End) => fallback
    case Left(err) => Halt(err)
    case Right(a) => Try(recv(a)) // 此时的 Right 指代 I2
  }

def haltT[I1, I2, O]: Tee[I1, I2, O] = Halt[[X] =>> Either[I1 => X, I2 => X], O](End)
def emitT[I1, I2, O](h: O, t1: Tee[I1, I2, O]): Tee[I1, I2, O] =  Emit(h,t1)

Process 的通用操作 zipWith 在底层是可以通过 Tee 来表示的。我们在帮助函数 zipWith_ 中构建了默认的拉链逻辑:先从左边读取,然后再从右边读取(当然反过来也是完全可以的),这个拉链操作将在任意一边穷尽时停止。

scala 复制代码
trait Process[F[_], O]:

  //...
  def zipWith[O2, O3](p2: Process[F, O2])(f: (O, O2) => O3): Process[F, O3] =
    (this tee p2)(zipWith_(f))

  def zip[O2](p2: Process[F, O2]): Process[F, (O, O2)] =
    zipWith(p2)((_, _))

end Process

def zipWith_[I1, I2, O](f: (I1, I2) => O): Tee[I1,I2,O] = awaitL[I1, I2, O](i =>
  awaitR(i2 => emitT(f(i, i2)))).repeat

其中,p1 tee p2 表示将两个流 p1p2 接入到一个 Tee 类型:

scala 复制代码
trait Process[F[_], O]:
  //... 
  def tee[O2, O3](p2: Process[F, O2])(t: Tee[O, O2, O3]): Process[F, O3] = {
    t match {
      case Halt(e) => this.kill onComplete p2.kill onComplete Halt(e)
      case Emit(h, t) => Emit(h, (this tee p2)(t))
      case Await(side, recv) => side match {
        case Left(isO) => this match {
          case Halt(e) => p2.kill onComplete Halt(e)
          case Emit(o, ot) => (ot tee p2)(Try(recv.asInstanceOf[Either[Throwable, O] => Tee[O, O2, O3]](Right(o))))
          case Await(reqL, recvL) =>
            await(reqL)(recvL andThen (this2 => this2.tee(p2)(t)))
        }
        case Right(isO2) => p2 match {
          case Halt(e) => this.kill onComplete Halt(e)
          case Emit(o2, ot) => (this tee ot)(Try(recv.asInstanceOf[Either[Throwable, O2] => Tee[O, O2, O3]](Right(o2))))
          case Await(reqR, recvR) =>
            await(reqR)(recvR andThen (p3 => this.tee(p3)(t)))
        }
      }
    }
  }

去向 Sink

最终肯定是要把 Process[IO, O] 的输出持久化到某个地方,比如说文件,数据库等等。可以把去向看作是递送函数 ( 或者说,递送 "IO指令" ) 的过程:

scala 复制代码
type Sink[F[_], O] = Process[F, O => Process[F, Unit]]

def fileW(file: String, append: Boolean = false): Sink[IO, String] =
  resource {IO{new FileWriter(file, append)}}{
    w => constant{ (s: String) => eval(IO{w.write(s)})}
  }{w => eval_{IO{w.close()}}}

def constant[A](a: A): Process[IO, A] = eval(IO{a}).repeat

这里将 String => Sink[IO, String] 的写行为视作一个 constant 并不断重复执行,直到读取完 file 的全部内容。

我们希望用 p to s 表示 p 将输出递送到 s ,而 to 组合子实际上可以使用之前的 tee 来实现。想象一下这样的过程:一端不断递送输出 o,而另一端不断递送 IO 指令 f,汇聚的方式则是:f(o)。思路很清晰,只是这样会得到一个嵌套的 Process 结构:Process[F, Process[F, Unit]]

不需担心。关于消除嵌套作用的组合子,我们其实在之前的 Monad 章节已经介绍过了,解决方案就是join,其语义和 flatten 相同。

scala 复制代码
trait Process[F[_], O]:
  //...
  def to[O2](sink: Sink[F, O]): Process[F, Unit] =
    join {(this.zipWith(sink))((o, f) => f(o))}
end Process

// p 得是特定的 Process, 因此把 join 定义为外部函数。
def join[F[_], A](p: Process[F, Process[F, A]]): Process[F, A] =
  p.flatMap(pa => pa)

def flatMap[O2](f: O => Process[F, O2]): Process[F, O2] = this match
  case Await(req, recv) => Await(req, recv andThen (_.flatMap(f)))
  case Emit(head, tail) => Try(f(head)) ++ tail.flatMap(f)
  case Halt(err) => Halt(err)

def map[O2](f: O => O2): Process[F, O2] = this match
  case Await(req, recv) => Await(req, recv andThen(_.map(f)) )
  case Emit(head, tail) => Try{Emit(f(head), tail.map(f))}
  case Halt(err) => Halt(err)

现在这个重构的 Process API 也是一个 Monadic trait 了。看看我们还能做些什么:

比如,首先从 namelist.txt 中加载多个目标文件路径,然后将这些文件的字符内容归并 (gather) 到一个 result.txt 中:

scala 复制代码
  def usr_dir(content_root: String)(file_name: String) = content_root + file_name
  val p = usr_dir("src/main/scala/monadsIO/")

  val reduce = (for {
    out <- fileW(p("hello.txt"), append = true).once
    file_name <- lines(p("namelist.txt"))
    _ <- lines(p(file_name))
          .flatMap(s => out(s))
  } yield ()).drain

  val check_point = Semaphore(1)
  StackSafe.run(runLog(reduce, check_point))

  // 确保 runLog 释放外部资源 (这里以信号量为例子)
  assert {check_point.availablePermits() == 1}
  println("done")

我们只需要一份输出流,因此对 fileW(...) 调用了 once 方法。它的定义如下:

scala 复制代码
trait Process[F[_], O]:
  // ... 
  def take(n: Int): Process[F, O] = this |> take_(n)
  def once: Process[F, O] = take(1)

end Process

def take_[I](n: Int): Process1[I, I] =
  if (n <= 0) halt1 else await1[I, I](i => Emit(i, take_(n - 1)))

或者选择将多文件的处理结果 分派 (dispatch) 到各自的 .backup 拷贝中。可以在任意位置对数据流进行映射,过滤等操作。

scala 复制代码
val scatter = (for{
  file_name <- lines(p("namelist.txt"))
  _ <- lines(p(file_name))
    // 将读取的数值转换为 Int,过滤之后重新按照字符串输出
    .map(_.toInt).filter(_ > 20).map(i => s"[$i]\n")
    .to(fileW(p(file_name + ".backup")))
} yield ()).drain

结束

关于流式 IO 的设计有很多广泛的应用场景。有相当多的程序都可以转换成流式处理:

文件 IO ------ 我们演示过如何对字符文件进行处理了,但是我们的库同样也可以在改造后用于字节文件。

状态机,Actor ------ 大型的系统通常会使用传递消息的方式对内部的各个组件进行解耦。笔者曾简要介绍过 Typed Akka 库(Akka Typed 探索:基于 Actor 模型的设计模式与路由机制 - 掘金 (juejin.cn)),本章 Process 的设计哲学与 Akka 库的 Behavior 没有本质什么不同:它们都是 "接收消息,处理消息,切换状态" 的状态机。

大数据,分布式系统 ------ 流式处理的库可以被轻易地分布和并行,用于处理巨大的数据。这些流式处理的节点是没有必要在同一台机器上的。

相关推荐
范范082512 分钟前
Scala基础入门:从零开始学习Scala编程
开发语言·学习·scala
Jasonakeke15 分钟前
Flask 处理响应
后端·python·flask
宋发元39 分钟前
使用Go语言绘制水平柱状图教程
开发语言·后端·golang
九局下半1 小时前
【SkyWalking】如何在业务系统中控制SkyWalkingAgent的生命周期
后端
一然明月2 小时前
ASP.NET Core 基础 - 入门实例
后端·asp.net
嫦娥妹妹等等我3 小时前
Perl 语言入门学习
学习·scala·perl
一只懒鱼a3 小时前
SpringBoot之外部化配置
java·spring boot·后端·spring
小码王科技3 小时前
免费【2024】springboot 二手家电管理平台的设计与实现
java·spring boot·后端·毕业设计
Slow菜鸟3 小时前
SpringBoot教程(二十) | SpringBoot整合异步任务
java·spring boot·后端
猿究院-张睿泽4 小时前
Spring的基本概念和结构
java·开发语言·后端·mysql·spring