Monad 在实际开发中的应用

版权归作者所有,任何形式转载请联系作者。
作者:tison(来自豆瓣)
来源:https://www.douban.com/note/733279598/

Monad 在实际开发中的应用

不同的人会从不一样的角度接触 Monad。大多数网上的教程和介绍都从其严格的定义出发,加上几个玩具示例就当讲解完毕。诚然,不少 FP 的爱好者都是形式逻辑的拥趸或强于数学的,但是我对 Monad 的理解却不是从其定义入门的。相反,我是先频繁接触了其实例,这其中包括所有开发者都熟悉的列表(List),现代开发者应该熟悉的 Option/Maybe/Optional 和进一步的 Try/Either/Result,以及并发程序开发者熟悉的 Promise 等。当某天我忽然看到某一段文字提到说这些实例就是 Monad 的时候,结合我自己的使用经历,突然能够理解其定义的来由和所要解决的问题。或许这就是一个平凡的开发者接收编程手段演进的过程吧,即从实践经验出发,总结规律并对应到定义中来。

我也不是很明白怎么从定义和抽象实例中去讲明白 Monad 是什么,有什么用。所以按照我自己的尤里卡路径,我打算从它的几个经典实例出发,希望能帮助你思考这些抽象和名词背后的一般思想。这里我会提及 Try, Promise 和 List,不会包括函数式拥趸热爱的 IO Monad,因为后者非常违反纯函数式以外的世界的直觉。

Try

第一个要讲的是 Try,这是考虑到并发编程暂时还没有成为必备技能,Promise 并不是人人都会遇到的,而 List 开发者过于熟悉,从另一个角度看可能会有点反直觉。

Try 要解决的问题和传统的 try-catch 控制块是相似的,也就是处理错误和异常。我们来看一下传统的 try-catch 控制块写出来的代码给人的直观感受。

try {
      ... // some initializations
      ... // some operations that may cause Exception
} catch (XxxException e) {
    ... // ideally we do recovery
    ... // but most of time we log and rethrow
    ... // or swallow it
} finally {
    ... // some cleanups that must be done
}

这个结构在不嵌套的时候以及在 try 中只包含少数语句的时候看起来还不错,因为我们还能很清楚地知道我们在做什么。但是这个前提条件隐含着两个问题。其一,由于 try 开启了一个新的作用域的缘故,我们很多时候会写一个很大的 try 块,而不假思索的大 try 块会让我们忘记到底 try 里面的语句哪个会发生什么异常,以至于即使抛出了异常,我们也只知道异常发生了,而不知道是谁由于什么缘故触发的。如果我们细分的拆成若干个小 try 块,那么我们很快会被满屏的缩进和由于新作用域的缘故定义在 try 外而使用在 try 之后的值,以及需要额外做的 null check 干扰得无法阅读实际业务代码。其二,有的时候我们通过嵌套的方式来处理需要具体 catch 和恢复的可能抛出异常的语句,但是这种缩进正如后面要在 Promise 里讲的 callback hell 一样,会快速的让你失去层次的敏感度。实践经验指出只要有两层 try-catch 就能让一个新接手代码的开发者对这块代码晕菜。

那么 Try Monad 是怎么解决这个问题的呢?我们来看一段典型的 Try 代码

val readFromFile = Try { /* IO */ } // possible IOException
val parseTheContent = readFromFile.flatMap(parse _) // possible ParseException

val tolerantParseException = parseTheContent.recoverWith {
  case _ : ParseException => /* try to fix and retry */
}

tolerantParseException.map(...)/* ... */

这段代码首先通过 Try { ... } 构造 Try Monad 的实例,这对应 Haskell Monad 中的 return 函数,即把一个类型升格为 Monad。我们直接看这个函数做了什么

object Try {
  /** Constructs a `Try` using the by-name parameter.  This
   * method will ensure any non-fatal exception is caught and a
   * `Failure` object is returned.
   */
  def apply[T](r: => T): Try[T] =
    try Success(r) catch {
      case NonFatal(e) => Failure(e)
    }

}

我们忽略 NonFatal 这个问题,这段代码的意味是执行一个可能抛出异常的操作,如果操作成功,返回其返回值,如果抛出异常,则记录异常。Try 有两个子类

final case class Success[+T](value: T) extends Try[T] { ... }
final case class Failure[+T](exception: Throwable) extends Try[T] { ... }

分别对应这两种情况。对于后续代码中 map 和 forEach 这样处理正常逻辑的代码,如果 Try 是一个 Failure,它会永远返回它自己,也就是说第一个错误的原因被持续的传递下去。直到调用 recover 或 recoverWith,对于这两个方法,相反的 Success 永远返回它自己,但是 Failure 能相应传进来的偏函数,匹配具体的异常类型并试图恢复。

因此,上面代码的逻辑就是,从文件中读入数据并解析,如果解析异常我们试着去恢复,随后进行一系列操作。如果一开始的读入有异常,我们直到最后都拿到一个 IOException,这可能在后面被恢复或吞掉或直接作为返回值向上返回交给上层处理。

实际上,我们可以用 try-catch 控制块去实现这段代码的逻辑,但是我们会发现逻辑迷失在缩进、作用域和控制流的跳转上;而使用 Try Monad,我们可以以线性的符合直觉的处理方式来对逻辑进行编码。这也是函数式编程的一个思想,即尽可能把所有的情况都纳入类型系统中,提供最简单的控制流(最极端的情况下只有 if-else 和 match-case)以保证程序逻辑是顺着下来的,而不用做奇怪的跳转。

