Scala 函数式编程核心抽象指南

版本说明:本指南以 Scala 3 语法为主(usinggivenenum、类型 lambda [A] =>> ... 等)。部分章节使用 Scala 2 语法以展示兼容场景,均会在代码块前单独标注。

第一章 理解高阶类型参数 F[_]:从具体类型到类型构造器

我们将深入理解函数式编程中无处不在的 F[_] 结构。通过“盒子”比喻,我们逐步从具体类型走到高阶抽象,并展示它在实际开发中的定义、实现与使用。

1. 从具体的盒子开始

在 Scala 中,我们每天都在使用“装东西的盒子”:

val num: Int = 42                    // 一个整数
val list: List[Int] = List(1, 2, 3)  // 一个装着整数的列表
val maybe: Option[String] = Some("Hi") // 一个可能装着字符串的盒子

IntStringBoolean 这些是具体类型,可以直接用来声明变量。而 List[Int]Option[String] 也是具体类型,但它们是由两部分组成的:一个盒子模具和一个物品类型

  • List 是一个盒子模具,你给它 Int,它就造出 List[Int] 这个具体的盒子类型。
  • Option 也是一个模具,你给它 String,它就造出 Option[String]

那么,ListOption 本身是什么?它们不是具体的类型(不能直接声明 val x: List),而是类型构造器。为了描述类型构造器与具体类型的区别,我们需要一个概念:Kind(阶)

2. 类型的“类型”:Kind(阶)

在编程中,有类型(42: Int"hi": String),而类型本身也有类型,这个“类型的类型”就叫 Kind

我们用 *(读作“type”)来表示一个完全具体、不需要任何参数的类型

  • Int 的 Kind 是 *
  • String 的 Kind 是 *
  • List[Int] 的 Kind 也是 *(因为它已经是具体类型)

List 本身呢?它不是一个具体的类型,而是需要接收一个具体类型才能变成具体类型的东西。它的 Kind 记为 * -> *,意思是“你给我一个 *,我还你一个 *”。这就是一阶类型构造器

同理:

  • Option 的 Kind 是 * -> *
  • Future 的 Kind 是 * -> *
  • IO 的 Kind 是 * -> *

