跳到内容

第 9 卷 / 第 4 章 / 第 3 课

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

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

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──┘

结合律允许保持元素顺序、改变分组括号,不允许随意交换元素:字符串拼接有结合律,交换 a 与 b 却会改变结果。分布式聚合如果还会重排输入,需要交换律,或明确保留顺序的协议。

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 的校验设计可表达这种已知结构,并选择累积错误。错误累积取决于具体实例,不是每个 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 也有 map 和 flatMap,却是惰性、一次性消费的操作管道,实现还可省略不影响结果的阶段。

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

把下面代码保存为 OptionalLawsDemo.java,用 JDK 17 或更高版本执行 java OptionalLawsDemo.java:

java
import java.util.Optional;
import java.util.function.Function;

class OptionalLawsDemo {
    public static void main(String[] args) {
        Function<String, String> f = value -> null;
        Function<String, String> g = value -> "fallback";
        Optional<String> input = Optional.of("x");
        System.out.println(input.map(f).map(g));
        System.out.println(input.map(f.andThen(g)));
        System.out.println(("a" + "b") + "c");
        System.out.println("a" + ("b" + "c"));
        System.out.println("b" + "a" + "c");
    }
}

前两行输出为 Optional.empty 和 Optional[fallback]:Optional.map 把 f 返回的 null 变成缺失,拆开映射就跳过了 g,先组合函数却会调用 g。限制函数纯粹且不返回 null,才能避开这个反例。后三行是 abc、abc、bac,展示重新分组与交换顺序的区别。方法名能提供线索,还不能直接颁发代数规律合格证。

把错误放进返回类型 ​

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

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

组合函数可以决定:遇到第一个错误就停止,还是累计多个校验错误。调用方从类型上看见已建模的业务失败;Java 实现仍可能抛出未检查异常,这个声明不能证明 API 绝不抛错。

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

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

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

在普通 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 = List.copyOf(effects); }
}

这是 Java 17 声明片段,Order 和 OrderPlaced 由应用领域模型提供。复制 effects 列表能保护成员集合,若要整个计划不可变,payload 类型也必须不可变。

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

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

学习抽象的顺序 ​

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

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

完成检查 ​

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

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

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

参考资料 ​

Built with VitePress | Software Systems Atlas