- 后端
【免费下载链接】cats
Lightweight, modular, and extensible library for functional programming.
本文是 Cats 函数式编程库中cats-free模块的完整技术指南。自由单子(Free Monad)是一种从任意Functor构造出Monad的通用构造,它允许你把"程序"当作纯数据来表示、组合与重解释:先构建语法树,再通过自然变换把它编译到任意目标解释器执行。读完本文,你将掌握 Free Monad 的五大核心能力——把有状态计算表示为数据、栈安全地运行递归计算、构建嵌入式 DSL、用自然变换重定向计算到不同解释器,以及用FreeT在构建 AST 的同时混入其他单子效应,并能在真实项目中落地这些模式。
本文全部内容基于 Cats 仓库中的官方 Free Monad 文档,并辅以 Free.scala、FreeT.scala、InjectK.scala、EitherK.scala 等源码实现与测试用例作为佐证。
什么是 Free Monad
一个自由单子(free monad)是一种构造:它允许你从任意Functor构建出一个Monad。和其他单子一样,它是一种表示与操作计算的纯函数式方式。
具体来说,自由单子提供了以下实用能力:
- 将有状态的计算表示为数据,并在之后运行它;
- 栈安全地运行递归计算;
- 构建嵌入式 DSL(领域特定语言,Domain-Specific Language);
- 使用自然变换(natural transformation)将计算重定向到另一个解释器。
在 Cats 中,表示自由单子的类型简写为Free[_]。从源码结构看,Free的定义位于 free/src/main/scala/cats/free/Free.scala,其核心注释明确写道:"A free operational monad for some functorS",即"针对某个函子S的自由操作单子",并且绑定操作使用堆而非栈来完成,从而实现尾调用消除。
在 Cats 中,表示自由单子的类型简写为
Free[_]。
使用 Free Monad:添加依赖
如果你想使用 Cats 的自由单子,需要为项目添加cats-free模块的库依赖。
在 Cats 仓库中,cats-free模块在 build.sbt 中定义为跨平台项目(JSPlatform / JVMPlatform / NativePlatform):
lazy val free = crossProject(JSPlatform, JVMPlatform, NativePlatform) .crossType(CrossType.Pure) .dependsOn(core, tests % "test-internal -> test") .settings(moduleName := "cats-free", name := "Cats Free")在你的项目中,通常只需要引入(以 sbt 为例):
libraryDependencies += "org.typelevel" %% "cats-free" % "<version>"cats-free依赖cats-core,包含Free、FreeT、Trampoline、Coyoneda等核心类型(位于 free/src/main/scala/cats/free/)。理解自由单子最好的方式是在实际代码中观察它——下面我们就用它来创建一个嵌入式 DSL。
如果你对自由单子背后的范畴论理论感兴趣,可以直接跳到文末的 理论章节。
实战:用 Free[_] 构建键值存储 DSL
研究你的主题
假设我们要为一个键值存储(key-value store)创建 DSL,希望对键做三件事:
put:将一个value关联到它的key存入存储;get:根据key从存储中取出value;delete:根据key从存储中删除value。
我们的构想是:用嵌入式 DSL 把这一系列操作写成一段"程序","编译"这段程序,最后"执行"它来真正地与键值存储交互。例如:
put("toto", 3) get("toto") // returns 3 delete("toto")同时我们希望:
- 计算被表示为纯的、不可变的值;
- 创建程序与执行程序分离;
- 能够支持多种不同的执行方式。
研究你的语法
与键值存储交互共有 3 条命令:
Put:把一个值与一个键关联后存入存储;Get:根据键把关联的值从存储中取出;Delete:根据键把值从存储中删除。
创建表示语法的 ADT
ADT 是Algebraic Data Type(代数数据类型)的缩写。在此语境下,它指一组封闭的类型集合,可以组合出复杂、递归的值。
我们需要创建一个 ADT 来表示键值操作:
sealed trait KVStoreA[A] case class PutT extends KVStoreA[Unit] case class GetT extends KVStoreA[Option[T]] case class Delete(key: String) extends KVStoreA[Unit]注意这里每个 case class 都带有一个类型参数A(KVStoreA[A]),它表示"这条操作执行成功后的返回类型":Put和Delete返回Unit,Get返回Option[T]。这个类型参数正是 Free Monad 能够把命令序列"缝合"成单子流程的关键——每个命令的返回类型会成为下一条命令的输入。
释放(Free)你的 ADT
把 ADT"解放"成 Free Monad 需要五个基本步骤:
- 创建基于
Free[_]与KVStoreA[_]的类型; - 使用
liftF为KVStore[_]创建智能构造器(smart constructors); - 用键值 DSL 操作构建一个程序;
- 为 DSL 操作的程序构建一个编译器;
- 执行编译后的程序。
1. 创建基于 ADT 的 Free 类型
import cats.free.Free type KVStore[A] = Free[KVStoreA, A]这里KVStore[A]就是Free[KVStoreA, A]的别名:外层函子为KVStoreA,最终计算结果类型为A。
2. 使用 liftF 创建智能构造器
这些方法会让 DSL 的使用体验好得多,并把KVStoreA[_]的值"提升"到KVStore[_]单子中(注意第二个类型参数少了字母 "A")。
import cats.free.Free.liftF // Put returns nothing (i.e. Unit). def putT: KVStore[Unit] = liftFKVStoreA, Unit) // Get returns a T value. def getT: KVStore[Option[T]] = liftF[KVStoreA, Option[T]](GetT) // Delete returns nothing (i.e. Unit). def delete(key: String): KVStore[Unit] = liftF(Delete(key)) // Update composes get and set, and returns nothing. def updateT: KVStore[Unit] = for { vMaybe <- getT _ <- vMaybe.map(v => putT)).getOrElse(Free.pure(())) } yield ()从源码看,Free.liftF的实现极其简单:
def liftF[F[_], A](value: F[A]): Free[F, A] = Suspend(value)它只是把F[A]包进Suspend节点。update展示了 Free Monad 的灵活之处:get返回KVStore[Option[T]],借助Option的map/getOrElse可以优雅地处理"键不存在"的分支,并用Free.pure(())作为空操作。
3. 构建程序(Program)
现在我们已经能构造KVStore[_]值,可以用for-comprehension写出"程序":
def program: KVStore[Option[Int]] = for { _ <- put("wild-cats", 2) _ <- updateInt) _ <- put("tame-cats", 5) n <- getInt _ <- delete("tame-cats") } yield n这看起来像是单子式流程,但注意:它实际上只是构建了一个递归数据结构,用来表示操作序列。程序在构建阶段不产生任何执行效果。
4. 为程序编写编译器(Compiler)
现在你应该理解了:Free[_]用来创建嵌入式 DSL。单独看,这个 DSL 只表示一个操作序列(由递归数据结构定义),它本身不产生任何东西。
Free[_]是你编程语言内部的编程语言!
所以,和任何编程语言一样,我们需要把抽象语言编译成有效的语言,然后运行它。
为此,我们使用类型容器之间的自然变换。自然变换作用于F[_]和G[_]这样的类型之间(这个特定的变换写作FunctionK[F, G],或使用符号别名F ~> G)。
在我们的例子中,用一个简单的可变Map来表示键值存储:
import cats.arrow.FunctionK import cats.{Id, ~>} import scala.collection.mutable // the program will crash if a type is incorrectly specified. def impureCompiler: KVStoreA ~> Id = new (KVStoreA ~> Id) { // a very simple (and imprecise) key-value store val kvs = mutable.Map.empty[String, Any] def applyA: Id[A] = fa match { case Put(key, value) => println(s"put($key, $value)") kvs(key) = value () case Get(key) => println(s"get($key)") kvs.get(key).asInstanceOf[A] case Delete(key) => println(s"delete($key)") kvs.remove(key) () } }请注意,这个impureCompiler是不纯的——它修改了kvs,并且用println产生了日志输出。函数式编程的全部目的不是禁止副作用,而是把副作用推到系统的边界,以已知且可控的方式处理它们。
Id[_]表示最简单的类型容器:类型本身。因此Id[Int]就是Int。这意味着我们的程序会立即执行并阻塞,直到最终值返回。
然而,我们很容易使用其他类型容器来获得不同的行为,例如:
Future[_]:异步计算;List[_]:收集多个结果;Option[_]:支持可选结果;Either[E, *]:支持失败;- 一个伪随机单子:支持不确定性;
- 等等……
这种"换一个解释器"的能力正是 Free Monad 的核心价值:同样的程序数据,可以在不同语义下运行,而程序本身无需任何修改。
5. 运行你的程序
最后一步自然是编译之后运行程序。
Free[_]只是一个递归结构,可以看作"产生其他操作的操作序列"。在这方面它与List[_]类似:我们常用折叠(如foldRight)从列表中获得单个值——折叠遍历这个结构并合并其内容。
运行Free[_]的思路完全相同:折叠递归结构,具体方式为:
- 消费每个操作;
- 用
impureCompiler把操作编译到有效语言中(如有副作用则应用它); - 计算下一个操作;
- 递归继续,直到到达
Pure状态,返回它。
这个操作称为Free.foldMap:
final def foldMap[M[_]](f: FunctionK[S,M])(M: Monad[M]): M[A] = ...M必须是一个Monad才能被"展平"(著名的Monad之下的幺半群特质)。由于Id是Monad,我们可以使用foldMap。
用之前的impureCompiler运行你的Free:
val result: Option[Int] = program.foldMap(impureCompiler)foldMap的一个重要特性是它的栈安全性(stack-safety)。它在栈上评估计算的每一步,然后退栈并重新开始。这个过程称为trampolining(蹦床)。
只要你的自然变换是栈安全的,foldMap就永远不会栈溢出。蹦床是堆密集型的,但栈安全性提供了使用Free[_]处理数据密集型任务以及流等无限进程所需的可靠性。
从源码看,Free.foldMap正是通过M.tailRecM实现栈安全的:
final def foldMap[M[_]](f: FunctionK[S, M])(implicit M: Monad[M]): M[A] = M.tailRecM(this)(_.step match { case Pure(a) => M.pure(Right(a)) case Suspend(sa) => M.map(f(sa))(Right(_)) case FlatMapped(c, g) => M.map(c.foldMap(f))(cc => Left(g(cc))) })每次循环要么返回Right(结果就绪),要么返回Left(继续计算),把"下一步做什么"以堆上的值传递,从而避免 JVM 调用栈的深度增长。
6. 使用纯编译器(可选)
前面的例子使用了带副作用的自然变换。这可行,但你可能更愿意以"更纯"的方式折叠你的Free。State 数据结构可以在不可变Map中跟踪程序状态,完全避免可变性:
import cats.data.State type KVStoreState[A] = State[Map[String, Any], A] val pureCompiler: KVStoreA ~> KVStoreState = new (KVStoreA ~> KVStoreState) { def applyA: KVStoreState[A] = fa match { case Put(key, value) => State.modify(_.updated(key, value)) case Get(key) => State.inspect(_.get(key).asInstanceOf[A]) case Delete(key) => State.modify(_ - key) } }(可以看到,我们又一次遇到了 Scala 的模式匹配受 JVM 类型擦除限制的地方,但克服起来并不难。)
运行:
val result: (Map[String, Any], Option[Int]) = program.foldMap(pureCompiler).run(Map.empty).value这里foldMap把KVStoreA的每条操作映射为一次State变换,最终通过.run(Map.empty).value得到最终状态Map[String, Any]与结果Option[Int]的二元组——全程没有任何可变状态。相比impureCompiler,纯编译器把程序的状态推进完全纳入类型系统,天然可重放、可测试。
栈安全的源码级验证
自由单子"栈安全"的说法并非空谈,FreeSuite.scala 中有大量直接测试:
- toString 是栈安全的:对
Free.pureList, Int连续做100 万次map,仍能正常输出; tailRecM是栈安全的:通过tailRecM递归 5 万次;foldMap是栈安全的:一个递归 1 万层的程序用Id解释器执行后正确返回10000;.runTailRec与.run:各 10 万次递归的flatMap链都能正常求值。
这些测试共同印证了Free把递归"编码"在堆上而非栈上的设计结论。
组合 Free Monads 的 ADT
真实世界的应用常常需要组合不同的代数(algebra)。Swierstra 在Data types à la carte中描述的injection 类型类让我们可以在Free的上下文中组合不同的代数——在 Cats 中,它就是InjectK(位于 core/src/main/scala/cats/InjectK.scala,在 Cats 1.0 之前称为Inject)。
让我们看一个简单例子:把两个互不相关的 ADT 组合成一个EitherK,从而形成更复杂的程序。
import cats.data.EitherK import cats.free.Free import cats.{Id, InjectK, ~>} import scala.collection.mutable.ListBuffer定义两个 ADT:
/* Handles user interaction */ sealed trait Interact[A] case class Ask(prompt: String) extends Interact[String] case class Tell(msg: String) extends Interact[Unit] /* Represents persistence operations */ sealed trait DataOp[A] case class AddCat(a: String) extends DataOp[Unit] case class GetAllCats() extends DataOp[List[String]]ADT 定义好后,我们可以正式声明:一个Free程序就是其各代数(algebra)的EitherK。
type CatsApp[A] = EitherK[DataOp, Interact, A]从源码看,EitherK的底层就是scala.util.Either的类型构造器版本:
final case class EitherK[F[_], G[_], A](run: Either[F[A], G[A]])而InjectK提供一对互逆的注入/投影函数:
abstract class InjectK[F[_], G[_]] { def inj: FunctionK[F, G] // 从 F 无损注入到 G def prj: FunctionK[G, λ[α => Option[F[α]]]] // 部分逆:从 G 投影回 F }注入是"不丢失任何信息"的函子变换:对于每个ga: G[A],至多有一个fa: F[A]满足inj(fa) = ga。
为了利用单子组合,我们使用智能构造器把每个代数提升到Free上下文:
class Interacts[F[_]](implicit I: InjectK[Interact, F]) { def tell(msg: String): Free[F, Unit] = Free.liftInjectF) def ask(prompt: String): Free[F, String] = Free.liftInjectF) } object Interacts { implicit def interacts[F[_]](implicit I: InjectK[Interact, F]): Interacts[F] = new Interacts[F] } class DataSource[F[_]](implicit I: InjectK[DataOp, F]) { def addCat(a: String): Free[F, Unit] = Free.liftInjectF) def getAllCats: Free[F, List[String]] = Free.liftInjectF) } object DataSource { implicit def dataSource[F[_]](implicit I: InjectK[DataOp, F]): DataSource[F] = new DataSource[F] }这里的Free.liftInject[F]对应于源码中Free.liftInject:在给定InjectK[F, G]的前提下,把F[A]注入到G[A]后再liftF进 Free:
def liftInject[G[_]]: FreeLiftInjectKPartiallyApplied[G] = ... // 内部实现: // Free.liftF(I.inj(fa)) // I: InjectK[F, G]现在 ADT 可以轻松组合,并在单子上下文中自然地交错使用:
def program(implicit I : Interacts[CatsApp], D : DataSource[CatsApp]): Free[CatsApp, Unit] = { import I._, D._ for { cat <- ask("What's the kitty's name?") _ <- addCat(cat) cats <- getAllCats _ <- tell(cats.toString) } yield () }最后,为每个 ADT 写一个解释器,并用FunctionK组合成EitherK的解释器,以便编译并应用到我们的Free程序:
object ConsoleCatsInterpreter extends (Interact ~> Id) { def applyA = i match { case Ask(prompt) => println(prompt) readLine() case Tell(msg) => println(msg) } } object InMemoryDatasourceInterpreter extends (DataOp ~> Id) { private[this] val memDataSet = new ListBuffer[String] def applyA = fa match { case AddCat(a) => memDataSet.append(a); () case GetAllCats() => memDataSet.toList } } val interpreter: CatsApp ~> Id = InMemoryDatasourceInterpreter or ConsoleCatsInterpreterInMemoryDatasourceInterpreter or ConsoleCatsInterpreter正是利用FunctionK的组合能力,把两个分别针对DataOp与Interact的解释器合并成一个针对EitherK[DataOp, Interact, *]的解释器(or即FunctionK.or,对EitherK的左右两侧分别派发)。
现在运行我们的程序——提示输入时键入 "snuggles":
import DataSource._, Interacts._ val evaled: Unit = program.foldMap(interpreter)执行时,ask打印提示并从控制台读取名字,addCat存入内存列表,getAllCats取回列表,tell打印结果——控制台上会看到类似List(snuggles)的输出。
组合的关键意义在于:Free程序本身完全不知道解释器的存在。你可以为同一组 ADT 编写任意多个解释器(内存版、数据库版、测试桩版),程序数据一字不改。
For the curious ones: Free 在理论上是什么
从数学角度讲,一个自由单子(至少在编程语言语境下)是某个遗忘函子(forgetful functor)的左伴随构造:该遗忘函子的定义域是 Monads 范畴,陪域是 Endofunctors 范畴。是不是有点懵?
具体来说,它只是一个巧妙的构造,允许我们从任意函子构建一个非常简单的 Monad。
上述遗忘函子取出一个Monad之后会:
- 遗忘其 monadic 部分(例如
flatMap函数); - 遗忘其 pointed 部分(例如
pure函数); - 保留其 functor 部分(例如
map函数)。
通过反转所有箭头来构造左伴随,我们推断出自由单子基本上是这样一种构造,它:
- 接受一个函子;
- 加上 pointed 部分(例如
pure); - 加上 monadic 行为(例如
flatMap)。
在实现层面,为了从函子构建单子,我们使用下面这个经典的归纳定义:
sealed abstract class Free[F[_], A] case class Pure[F[_], A](a: A) extends Free[F, A] case class Suspend[F[_], A](a: F[Free[F, A]]) extends Free[F, A](这推广了不动点函子的概念。)
在这个表示中:
Pure从一个A值构建Free实例(它物化了pure函数);Suspend通过把F应用于先前的Free来构建新的Free(它物化了flatMap函数)。
于是,一个典型的Free结构可能长这样:
Suspend(F(Suspend(F(Suspend(F(....(Pure(a))))))))Free是一个递归结构。它把F[A]中的A当作递归的"载体"(carrier),以Pure作为终止元素。
从计算的角度看,Free递归结构可以看作操作序列:
Pure返回一个A值并结束整个计算;Suspend是一个续体(continuation):它用挂起函子F(例如可以表示一条命令)挂起当前计算,并把控制权交还给调用者;A表示绑定到本次计算的值。
请注意,这个Free构造有一个有趣的特性:它在堆上编码递归,而不是像经典函数调用那样在栈上编码递归。这提供了前面提到的栈安全性,使得非常大的Free结构也能被安全地求值。
对照 Cats 源码,Free的三个构造子 正是Pure、Suspend与FlatMapped,其中Suspend实际存放的是S[A](即文档中归纳定义的F[Free[F, A]]在单层上的简化):
final private[free] case class Pure[S[_], A](a: A) extends Free[S, A] final private[free] case class Suspend[S[_], A](a: S[A]) extends Free[S, A] final private[free] case class FlatMapped[S[_], B, C](c: Free[S, C], f: C => Free[S, B]) extends Free[S, B]flatMap的实现(Free.scala)直接构造FlatMapped(this, f),并用注释说明:"所有左结合绑定都会被重新结合为右结合"。Free的Monad实例(catsFreeMonadForFree)把pure与flatMap分别映射到这两个构造子,并且是StackSafeMonad。
For the very curious ones
如果你去看 cats 中的实现,你会看到Free[_]ADT 中还有另一个成员:
case class FlatMapped[S[_], B, C](c: Free[S, C], f: C => Free[S, B]) extends Free[S, B]FlatMapped表示一次对子程序c的调用;当c完成时,它用c的结果调用函数f来继续计算。
它实际上是Free结构的一个优化,用于解决非常深的递归Free计算隐含的平方复杂度问题。
这与反复向List[_]追加元素的问题完全相同:随着操作序列变长,一次"贯穿"结构的flatMap会越来越慢。有了FlatMapped,Free变成右结合结构,从而避免了平方复杂度。
FreeT:在构建 AST 时混入其他效应
很多时候,我们希望在构建 Free Monad 的语法树时,穿插一些没有声明在 ADT 中的其他效应。FreeT解决了这个问题:它允许我们混合"构建 AST 的步骤"与"在其他基础单子中调用动作"。
从源码看,FreeT是Free的单子变换器(monad transformer):
sealed abstract class FreeT[S[_], M[_], A] extends Product with Serializable它的Suspend节点(FreeT.scala)同时携带基础单子效应M:
private[free] case class Suspend[S[_], M[_], A](a: M[Either[S[A], A]]) extends FreeT[S, M, A]即每个挂起点是一个M[Either[S[A], A]]:Left表示一条 ADT 命令,Right表示已经求值出的纯值。由此,FreeT在每一步都可以"选择"是执行 ADT 命令还是直接执行基础单子M中的操作。文档还说明:FreeT 的栈安全基于 Phil Freeman 的论文Stack Safety for Free,其 Scala 实现源自 Scalaz 的FreeT。
下面是一个基础的控制台应用示例:当用户输入文本时,我们用独立的State单子记录用户输入了什么。
在这个例子中,FreeT为我们提供了把"含义"(denotation)委托给State单子的选择,这比在我们自己的 ADT 里模拟State操作具有更强的等式保证(equational guarantees)。
import cats.free._ import cats._ import cats.data._ /* A base ADT for the user interaction without state semantics */ sealed abstract class Teletype[A] extends Product with Serializable final case class WriteLine(line : String) extends Teletype[Unit] final case class ReadLine(prompt : String) extends Teletype[String] type TeletypeT[M[_], A] = FreeT[Teletype, M, A] type Log = List[String] type TeletypeState[A] = State[List[String], A] /** Teletype smart constructors */ object TeletypeOps { def writeLine(line : String) : TeletypeT[TeletypeState, Unit] = FreeT.liftFTeletype, TeletypeState, Unit) def readLine(prompt : String) : TeletypeT[TeletypeState, String] = FreeT.liftFTeletype, TeletypeState, String) def log(s : String) : TeletypeT[TeletypeState, Unit] = FreeT.liftTTeletype, TeletypeState, Unit) } def program : TeletypeT[TeletypeState, Unit] = { for { userSaid <- TeletypeOps.readLine("what's up?!") _ <- TeletypeOps.log(s"user said : $userSaid") _ <- TeletypeOps.writeLine("thanks, see you soon!") } yield () } def interpreter = new (Teletype ~> TeletypeState) { def applyA : TeletypeState[A] = { fa match { case ReadLine(prompt) => println(prompt) val userInput = "hanging in here" //scala.io.StdIn.readLine() StateT.pure[Eval, List[String], A](userInput) case WriteLine(line) => StateT.pure[Eval, List[String], A](println(line)) } } } import TeletypeOps._ val state = program.foldMap(interpreter) val initialState = Nil val (stored, _) = state.run(initialState).value这里的两个操作符值得对照源码理解:
FreeT.liftF:把S[A]命令提升为Suspend(M.pure(Left(value))),即"这是一条 ADT 命令";FreeT.liftT:把M[A]效应直接提升为Suspend(M.map(value)(Right(_))),即"直接执行基础单子动作"。
所以在同一个 for-comprehension 里,readLine/writeLine走 ADT 解释器,而log直接以State效应执行——这正是 FreeT"交错构建"能力的体现。运行后stored是经过log累积的日志列表,例如List("user said : hanging in here")。
另一个例子是FreeT与上下文Ctx的基础用法:为Ctx提供Try解释器,并组合OptionT以减少样板代码:
import cats.free._ import cats._ import cats.data._ import cats.syntax.all._ import scala.util.Try sealed trait Ctx[A] case class Action(value: Int) extends Ctx[Int] def op1: FreeT[Ctx, Option, Int] = FreeT.liftFCtx, Option, Int) def op2: FreeT[Ctx, Option, Int] = FreeT.liftTCtx, Option, Int) def op3: FreeT[Ctx, Option, Int] = FreeT.pureCtx, Option, Int val opComplete: FreeT[Ctx, Option, Int] = for { a <- op1 b <- op2 c <- op3 } yield a + b + c /* Our interpreters */ type OptTry[A] = OptionT[Try, A] def tryInterpreter: Ctx ~> OptTry = new (Ctx ~> OptTry) { def applyA: OptTry[A] = { fa match { case Action(value) => OptionT.liftF(Try(value)) } } } def optTryLift: Option ~> OptTry = new (Option ~> OptTry) { def applyA: OptTry[A] = { fa match { case Some(value) => OptionT(Try(Option(value))) case None => OptionT.none } } } val hoisted = opComplete.hoist(optTryLift) val evaluated = hoisted.foldMap(tryInterpreter) val result = evaluated.value这里展示了两条FreeT特有的操作链:
hoist(FreeT.scala,等价于mapK):把基础单子从Option换成OptionT[Try, *],即把程序"提升"到更丰富的效应栈中;foldMap:用tryInterpreter把Ctx命令逐一解释为OptionT[Try, *]中的动作。
最后result的类型为OptionT[Try, Int],.value得到Try[Option[Int]]。注意三个op的构造方式也各不相同:op1是 ADT 命令(liftF),op2是基础单子效应(liftT),op3是纯值(pure)——三者可以自由地编织进同一个 for-comprehension,这正是 FreeT 相比 Free 的增量价值。
关于FreeT的栈安全性,FreeTSuite.scala 中也有直接验证:5 万次flatMap的 FlatMap 栈安全测试、mapK与compile的栈安全测试(均以 5 万步规模运行)。
更多阅读与后续工作
文档还列出了Free[_]的许多值得关注的应用,官方文档承诺未来会补充,例如:
- Trampoline
- Option
- Iteratee
- Source
- 等等……
以及Coyoneda Trick的讨论。
其中Trampoline在 Cats 中已经可以直接使用:它在 package.scala 中被定义为Free在Function0函子上的一种特化:
type Trampoline[A] = Free[Function0, A]当你的计算不需要自定义 ADT,只想获得栈安全的递归能力时,Trampoline是最轻量的选择。相关的Coyoneda、Yoneda、Cofree、FreeApplicative、FreeInvariantMonoidal等类型也都位于 free/src/main/scala/cats/free/ 目录下,并配有对应的测试套件,可以作为进一步阅读的起点。
总结
通过本文,你从零走完了一条完整的 Free Monad 实践路径:
- 概念:Free Monad 是用任意
Functor构造Monad的纯函数式构造,核心是把计算表示为可重解释的数据; - 五步法:定义 ADT →
type别名 →liftF智能构造器 → for-comprehension 程序 → 自然变换编译器 →foldMap执行; - 解释器重定向:同一个程序可用
Id(同步立即执行)、State(纯状态推进)、Future(异步)、Either(失败)等不同容器解释,实现"程序与执行分离"; - 组合:
InjectK+EitherK让多个 ADT 无缝组合成更复杂的程序,且每个 ADT 保持独立的解释器; - 栈安全:
Pure/Suspend/FlatMapped三节点结构把递归编码在堆上,foldMap基于tailRecM实现 trampolining; - FreeT:在构建 AST 的同时通过
liftT混入基础单子效应,并通过hoist切换效应栈。
掌握了这些模式,你就能在 Cats 项目中用自由单子构建出"一次定义、多端解释"的领域语言,把副作用隔离在解释器边界,让业务逻辑保持纯粹、可测试、可组合。
- 后端
【免费下载链接】cats
Lightweight, modular, and extensible library for functional programming.
相关推荐
java-design-patterns 组合子模式(Combinator Pattern)实战:用函数组合构建可复用的文本查询 DSL
java design patterns 组合子模式(Combinator Pattern)实战:用函数组合构建可复用的文本查询 DSL 组合子模式(Combi
示例工程教程Geyser扩展开发入门:从零编写你的第一个插件
Geyser扩展开发入门:从零编写你的第一个插件 什么是Geyser扩展 Geyser作为连接Minecraft: Java版和基岩版的桥梁,提供了强大的扩展系
后端FP in Scala Free Monad:构建可组合的领域特定语言
FP in Scala Free Monad:构建可组合的领域特定语言 Free Monad 是函数式编程中一个强大的抽象,它允许我们构建可组合的领域特定语言(
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考