用盒子比喻来说:

  • 0 阶(Kind *:已经做好的、可以直接装东西的盒子,比如 IntList[String]
  • 1 阶(Kind * -> *:一个空盒子模具,你需要放入一种物品的类型,它才给你一个具体的盒子类型,比如 ListOptionIO

多参数类型构造器:多槽模具

并非所有模具都只接收一个类型参数。有些模具需要两个或多个物品类型才能造出一个具体盒子。这些就是多参数类型构造器

  • Either 的 Kind 是 * -> * -> *,因为它需要两个类型:一个错误类型,一个成功类型,才能得到 Either[E, A] 这种具体盒子。
  • Map 的 Kind 是 * -> * -> *,需要键类型和值类型。
  • 函数类型 Function1(即 A => B)的 Kind 也是 * -> * -> *

比喻Either 是一个有两个槽的模具。你往第一个槽放入 String(错误类型),第二个槽放入 Int(成功类型),它就造出 Either[String, Int] 这个具体的“可能出错也可能成功的盒子”。

在 Scala 中,这些多参数模具本身不能直接填入 F[_],因为 F[_] 要求模具的 Kind 必须是 * -> *。但我们可以通过部分应用来固定某些槽,使其变成一个 * -> * 的模具。例如:

// 固定左边为 String,右边仍待填充 —— 变成了单槽模具
type ErrorOr[A] = Either[String, A]

// 也可以使用 Scala 3 的类型 lambda 匿名写法
type ErrorOrLambda = [A] =>> Either[String, A]   // Scala 3 语法

这就把一个双槽模具变成了一个单槽模具,从而融入高阶抽象的世界。

3. 类型构造器的占位符:F[_]F[_, _]

当我们想写一个函数,它能处理任意一种盒子(单槽模具),就需要一个类型构造器的占位符。Scala 使用 F[_] 来表示“某个接收一个类型参数的盒子模具”(即 Kind 为 * -> * 的东西)。

例如,一个通用的“计算盒子内元素个数”的函数可以这样声明:

def countElements[F[_]](fa: F[Int]): Int = ???

F[_] 声明了 F 的 Kind 为 * -> *fa 的类型 F[Int] 则是用模具 F 和物品 Int 造出的具体盒子。

如果我们要抽象一个双槽模具呢?Scala 3 允许使用 F[_, _] 语法:

// Scala 3 语法
def describeBifunctor[F[_, _]: Bifunctor, A, B](fab: F[A, B]): String = ???

F[_, _] 表示 F 是一个 Kind 为 * -> * -> * 的类型构造器。Bifunctor 就是一个针对双参数模具的高阶类型类,它的 Kind 是 (* -> * -> *) -> *

同样,我们可以定义接受双参数模具的高阶 trait:

trait Bifunctor[F[_, _]]:
  def bimap[A, B, C, D](fab: F[A, B])(f: A => C, g: B => D): F[C, D]

BifunctorEitherTuple2 等双参数模具提供能力认证。这进一步说明:高阶抽象的阶数由我们抽象的模具的 Kind 决定

  • 抽象单参数模具 → (* -> *) -> *(如 FunctorMonad
  • 抽象双参数模具 → (* -> * -> *) -> *(如 Bifunctor

4. 用 F[_] 构建高阶抽象(Kind 的升阶)

当一个 traitclass 自身接受 F[_] 作为类型参数时,它就变成了一个高阶类型构造器。因为它的参数本身已经是一个类型构造器了(Kind * -> *),所以它自身的 Kind 就是 (* -> *) -> *

最典型的例子就是函数式编程中的核心类型类:

trait Functor[F[_]]:
  def map[A, B](fa: F[A])(f: A => B): F[B]

Functor 不关心盒子里装的是什么,它只关心盒子模具 F 是否具备 map 能力。它的 Kind 是 (* -> *) -> *

比喻Functor 是一个“模具认证机构”,你给它一个单槽模具(如 List),它给你一个 Functor[List] 的证书,证明 List 可以调用 map

同样,MonadApplicativeFoldable 等都是这种高阶类型构造器,它们的 Kind 都是 (* -> *) -> *。这也解释了为什么你不能写 Functor[Int] —— 因为 Int 的 Kind 是 *,不符合 Functor 需要 * -> * 的要求,编译器会拒绝。

5. 在实际业务中定义抽象:Repository 的例子

在函数式领域驱动设计(DDD)中,我们经常需要将副作用(如数据库访问)抽象出来。这时 F[_] 就派上了大用场。

为了后续示例的自包含,我们首先定义必要的领域类型:

type OrderId = String
case class OrderError(msg: String)
case class Order(id: OrderId, items: List[String], status: String)
object Order:
  def cancel(order: Order, reason: String): Either[OrderError, Order] =
    // 纯函数:根据当前订单状态决定是否取消
    if order.status == "placed" then
      Right(order.copy(status = "cancelled"))
    else
      Left(OrderError(s"Cannot cancel order in ${order.status}"))

import java.time.Instant
trait Clock[F[_]]:
  def realTimeInstant: F[Instant]

然后定义抽象的 OrderRepository

trait OrderRepository[F[_]]:
  def find(id: OrderId): F[Option[Order]]
  def save(order: Order): F[Unit]

OrderRepository 自身也是一个高阶类型构造器,Kind 为 (* -> *) -> *。它接收一个类型构造器 F(Kind * -> *),表示产生副作用的计算上下文F 可以是:

  • Future:表示一个异步计算
  • IO(Cats Effect):表示一个惰性的、可组合的副作用蓝图
  • ErrorOr(我们之前定义的 Either[String, A] 别名):表示一个可能失败的纯计算

注意这里的 ErrorOr 正是我们前面提到的“多参数模具部分应用”的实例:Either 原本是双槽模具,固定错误类型后变成了单槽模具,因此可以填入 F[_] 的位置。这种灵活性完全归功于 Kind 系统。

F[_] 在 Repository 中的三层角色

OrderRepository 中的 F[_] 不仅仅是一个类型参数占位符,它精确地体现了函数式架构的核心原则:变与不变的分离。具体来说,F[_] 在此处扮演了三层角色。

第一层:统一接口形状(不变部分)

无论 F 最终被替换为 IOFuture 还是 Either[String, *]findsave 方法的输入输出类型结构永远保持不变:

  • find 永远接收 OrderId,永远返回“一个可能装着 Order 的东西”。
  • save 永远接收 Order,永远返回“一个表示完成的东西”。

这些固定的形状就是业务逻辑可以依赖的不变量,所有应用服务代码都基于此骨架编写,完全不关心 F 的具体身份。

第二层:赋予效果能力(可变部分)

当我们将 F 具体化为不同的类型构造器时,这些类型各自携带了不同的“操作集”——这并非由 OrderRepository 定义,而是 F 本身作为 IOFutureEither 从标准库或 Cats Effect 中带来的能力:

F 的具体值 赋予的操作集(可变部分) 含义
IO mapflatMapstart(并发)、timeoutretry 惰性的、可组合的、支持并发的副作用蓝图
Future mapflatMap,急切执行 立即启动的异步计算
Either[String, *] 仅有 mapflatMap,无副作用 纯同步计算,只能表达成功或失败

比喻OrderRepository 就像一个万能插座,它只规定了“插头必须是三脚的,供电电压是 220V”。你可以插上一个电风扇(IO),它带来吹风的能力;也可以插上一个电暖器(Future),它带来加热的能力。插座本身不关心你插什么,它只定义接口。

第三层:限定返回类型的双层结构

F[_] 确实限定了返回类型的“外壳”,但限定的方式非常精确:

  • find 返回 F[Option[Order]],而不是裸的 Option[Order],也不是 IO[Option[Order]]——它只规定外层必须是某个效果类型,内层数据类型固定
  • F[_] 没有限定 F 具体是 IO 还是 Future,这个决定权被推迟到了使用接口的地方(调用者或依赖注入)。

比喻F[_] 像快递包装盒的规格标签,规定“里面必须是一个信封,信封里装着一封信”,但至于这封信是通过邮政、顺丰还是专人送达,标签上不写,由你实际发货时再决定。

这三层角色共同带来了三个工程收益:

  1. 业务逻辑与效果解耦:单元测试时可以将 F 实例化为 Either[String, *]Id,完全同步地验证业务逻辑,无需模拟异步运行时。
  2. 效果能力可组合:如果需要给某个操作加上“10 秒超时”和“失败重试 3 次”,这些能力是 IO 自带的操作集,只需在调用处用 IO 的组合子包装,而不需修改 OrderRepository 接口。
  3. 类型安全的双层错误处理F[Either[String, Order]] 这样的返回类型强制调用方同时处理“效果失败”(如数据库连接超时,IO 层面)和“业务失败”(如“订单未找到”,EitherLeft),编译器确保不会遗漏。

6. 将抽象变为具体:实现 OrderRepository

当我们决定用 IO 作为实际的副作用类型时,就可以这样实现:

Scala 3 / Cats Effect 3 写法:

import cats.effect.IO

class DbOrderRepository extends OrderRepository[IO]:
  def find(id: OrderId): IO[Option[Order]] =
    IO.blocking {
      // 实际访问数据库
      database.runQuery(s"SELECT * FROM orders WHERE id = $id")
    }

  def save(order: Order): IO[Unit] =
    IO.blocking {
      database.runUpdate(s"UPDATE orders SET ...")
    }

这里我们把 F[_] 具体化IOIO 本身就是 Kind 为 * -> * 的模具,完全符合 F[_] 的要求。方法签名中的 F[Option[Order]] 变成了 IO[Option[Order]],一切都对上了。

如果我们想要一个不涉及真实副作用的测试实现,可以换用我们之前定义的 ErrorOr 类型别名(即 Either[String, A]):

// 先定义类型别名,将 Either 变成单槽模具
type ErrorOr[A] = Either[String, A]  // 此处重复定义仅为示例自包含,前文已有相同定义

class InMemoryOrderRepository extends OrderRepository[ErrorOr]:
  def find(id: OrderId): ErrorOr[Option[Order]] =
    Right(store.get(id))

  def save(order: Order): ErrorOr[Unit] =
    Right { store = store + (order.id -> order) }

ErrorOr 的 Kind 是 * -> *,所以它也能填入 F[_] 的位置。这就是高阶抽象的威力:同一个接口,多种运行时解释,甚至可以从异步上下文(IO)切换到纯同步上下文(ErrorOr)。

7. 在应用服务中使用抽象:组合 F[_] 上下文

有了抽象的 OrderRepository[F],我们可以编写同样抽象的应用服务。这里我们展示两种语法风格。

Scala 3 风格(使用 usingsummon):

import cats.MonadError
import cats.syntax.all.*

def cancelOrder[F[_]: Clock](
  id: OrderId, reason: String
)(
  using
  repo: OrderRepository[F],
  ME: MonadError[F, OrderError]
): F[Order] =
  for
    now   <- summon[Clock[F]].realTimeInstant   // summon 相当于 implicitly(Scala 3 推荐用法)
    opt   <- repo.find(id)
    order <- ME.fromEither(opt.toRight(OrderError("Not found")))
    cancelled <- ME.fromEither(Order.cancel(order, reason))
    _ <- repo.save(cancelled)
  yield cancelled

Scala 2 风格(使用 implicit 参数):

import cats.MonadError
import cats.implicits._

def cancelOrder[F[_]: Clock](
  id: OrderId, reason: String
)(
  implicit repo: OrderRepository[F],
  ME: MonadError[F, OrderError]
): F[Order] =
  for {
    now       <- implicitly[Clock[F]].realTimeInstant
    opt       <- repo.find(id)
    order     <- ME.fromEither(opt.toRight(OrderError("Not found")))
    cancelled <- ME.fromEither(Order.cancel(order, reason))
    _         <- repo.save(cancelled)
  } yield cancelled

这里 F[_] 要求 F 是一个单槽模具,而 MonadError[F, OrderError] 则说明 F 必须具备在 OrderError 错误类型下处理错误的能力。MonadError 本身也是一个高阶类型类,它的 Kind 是 (* -> *) -> * -> *(因为除了接受一个类型构造器 F 外,还接受一个具体的错误类型 E)。函数体内所有操作都在 F 上下文里进行,for 推导自动处理 flatMap,错误也会自动短路。

调用时,我们只需提供具体的实现:

// 生产环境(Scala 3)
given OrderRepository[IO] = new DbOrderRepository
// cancelOrder[IO](id, reason) 将返回 IO[Order]

// 测试环境(Scala 2)
implicit val testRepo: OrderRepository[ErrorOr] = new InMemoryOrderRepository
// cancelOrder[ErrorOr](id, reason) 将返回 ErrorOr[Order]

整个应用逻辑只写一次,通过传入不同的 F 实例来切换运行方式。这里 F 既可以是 IO,也可以是部分应用后的 ErrorOr,多参数模具的灵活性在这里体现得淋漓尽致。

8. 更深的高阶:Monad Transformer 中的 Kind

前面我们看到,当业务逻辑需要同时处理“副作用上下文 F”和“可失败性 Either”时,会写出 F[Either[E, A]] 这种嵌套类型。直接用 for 推导操作这种嵌套类型很麻烦,这时就需要 Monad Transformer,比如 EitherT

EitherT 的定义如下:

final case class EitherT[F[_], E, A](value: F[Either[E, A]])

它的 Kind 是多少?

  • F[_]* -> *
  • E*
  • A*

所以 EitherT 的 Kind 是 (* -> *) -> * -> * -> *,属于极高阶的类型构造器。

从抽象到具体:一个看得见的例子

让我们用本指南中的实际类型来验证这个 Kind 推导。假设我们已经有 Order 类型,使用 IO 作为效果,String 作为错误类型:

import cats.effect.IO

// 一个具体的 F[Either[E, A]] 值
val orderEither: IO[Either[String, Order]] = IO.pure(Right(Order("1", Nil, "draft")))

// 用 EitherT 包装它
val orderEitherT: EitherT[IO, String, Order] = EitherT(orderEither)

现在看看这个具体类型的 Kind 是如何逐层传递的:

  1. IO 的 Kind 是 * -> *
  2. String 的 Kind 是 *
  3. Order 的 Kind 是 *

将这三个参数代入 EitherT[F[_], E, A]

  • F = IO (Kind * -> *
  • E = String (Kind *
  • A = Order (Kind *

因此,EitherT[IO, String, Order] 本身是一个具体类型(Kind *),可以直接用来声明变量,就像 IntList[String] 一样。这正是 Kind 系统的美妙之处:一个极高阶的类型构造器,在填入了所有需要的类型参数后,最终产出的仍然是一个普通的、可以实例化的具体类型。

比喻EitherT 就像一台“模具组合机”,它接收三个零件——一个单槽模具 F、一个错误类型 E 和一个成功类型 A——然后将它们装配成一个完整的、可以直接使用的“成品盒子”模具。上例中,我们给它 IOStringOrder,它就造出了 EitherT[IO, String, Order] 这个能装东西的盒子。

它的作用是:把两层盒子压成一个单层盒子,使得我们可以在 for 推导中像操作普通 Monad 一样操作它。Kind 系统在这里确保了所有嵌套的正确性。

"两层盒子压成一层"的本质

当你有 IO[Either[String, Int]] 这样的类型时,要操作里面的 Int,必须依次拆开外层 IO 和内层 Either。这相当于每次都要手动拆两层包裹EitherTIO[Either[String, A]] 包装成 EitherT[IO, String, A] —— 一个新的单层盒子,你可以在 for 推导中直接操作 A,遇到错误自动短路,无需关心内部结构。

对比示例

假设我们有两个返回 IO[Either[String, Int]] 的异步操作:

import cats.effect.IO
import cats.syntax.all._
import cats.data.EitherT

def fetchAge(userId: Int): IO[Either[String, Int]] =
  IO.pure(Right(25))

def validateAge(age: Int): IO[Either[String, Int]] =
  if age >= 18 then IO.pure(Right(age))
  else IO.pure(Left("Too young"))

不用 EitherT:手动拆两层盒子

def process(userId: Int): IO[Either[String, Int]] =
  fetchAge(userId).flatMap { eitherAge =>   // 拆开 IO 盒子
    eitherAge match {                       // 再拆开 Either 盒子
      case Right(age) =>
        validateAge(age).map { eitherValid => // 又拆开 IO 盒子
          eitherValid match {                // 又要拆开 Either
            case Right(validAge) => Right(validAge)
            case Left(err)      => Left(err)
          }
        }
      case Left(err) => IO.pure(Left(err))
    }
  }

用 EitherT:压平成单层盒子

def processT(userId: Int): EitherT[IO, String, Int] =
  for {
    age      <- EitherT(fetchAge(userId))   // 直接拿到 age: Int
    validAge <- EitherT(validateAge(age))   // 直接拿到 validAge: Int
  } yield validAge

最后调用 .value 可以随时还原成原始的 IO[Either[String, Int]] 类型,与外界接口完全兼容:

val result: IO[Either[String, Int]] = processT(1).value

比喻EitherT 就像一台自动开箱机,把两层包裹变成一个单层盒子,for 推导中的 <- 直接给出里面的值。当遇到 Left 时,整个计算自动短路,无需显式判断。相当于你把套了两层的包裹扔进去,它吐出一个只包了一层的包裹。整个过程中你对这个新包裹做的任何操作,机器都自动帮你处理里面的箱子结构。

9. 总结:F[_] 与 Kind 的思维模型

概念 说明 Kind 比喻
具体类型 (Int, List[String]) 可以直接使用的类型 * 做好的、可直接装东西的盒子
单参数类型构造器 (List, Option, IO) 接收一个类型参数才成为具体类型 * -> * 单槽盒子模具
多参数类型构造器 (Either, Map, Function1) 接收多个类型参数才成为具体类型 * -> * -> * 多槽盒子模具
F[_] 单槽模具的占位符 要求 F* -> * “某个模具”的标签
F[_, _] 双槽模具的占位符 要求 F* -> * -> * “某个双槽模具”的标签
高阶类型构造器(单参数抽象)
(Functor[F[_]], OrderRepository[F[_]])
接受单槽模具作为参数 (* -> *) -> * 单槽模具的认证机构/抽象服务
高阶类型构造器(多参数抽象)
(Bifunctor[F[_, _]])
接受双槽模具作为参数 (* -> * -> *) -> * 双槽模具的认证机构
部分应用类型 (ErrorOr[A] = Either[String, A]) 将多槽模具固定部分槽位,变成低阶模具 * -> * 把双槽模具的一个槽封住,变成单槽模具
高阶 Monad Transformer (EitherT[F[_], E, A]) 接受单槽模具 F、错误类型 E、成功类型 A,返回压平后的盒子 (* -> *) -> * -> * -> * 把“盒子套盒子”压成单层盒子的机器
F[_] 在 Repository 中的角色 统一接口形状,赋予效果能力,限定返回类型的双层结构 万能插座:只定义插头规格,不限定电器种类
实现 OrderRepository[IO] 填入具体单槽模具 IO 生成具体类型 * 往模具槽位插入 IO
使用 for 推导在 F[_] 上下文中编程 依赖类型类操作抽象盒子 对任意符合条件的 F 工作 用证书赋予的能力组合盒子

Kind 是类型的类型系统,它保证了我们在使用 F[_]F[_, _] 等占位符时不会犯“将具体类型当作类型构造器”的错误。理解 Kind 后,你就会明白:

  • 为什么 Functor[Int] 不合法,而 Functor[List] 合法。
  • 为什么 Either 本身不能直接传给 F[_],但 ErrorOr(即 Either[String, A])可以。
  • 为什么有 Functor(针对单槽)也有 Bifunctor(针对双槽)。
  • 为什么 EitherT 能够优雅地解决嵌套盒子的问题。

一句话概括F[_] 让我们从“针对具体盒子编程”上升到“针对所有符合条件的盒子编程”。而 Kind 则是我们描述这些“盒子模具”和“模具认证机构”的通用语言。它们共同构成了函数式编程中实现高度抽象、多态与关注点分离的核心工具。


第二章 核心抽象一览

在深入各种函数式抽象之前,我们必须先理解它们操作的数据结构本质。所有函数式组合都建立在代数数据类型(Algebraic Data Type, ADT) 之上。ADT 由两种基本“零件”构成,这正好对应我们日常建模的两种逻辑:“与”和“或”。

1. 代数数据类型:和类型与积类型

类型系统之所以强大,很大程度上是因为它允许我们像搭积木一样,用和类型积类型组合出精细的领域模型。这正是 代数数据类型(ADT) 的核心。

积类型(Product Type)——“与”的关系

积类型描述的是多个值同时存在。一个积类型的值包含它所声明的所有字段,缺一不可。在 Scala 中,case classtuple 就是积类型。

case class Rectangle(width: Double, height: Double)

RectangleDoubleDouble 的积。一个 Rectangle 实例同时拥有 width height。其可能取值的数量,是各个字段取值数量的乘积(笛卡尔积),这也是“积类型”名称的由来。

比喻:积类型像一份固定套餐,你必须同时拿到主食、配菜和饮料,才是一份完整的餐点。

和类型(Sum Type)——“或”的关系

和类型描述的是一组可能情形中的恰好一种。一个和类型的值只能是若干变体中的某一个。Scala 中用 enumsealed trait + case class 来表达和类型。

enum Shape:
  case Circle(radius: Double)
  case Rectangle(width: Double, height: Double)

ShapeCircle Rectangle 的和。一个 Shape 值要么是 Circle,要么是 Rectangle,不可能同时是两者。其可能取值的总数,大致等于各变体取值数量的

比喻:和类型像自动售货机,你只能按下其中一个按钮,得到对应的商品,不能同时得到两种。

两者混用:构建精确的领域模型

真正的威力来自混用。一个和类型的变体内部,往往包含积类型(多个字段);而一个积类型的字段,又可以是另一个和类型。这种嵌套组合让我们能够让非法状态不可表示

例如,建模一个网络请求的响应:

enum Response:
  case Success(statusCode: Int, body: String)   // 积:状态码 与 响应体
  case Failure(errorCode: Int, message: String)  // 积:错误码 与 错误信息

你永远不会遇到“成功但缺少 body”或“失败却有 body”的无效状态,因为类型系统从根本上禁止了这种组合。编译器在模式匹配时会强制你处理所有情况,从而消除一整类运行时错误。

与 UML 的对应关系(这会在第五章详细展开):

  • 和类型 → 抽象基类 / 枚举状态
  • 积类型 → 聚合/实体/值对象的属性集合
  • 密封特质 → 固定的子类层次,保证穷尽检查

代数数据类型是我们接下来要讨论的所有抽象——Functor、Monad、Applicative 等——所操作的“盒子”的底层结构。Option[A] 就是 Some[A]None 的和类型;Either[E, A]Left[E]Right[A] 的和类型;而它们内部又都是积类型。理解这一点,你就理解了函数式编程的类型地基。

2. Functor(函子)——“可以对盒子里的值应用普通函数”

比喻:你有一个盒子 F[A],里面可能装着 A(也可能空着,但盒子结构不变)。你有一个函数 A => B,想把盒子里的东西变成 B。Functor 让你不必打开盒子,直接用 map 把这个函数“送进”盒子里,作用于内容,得到 F[B]

能力map
Scala 特征

trait Functor[F[_]]:
  def map[A, B](fa: F[A])(f: A => B): F[B]

示例

val maybeNum: Option[Int] = Some(5)
maybeNum.map(n => n * 2)  // Some(10)

val listNums = List(1, 2, 3)
listNums.map(_ + 1)       // List(2, 3, 4)

场景:任何需要对容器/上下文中的值做纯转换的地方。比如从 Option[User] 提取用户姓名,或把 List[Order] 转成 List[Invoice]

3. Monad(单子)——“可以串联返回盒子的函数,且自动压平双层盒子”

比喻:还是那个盒子。现在你有一台机器,但它不是输出普通 B,而是输出一个装着 B 的盒子 A => F[B](比如根据用户 ID 查询订单,可能不存在)。如果只靠 Functor 的 map,你会得到 F[F[B]]——双层盒子的尴尬。Monad 提供了 flatMap,它能把内层盒子拆掉,直接给你 F[B]

能力flatMap(也常配合 pure/unit,即把一个值装进最小的盒子)
Scala 特征

trait Monad[F[_]] extends Functor[F]:
  def pure[A](a: A): F[A]           // 把一个普通值包进盒子
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
  // map 可以用 flatMap + pure 实现
  def map[A, B](fa: F[A])(f: A => B): F[B] = flatMap(fa)(a => pure(f(a)))

示例(依赖型查询,防止嵌套 Option):

def findUser(id: Int): Option[String] = if id == 1 then Some("Alice") else None
def findOrders(user: String): Option[List[String]] = if user == "Alice" then Some(List("o1")) else None

// flatMap 串联,避免 Option[Option[_]]
val orders = Option(1).flatMap(findUser).flatMap(findOrders) // Some(List("o1"))
// for-comprehension 是语法糖
val orders2 = for {
  id   <- Option(1)
  user <- findUser(id)
  ords <- findOrders(user)
} yield ords

场景:任何可能失败的计算链(Option、Either、Try)、异步任务链(Future、IO)、状态传递等。当你需要根据第一个盒子的值决定下一步返回哪个盒子,且不想盒子无限嵌套时,就是 Monad 的用武之地。

4. Applicative(应用函子)——“可以同时打开多个盒子,把普通函数也装进盒子”

比喻:你有多个盒子,想把它们的内容组合起来。比如你有 F[A]F[B],以及一个普通函数 (A, B) => C。Functor 只能一次进一个盒子,要把两个盒子的值一起取出组合,它做不到。Applicative 允许你把函数也装进盒子F[(A, B) => C]),然后把盒子应用到盒子上,得到 F[C]。这相当于在上下文里“并行”或“独立”地组合多个值,各计算之间没有依赖关系。

能力pure + ap(或者 map2, product
Scala 特征(简化版):

trait Applicative[F[_]] extends Functor[F]:
  def pure[A](a: A): F[A]
  def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]
  // 或者提供 map2
  def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C): F[C]

示例(Scala 2 风格):

import cats.Applicative
import cats.implicits._

val maybeName: Option[String] = Some("Alice")
val maybeAge: Option[Int] = Some(25)

// Applicative 风格:把两个 Option 组合起来
val person: Option[String] = (maybeName, maybeAge).mapN((name, age) => s"$name is $age years old")
// Some("Alice is 25 years old")

注意:mapN 就是 Applicative 的语法,它允许你并行(其实是独立)处理多个独立上下文,而不依赖前一个的结果。

与 Monad 区别:Monad 的 flatMap依赖顺序的,前一步的输出决定后一步的行为,所以必须串行。而 Applicative 各参数间没有依赖,可以静态分析出所有盒子,甚至并行执行(如对 Future 并发)。

场景:表单验证(同时检查多个字段,汇总所有错误)、多个独立的异步请求并发执行然后组装结果、构造复合数据结构(用多个构造参数包装在上下文中)。

5. Monoid(幺半群)——“相同类型的值,可以结合,且存在一个'空'值”

比喻:这次我们跳出盒子,看值本身。Monoid 描述的是同一类型的事物,它们有一个合并操作(combine),并且有一个什么都不做的值(empty),合并空值相当于没动。比如整数加法:合并是 +,空值是 0;字符串连接:合并是 +,空值是 ""

能力combine + empty
Scala 特征

trait Monoid[A]:
  def empty: A
  def combine(x: A, y: A): A

示例

import cats.Monoid
import cats.implicits._

val sum = Monoid[Int].combine(1, 2)          // 3
val concat = Monoid[String].combine("Hi", "!") // "Hi!"
val folded = List(1, 2, 3).combineAll       // 6 (利用 Int 的加法 monoid)

场景:聚合操作(求和、拼接日志)、分布式计算的合并策略(MapReduce 的 combiner)、配置的合并(多层配置 fallback)、累加错误信息等。

5.1 Semigroup(半群)—— Monoid 的前置概念

在 Monoid 之前,还有更基础的 Semigroup(半群):它只要求结合操作 combine,而不要求有 empty。许多类型天然可以结合,但不存在合理的“零值”,此时就只能定义 Semigroup。

Scala 特征

trait Semigroup[A]:
  def combine(x: A, y: A): A

示例:非空列表的拼接就是标准的 Semigroup;而 Monoid 需要额外提供空列表作为 empty

在前面的验证示例中,我们用 List[String] 收集错误,两个错误列表合并依靠的正是 SemigroupListcombine 就是 ++)。只不过因为 Listempty = Nil,所以它同时是一个 Monoid。但很多实际场景中,combine 本身已经足够。Cats 中 Monoid[A] 扩展了 Semigroup[A],多数 combineAll 语法其实是 Semigroup 提供的,所以即使没有 empty,只要定义了 Semigroup 也可以折叠。

6. “Monadic”——一个形容词,不是独立概念

澄清:“Monadic” 不是与 Functor、Monad 并列的另一个抽象。它只是一个形容词,表示“与 Monad 相关的”或“monad 式的”。比如:

  • monadic 风格:指大量使用 flatMap 和 for-comprehension 的代码风格。
  • monadic 错误处理:用 EitherTryflatMap 链来处理错误。
  • monadic 容器:指实现了 Monad 接口的类型,如 OptionFuture 等。

所以当我们说“这段代码很 monadic”,意思就是它充满了 flatMap 链条。你不需要单独学习一个叫 Monadic 的概念,它只是对 Monad 使用的形容。

7. 一张关系图

从抽象层级和扩展关系来看:

Functor  (有 map)
   ↑
Applicative (有 pure + ap/mapN,增加了组合多个独立上下文的能力)
   ↑
Monad   (有 pure + flatMap,增加了依赖顺序的计算链能力)
  • 所有 Monad 都是 Applicative,所有 Applicative 都是 Functor。
  • Monoid(及 Semigroup)是另一个维度,它不处理 F[A] 的包装,而关注普通类型 A 的合并与空值

8. 错误处理新范式:Option / Either 如何取代 null 和异常

在传统的命令式编程中,我们习惯用 null 表示“缺失”,用 try-catch 处理异常。函数式编程通过类型系统提供了一个更安全、更明确的替代方案:将缺失错误显式地纳入类型,作为返回值的一部分。

告别 null:用 Option 表示可能缺失的值

Option[A] 是一个和类型,它只有两种可能:Some(a)None。任何可能“没有结果”的函数都应该返回 Option,而不是返回 null 或抛异常。这样,调用方被迫处理缺失情况,编译器会检查你是否覆盖了两种可能。

def findUser(id: Int): Option[String] =
  if id == 1 then Some("Alice") else None

// 必须显式处理缺失,否则编译错误(如果用了穷尽匹配)
val greeting = findUser(1) match
  case Some(name) => s"Hello, $name"
  case None       => "User not found"

比喻Option 像一个透明包装盒,要么装着东西,要么空空如也。你不能假装看不见,因为包装盒的形状要求你先打开确认。

告别异常:用 Either(或 Try)表示可能失败的计算

Either[E, A] 将错误也变成普通的值。函数成功时返回 Right(a),失败时返回 Left(e)。这样,错误不会突然跳出来打断程序流,而是作为数据在组合管道中传递。

def parseInt(s: String): Either[String, Int] =
  try Right(s.toInt)
  catch case _: NumberFormatException => Left(s"'$s' is not a number")

配合 flatMap,我们可以串联多个可能失败的操作,一旦某步失败,后续自动跳过:

val result = for
  a <- parseInt("10")
  b <- parseInt("20")
yield a + b
// 如果任何一步失败,整个 result 为 Left

try-catch 的本质区别

  • try-catch 是隐式的控制流跳转,错误路径不可见,容易遗漏。
  • Either(以及 Option)是显式的值传递,类型签名明确宣告“我可能失败”,编译器强制你处理。

在实际项目中,你可以将这种模式贯穿始终:可预期的错误(如验证失败、资源未找到)永远用 Either 或自定义 ADT 表达,只有真正的致命灾难(如 JVM 内存溢出)才依赖运行时机制。 这就是“让非法状态不可表示”在错误处理中的具体体现。

9. 实际编程中的组合使用场景

以一个用户注册验证为例,我们用 Scala + Cats 来展示它们如何协作:

Scala 2 风格示例

import cats.data.Validated
import cats.implicits._

case class User(name: String, age: Int, email: String)
// 注意:case class 自动生成了 User.apply(name, age, email),与 mapN 的参数匹配

// 验证函数返回 Either 或 Validated
def validateName(name: String): Either[String, String] =
  if name.nonEmpty then Right(name) else Left("Name empty")

def validateAge(age: Int): Either[String, Int] =
  if age >= 18 then Right(age) else Left("Too young")

def validateEmail(email: String): Either[String, String] = ???

// 需求:同时验证所有字段,并收集所有错误(而非遇到第一个错误就停)
// 用 Applicative 的 mapN,因为验证之间无依赖,可以独立进行
type ValidationResult[A] = Either[List[String], A]  // 实际中常用 ValidatedNel

def validateAll(name: String, age: Int, email: String): ValidationResult[User] = {
  // 把单错误 Either 转换成聚集错误的类型
  val vName = validateName(name).leftMap(List(_)).toValidated
  val vAge  = validateAge(age).leftMap(List(_)).toValidated
  val vEmail= validateEmail(email).leftMap(List(_)).toValidated

  // Applicative 语法 mapN:组合独立的验证结果,收集所有 Left 的错误列表(通过 Semigroup/Monoid 合并 List)
  (vName, vAge, vEmail).mapN(User.apply).toEither  // User.apply 由 case class 自动生成
}

leftMaptoValidatedmapN 的协作关系

上述代码中,从 Either[String, A] 到最终聚合所有错误的 Either[List[String], User],经历了三个关键步骤:

第一步:leftMap(List(_))

  • leftMapEither 上的方法,它仅对 Left 侧的值进行转换,对 Right 侧的值不做任何处理。
  • List(_) 是一个 lambda 表达式,作用是把单个错误字符串包装成单元素列表。
  • 因此 Left("Name empty").leftMap(List(_)) 的结果是 Left(List("Name empty"))——错误类型从 String 变成了 List[String]
  • 如果验证通过返回的是 Right("Alice")leftMap 什么都不做,仍然是 Right("Alice")

第二步:.toValidated

  • toValidatedEither 转换为 Validated 类型:Right 对应 Validated.ValidLeft 对应 Validated.Invalid
  • 所以 Left(List("Name empty")).toValidated 得到 Invalid(List("Name empty"))
  • 这一步的类型变化是:Either[List[String], A]Validated[List[String], A]

为什么必须转换为 Validated

  • EitherflatMap/for 推导是短路逻辑:遇到第一个 Left 就立即停止,适用于依赖性的计算链(如“查用户→查订单→算总价”)。
  • ValidatedmapN累积逻辑:它能将多个独立验证的错误全部收集,通过 Semigroup(这里是 List++)合并成一个错误列表。
  • 因此,如果想一次性向用户展示所有验证错误,就必须先切换到 Validated

第三步:.mapN(User.apply).toEither

  • mapN 将三个独立的 Validated 组合起来:如果全部是 Valid,则用 User.apply 构造 User 对象;如果存在 Invalid,则将它们的错误列表合并。
  • 最后的 .toEither 将结果从 Validated 转回 Either,以便与系统中其他返回 Either 的函数保持一致。

完整转换流程(以一次同时包含多个错误的验证为例):

// 输入:三个 Either[String, ?] 的验证结果
val vName  = validateName("").leftMap(List(_)).toValidated   // Invalid(List("Name empty"))
val vAge   = validateAge(16).leftMap(List(_)).toValidated     // Invalid(List("Too young"))
val vEmail = validateEmail("a@b").leftMap(List(_)).toValidated // Valid("a@b")

// mapN 将三者组合:遇到两个 Invalid,一个 Valid
// 错误列表通过 List 的 ++(Semigroup)合并
(vName, vAge, vEmail).mapN(User.apply)
// 结果: Invalid(List("Name empty", "Too young"))

// 最后通过 .toEither 转回 Either 类型
// 结果: Left(List("Name empty", "Too young"))

比喻Either 像一个“一错就停”的质检员,遇到第一个问题就罢工。Validated 则像一个“逐项记录”的检查清单,把所有问题全部记下来再一次性反馈。leftMap 负责把单个错误放进列表这个“错误收集箱”,toValidated 负责把质检模式从“短路”切换到“累积”,mapN 则负责同时检查所有项目并合并收集到的所有错误。

在这里:

  • FunctorleftMap 转换错误信息的形状。
  • ApplicativemapN 同时组合多个独立验证,收集所有错误。
  • Monoid:错误列表 List[String] 的合并(++)让我们能把多个错误聚合成一个列表。
  • Monad:如果我们用 for-comprehension 串联,就会变成短路逻辑(第一个失败就停止),但这里我们选择用 Applicative 获得累积错误的效果,体现了不同抽象的选择。

最后,一个简单的数据聚合展示 Monoid 单独使用:

val logs = List("log1", "log2", "log3")
val combinedLog = logs.combineAll  // 使用 String 的 Monoid,拼接所有日志

10. 总结成一张表

概念 核心操作 比喻 何时使用
和类型 enum / sealed trait 自动售货机的按钮选择 表达互斥的状态或分类
积类型 case class / tuple 固定套餐的多个组成部分 表达必须同时存在的属性集合
Functor map 对盒子里的值用普通函数 转换上下文内的值,如提取字段
Applicative pure + mapN / ap 同时打开多个独立盒子,把函数也装盒 多字段独立验证、并发请求组合
Monad pure + flatMap 串联返回盒子的函数,并压平盒子 依赖性的计算链:查用户→查订单→算总价
Semigroup combine 两个同类值可以合并 错误拼接、日志合并等,不需要零值时
Monoid empty + combine 同类值可合并,有“零”概念 求和、拼接、配置合并、需要空值作为起始值的聚合
Monadic (形容词) 描述大量使用 flatMap 的代码风格 说“这段代码很 monadic”
Option / Either 将缺失/错误纳入类型 透明包装盒 / 带有错误信件的信封 代替 null 和 try-catch,实现显式错误处理

第三章 综合应用

综合案例:Web 服务场景

现在,用一个完整的 Web 服务场景把 Functor、Applicative、Monad、Monoid 串在一起。假定你需要实现一个接口:

  • 输入 userId 和一个商品 ID 列表 productIds
  • 输出用户的姓名、信用等级,以及这些商品的总价。涉及三次数据库/服务调用,其中两次互相独立。

注意:以下示例使用 Scala 2 风格(implicitcats.implicits._ExecutionContext.Implicits.global),以展示与现有代码库的兼容写法。

1. 设定领域模型

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import cats.implicits._       // 提供 mapN、combineAll 等语法
import cats.Applicative

// 错误模型
case class AppError(message: String)
type AppResult[A] = Either[AppError, A]

// 数据模型
case class User(id: Int, name: String)
case class UserProfile(userId: Int, creditLevel: String)
case class Product(id: Int, price: Double)
case class Order(orderId: Int, userId: Int, productIds: List[Int])
case class UserSummary(name: String, credit: String, totalPrice: Double)

2. 模拟的"数据库/服务"函数(返回 Future[AppResult[_]]

def fetchUser(id: Int): Future[AppResult[User]] =
  Future.successful(Right(User(id, s"user_$id")))

def fetchProfile(userId: Int): Future[AppResult[UserProfile]] =
  Future.successful(Right(UserProfile(userId, "gold")))

def fetchLastOrder(userId: Int): Future[AppResult[Order]] =
  Future.successful(Right(Order(100, userId, List(1, 2, 3))))

def fetchProduct(id: Int): Future[AppResult[Product]] =
  Future.successful(Right(Product(id, 10.0 * id))) // 简单定价

3. 核心业务函数:组合一切

import cats.instances.double._ // 提供 Monoid[Double] 实例

def getUserSummary(userId: Int): Future[AppResult[UserSummary]] = {
  val userFuture: Future[AppResult[User]] = fetchUser(userId)

  userFuture.flatMap {
    case Right(user) =>
      // 两个独立的查询:profile 和 orders,没有依赖关系 -> 适合 Applicative 并行
      val profileFuture = fetchProfile(user.id)
      val orderFuture   = fetchLastOrder(user.id)

      // Applicative 组合:两个 Future 已各自启动,mapN 负责组合它们的结果
      val profileAndOrder: Future[AppResult[(UserProfile, Order)]] =
        (profileFuture, orderFuture).mapN { (profileRes, orderRes) =>
          for {
            profile <- profileRes
            order   <- orderRes
          } yield (profile, order)
        }

      // 有了 order 后,需要根据 productIds 查询每个商品价格
      profileAndOrder.flatMap {
        case Right((profile, order)) =>
          val productFutures: List[Future[AppResult[Product]]] =
            order.productIds.map(fetchProduct)

          // Future.sequence 底层使用了 Traverse 类型类,将 List[Future] 翻转为 Future[List]
          val productsFuture: Future[List[AppResult[Product]]] =
            Future.sequence(productFutures)

          productsFuture.map { productResults =>
            val productsEither: AppResult[List[Product]] = productResults.sequence

            // Functor map:在 Either 内部计算总价
            productsEither.map { products =>
              // Monoid 的应用:foldMap 来自 Foldable,利用 Monoid[Double] 自动求和
              val totalPrice = products.foldMap(_.price) // 需要 Monoid[Double] 实例
              UserSummary(user.name, profile.creditLevel, totalPrice)
            }
          }

        case Left(err) =>
          Future.successful(Left(err))
      }

    case Left(err) =>
      Future.successful(Left(err))
  }
}

这种显式的 flatMap 加模式匹配会带来嵌套地狱,这正是 FP 要消除的样板。我们需要一个更简洁的方式——Monad Transformer

4. 使用 EitherT 的清洁版本(Scala 2 风格)

import cats.data.EitherT
import cats.implicits._
// 提供 Double 的 Monoid 实例,使 foldMap 可用
import cats.instances.double._

def getUserSummaryClean(userId: Int): Future[AppResult[UserSummary]] = {
  val result = for {
    user <- EitherT(fetchUser(userId))

    // Applicative 并行:profile 和 order 没有依赖,直接在生成器中解构
    (profile, order) <- EitherT {
      val pf = fetchProfile(user.id)
      val of = fetchLastOrder(user.id)
      (pf, of).mapN { (profileRes, orderRes) =>
        for {
          p <- profileRes
          o <- orderRes
        } yield (p, o)
      }
    }

    // 获取所有商品
    products <- EitherT {
      val productFuts = order.productIds.map(fetchProduct)
      // Future.sequence 利用了 Traverse 类型类,详见第四章1.1
      Future.sequence(productFuts).map(_.sequence) // 合并成 AppResult[List[Product]]
    }

    // 计算总价(Monoid):foldMap 来自 Foldable,利用 Monoid[Double] 自动求和
    totalPrice = products.foldMap(_.price)
  } yield UserSummary(user.name, profile.creditLevel, totalPrice)

  result.value // 回到 Future[AppResult[UserSummary]]
}

如果觉得 foldMap 不够直观,也可以:

import cats.syntax.monoid._
val totalPrice = products.map(_.price).combineAll

5. 各抽象在代码中的体现

概念 所在代码 作用
和类型 AppResult[A] = Either[AppError, A] 每个步骤要么成功,要么失败,二选一
积类型 User, UserProfile, UserSummary 等 case class 聚合所需的多个字段
Functor productsEither.map(products => ...) Either 内部的成功值做纯转换(计算总价),不改变错误分支。
Monad for-comprehension 的每一个 <- 串联多个依赖的计算:先查 user,再查 profile/order,再查 products。EitherTFuture + Either 两层 Monad 压成一个,使用 flatMap 自动处理短路(一旦出错立刻短路,返回错误)。
Applicative (pf, of).mapN { ... } 并行启动 fetchProfilefetchLastOrder,因为它们彼此独立。虽然内部合并用了 for,但两个 Future 已经在同时执行。这样比顺序 flatMap 更高效。
Monoid products.foldMap(_.price) List[Product] 折叠成一个总价。这里的 Double 加法就是一个 Monoid(结合律、有零元 0.0)。

6. 能不能不用 Cats?

完全可以用标准库的 Futurefor 推导 + 模式匹配手动压平,但代码会急剧膨胀且丢失 Applicative 的并行表达。Cats 提供的 mapNcombineAllEitherT 是这些抽象在 Scala 中的标准实现,它们让你的代码和思想直接对齐,具体可以从三个层次来理解:

思想层面:用类型的语言描述业务

函数式编程的核心思想之一,就是把程序看成数据的流动和变换,而不是一系列指令的执行。

当你用 Either[Error, Result] 代替 try-catch,用 Option[A] 代替 null,用 map/flatMap/mapN 串联计算时,你实际上是在用类型的语言直接描述业务意图

  • "这个操作可能失败" → Either
  • "先查用户,再查订单,然后计算总价" → flatMap / for 推导
  • "这两个查询互不依赖,可以同时进行" → mapN
  • "把所有错误收集起来" → Validated + Semigroup

你的思想(业务的自然表达)和代码(类型的组合方式)是同一种语言,不需要翻译。

代码层面:消除样板,让意图浮现

如果不用 Cats 等函数式库,同样的业务逻辑会被大量的样板代码淹没。以第三章的 getUserSummary 为例:

不用 Cats:手动 flatMap + 模式匹配 + 嵌套拆箱,代码里充斥着 case Right(...)case Left(...)Future.flatMap 等技术细节。阅读者需要费力地从这些噪音中剥离出真正的业务意图。

用 Catsfor 推导 + EitherT + mapN,代码结构直接反映了业务逻辑的顺序和依赖关系。"先做 A,再做 B,然后做 C" 就是 for 里的三行,"A 和 B 并行" 就是 mapN

当样板代码被消除,剩下的就是纯粹的业务意图。这就是"代码和思想直接对齐"——你心里怎么想,代码就怎么写,中间没有翻译损耗。

抽象层面:通用模式有通用名称

日常开发中反复出现的模式——"先 A 后 B"、"A 与 B 并行"、"把一堆东西合并"——在函数式编程中都有精确的数学名称:

你的思想 代码形态 抽象名称
先 A 后 B fa.flatMap(a => f(a)) Monad
A 与 B 并行 (fa, fb).mapN((a, b) => ...) Applicative
把一堆东西合并 list.combineAll Monoid
对容器里每个东西做变换 fa.map(a => ...) Functor
翻转嵌套结构 list.sequence Traverse

当“先查用户,再查订单”这个想法,在代码里就是 for { user <- findUser(id); order <- findOrder(user) } yield ...,而不会淹没在 match、if-else、null 判断和异常捕获里,你的思想和代码就真正对齐了。你读代码就像在读自己当初的想法,修改代码就是在修改想法本身。这就是函数式编程给日常开发带来的最大价值——写代码不再是把想法翻译成实现,而是把想法直接写下来得到的对业务逻辑的精确、可执行的形式化描述


第四章 进一步抽象与总结

1. 进一步的抽象:Foldable、Traverse、MonadError 与效果类型

上面的综合案例背后其实隐藏着几个尚未点明的强大抽象,它们让整个组合生态更加完整。你可以把它们理解为“盒子操作工具箱”的延伸。

1.1 Foldable 与 Traverse

Foldable 赋予容器“折叠”的能力,它回答的是:如何将 F[A] 规约成一个值 B?
你在前文看到的 combineAllfoldMapfoldLeft 都来自 Foldable。任何实现了 Foldable 的类型都可以用统一的方式迭代并汇总其内部元素。

比喻:Foldable 像一台碎纸机,它把盒子里的所有内容一点点塞进一个函数,最终吐出单个值。

Traverse 则更进一步,它处理 F[G[A]] 这种“两层盒子”结构,将其翻转成 G[F[A]]。常见的场景是 List[Future[A]]Future[List[A]],这正是 Future.sequenceproductResults.sequence 的内部实现。Traverse 结合了 Functor 和 Foldable 的能力。

比喻:你有一盒(List)装着很多未完成的任务(Future),Traverse 就像用力摇晃,让任务盒子和内容盒子交换位置,得到一整个未来会交付的任务结果列表。

Scala 中的使用(Scala 2 风格):

import cats.Traverse
import cats.implicits._
import scala.concurrent.Future

val listOfFutures: List[Future[Int]] = List(Future(1), Future(2), Future(3))
val futureOfList: Future[List[Int]] = listOfFutures.sequence  // 翻转
val futureSum: Future[Int] = futureOfList.map(_.sum)

// 也可以直接使用 traverse,一步完成映射+翻转
val futureSum2: Future[Int] = Traverse[List].traverse(List(1,2,3))(x => Future(x * 2)).map(_.sum)

// Option 也可以折叠:
val optSum: Double = Option(42.0).foldMap(identity) // 42.0
val optNone: Double = Option.empty[Double].foldMap(identity) // 0.0

在我们之前的 EitherT 案例中,Future.sequence(productFuts) 调用的就是 Traverse 的实例,而 products.foldMap(_.price) 则是 Foldable 的典型应用。日常开发中我们也许不会直接写 FoldableTraverse 的接口,但几乎每个集合组合操作都在间接使用它们。

1.2 Monad Transformer 一览

除了 EitherT,Cats 还提供 OptionT[F[_], A]WriterT[F[_], L, A] 等 Transformer。它们都遵循相同的模式:接收一个高阶类型构造器 F 和一个“内层盒子”(如 OptionEitherWriter),将其压平成一个单层的 Monad。它们的 Kind 普遍在 (* -> *) -> * -> * -> * 级别,是函数式编程中极高阶抽象的典型代表。

  • OptionT[F[_], A]:用于处理 F[Option[A]] 嵌套。
  • EitherT[F[_], E, A]:用于处理 F[Either[E, A]] 嵌套。

有了这些 Transformer,你就可以在 for 推导中直接组合带上下文的可选值或错误处理,无需手动拆箱。

从嵌套到压平:实际转换示例

假设我们有一个异步的用户查询接口,返回 IO[Option[User]]

import cats.effect.IO
import cats.data.OptionT
import cats.syntax.all.*

def findUser(id: Int): IO[Option[String]] =
  IO.pure(if id == 1 then Some("Alice") else None)

直接操作嵌套结构:手动拆两层

def greet(id: Int): IO[Option[String]] =
  findUser(id).map { maybeUser =>
    maybeUser.map(name => s"Hello, $name")
  }
// 类型:IO[Option[String]],需要嵌套 map 来处理两层盒子

用 OptionT 压平:像操作普通 Option 一样

def greetT(id: Int): OptionT[IO, String] =
  for {
    name <- OptionT(findUser(id))   // name 直接是 String,不是 Option[String]
  } yield s"Hello, $name"

// 必要时可以还原:
val result: IO[Option[String]] = greetT(1).value

同样的模式也适用于 EitherT。假设查询可能失败:

def findUserE(id: Int): IO[Either[String, String]] =
  IO.pure(if id == 1 then Right("Alice") else Left("Not found"))

def greetE(id: Int): EitherT[IO, String, String] =
  for {
    name <- EitherT(findUserE(id))
  } yield s"Hello, $name"

val resultE: IO[Either[String, String]] = greetE(1).value

转换的本质OptionTF[Option[A]] 变成一个新的单层盒子 OptionT[F, A]EitherTF[Either[E, A]] 变成 EitherT[F, E, A]。在 for 推导中,<- 直接拿出最内层的 A,不需要手动拆解外层的 F 和内层的 Option/Either。这就是 Monad Transformer 提供的“自动开箱”能力。

比喻:就像俄罗斯套娃,原本要一层一层打开,而 OptionTEitherT 让你可以直接对最里面的娃娃说话,它们负责帮你打开和重新包装。

1.3 MonadError / ApplicativeError —— 在盒子里安全地抛出和捕获错误

Monad 和 Applicative 本身并没有“错误处理”的概念。但我们经常需要在 F[_] 上下文中显式地表示失败(例如 IOFutureEither)。这时 MonadErrorApplicativeError 就提供了统一的错误通道:

  • raiseError[A](e: E): F[A]:在盒子里“抛出”一个错误。
  • handleError(fa: F[A])(f: E => A): F[A]:捕获错误并将其恢复为正常值。

比喻:普通的盒子只知道装东西和做变换;但 MonadError 的盒子内部有个暗格,可以存放错误。你可以把错误丢进暗格,也能从暗格中把错误拿出来并换成补救的物品,且这一切都在盒内完成,不会跳出盒子污染外部。

Scala 示例(以 Either 为例,Scala 2 风格):

import cats.MonadError
import cats.implicits._

type Eff[A] = Either[String, A]

val monadError = MonadError[Eff, String]

val success: Eff[Int] = monadError.pure(42)
val failure: Eff[Int] = monadError.raiseError("Something went wrong")

val recovered: Eff[Int] = failure.handleError(msg => {
  println(s"Recovered from: $msg")
  0
})
// recovered: Right(0)

在 Cats Effect 或 ZIO 等实际运行时中,IO 也实现了 MonadError[IO, Throwable],你可以用同样的一套组合子来处理异步副作用中的错误,而无需任何 try-catch

前面的综合案例用了 EitherEitherT,本质也是 MonadError 的特殊形式。在实际业务中,如果你希望“统一处理任何意外错误”,可以引入 MonadError 抽象,把业务错误和突发异常用相同的组合子串联起来。

1.4 效果类型:IO / Task / ZIO —— 把副作用装进盒子

到目前为止,我们的盒子(OptionEitherFuture)要么是同步的,要么是不纯的。一旦触及真实的文件读写、网络调用、数据库访问,我们会希望把副作用描述为值,而不立即执行。这就是效果类型的用武之地:

  • Cats Effect 的 IO[A]:描述一个可能产生副作用、返回 A 的计算。
  • ZIO 的 ZIO[R, E, A]:需要环境 R,可能失败为 E,成功返回 A。

这些类型也是 Monad(同时又是 Functor、Applicative),但提供了额外的保证:引用透明。你可以像传普通值一样传递 IO,它是惰性的,直到被显式“运行”才会执行。这让你可以用 mapflatMapmapN 等所有我们讲过的工具来编织副作用流程,完全保持纯函数的组合能力。

比喻:普通的盒子装着最终的值,而 IO 是一个未拆封的包裹,上面贴着一张说明——“打开时会打电话给服务器并取回数据”。你可以拿着这个包裹继续组合更大的包裹,直到程序的入口才拆开它,此时所有副作用才真正发生。

示例(Scala 3 / Cats Effect 3 风格):

import cats.effect.IO

val greet: IO[String] = IO.println("What's your name?") *> IO.readLine.map(name => s"Hello, $name!")
val program: IO[Unit] = greet.flatMap(IO.println)

// 什么都不会发生,直到:
program.unsafeRunSync() // 仅在程序的真正末端调用

在大型 Scala 应用中,往往基于 Cats Effect 或 ZIO 构建,顶层都是 IOZIO,而内部业务逻辑完全用 Functor、Applicative、Monad 的组合子编写。这样,类型系统不仅约束了数据,还约束了副作用的发生时机和顺序,这与我们最初“用类型系统消除意外”的理念一脉相承。

2. 总结

  • 你用 和类型 表达“或”的语义,用 积类型 表达“与”的语义,两者组合成 ADT。
  • 你用 Functor 时,是“对盒子里的值做纯计算”。
  • 你用 Monad 时,是“根据盒子里的值决定下一步去哪个盒子,并保持一层盒子”。
  • 你用 Applicative 时,是“同时打开多个独立盒子,把它们的内容组合起来”。
  • 你用 Monoid(及 Semigroup)时,是“把一堆同类型的东西合并成一个,并有一个‘空’的概念”。
  • 你使用 Foldable / Traverse 时,是“对容器进行折叠或翻转嵌套结构”。
  • 你使用 MonadError 时,是“在盒子内部统一地抛出和恢复错误”。
  • 你使用 效果类型(IO 等) 时,是“把副作用也封装进盒子,让一切成为可组合的值”。

在这个完整例子中,它们各司其职,共同构建了安全、可读、可组合的业务流程。现在你应该可以清晰地看到,这些概念不是空中楼阁,而是你日常写 Scala 时已经在用(或可以立刻用起来)的思维工具。


第五章 如何培养抽象能力并在实践中应用

前面四章我们系统学习了 F[_]、Kind 以及 Functor、Monad、Applicative 等核心抽象,并通过 Web 服务案例看到了它们如何在实际业务中协作。然而,知道这些概念并不等于能自然地运用它们。从“理解”到“内化”再到“随手写出优雅的抽象”,需要刻意练习和正确的方法论。这一章,我们将分享培养抽象能力的详细建议,然后结合 DDD、CQRS 和 ADT 等实战模式,给出把指南中的抽象落地的具体指引。

1. 培养抽象能力的六条建议

1.1 从“识别重复”到“提取模式”

任何抽象都源于对重复的厌倦。当你发现自己多次写出类似的结构时,就是抽象的信号。

比喻:如果你在组装家具时,每次都要临时制作一个六角扳手,很快你就会想:“我为什么不买一套标准扳手?” 编程中的 mapflatMapcombineAll 就是这种标准扳手。

练习方法

  • 找一个包含业务逻辑的项目,找出三个以上“先查库、再判断、再转换”的代码块。
  • 尝试用 flatMapfor 推导消除显式的 match/if-else 嵌套。
  • 当你发现 ListOptionFuture 都能用相同模式组合时,就自然触摸到了 Monad 的轮廓。

1.2 从“具体类型”逐步走向“类型构造器”

不要一上来就追求高阶多态。正确的路径是:

  1. 为具体类型编写函数(比如 def sumList(xs: List[Int]): Int
  2. 泛化到某种容器def sum[F[_]: Foldable](xs: F[Int]): Int,并引入 Foldable
  3. 再泛化元素类型和组合逻辑def foldMap[F[_]: Foldable, A: Monoid](fa: F[A]): A

示例:之前指南中 countElements[F[_]: Foldable] 就是这样一步步抽象出来的。你可以用同样的过程处理自己的业务代码。

1.3 采用“类型优先”的设计方法

函数式编程鼓励先设计类型,再写实现。类型是契约,是编译器可验证的文档。

比喻:这好比在建筑之前绘制精确的蓝图。类型系统就是你的 CAD 软件,它能标出所有“结构冲突”的位置。

练习方法

  • 在面对一个新需求时,先不要写任何函数体,而是定义出所有的 ADT(代数数据类型)。
  • 写出关键函数的类型签名,用 ??? 占位。
  • 观察类型是否已经排除了非法状态(例如用 Either[Error, Result] 而不是 null + try-catch)。

1.4 阅读函数式库的源码和测试

Cats、ZIO、doobie 等库的源码充满了典范级别的抽象。重点关注它们的 trait 定义given/implicit 实例

建议

  • OptionmapflatMap 实现看起,再对比 EitherList 的实现,感受它们遵循的共同契约。
  • 阅读 Cats 中 FoldableListOption 提供的实例,理解“容器折叠”的多态。

1.5 用类型类代替继承来扩展行为

当你有多种数据类型需要相同操作时,不要急于用 extends 把它们绑在一起,而是定义类型类并提供实例。

比喻:继承就像在出生时就决定了你的职业,而类型类则是你可以随时考取的“资格证书”。FunctorMonadMonoid 都是这样的证书。

练习:为你自己项目中的 UserOrder 等定义 ShowEncoder 类型类,体验“行为附加”的灵活性。

1.6 刻意在测试中使用纯解释器

利用 F[_] 抽象,将 Repository 等接口实现为 Either[Error, *]Writer 的测试版本,可以让你在完全不接触真实副作用的情况下验证组合逻辑。这强迫你思考“我的业务逻辑应该与什么效果无关”。

2. 在实际开发中落地的具体策略

理解抽象后,真正困难的是如何在日常项目中布局这些抽象,让代码既灵活又不过度设计。我们将结合 DDD、CQRS 和 ADT 实现来给出路线图。

2.1 用 ADT 直接映射 UML 类图与 DDD 聚合 —— 函数式聚合的标准模式

在面向对象设计中,我们习惯用 UML 类图描述实体、值对象和枚举。在函数式 Scala 中,实现聚合的推荐方式是:用 sealed trait 定义聚合状态类型,用 case class 实现每个具体状态,并在伴生对象中提供纯函数表达状态转换。这种“trait → case class 表状态 → 伴生对象提供方法”的模式,完美平衡了类型安全和关注点分离,是 Scala 函数式 DDD 的标准实践。

其核心原则是:

  • 聚合本身是数据:不含任何副作用行为,仅通过 ADT 精确建模。
  • 行为是纯函数:聚合的状态转换逻辑放在伴生对象或领域服务中,输入当前状态和命令,返回新状态或错误。
  • 状态被显式建模:每种生命周期状态都是一个 case class,编译器能穷尽检查状态处理。

我们继续使用订单领域的例子。在 UML 中可能画出 Order 聚合包含 OrderItem 值对象和 OrderStatus 枚举。用 Scala 3 表达:

// 值对象
case class OrderItem(productId: String, quantity: Int, unitPrice: BigDecimal)
case class Address(street: String, city: String)

// 聚合状态(和类型)
enum OrderStatus:
  case Draft, Placed, Cancelled, Shipped

// 聚合根(积类型 + 和类型组合)—— 不使用 trait 抽象时也可以直接用 case class
// 但更灵活的是为不同状态提供不同的 case class,用 sealed trait 统一
sealed trait Order:
  def id: String
  def items: List[OrderItem]
  def shippingAddress: Address

case class DraftOrder(
  id: String,
  items: List[OrderItem],
  shippingAddress: Address
) extends Order

case class PlacedOrder(
  id: String,
  items: List[OrderItem],
  shippingAddress: Address,
  placedAt: java.time.Instant
) extends Order

case class CancelledOrder(
  id: String,
  items: List[OrderItem],
  shippingAddress: Address,
  reason: String
) extends Order

// 行为:纯函数放在伴生对象中
object Order:
  def place(order: Order, now: java.time.Instant): Either[String, Order] =
    order match
      case o: DraftOrder if o.items.nonEmpty =>
        Right(PlacedOrder(o.id, o.items, o.shippingAddress, now))
      case _: DraftOrder =>
        Left("Order must have at least one item")
      case _ =>
        Left(s"Cannot place order in current state")

  def cancel(order: Order, reason: String): Either[String, Order] =
    order match
      case o: PlacedOrder =>
        Right(CancelledOrder(o.id, o.items, o.shippingAddress, reason))
      case _ =>
        Left(s"Cannot cancel order in current state")

关键点

  • sealed trait Order 作为所有可能状态的抽象,使得任何处理 Order 的函数都必须处理所有状态变体。
  • 每个具体状态(DraftOrder, PlacedOrder, CancelledOrder)携带该状态特有的数据,例如 PlacedOrderplacedAtCancelledOrderreason。这消除了“用 Option 字段模拟不同状态”的坏味道。
  • 伴生对象 Order 中的纯函数 placecancel 接收当前 Order,返回 Either[String, Order]。它们可以单独进行单元测试,完全不依赖于数据库或外部服务。
  • 这种模式完美体现了“类型系统消除非法状态”的理念:你无法创建出一个“已下单但没有下单时间”的订单,因为 PlacedOrder 强制要求 placedAt

重要:如果你的领域状态相对简单,也可以只用单一 case class 加一个 status: OrderStatus 枚举字段,然后通过智能构造器或函数保证状态转换的合法性。但trait → case class → 伴生对象方案更适合状态差异大、每个状态携带不同数据的场景,也是函数式 DDD 中最具表现力的建模方式。

2.2 结合 CQRS 分离命令与查询的抽象

CQRS(命令查询职责分离)的核心思想是:改变状态的操作(命令)和读取状态的操作(查询)应该使用不同的模型和接口。在函数式世界中,这天然对应 返回 F[Unit]F[Either[Error, Unit]] 的命令,以及返回 F[View] 的查询

利用 F[_] 抽象,我们可以设计出完全解耦的领域接口:

// 命令侧(写)
trait OrderCommands[F[_]]:
  def placeOrder(orderId: String, now: Instant): F[Either[String, Order]]
  def cancelOrder(orderId: String, reason: String): F[Either[String, Order]]

// 查询侧(读)
trait OrderQueries[F[_]]:
  def getOrder(id: String): F[Option[Order]]
  def getOrdersByStatus(status: OrderStatus): F[List[Order]]

然后,应用服务通过组合这些接口以及纯业务逻辑来完成用例。

CQRS 比喻:命令侧像邮局的收件柜台,只负责接收包裹并触发内部流程,不负责告诉你包裹现在在哪里;查询侧像快递跟踪系统,只负责回答“你的包裹怎么样了”。两者可以用完全不同的存储策略(例如事件溯源、读模型投影),但对外暴露的抽象统一通过 F[_] 隔离。

2.2.1 延伸:当 CQRS 遇上事件溯源(Event Sourcing)—— 抽象的实现

事件溯源(ES)将状态变更建模为不可变事件的序列。聚合的当前状态不直接存储,而是通过重放事件流得到。这与我们前面的 ADT 模型天然契合:

  • 事件 也是和类型(sealed trait OrderEvent),每个业务操作产生一个或多个事件。
  • 命令处理 变成了纯函数:Command -> List[Event](或失败)。
  • 状态还原 是左折叠:List[Event] -> Order

我们可以在 Order 伴生对象中增加事件定义和演化逻辑。首先需要为聚合提供一个表示空状态的初始值:

import java.time.Instant

// 事件(和类型)
enum OrderEvent:
  case OrderPlaced(orderId: String, items: List[OrderItem], address: Address, at: Instant)
  case OrderCancelled(orderId: String, reason: String, at: Instant)

object Order:
  // 空状态,作为事件重放的起点
  def empty: Order = DraftOrder(id = "", items = Nil, shippingAddress = Address("", ""))

  // 命令处理:纯函数,返回事件列表或错误
  def handlePlace(order: Order, now: Instant): Either[String, List[OrderEvent]] =
    order match
      case _: DraftOrder if order.items.nonEmpty =>
        Right(List(OrderEvent.OrderPlaced(order.id, order.items, order.shippingAddress, now)))
      case _: DraftOrder =>
        Left("Order must have at least one item")
      case _ =>
        Left(s"Cannot place order in current state")

  // 状态演化:将事件流折叠为当前状态
  // 注意:此为简化示例,实际项目中需根据业务规则决定状态转换的有效性
  def evolve(history: List[OrderEvent]): Order =
    history.foldLeft(Order.empty) { (current, event) =>
      event match
        case OrderEvent.OrderPlaced(id, items, addr, at) =>
          PlacedOrder(id, items, addr, at)
        case OrderEvent.OrderCancelled(id, reason, _) =>
          current match
            case p: PlacedOrder => CancelledOrder(p.id, p.items, p.shippingAddress, reason)
            case other => other
    }

此时,命令侧接口变为存储事件并发布:

trait EventStore[F[_]]:
  def append(aggregateId: String, events: List[OrderEvent], expectedVersion: Long): F[Either[String, Unit]]
  def read(aggregateId: String): F[List[OrderEvent]]

而应用服务则变成“加载事件 → 重建状态 → 处理命令 → 存储新事件”的标准流程:

import cats.Monad
import cats.syntax.all.*

def handlePlaceOrder[F[_]: Monad](
  orderId: String, now: Instant
)(using eventStore: EventStore[F]): F[Either[String, Order]] =
  eventStore.read(orderId).flatMap { history =>
    val current = Order.evolve(history)
    Order.handlePlace(current, now) match
      case Right(events) =>
        eventStore.append(orderId, events, history.size.toLong).map(_.map(_ => Order.evolve(history ++ events)))
      case Left(err) =>
        Monad[F].pure(Left(err))
  }

ES 的优势:完整的审计日志、方便的时间旅行调试、天然支持 CQRS 的读模型投影。同时,事件本身也是 ADT,保证了类型的严密性。

2.3 在应用服务中编织 Functor、Monad、Applicative

现在我们可以将前几章学到的抽象直接应用到应用服务中。以下示例展示了一个典型的 下单用例,它需要:

  • 校验库存(外部服务)
  • 创建订单
  • 保存订单
  • 返回结果

利用 Applicative 并行校验、Monad 串联依赖步骤、Monoid 聚合错误:

import cats.Monad
import cats.syntax.all.*
import java.time.Instant

// 假设已有库存服务接口
trait InventoryService[F[_]]:
  def checkAvailability(items: List[OrderItem]): F[Either[String, Unit]]

def processPlaceOrder[F[_]: Monad](
  orderId: String,
  items: List[OrderItem],
  address: Address,
  now: Instant
)(
  using
  inventory: InventoryService[F],
  commands: OrderCommands[F],
  queries: OrderQueries[F]
): F[Either[String, Order]] =
  // 首先并行校验库存和订单存在性(Applicative)
  val checks: F[(Either[String, Unit], Option[Order])] =
    (inventory.checkAvailability(items), queries.getOrder(orderId)).tupled

  checks.flatMap {
    case (Right(_), None) => // 库存OK,订单不存在
      val newOrder = DraftOrder(orderId, items, address)
      val placed   = Order.place(newOrder, now) // 纯业务逻辑
      // 将 Either 中的 Order 送入命令接口进行持久化,结果保持在 F 中
      placed.fold(
        err => Monad[F].pure(Left(err)),
        order => commands.placeOrder(order.id, now)
      )
    case (Left(err), _) =>
      Monad[F].pure(Left(s"Inventory error: $err"))
    case (_, Some(_)) =>
      Monad[F].pure(Left("Order already exists"))
  }

这里运用了

  • Functorplaced.fold(...) 中对 Either 的两种情况进行映射,将纯校验结果与副作用操作连接。
  • MonadflatMap 串联查询、校验和持久化。
  • Applicative(inventory.checkAvailability(...), queries.getOrder(...)).tupled 让两个独立操作并行(如果 F 支持并发,如 IO)。
  • Monoid:错误信息使用 String 拼接(实际项目中可用 List[String] 或专用错误类型)。

2.4 用“乐高积木”比喻理解组合

我们一直强调函数式编程的核心是组合。现在可以把整个应用架构想象为乐高积木:

  • 最小的积木块:纯函数(A => B)、值对象、ADT。
  • 标准连接件mapflatMapfoldMapmapN 等组合子。它们能让任何符合形状的积木完美拼接。
  • 特殊功能组件:类型类(FunctorMonad)定义了积木的“接口”,保证拼接的安全性。
  • 大型场景套装EitherTIOZIO 等是预制的高级模块,但它们内部依然遵循相同的连接标准。

当你用 F[_] 抽象定义 Repository 时,你实际上是制定了一个“插槽标准”:任何符合该形状的积木(IOFuture、测试桩)都可以插进来,整个结构(业务逻辑)不用做任何改变。这就是抽象的最终威力。

2.5 从 UML 到 ADT,再到完整实现的端到端流程

为了让整个流程更加具体,我们总结一个推荐的开发步骤:

  1. 绘制领域草图(UML 类图 / 事件风暴):识别聚合、实体、值对象、状态转换。
  2. 将静态结构映射为 ADT:采用 sealed trait 定义状态抽象,case class 实现每个具体状态,伴生对象提供纯函数 这一标准模式。值对象用 case class 建模(必要时用智能构造器保护不变性)。
  3. 将行为建模为纯函数:在伴生对象或领域服务中编写 def transition(state, command): Either[Error, NewState]
  4. 抽离副作用接口:定义 trait Repository[F[_]]trait ExternalService[F[_]],用 F[_] 表示效果。
  5. 实现应用服务:用 for 推导或 mapN 组合纯业务函数与副作用接口,返回 F[Either[Error, Result]]
  6. 提供具体解释器:生产环境用 IO 实现,测试环境用 EitherWriter 实现。
  7. 持续重构,提取更通用的抽象:当你发现多个应用服务有类似的“校验-操作-持久化”模式时,考虑提取更通用的类型类(如 UnitOfWork[F[_]])或辅助函数。

这个流程兼顾了领域驱动的严谨性和函数式编程的灵活性,可以让团队在复杂业务中保持代码的可维护性。

2.6 数据流图(DFD)—— 函数式架构的可视化利器

在面向对象设计中,UML 类图是描述领域模型的通用语言,它强调静态结构:类、属性、继承、关联。但在函数式编程中,我们更关注数据如何流动和转换,而非对象之间的职责分配。对于结合了 DDD、CQRS 和事件溯源(ES)的函数式系统,传统的类图往往显得力不从心,而数据流图(DFD)则能更自然地表达系统的本质。

2.6.1 DFD 的基本概念与符号

数据流图是一种图形化技术,它描绘信息流和数据从输入移动到输出的过程中所经受的变换。它的核心价值在于:

  • 纯逻辑视角:没有任何具体的物理元素,只描绘信息在软件中流动和被处理的情况。这与我们强调的“先设计类型,再考虑实现”完全一致。
  • 易于沟通:即使不是专业的计算机技术人员也容易理解它,是分析员与用户之间极好的沟通工具。
  • 分层细化:可以在任何抽象层次上使用,从顶层系统总览到具体处理的细节。层次越低,表现出的信息流和功能细节越多。
  • 不涉及实现细节:设计时只需考虑系统必须完成的基本逻辑功能,完全不需要考虑怎样具体实现。这与 DDD 的战略设计思维高度契合。

DFD 有四种基本符号:

符号 图形 含义 在函数式中的对应
数据源点/终点 正方形/立方体 系统边界外的实体(人或外部系统) HTTP 客户端、消息队列消费者、外部 API
处理 圆角矩形/圆形 变换数据的处理逻辑 纯函数、应用服务函数
数据存储 开口矩形/平行横线 处于静止状态的数据 EventStore、ReadModel、数据库
数据流 箭头 处于运动中的数据 类型实例的流动(如 CommandEventEither[Error, Result]

重要原则

  • 数据存储和数据流都是数据,仅仅所处的状态不同。数据存储是处于静止状态的数据,数据流是处于运动状态的数据。
  • 在数据流图中应该描绘所有可能的数据流向,而不应该描绘出现某个数据流的条件。不要试图在 DFD 中表现分支条件或循环——这些是代码层面的细节。
  • 处理的名字最好由一个具体的及物动词加上一个具体的宾语组成(如“校验库存”、“持久化事件”),避免使用“加工”、“处理”等空洞笼统的动词。
2.6.2 DFD 的分层画法

DFD 采用自顶向下的分层方法,这与我们从领域建模到代码实现的逐步细化过程完美对应。

顶层数据流图:只包含一个处理(代表整个系统)和与之交互的外部实体,突出系统的边界。

[仓库管理员] ──事务──►  ┌────────────┐  ──订货报表──► [采购部]
                        │  订货系统  │
                        └────────────┘

第 0 层数据流图:将顶层处理分解为系统的主要功能(通常 3-7 个处理),并引入数据存储。

[仓库管理员] ──事务──►  ┌────────────┐
                        │ 1.处理事务 │──► 库存清单
                        └────────────┘
                              │
                              ▼
                        ┌────────────┐
                        │ 2.产生报表 │──► 订货报表 ──► [采购部]
                        └────────────┘
                              │
                              ▼
                         订货信息

第 1 层数据流图:对第 0 层的某个处理进一步细化。例如,“处理事务”可以分解为“接收事务”、“更新库存清单”和“处理订货”。

[仓库管理员] ──事务──►  ┌────────────────┐
                        │ 1.1 接收事务   │
                        └────────────────┘
                              │
                              ▼
                        ┌────────────────┐
                        │ 1.2 更新库存   │──► 库存清单
                        └────────────────┘
                              │
                              ▼
                        ┌────────────────┐
                        │ 1.3 处理订货   │──► 订货信息
                        └────────────────┘

何时停止分解:当进一步分解将涉及如何具体实现一个功能时,就不应该再分解了。例如,“按一定格式打印报表”就属于实现细节,不应该出现在 DFD 中。这个停止规则与我们写代码时的关注点完全一致:在领域层只关心“做什么”,在基础设施层才考虑“怎么做”。

2.6.3 将事件风暴转化为数据流图

事件风暴(Event Storming)是 DDD 中常用的领域探索技术,它通过识别领域事件、命令、聚合等来建模。事件风暴的产出物可以非常自然地转换为 DFD:

  • 领域事件 → 数据流(Event 类型的实例)
  • 命令 → 外部实体或输入数据流(Command 类型的实例)
  • 聚合 → 处理过程 + 数据存储(接收事件流,输出状态)
  • 读模型 → 数据处理过程(监听事件,更新视图)和专用的数据存储
  • 外部系统 → 外部实体

下面我们以订单下单的完整流程为例,展示一个集成了 CQRS 和 ES 的第 0 层 DFD:

外部实体: 用户
  │
  │ PlaceOrder(command)
  ▼
处理过程: 应用服务 (PlaceOrder Handler)
  │
  ├─► 数据存储: EventStore (读取事件流) ─► List[OrderEvent] ──┐
  │                                                           │
  │                                                           ▼
  │                                               处理过程: Order.evolve
  │                                                           │
  │                                                           ▼
  │                                                       Order (当前状态)
  │                                                           │
  │                                                           ▼
  │                                               处理过程: Order.handlePlace
  │                                                           │
  │                                          Either[Error, List[OrderEvent]]
  │                                                           │
  │                                                 (如果是 Right)
  │                                                           ▼
  └──────────────────────────────────────► 数据存储: EventStore (追加新事件)
                                                         │
                                                         ▼
                                                    新事件持久化
                                                         │
                                                         ▼
                                              处理过程: 事件发布 (Event Publisher)
                                                         │
                                                         ▼
                                              处理过程: 读模型投影 (Projection)
                                                         │
                                                         ▼
                                              数据存储: 读模型 (OrderView)

这个图清晰地表达了数据在系统中的流转路径,以及哪些部分是纯函数(Order.evolveOrder.handlePlace),哪些是有副作用的(EventStoreEvent Publisher)。与类图关注“谁有什么属性、谁继承谁”不同,DFD 关注的是“数据从哪里来、经过什么转换、最终到了哪里”,这与 FP 的思维方式完全一致。

我们也可以将这个 DFD 进一步细化为第 1 层,聚焦于应用服务内部的纯函数组合:

PlaceOrder(command)
  │
  ▼
处理 1.1: 解析命令 ──► 校验后的参数 (orderId, items, address, now)
  │
  ▼
处理 1.2: 加载事件 ──► EventStore.read ──► List[OrderEvent]
  │
  ▼
处理 1.3: 重建状态 ──► Order.evolve ──► Order (当前状态)
  │
  ▼
处理 1.4: 执行领域逻辑 ──► Order.handlePlace ──► Either[Error, List[OrderEvent]]
  │
  ▼ (如果是 Right)
处理 1.5: 持久化事件 ──► EventStore.append ──► Either[Error, Unit]
  │
  ▼ (如果是 Right)
处理 1.6: 重构最新状态 ──► Order.evolve(history ++ newEvents) ──► Order (新状态)
  │
  ▼
Order (返回给调用方)
2.6.4 如何在函数式 DDD 项目中使用 DFD

在实际团队中,我们可以将 DFD 作为设计文档的一部分,具体步骤如下:

  1. 从事件风暴开始:用便利贴识别关键事件、命令和聚合,这和传统 DDD 一致。这一步产出的是领域事件和命令的列表。
  2. 勾勒顶层 DFD:画出顶层图,确认所有外部实体(数据源点和终点)都已识别。这是验证系统边界的有效手段。
  3. 分解为第 0 层 DFD:将识别出的命令、事件、读模型和服务,用箭头连接,表示主要的数据流向。为每个处理命名(动词+宾语),为每条数据流标注 ADT 类型(如 PlaceOrderCommandOrderPlacedEventEither[Error, Order])。
  4. 必要时绘制第 1 层 DFD:对复杂的处理(如应用服务内部)进行细化,展示纯函数之间的数据转换关系。
  5. 识别纯函数与副作用:将处理过程分为纯函数(标为 pure)和副作用处理过程(标为 effect),后者最终由 IOF[_] 实现。这一步也帮助你确定哪些部分需要编写单元测试,哪些需要集成测试。
  6. 映射到代码:纯函数对应伴生对象或领域服务中的 def ... : Either[Error, Result];副作用过程对应 trait 接口(如 EventStore[F]);数据存储对应具体的 IOFuture 实现;整个应用服务函数将 DFD 的一个处理路径用 flatMap 串联起来。
  7. 持续维护:当业务变更时,先修改 DFD,再调整类型和函数,保持设计和代码的一致性。DFD 的简洁性使得它很容易在代码评审时作为辅助图。

DFD 不替代代码,但它提供了一种类型级别的鸟瞰图,尤其适合在 FP 团队中进行架构评审和新人 onboarding。当你发现类图太过静态,无法表达你的 ADT 和 flatMap 管道时,不妨试试用 DFD 重新描绘你的系统。

DFD 与函数式编程的天然契合点

  • DFD 中的处理过程对应纯函数,数据流对应类型签名中的输入输出类型。
  • DFD 强调“数据从哪来、经过什么变换、到哪去”,这正是 A => B 的思维。
  • DFD 将副作用隔离在数据存储和外部实体中,与 F[_] 将副作用推到系统边界的思想一致。
  • DFD 的分层细化,与我们从领域服务到应用服务再到基础设施的逐层实现完美对应。

3. 本章总结

  • 培养抽象能力需要循序渐进:从消除重复开始,逐步泛化类型,练习类型优先的设计,阅读优秀函数式代码,拥抱类型类。
  • ADT 是 UML 类图的函数式映射,而且 Scala 中实现聚合的推荐模式是 sealed trait → case class 表状态 → 伴生对象提供纯函数。这让状态转换显式化、类型安全且易测试。
  • CQRS 与 F[_] 是天作之合,而且结合事件溯源(ES)后,命令处理变成纯函数,状态通过事件折叠得到,审计与一致性大幅增强。
  • 数据流图(DFD)是 FP 架构的有效工具:它强调数据流动和类型转换,比类图更贴近函数式的思维方式。DFD 的分层画法(顶层 → 0层 → 1层)与我们从领域建模到代码实现的逐步细化过程完美对应。结合事件风暴,DFD 可以直观地表达 CQRS+ES 系统的命令、事件、纯函数和副作用之间的关系,让类型流和业务流一目了然。
  • 在日常开发中主动寻找组合机会:凡是需要“先 A 后 B”、“A 与 B 并行”、“聚合一堆东西”的地方,都能用 Monad、Applicative、Monoid 来简化。
  • 将抽象视为乐高积木的通用连接件:你投入精力定义的 F[_] 接口和类型类,最终会让系统像拼积木一样灵活和安全。
  • 用纯解释器测试副作用逻辑:将 F[_] 实例化为 EitherWriter,可以在不触碰真实外部系统的情况下验证复杂的组合逻辑,这与 1.6 节的建议一脉相承。

最后,请记住:抽象不是为了炫技,而是为了让复杂变得简单,让变化变得可控。当你在一个中型项目中真正体会到“改一处类型签名,编译器就能指出所有需要适配的地方”时,你就已经走上了函数式编程的康庄大道。

posted @ 2026-07-13 15:24  没头脑的老毕  阅读(18)  评论(0)    收藏  举报