函数式编程如何消除副作用——Monad 入门与总结
A monad is just a monoid in the category of endofunctors.
从范畴论的角度来说,单子(monad) 不过是自函子范畴上的一个幺半群而已。
什么是monad? 其实,作为程序员,我们不需要深入理解 Monad 背后的数学含义就可以应用 monad 优化我们的代码。本文将总结monad的核心特性,同时给出少量代码示例,当然 monad 作为函数式编程中的核心概念,我们不会局限于某一种语言,而是怎么方便理解怎么介绍。
1. 定义
- pure 构造函数, 对于类型T,可以封装为类型 M[T],返回对应实例
- Bind 也就是我们常用的 flatMap 方法,monad 可以使用 bind 方法实现拆包, 避免出现 M[M[T]] 的形式。
使用 haskell 定义如下:
pure :: a -> M a
bind :: (M a) -> (a -> M b) -> (M b)
其中 :: 表示定义,多个箭头可以理解为柯里化的函数,可以简单理解:只看最后一个箭头,等价于函数:多个参数 -> 结果。
使用scala 定义如下:
trait Monad[M[_]] {
def pure[A](a: A): M[A]
def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]
}
这里类型 M 就是Monad,scala 支持 type class, M 可以有多种“实现类型”,如 Option, List, Future, Either 等。
2. 特点
Monad 是一种函数式编程中的抽象结构,常用于封装和处理计算上下文,特别是在有副作用的计算场景下。Monad 提供了一种优雅的方式来组合计算,将程序逻辑与副作用分离。其主要特点包括:
-
封装计算上下文
Monad 将一个值及其上下文(例如可能出错的计算、异步操作等)进行封装,并提供统一的接口来处理这些上下文。Option 可以封装可能为空的值,Either
Monad (Vavr 中的 Try)可以封装可能会发生错误的计算,CompletableFuture 封装了未来的计算。 -
支持链式操作(map / flatMap)
Monad 提供了 map, flatMap 方法将多个计算串联起来的机制。通过 flatMap 操作,Monad 允许我们将一个计算的结果传递给下一个计算,同时处理可能出现的上下文(例如空值、错误等)。如果我们有一个Optional<Integer>
,可以通过flatMap
进行链式操作,而不需要显式检查空值:
Optional<Integer> opt = Optional.of(5);
Optional<Integer> result = opt.flatMap(x -> Optional.of(x * 2));
-
纯函数式的组合
Monad 是函数式编程中的一个重要概念,它支持通过纯函数组合多个计算。每个 Monad 都遵循特定的函数接口,不改变原始值,而是返回一个新的 Monad,使得计算过程保持纯函数式的风格。使用带有副作用的方法时需要特别注意。 -
单位元和结合律
-
单位元(pure):将普通值放入 Monad 中的操作。
pure
方法定义了将一个普通的值放入一个 Monad 中,形成一个新的 Monad。pure 操作和 flatMap 可以不分先后:monad.pure(a).flatMap(f) == f(a) ma.flatMap(monad.pure) == ma
-
结合律(Associativity):多个 Monad 操作可以按照任意顺序组合,结果是相同的。结合律的意义在于,Monad 操作的顺序是可以重新排列的,只要操作链保持相同。以代码表示如下:
(ma.flatMap(f)).flatMap(g) == ma.flatMap(x => f(x).flatMap(g))
object FutureMonadExample extends App { // 两个异步操作,模拟计算过程 def addOne(x: Int): Future[Int] = Future(x + 1) def double(x: Int): Future[Int] = Future(x * 2) // 模拟Future类型的值 val ma: Future[Int] = Future(5) // 左侧 (ma.flatMap(addOne)).flatMap(double) val left = ma.flatMap(addOne).flatMap(double) // 右侧 ma.flatMap(x => addOne(x).flatMap(double)) val right = ma.flatMap(x => addOne(x).flatMap(double)) // 等待结果并打印 val resultLeft = Await.result(left, 1.second) val resultRight = Await.result(right, 1.second) println(s"Left result: $resultLeft") // Output: Left result: 12 println(s"Right result: $resultRight") // Output: Right result: 12 }
这里我们需要注意,实现相同结果,可能有不同的代码写法。
Scala 提供的 for-comprehension 语法糖可以简化并统一使用Monad计算的实现形式:
for { a <- ma a1 <- addOne(a) result <- double(a1) } yield result
其中,左箭头(
<-
) 可以理解为解包操作,即从 monad 里拿出数据。
- 组合计算逻辑
Monad 的接口支持组合不同的计算,将复杂的逻辑按步骤拆分,通过 Monad 将它们无缝组合起来。Monad 使得函数可以像积木一样组合,减少了显式的控制流(如 if-else、循环等)的使用,提升了代码的可读性和可维护性。
val res = for {
r1 <- future1
r2 <- future2
r3 <- future3
} yield (r1+r2+r3)
以 Future 为例,以上 for-comprehension 对于多个 Future 的结果进行求和,并返回最终结果。
在 Java 中可以表示为:
// vavr 下 Future 类
future1.flatMap(r1 -> future2.flatMap(r2 -> future3.map(r3 -> r1 + r2 + r3)));
// 标准库 CompletableFuture,这里略去 executor 参数
cf1.thenCompose(r1 -> cf2.thenCompose(r2 -> cf3.thenApply(r3 -> r1 + r2 + r3)));
-
惰性求值和延迟执行
很多Monad 的执行在大多数情况下是延迟的,计算不会立即执行,直到 Monad 最终被程序驱动或某些操作触发。在 Java 中 Stream流也遵循这个设计原则,其由最终操作(如collect, count, iterator, forEach等) 触发计算。 -
经典 Monad
Monad 有很多种类型,适用于不同场景:-
Option: 处理可能为空的值,这种处理模式深刻影响了不同语言对于空指针的处理。
- Java 中为 Optional,其严格来说不是 monad, 不符合结合律,简单理解其根本原因就是 null != Optional.empty()
- Scala、Rust 中的 Option
-
Either/Try: 用于错误处理,封装可能出现的错误,并由此衍生出一种异常处理模式——基于轨道编程,于此相对应的是方法抛异常的处理模式。使用Either 的好处是:所有的异常都需要显式处理,便于测试,不易出错。
- Rust: Result<T, E>
- C++: std::expected<T, E>
-
List: 用于封装多个值,并由此衍生出一种编程模式:流式编程。很多语言都支持流程编程实现,少数语言如 Python 使用了切片表达式。
-
还有一些过于复杂且应用较少的monad,比如 封装状态的 State,封装读写的 Reader, Writer, 用于封装 I/O 操作的IO, 封装执行过程的 Continuation 等。
-
3. 解包
monad 封装了值,很多人想直接对值进行操作。实际上,大多数时间,我们需要使用 monad 自带的上下文,解包操作反而是多余的。后续的计算我们只需要放在 monad 中进行即可(使用 flatMap/map)。monad 可以和 monad 组合使用,表示封装了多种上下文。
3.1 模式匹配
当前我们可以实现解包操作,与简单地调用get不同,最佳操作实际上是模式匹配。因为 monad 结果可能封装在不同的子类中,可以理解为一种和类型,如 Option 有 Some(value) 和 None() 两种实现, Either 有Left(err) Right(value) 两种实现。
def processOption(opt: Option[Int]): String = opt match {
case Some(value) => s"Value is: $value" // 匹配 Some
case None => "No value present" // 匹配 None
}
3.2 traverse
这里参考了 Scala 拓展类库 Cats 的定义:我们有 F[A] 的实例和函数 A => G[B],使用 map 操作后,得到 F[G[B]]。但是我们往往需要获得G[F[B]]类型的结果。此时可以使用 traverse 操作实现。
def updateUser(user: User): Future[User] = { /* ... */ ??? }
def updateUsers(users: List[User]): Future[List[User]] = {
users.traverse(updateUser)
}
以上代码对于每一个用户进行更新操作,最终结果封装在Future[List[User]] 里,如果有一个用户更新失败,则总的更新结果视为失败。
我们可以形象化地理解:函数视为相机,其可以对旅行中的美景A进行拍照,返回相片G[B],B代表滤镜下的景色。F[A] 就是我们要去旅行的目的地,通常目的地有多个(List),或者单一目的地(Option、Either等),甚至没有(Option下的Node实现,Either 下的 Left 实现)。一次旅行(traverse) 后,我们收集所有的照片发了朋友圈,即返回值 G[F[B]]
3.3 sequence
Sequence 操作就是组合 monad 的结果,等价于obj.traverse(x -> x)
.
val foo: List[Future[String]] = List(Future("hello"), Future("world"))
val bar = foo.sequence // will have Future[List[String]] type
以上代码中,理想结果(happy path)是当所有 Future 都是正确返回时,最终返回组装结果。如果多个future失败,那么异常信息在 bar 中仅能体现一条。
3.4. monad transformer
单子转换器定义为一种类型转换器,接受一个 monad,返回另一种monad 类型。其核心可以理解为多个monad的嵌套,比如Future[Option[T]] 类型,其同时具有多种上下文或者副作用,但是其包装的值还是一个。对于Scala Cats 类库将其封装为OptionT[Future, T] 类型,其中OptionT表示内层封装了Option类型,外层为Future类型,这种使用 Option 的方式非常常见。
class Money { /* ... */ }
def findUserById(userId: Long): OptionT[Future, User] = { /* ... */ ??? }
def findAccountById(accountId: Long): OptionT[Future, Account] = { /* ... */ ??? }
def getReservedFundsForAccount(account: Account): OptionT[Future, Money] = { /* ... */ ??? }
def getReservedFundsForUser(userId: Long): OptionT[Future, Money] = for {
user <- findUserById(userId)
account <- findAccountById(user.accountId)
funds <- getReservedFundsForAccount(account)
} yield funds
如上代码示例中,for-comprehension 代码 flatMap,map 依然直接作用于封装的值 T。为避免本文对于新手过于困难,StateT, EitherT , ConT 不作解释,感兴趣的读者可以前往 Cats 官方文档学习。
4. 总结
回到最初的问题,什么是 monad,其实这个问题很难讲的清楚。因为其是一种抽象,可以理解为一种设计模式,一种封装好的上下文容器,甚至是马桶搋子。虽然我们无法具体描述其是什么,但是其在编程中却无处不在(比如Option、List),甚至深刻影响了很多现代编程语言。
对于OOP和面向过程编程的程序员来说,除去高昂的学习成本,使用monad编写的代码过于复杂,副作用也似乎不必进行如此复杂的处理。这可能也是函数式编程没有成为主流的原因。不过,没有人能够否认纯函数带来的诸多好处,如果你的代码可以有多种实现,不妨考虑下纯函数的实现方式。