4.3 代数抽象与副作用:从 Monoid 到 Monad
阿花把一串业务步骤组合起来后,异常、日志和异步状态层层嵌套;她想知道哪些组合规律可以由类型表达。
Monoid、Functor、Monad 常被讲成隐喻,结果读者记住“盒子”和“管道”,却不知道它们约束了哪些操作。更可靠的学习方式是看三个问题:有哪些值、允许哪些组合、组合必须满足什么规律。
Monoid:可并行合并的最小结构
一个 Monoid 由以下内容构成:
- 一组值;
- 二元组合操作
combine(a, b); - 单位元
empty; - 组合操作满足结合律。
combine(combine(a, b), c) = combine(a, combine(b, c))
combine(empty, a) = a
combine(a, empty) = a例子包括:
| 值 | 组合操作 | 单位元 |
|---|---|---|
| 整数 | 加法 | 0 |
| 整数 | 乘法 | 1 |
| 字符串 | 拼接 | "" |
| 列表 | 连接 | 空列表 |
| 集合 | 并集 | 空集合 |
同一种值可以有不同 Monoid。整数加法和乘法的单位元不同,所以只说“整数是 Monoid”不完整,必须说明组合操作。
结合律让系统可以先分块聚合再合并:
分片 A ──sum──┐
分片 B ──sum──┼──sum──> 总和
分片 C ──sum──┘这正是并行归约、MapReduce 和分布式指标汇总的重要基础。若操作不满足结合律,分块顺序变化就可能改变结果。
Functor:在上下文中映射
把普通函数 A -> B 应用到某种上下文 F<A>,得到 F<B>,通常写作 map:
map : (A -> B) -> F<A> -> F<B>Java Optional.map 展示了这种形状:
Optional<User> user = repository.findUser(id);
Optional<String> email = user.map(User::email);映射改变内部值,但保留“可能没有值”的上下文。要保持可预测,map 应满足两条核心规律:
map(identity) = identity
map(f 然后 g) = map(f) 然后 map(g)规律的意义很实际:重构时加入一次恒等映射不应改变行为;合并或拆开连续映射不应改变结果。如果 map 偷偷写数据库或依赖调用次数,这些等价变换就会失效。
Applicative:组合彼此独立的上下文计算
假设创建用户需要同时校验姓名和邮箱,并希望一次返回全部错误:
Validated<Name> + Validated<Email>
↓ 组合
Validated<User>两项校验互不依赖,可以分别执行,再把成功值交给构造函数;失败时累积错误。Applicative 抽象描述的正是这类“结构已知、计算相互独立”的组合。
它与下一节的差别在于:后一项计算是否需要前一项产生的值才能决定。
Monad:让下一步依赖上一步的值
Monad 常见的核心操作可写成:
pure : A -> M<A>
flatMap : M<A> -> (A -> M<B>) -> M<B>例如查用户后,必须拿到用户的组织 ID 才知道下一步查哪个组织:
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 借用了函数式组合模式。是否构成满足特定规律的形式实例,需要明确类型、操作和等价关系,不能因为方法名相同就直接断言。
把错误放进返回类型
异常会离开普通返回路径。对可预期的业务失败,可以用显式结果类型:
sealed interface Result<T> {
record Ok<T>(T value) implements Result<T> {}
record Error<T>(String code, String message) implements Result<T> {}
}组合函数可以决定:遇到第一个错误就停止,还是累计多个校验错误。调用方从类型上看见失败可能性,不必阅读实现才发现某处会抛异常。
这不表示所有异常都要改成返回值。内存耗尽、违反内部不变量等不可恢复故障,与“邮箱格式不正确”不是同类问题。错误建模要区分预期业务分支和程序缺陷。
副作用管理不是消灭现实世界
函数式语言也要读写文件、访问网络。关键是让动作的组合与动作的执行分离,或者至少把副作用集中到边界。
在普通 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。这使规则测试无需真的写数据库,也让副作用的顺序、重试和幂等要求有明确落点。
它并不自动解决跨数据库与消息系统的一致性;执行层仍可能需要事务性发件箱、幂等键等机制。抽象能暴露问题边界,不能替代基础设施保证。
学习抽象的顺序
- 先熟练写纯函数和不可变值;
- 用
map、filter、fold描述常见变换; - 识别可结合的聚合操作;
- 区分独立计算和依赖式计算;
- 最后再学习 Functor、Applicative、Monad 的形式规律。
若一个抽象不能让错误处理、组合方式或测试边界更清楚,就不必为了“函数式”把它塞进业务代码。
完成检查
设计一个注册流程:校验姓名、邮箱和密码,然后查询推荐人,最后生成待执行的持久化与通知动作。
- 哪些校验彼此独立,可以累计错误?
- 哪一步必须依赖上一步的值?
- 哪些操作是纯计算,哪些是副作用?
- 组合操作是否满足结合律?
- 重试执行动作时怎样避免重复通知?
能用这些问题解释设计,才算理解了抽象;记住“Monad 是盒子”远远不够。