外部作用和 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)。
分解作用
从最简单的例子开始引出我们要探讨的话题。比较两个玩家 p1
和 p2
的分数,然后将获胜者信息输出到控制台。下面是一段带有 IO 外部作用的实现,见 contest
:
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
当中:
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
函数仍然有两个职责,一个是计算需要展示的信息,然后打印那个消息到控制台。
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
,事实上总是可以将其拆解成两个步骤:
- 纯函数
A => D
,D
表示 description,也就是对f
的流程描述。 - 非纯函数
D => B
,对上述描述的解释器。解释器会涉及诸多外部作用,比如文件 IO,网络 IO,数据库访问,提交任务到线程池等等。
遵循这个思路,我们可以不断地将外部作用逐步推到最外层,而这些非纯的函数为 声明式的 shell。不过随着程序的执行,我们总是会到达这层边界。此时应该怎么办?
IO 类型
现在引入一个新的数据类型 IO
来 描述 一个即将要发生的外部作用:
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 计算。比如想要编写一个程序,它首先是提示用户以华氏度输入温度,随后它将转换成摄氏度然后返回。这个需求很容易用一般代码实现:
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
类型结果!
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
的输出:
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
现在可以返回一个有意义的值。另一方面,将 flatMap
和 map
定义为 IO 的内部方法,把 IO 提升为一个 monadic trait,这样就可以使用 Scala 提供的 for 推导式了。
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 的组合子。比如:
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 来表达计算有诸多优点。比如:
- IO 计算是一个普通的值。这意味着任意一段 IO 的描述可以被到处传递,或是复用,或是动态创建。
- 声明与执行相分离。
IO[A]
本身代表了与外界交互的抽象,交互的对象可以是文件,数据库,或者是网络。比如,完全可以改写ReadLine
,使其从某个*.txt
当中获取输入。但当我们专注于描述计算过程的时候,并不需要关心这些细节。
一切看起来十分美好。只是目前的 IO Monad 潜藏着一个严重的问题:栈溢出错误,比如前文的所定义的 forever
方法。
将一个控制流转换为数据构造子【重要】
归根结底,当前的 forever
方法会在 flatMap
返回之前再一次调用自身,它自身并不是一个尾调用。
我们已经习惯了使用函数调用让程序控制流程,现在不妨将控制流程定义为数据类型。比如将 flatMap
调用替换成返回 FlatMap
实例,然后将解释 IO 作用的过程分离到另一个尾递归的 run
函数当中。它看起来应当是这样的:
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
对象之后立刻停止。
不妨将 forever
和 run
函数放到一起去看。如果 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
函数的等价逻辑,写起来应该是这样的:
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 中,( 尤其是大量地 ) 组合函数理论上存在栈溢出的风险,这很容易模拟:
val g = List.fill(100_000)(identity).foldLeft(identity)(_ compose _)
println(g(42))
我们现在可以用构造好的 IO Monad 来优化任意可能栈溢出的递归调用:
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 功能以消除栈溢出。
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
类型,它代表了可组合的异步流程:
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:
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
别名所表达的已经很明显了:Tailrec
和 Async
都只是 Free
的一种特殊情况。
Free 显然也是一个 monadic trait。下面针对 Free
给出必要的实现 (见 freeMonad
方法),以及针对 Function0[A]
类型所实现的 runTrampoline
方法。
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
是怎么实现的:
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[_]
作用的指令以数据结构形式存储了起来。它总结了任意指令流的三种基本状态:
- Return:终止状态,表示计算已经完成,并且有一个值
A
可以使用。 - Suspend:暂停状态,表示计算正在等待一个外部作用
F[A]
完成,一旦操作结束,它将返回一个A
。 - FlatMap:连续状态,表示计算正在进行中。它表示一个当前的状态
fa
和函数f
,这个函数描述了如何用当前计算的结果进行下一步的计算。
如此层级的抽象能为我们带来什么好处呢?为了进一步对 Free 的作用有更清晰的认识,下面看另一个例子:
支持控制台 IO 的 Monad
我们构造一个 Console[A]
类型,它抽象了一个产生 A
的控制台交互:输入 ReadLine
和输出 PrintLine
。Console[A]
定义了两种可用的转换,即我们之前提到的 Par[A]
类型 ( 提交到一个线程池执行,等价于异步操作 ) 和 Function0[A]
类型 ( 仅在主线程执行,等价于同步操作 )。
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 描述控制台交互流程了:
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]
。
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
做更进一步泛化,这里的实现都是基于类型推导的。
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]
:
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
的声明:
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
,我们可以有两种解释方式,见 description1
和 description2
,Free[F[_], A]
将计算的声明和执行相互分离。
Free::run 的栈安全问题
看起来 Free::run
是一个通用的解释器。但我们在前文提到 Free 本身无法保证 run
方法是栈安全的。具体来说,这取决于外部提供的 IO Monad,但很不幸的是 Func0M
和 ParM
都无法满足要求。
为了证明这个致命而隐晦的 bug 的确存在,现在通过 .forever
构建出一个无限循环的指令流。运行这段 loop_process
就可以观察到栈溢出错误。这里以 Function0[A]
作为解释器的情况进行说明,当然同样的问题也会出现在 Par[A]
解释器上。
val loop_process: Free[Console, Unit] = printLine("beware of StackOverFlow!").forever
// SOF
loop_process.run(console2function0)(using func0M)
再来单独观察下方 func0M
提供的 flatMap
组合子,它以内联形式组合函数。
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
:
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
函数内部:
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]
专用的尾递归解释器:
Free.runTrampoline {translate(loop_process)(console2function0)}
可以通过类似的手段改进 Par
解释器,本篇不再赘述。
为什么 Free 不足以支撑流式 IO
当前的 Free 还并不是最终实现,它们不太兼容流式 IO。所谓流式 I/O,指一次只处理一小部分数据,这样可以减少内存的使用,提高程序的效率。在流式 I/O 中,数据是逐个被处理的,而不是一次性地被加载到内存中。但目前为止,Free 的设计并没有考虑这一点。
假如需要编写一个程序读取 fahrenheit.txt
,它的每一行存储了华氏度,我们需要逐行转化为摄氏度之后输出到 celsius.txt
当中。基于目前的 Free API,其实现可以如下表示:
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 处理:
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) 这篇实践中构建出来的。这里截取了一部分必要的定义。
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
的同步操作。我们在后面的代码演示中还会复用这段代码。
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 的实现似乎打破了这一点。本章会进一步延申关于引用透明的概念,即假定一些应用被限制在表达式内部,并且能够保证程序的其余部分是感知不到的。
我们先来看一段朴素的快排代码:
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 的类型系统提供两点保证,违反任意一条将编译不通过:
- 一旦某个过程持有一个可变对象的引用,则它对外部不可见。
- 可变对象在被创建的范围之外也是不可见的。
quicksort
的例子满足第一条,因为 arr
对外部是不可见的,而第二条的要求则更细,即不能把可变状态的引用发布到可变范围之外的地方,我们将这种内化影响的数据结构称之 StateTransition
( 后文简称 ST
):
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 了:
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
指令流内部被捕获。
注意,STRef
被 sealed
关键字修饰,这意味着我们只能通过调用伴生对象的 apply()
方法去创建它。
// 一种可变引用的代数表达
// 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
指令创建并变量了:
val process = for {
r1 <- StateTransitionRef(10)
x <- r1.read
_ <- r1.write(20)
} yield x
尝试在外部修改引用
下一步要做的事情就是想办法让 ST
流程运行起来。这实现起来并不难,更重要的是避免 STRef
发布到外界。我们得先分辨哪些行为是安全的,哪些则是不安全的。对于一段 ST
指令流:
- 返回
ST[S, STRef[S, V]]
是不安全的,这意味着外部可以获取当前ST
的STRef
并修改引用。 - 返回
ST[S, V]
是安全的,这意味着发布了一个不变的字面量V
。
假设我们编写一段返回 ST[S, STRef[S, V]]
的 ST
指令,那么 Scala 编译器应当阻止这个行为。从更抽象的层面来说,编译器应该阻止发布任何 ST[S, T]
。只要 T
涉及到了 S
,就这意味着 T
是属于某个 ST
的内部状态。为了安全地运行 ST
,我们创建了一个新的 trait RunnableST
:
trait RunnableStateTransition[A]:
def apply[S]: StateTransition[S, A]
object StateTransition:
//..
def run[A](runST: RunnableStateTransition[A]): A = runST.apply[Unit].run(())._1
RunnableST
的 apply
和它返回的 ST
绑定了同一个 S
令牌。然而这里做出的限制是:构建 RunnableST
实例的时候不允许从外部确定 S
类型。这样做的直接结果是用户没法在外部让 STRef
在不同的 ST
指令流之间流通,见下方的代码。一旦我们这么做,Scala 编译器将报出编译错误,因为它无法证明某个 ST
流的 STRef
和另一段 ST
指令流持有的 S
类型是同一类型。
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
运行。
引用安全的可变数组
如果已经理解了 STRef
和 RunnableST
,那么再构建一个 STArray
也不是一件难事了。类似的,可变数组有三个基本的原语:分配,读取和修改。
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
。如你所见,qs
,partition
,以及 STArray
内部的 swap
方法都是纯函数。由 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
})
}
纯粹性是相对于上下文的
我们已经意识到,只有将可变数据被局限在一定的范围内,且不会泄露引用,这个外部作用才不可见。然而,作用可不可见也要分谁的视角。比如:
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 这样的外部作用显然也是副作用的一种,但是从情感上来说:“一个副作用发生了”,这往往是我们反而不希望发生的,因为其他观测或跟踪那个外部状态的调用极有可能会受到影响,从而破坏整体的引用透明性,或者是程序的语义。
具体怎么样算 "不影响程序的语义" 呢?再看一个例子:
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]
,而是一个状态机:
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 有三种状态:
Emit(head, tail)
将head: O
元素发射 (emit) 到输出流,tail: Processp[I, O]
代表了后续一系列的状态。默认情况下tail
的值为Halt()
,表示发射当前元素之后没有其他的 IO。Await(recv)
尝试从输入流中得到下一个有效值并传入到recv
函数中。在recv
被触发之后,切换到其他状态。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,或称 ‘递送" ) 给输出流。
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。为了处理整个流,我们得递归这个过程,令其持续地接受输入。见 repeat
和 lift
组合子的实现:
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 p
被 repeat
组合子修饰,这意味着 p
在到达 Halt 状态之后会立刻重启自身;如果 p
的状态是 Await(recv)
且外部传入了有意义的值 i
,则在应用 i
后继续递归这个过程 —— 这个逻辑被包装在了另一个 Await 中等待延迟执行。这样 p.repeat
和 apply
就构成了交替执行的协程,这种技巧在之前的 IO Monad 章节中已经出现过了。
非等待 (Await) 的 Process 不能直接使用 repeat
。诸如 Emit(1).repeat
会导致程序构建 Process 的过程中出现 SOF 错误,而 Halt().repeat
则会使程序陷入忙等。Process 是流转换器,即使我们要得到一个无限流,那也应该是从另一个无限流当中得来的。
// 先构建一个 LazyUnit((), (), ...) 流,
// 再通过 (_:Unit) => 1 变换成 LazyUnit(1, 1, ...)
lift((_:Unit) => 1)(LazyList.continually(()))
更多例子
另一个实用的是过滤流 filter
。它的实现非常直观,如果满足则递送到输出,否则忽略这个输入并重启。
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
类型为例子。
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)
还有很多实用的逻辑,比如 take
,drop
,takeWhile
,dropWhile
等,它们的用法与 List / LazyList 的同名 API 完全相同,因此用户可以按照熟悉地方式转换流。
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
求平均值:
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 在首次检测到无输入值时发射计数值然后停止。它的实现可以是这样:
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
显然,sum
,count
,mean
都具有相同的模式,每个模式都有自己的内部状态。我们可以将其提取出一个公用的 loop:
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
似乎是可以通过组合 count
和 sum
来实现的。在此之前,我们可以先探讨一下 Process 之间可以被如何组合。
组合流与追加处理
在我们的构想中,Process 应当是可组合的。基础的操作是管道操作,这里使用 |>
符号来表示。若有管道 p1 |> p2
,则输入 I
会经由 p1
转换为 O
,随后再作为输入传递给 p2
,最终递送出 O2
。由于 p1
和 p2
都是有状态的流转换器,因此它们在产生输出之后都会各自切换到下一个状态。
// 当前状态: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
的状态:
- 如果
p1
为 Emit,则发射h1
后切换到t1
。另一方面,h1
被发射给p2
,此时p2
被解析为f(Some(h1))
。 - 如果
p1
也为 Await,那么依序将p1
和p2
合并为一个Await
。在新的延迟操作中,p1
被解析为g(i)
,i
代表之后向这条管道的输入。 - 如果
p1
为 Halt,则显然p2
不会接收到有意义的值,这里使用f(None)
来驱动p2
切换到下一个状态。
有了 |>
我们可以很容易地实现 map
组合子:
def map[O2](f: O => O2): Process[I, O2] = this |> lift(f)
很显然,Process 也是一个函子。这里如果忽略输入类型 I
,那么 Process[_, O]
代表了一系列 O
的值。
可以 append
(++
) 一个流到另一个。比如 x ++ y
表示执行完 x
之后将余下的输入继续执行 y
。因此,x
必须是一个有限流,否则 y
永远不会被应用。
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
为了避免混淆,这里通过举例来区分 ++
和 |>
的作用:
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)
对于第一个例子 procees1
:liftOne(_ + 1)
对流内的第一个元素应用加一,随后切换到 liftOne(_ + 1)
,对流的第二个元素应用加二。这样最终的结果就是:List(2, 4)
。
对于第二个例子 process2
:lift(_ + 1)
在对流内的每一个元素应用加一之后发射给 lift(_ + 2)
,然后再应用加二。这样最终的结果就是 List(4, 5)
。
构建 Process Monad
可以基于 ++
组合子进一步定义出 flatMap
:
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
的定义是,发射一个给定的输入后就停下来。
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
:
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 sum
和 count
两个流来实现求均值 mean
了:
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 作用。
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: B
和 g: (B, A) => B
两个参数,这样用户可以按照自己预期的方式来收集结果。现在可以很容易地处理前文提出的需求了:下面的代码演示了用 Process 读取文件,过滤注释行,将华氏度有效值转换为摄氏度,最后以字符串形式收集结果。
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 是可组合的。在原语组合子构造完毕后,只需要使用 |>
,lift
,filter
就能够搞定大部分的操作。
可拓展的处理类型
LazyList[A]
毕竟只是数据流 A
的一种可能形式。进一步将外界的输入流抽象为附带作用的 F[A]
:
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
:
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 的一个特例:
// 在前文中,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
内部:
// 方便以柯里化的方式初始化 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 状态之后会停止。如果模式匹配出的 e
是 End
类型,则可以判定程序正常结束,否则将其视作异常中断。关于如何处理捕获到的异常,用户可以在 case Halt(e) => ...
这条分支上做更多的拓展,比如将错误信息写入到日志等等,也可以抛出异常,直到 runLog
最外部的 try ... catch
段去处理。
runLog
总是会返回所有累积的正确结果,并在最后关闭一些外部资源。在这里,外部资源是用一个信号量 Semaphore
对象来模拟的。下面是一个通过 runLog
实现顺序读取文件的例子:
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)
有一个瑕疵是,runLog
的 src
也会隐含地持有一个文件资源 ( 在刚才的例子里是 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 信号并继续运行一些重要的资源释放操作,尽管消费者希望提前结束。
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
组合子:
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 了。我们为此分别实现构建了 eval
和 eval_
函数,其中 eval_
更特殊一些,它不对外传递后续的值。drain
方法会忽略当前 Process 的后续输出,直到递送 Halt(e)
为止。
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
保证它也是资源安全的:
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()}}}
单一输入过程
对于任意 I
,Process[F[_], I]
并不限制它的来源 F
。譬如 Option[I]
,List[I]
都可以作为 I
的流。考虑一种特殊的 Process,它只接受纯 I
类型的输入,又称 单一输入过程。想在现有 Process 的定义下构建这样一个过程会有点绕,因为不能直接将元素类型 I
视作 F[_]
。
回想我们在 Monad 章节曾构造的 Id[A]
:
// 一个实际指向 A 的高阶类型别名 Id[A]。
type Id[A] = A
Id[A]
倒是可以适配 F[_]
的形状。只是没法直接创建关于 Id
的实例,因为它只是一个类型参数的别名。在 Scala 2 中,可以通借助 类型投影 机制构造一个辅助接口,见下方新的 Id[I]
的定义:
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]
:
type Process1[I,O] = Process[Is[I]#f, O]
当然,我们本质上做的事情还是类型 Lambda。在 Scala 3 中,可以直接如此定义 Process1
别名,以替代 Id[I]#f
这样的写法,两者的思想其实是一样的。
type Process1[I, O] = Process[[_] =>> I, O]
现在可以为 Process1 构建一系列配套的实用函数了:
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
实例,它只是一个占位符,用于固定 [_] =>> I
的 I
。
Object().asInstanceOf[I]
是一个非法的类型转换,坦率地说这样做是有风险的。但我们的代码可以确保它不会在其他场合中被调用,所以程序在运行时不会抛出ClassCastException
。当然,可读性更好且更安全的设计是另行构建一个专门适配 Process1 的,不携带多余req
参数的 Await1 类型,代价则是维护更多重复的代码。
新的 |>
组合子接受一个 Process1[O, O2]
,因为当前 Process 的单一输出 O
被直接递送给 p2
,不需要经过任何的外部作用 F[_]
。类似地, filter
组合子返回一个 Process1[I, I]
,因为过滤操作也是内化的。
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]
,且 [_] =>> O
和 O
都指向同一个 O
,但这是我们基于语义 (或者称逻辑) 推断的结果。而在编译器视角,Await 定义中的流和元素是两个类型:F
与 A
。因此,在 Scala 3 的版本中,不进行类型转换的 Try(recv(Right(h)))
是无法通过编译的。
当已知 p1
( 也就是 this
) 为 Halt(err)
时,就没有必要再执行 p2
了。这个时候只需向 p2
传递 p1
的 err
并令其尽快切换到 Halt,这可以通过调用 kill
帮助函数来实现。
多个输入流
想象现在有两个记录华氏温度的文件 f1
和 f2
,我们想读取这两个文件记录的所有有效数值,并将它们转换成摄氏度之后汇集到一个文件。这个场景同样可以用到泛化的 Process 类型。
根据这个需求,我们具化一个新类型 Tee ( "T" 的读音,其字母形状看起来就像是两个流交汇)。还是需要适配 F
类型参数,只不过这一次要更加抽象一点。
type Tee[I1, I2, O] = Process[[X] =>> Either[I1 => X, I2 => X], O]
Either[_, _]
的大小长短正好能容纳两个流的类型 I1
和 I2
。其中 I1 => X
和 I2 => X
限制了两个流最终的输出 X
必须是相同的,这样才能汇聚在一起。至于是 I1 => X
,Map[I2, X]
还是其他的形式也是无所谓的,只要观察向 awaitL
/ awaitR
传入的 req
就知道怎么回事了,它们只是用于装载两个类型参数的容器。紧接着可以分别定义出选择接收 I1
的 awaitL
以及选择接收 I2
的 awaitR
,递送 haltT
和停止 emitT
:
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_
中构建了默认的拉链逻辑:先从左边读取,然后再从右边读取(当然反过来也是完全可以的),这个拉链操作将在任意一边穷尽时停止。
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
表示将两个流 p1
和 p2
接入到一个 Tee 类型:
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指令" ) 的过程:
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
相同。
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
中:
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
方法。它的定义如下:
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
拷贝中。可以在任意位置对数据流进行映射,过滤等操作。
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 没有本质什么不同:它们都是 “接收消息,处理消息,切换状态” 的状态机。
大数据,分布式系统 —— 流式处理的库可以被轻易地分布和并行,用于处理巨大的数据。这些流式处理的节点是没有必要在同一台机器上的。