☰
Cats Free Monad 实战指南:用嵌入式 DSL 构建可组合、可重定向的纯函数式程序
2026/10/12 20:08:11 网站建设 项目流程
  • 后端

【免费下载链接】cats

Lightweight, modular, and extensible library for functional programming.

项目地址:https://gitcode.com/gh_mirrors/ca/cats
点击查看免费下载

本文是 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 需要五个基本步骤:

  1. 创建基于Free[_]与KVStoreA[_]的类型;
  2. 使用liftF为KVStore[_]创建智能构造器(smart constructors);
  3. 用键值 DSL 操作构建一个程序;
  4. 为 DSL 操作的程序构建一个编译器;
  5. 执行编译后的程序。
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 ConsoleCatsInterpreter

InMemoryDatasourceInterpreter 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特有的操作链:

  1. hoist(FreeT.scala,等价于mapK):把基础单子从Option换成OptionT[Try, *],即把程序"提升"到更丰富的效应栈中;
  2. 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 实践路径:

  1. 概念:Free Monad 是用任意Functor构造Monad的纯函数式构造,核心是把计算表示为可重解释的数据;
  2. 五步法:定义 ADT →type别名 →liftF智能构造器 → for-comprehension 程序 → 自然变换编译器 →foldMap执行;
  3. 解释器重定向:同一个程序可用Id(同步立即执行)、State(纯状态推进)、Future(异步)、Either(失败)等不同容器解释,实现"程序与执行分离";
  4. 组合:InjectK+EitherK让多个 ADT 无缝组合成更复杂的程序,且每个 ADT 保持独立的解释器;
  5. 栈安全:Pure/Suspend/FlatMapped三节点结构把递归编码在堆上,foldMap基于tailRecM实现 trampolining;
  6. FreeT:在构建 AST 的同时通过liftT混入基础单子效应,并通过hoist切换效应栈。

掌握了这些模式,你就能在 Cats 项目中用自由单子构建出"一次定义、多端解释"的领域语言,把副作用隔离在解释器边界,让业务逻辑保持纯粹、可测试、可组合。

  • 后端

【免费下载链接】cats

Lightweight, modular, and extensible library for functional programming.

项目地址:https://gitcode.com/gh_mirrors/ca/cats
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询