跳到内容

4.3 代数抽象与副作用:从 Monoid 到 Monad

阿花把一串业务步骤组合起来后,异常、日志和异步状态层层嵌套;她想知道哪些组合规律可以由类型表达。

MonoidFunctorMonad 常被讲成隐喻,结果读者记住“盒子”和“管道”,却不知道它们约束了哪些操作。更可靠的学习方式是看三个问题:有哪些值、允许哪些组合、组合必须满足什么规律。

Monoid:可并行合并的最小结构

一个 Monoid 由以下内容构成:

  • 一组值;
  • 二元组合操作 combine(a, b)
  • 单位元 empty
  • 组合操作满足结合律。
text
combine(combine(a, b), c) = combine(a, combine(b, c))
combine(empty, a) = a
combine(a, empty) = a

例子包括:

组合操作单位元
整数加法0
整数乘法1
字符串拼接""
列表连接空列表
集合并集空集合

同一种值可以有不同 Monoid。整数加法和乘法的单位元不同,所以只说“整数是 Monoid”不完整,必须说明组合操作。

结合律让系统可以先分块聚合再合并:

text
分片 A ──sum──┐
分片 B ──sum──┼──sum──> 总和
分片 C ──sum──┘

这正是并行归约、MapReduce 和分布式指标汇总的重要基础。若操作不满足结合律,分块顺序变化就可能改变结果。

Functor:在上下文中映射

把普通函数 A -> B 应用到某种上下文 F<A>,得到 F<B>,通常写作 map

text
map : (A -> B) -> F<A> -> F<B>

Java Optional.map 展示了这种形状:

java
Optional<User> user = repository.findUser(id);
Optional<String> email = user.map(User::email);

映射改变内部值,但保留“可能没有值”的上下文。要保持可预测,map 应满足两条核心规律:

text
map(identity) = identity
map(f 然后 g) = map(f) 然后 map(g)

规律的意义很实际:重构时加入一次恒等映射不应改变行为;合并或拆开连续映射不应改变结果。如果 map 偷偷写数据库或依赖调用次数,这些等价变换就会失效。

Applicative:组合彼此独立的上下文计算

假设创建用户需要同时校验姓名和邮箱,并希望一次返回全部错误:

text
Validated<Name>  +  Validated<Email>
             ↓ 组合
       Validated<User>

两项校验互不依赖,可以分别执行,再把成功值交给构造函数;失败时累积错误。Applicative 抽象描述的正是这类“结构已知、计算相互独立”的组合。

它与下一节的差别在于:后一项计算是否需要前一项产生的值才能决定。

Monad:让下一步依赖上一步的值

Monad 常见的核心操作可写成:

text
pure    : A -> M<A>
flatMap : M<A> -> (A -> M<B>) -> M<B>

例如查用户后,必须拿到用户的组织 ID 才知道下一步查哪个组织:

java
Optional<Organization> organization =
    repository.findUser(userId)
        .flatMap(user -> repository.findOrganization(user.organizationId()));

如果用户不存在,后续查询不执行;如果存在,函数返回新的 Optional<Organization>flatMap 避免产生 Optional<Optional<Organization>>

这比“Monad 是装值的盒子”更准确:Monad 提供的是带上下文计算的依赖式排序方式。不同上下文赋予排序不同含义:

  • Maybe / Optional:某一步为空,后续停止;
  • Either / Result:某一步失败,传播错误;
  • List:每一步可能产生多个结果,形成组合;
  • State:把状态从一步传给下一步;
  • IO:把外部交互表示为可组合的动作。

形式上的 Monad 还要求左单位元、右单位元和结合律。工程代码未必需要手推证明,但这些规律解释了为何等价的链式重组不应改变语义。

不要把相似 API 直接盖章为 Monad

Java Optional.flatMap 很像 Monad 的 bind,但 Java API 还有 null 处理、对象语义等自身约定。Java Stream 也有 mapflatMap,却是惰性、一次性消费的操作管道,实现还可省略不影响结果的阶段。

因此更稳妥的表述是:这些 API 借用了函数式组合模式。是否构成满足特定规律的形式实例,需要明确类型、操作和等价关系,不能因为方法名相同就直接断言。

把错误放进返回类型

异常会离开普通返回路径。对可预期的业务失败,可以用显式结果类型:

java
sealed interface Result<T> {
    record Ok<T>(T value) implements Result<T> {}
    record Error<T>(String code, String message) implements Result<T> {}
}

组合函数可以决定:遇到第一个错误就停止,还是累计多个校验错误。调用方从类型上看见失败可能性,不必阅读实现才发现某处会抛异常。

这不表示所有异常都要改成返回值。内存耗尽、违反内部不变量等不可恢复故障,与“邮箱格式不正确”不是同类问题。错误建模要区分预期业务分支和程序缺陷。

副作用管理不是消灭现实世界

函数式语言也要读写文件、访问网络。关键是让动作的组合与动作的执行分离,或者至少把副作用集中到边界。

在普通 Java 服务中,可以采用朴素版本:

java
sealed interface CheckoutEffect {
    record SaveOrder(Order order) implements CheckoutEffect {}
    record PublishEvent(OrderPlaced event) implements CheckoutEffect {}
}

record CheckoutDecision(
    Order order,
    List<CheckoutEffect> effects
) {}

纯核心产生 CheckoutDecision,外壳解释并执行 effects。这使规则测试无需真的写数据库,也让副作用的顺序、重试和幂等要求有明确落点。

它并不自动解决跨数据库与消息系统的一致性;执行层仍可能需要事务性发件箱、幂等键等机制。抽象能暴露问题边界,不能替代基础设施保证。

学习抽象的顺序

  1. 先熟练写纯函数和不可变值;
  2. mapfilterfold 描述常见变换;
  3. 识别可结合的聚合操作;
  4. 区分独立计算和依赖式计算;
  5. 最后再学习 Functor、Applicative、Monad 的形式规律。

若一个抽象不能让错误处理、组合方式或测试边界更清楚,就不必为了“函数式”把它塞进业务代码。

完成检查

设计一个注册流程:校验姓名、邮箱和密码,然后查询推荐人,最后生成待执行的持久化与通知动作。

  • 哪些校验彼此独立,可以累计错误?
  • 哪一步必须依赖上一步的值?
  • 哪些操作是纯计算,哪些是副作用?
  • 组合操作是否满足结合律?
  • 重试执行动作时怎样避免重复通知?

能用这些问题解释设计,才算理解了抽象;记住“Monad 是盒子”远远不够。

参考资料

Built with VitePress | Software Systems Atlas