那么,这跟 Monad 有什么关系呢(笑)。前面提到 try-catch 有两个问题,现在其一作用域导致的大 try 块已经被 Try {...} 也就是所谓的 return 函数弄到了 Try Monad 的包装里面,我们实际操作的是其中的 value 和 exception,但这是 Monad 的父类型类 Functor 就有的要求。对于第二个问题,嵌套的 try 块,它的解决才彰显出 Monad 最强大的地方,也就是 Haskell 中所谓的 bind 函数,我更喜欢 Scala 中沿用列表的称呼 flatMap 函数。

在 Try 的实例中,我们对 value 的操作可能引入一个新的可能产生异常的动作(例如上面的 parse),这不同于 map 的时候我们的类型从 Try[T] 到 Try[U],parse 产生的是 Try[Try[U]],这样在后面的解包处理的过程里面,我们就要手动的解两层嵌套的包装,一旦串接的操作变多,我们将人为的记住需要解包的层数并进行机械的解包动作,虽然我们最终感兴趣的只是其中的值。更加令人不快的是,我们明知道 parse 做的就是把值从前面的包装取出来,对应的产生一个我们需要的 Try Monad 的结果,我们本不需要把它再装入前面的包装中。这就是 flatMap 存在的意义,把装到前面的包装中这个动作给去掉了。因此我们无论做多少次可能产生异常串接,最终的结果类型都是 Try[T]。可以说,不同于 Functor 和 Applicative Functor 的 flatMap 函数就是 Monad 的精髓。

Promise

其实我打算用 Java 的 CompletableFuture 来做例子,后者把 Promise 和 Future 的职责糅合在一起,说不定意外的好理解一点(实际上 Scala 内部实现的 Promise 就是同时混入 Promise 和 Future 的)。

在开题的时候我原本以为 Promise 和 Try 分别代表了不同的 Monad 实例,但是其实在错误恢复和处理以及多个子类型上面它们相似程度还不少。所以对于 Promise 和 Try 类似能够分别代表异步计算成功或失败以及对应的线性处理以对付 callback hell 的问题就一笔带过。这里着重讲一下在 Try Monad 中很自然但是在 Promise Monad 中尤为重要的另一个特性:

通过使用 map/flatMap 串接操作,能保证计算是顺序执行的。

我们来看下面一段代码

CompletableFuture<...> asyncOp1 = ...;
asyncOp1.thenCompose(res -> /* another async op */)
        .thenApply(res -> /* sync op */)

抛去其 Async 版本带来的由于 Java Executor 框架引入的异步问题,这段代码第一个异步操作 asyncOp1 后接了一个异步操作,在后面这个异步操作结束后接了一个同步操作。这个过程还可以无限的延续下去。由于 Monad map/flatMap 天然的顺序计算特性,即拿到操作数才能做下一步的动作,我们能够保证这些异步动作是按照安排好的顺序依次执行的。这其实也是 callback 想解决的问题,同时在并发程序开发中能够帮助 reasoning 代码。关于并发程序开发中怎么同步和怎么选择顺序和异步操作的问题,那就是另一个有趣的主题了。

List

上面的两个例子有个共同的特点,即都表明了计算的成功或失败。但是这一点在 Monad 里面其实不是必须的。

我们看到 List 也是个 Monad,对于这个大家都很熟悉的类我就不多做基础的介绍,相反的,从 Monad 的定义来考察 List 是怎么成为 Monad 的。

对于 Monad 来说,它需要一个 return 函数和一个 bind 函数。对于 List,它的 return 就是 x = [x], 而 bind 就是 List 的 flatMap 函数。

List 是一个更简单的例子,能够帮助我们看到 flatMap 发生的具体情况。例如我们要做一个九九乘法表,命令式的写法是

for (int i = 1; i < 10; i++) {
  for (int j = i; j < 10; j++) {
    System.out.println(i + " x " + j + " = " + i * j);
  }
}

而利用 List Monad 的 flatMap 函数,我们可以写作

mapM_ putStrLn
   $ do 
       x <- [1..9]
       y <- [x..9]
       return (show x ++ " + " ++ show y ++ " = " ++ show (x * y))

在 Java Stream 中我们可以拿到 x * y 的结果,但是捕获前面的 x 和 y 稍微有点困难(可以使用 forEach,但是其实 forEach 已经是强制解包消费无法再装包了)。

IntStream
    .range(1, 10)
  .flatMap(x -> IntStream.range(x, 10).map(y -> x * y))
  .forEach(System.out::println)

小结

Monad 的使用场景还是很广泛的,无论是在异常处理和并发编程里崭露头角的 Try 和 Promise,还是伴随我们已久的 List,还有函数式的世界里为了处理状态变化的 State Monad 和为了附加副作用的 IO Monad,说到底,Monad 的核心就在于 flatMap 函数和附加在装包解包上可以自定义的动作(在 Haskell 里,底层平台利用这个任意附加的操作实现了 IO Monad 的副作用)。从代码工匠的角度来看,多看多思考使用 Monad 特性的优质代码,能够帮助理解和学习 Monad 的实际作用。这部分的代码项目比较多,简单的可以推荐 Pravega 和 Apache Flink 这两个大量使用了 Promise 的项目。书籍方面推荐《Java 函数式编程》 和 《魔力 Haskell》。上面的介绍里混杂了很多 Monad 有但不是独有的内容,跟随这两本书理解函数式编程里面是怎么由简到繁,一步步地针对新的问题提供新的解法的,这个过程非常有趣。

你可能感兴趣的:(Monad 在实际开发中的应用)