函数式编程如何消除副作用——Monad 入门与总结

260 阅读9分钟

函数式编程如何消除副作用——Monad 入门与总结

A monad is just a monoid in the category of endofunctors.

从范畴论的角度来说,单子(monad) 不过是自函子范畴上的一个幺半群而已。

什么是monad? 其实,作为程序员,我们不需要深入理解 Monad 背后的数学含义就可以应用 monad 优化我们的代码。本文将总结monad的核心特性,同时给出少量代码示例,当然 monad 作为函数式编程中的核心概念,我们不会局限于某一种语言,而是怎么方便理解怎么介绍。

1. 定义

  1. pure 构造函数, 对于类型T,可以封装为类型 M[T],返回对应实例
  2. 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 提供了一种优雅的方式来组合计算,将程序逻辑与副作用分离。其主要特点包括:

  1. 封装计算上下文
    Monad 将一个值及其上下文(例如可能出错的计算、异步操作等)进行封装,并提供统一的接口来处理这些上下文。Option 可以封装可能为空的值,Either Monad (Vavr 中的 Try)可以封装可能会发生错误的计算,CompletableFuture 封装了未来的计算。

  2. 支持链式操作(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));
  1. 纯函数式的组合
    Monad 是函数式编程中的一个重要概念,它支持通过纯函数组合多个计算。每个 Monad 都遵循特定的函数接口,不改变原始值,而是返回一个新的 Monad,使得计算过程保持纯函数式的风格。使用带有副作用的方法时需要特别注意。

  2. 单位元和结合律

  • 单位元(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 里拿出数据。

  1. 组合计算逻辑
    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)));
  1. 惰性求值和延迟执行
    很多Monad 的执行在大多数情况下是延迟的,计算不会立即执行,直到 Monad 最终被程序驱动或某些操作触发。在 Java 中 Stream流也遵循这个设计原则,其由最终操作(如collect, count, iterator, forEach等) 触发计算。

  2. 经典 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编写的代码过于复杂,副作用也似乎不必进行如此复杂的处理。这可能也是函数式编程没有成为主流的原因。不过,没有人能够否认纯函数带来的诸多好处,如果你的代码可以有多种实现,不妨考虑下纯函数的实现方式。