版权归作者所有,任何形式转载请联系作者。

作者: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)

UPDATE

我发现 Java 的场景是我没有理解 primitive 数据类型的特殊性,实际上它是可以达到跟 Haskell 一样的效果的,虽然没有 do 语法糖看起来更像是展开 do 语法糖之后的样子

IntStream.range(1, 10).boxed().flatMap(x ->
IntStream.range(x, 10).boxed().flatMap(y ->
Arrays.stream(new String[]{ x + " * " + 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 在实际开发中的应用的更多相关文章

  1. TDD在Unity3D游戏项目开发中的实践

    0x00 前言 关于TDD测试驱动开发的文章已经有很多了,但是在游戏开发尤其是使用Unity3D开发游戏时,却听不到特别多关于TDD的声音.那么本文就来简单聊一聊TDD如何在U3D项目中使用以及如何使 ...

  2. React在开发中的常用结构以及功能详解

    一.React什么算法,什么虚拟DOM,什么核心内容网上一大堆,请自行google. 但是能把算法说清楚,虚拟DOM说清楚的聊聊无几.对开发又没卵用,还不如来点干货看看咋用. 二.结构如下: impo ...

  3. Android学习探索之Java 8 在Android 开发中的应用

    前言: Java 8推出已经将近2年多了,引入很多革命性变化,加入了函数式编程的特征,使基于行为的编程成为可能,同时减化了各种设计模式的实现方式,是Java有史以来最重要的更新.但是Android上, ...

  4. Java开发中的23种设计模式详解

    [放弃了原文访问者模式的Demo,自己写了一个新使用场景的Demo,加上了自己的理解] [源码地址:https://github.com/leon66666/DesignPattern] 一.设计模式 ...

  5. 总结iOS开发中的断点续传那些事儿

    前言 断点续传概述 断点续传就是从文件赏赐中断的地方重新开始下载或者上传数据,而不是从头文件开始.当下载大文件的时候,如果没有实现断点续传功能,那么每次出现异常或者用户主动的暂停,都会从头下载,这样很 ...

  6. 【初码干货】使用阿里云对Web开发中的资源文件进行CDN加速的深入研究和实践

    提示:阅读本文需提前了解的相关知识 1.阿里云(https://www.aliyun.com) 2.阿里云CDN(https://www.aliyun.com/product/cdn) 3.阿里云OS ...

  7. C#开发中使用配置文件对象简化配置的本地保存

    C#开发中使用配置文件对象简化配置的本地保存 0x00 起因 程序的核心是数据和逻辑,开发过程中免不了要对操作的数据进行设置,而有些数据在程序执行过程中被用户或程序做出的修改是应该保存下来的,这样程序 ...

  8. iOS开发中静态库之".framework静态库"的制作及使用篇

    iOS开发中静态库之".framework静态库"的制作及使用篇 .framework静态库支持OC和swift .a静态库如何制作可参照上一篇: iOS开发中静态库之" ...

  9. iOS开发中静态库制作 之.a静态库制作及使用篇

    iOS开发中静态库之".a静态库"的制作及使用篇 一.库的简介 1.什么是库? 库是程序代码的集合,是共享程序代码的一种方式 2.库的类型? 根据源代码的公开情况,库可以分为2种类 ...

随机推荐

  1. SpringBoot日志相关

    SpringBoot使用的是SLF4j当门面,Logback当实现完成 日志级别 数字越大,级别越高,框架只会输出大于等于当前日志级别的信息 ERROR 40 WARN 30 INFO 20 DEBU ...

  2. 01-k8s 架构

    原文地址:https://github.com/kubernetes/kubernetes/blob/release-1.3/docs/design/architecture.md Kubernete ...

  3. java连接mysql数据库jdbc

    jdbc.driver = com.mysql.jdbc.Driverjdbc.url = jdbc:mysql://localhost:3306/数据库名jdbc.username = rootjd ...

  4. 【MySQL】服务无法启动(Mac)

    如图所示: 点击 Start MySQL Server 没反应-- 终端输入 mysql 命令时报错如下: ERROR 2002 (HY000): Can't connect to local MyS ...

  5. 【iOS】stringWithFormat 保留小数点位数 float double

    以前就见过,如下: text = [NSString stringWithFormat:@"%.1f", percentageCompleted]; 但一直没在意.刚一时好奇,查了 ...

  6. 安装使用xen虚拟化工具

    换了一家新公司,需要拿出一套虚拟化方案,就把业界的主流虚拟化技术划拉了一遍,给领导交了一份报告,具体的技术部分已经在之前的随笔里了,本篇文章主要介绍的是xen虚拟化工具的安装: Xen官方部署文档:h ...

  7. IDEA自学

    使用Eclipse很长时间了,想换个IDE用,都说IDEA好用,今天试试 百度了一下IDEA,了解到IDEA社区版免费,上百度,下载个社区版(exe,zip两种)懒人选择exe 手动安装别怕安错,只管 ...

  8. React之动画实现

    React之动画实现 一,介绍与需求 1.1,介绍 1,Ant Motion Ant Motion能够快速在 React 框架中使用动画.在 React 框架下,只需要一段简单的代码就可以实现动画效果 ...

  9. loadrunner中的ie浏览器无法使用

    我的loadrunner是12.55版本的,windows10系统 在我们学习loadrunner的过程中,会出现下面一个问题: 在录制脚本时,loadrunner中的ie浏览器无法使用处于飘红状态. ...

  10. 用 程序 解决 windows防火墙 的 弹窗 问题

    今天用户反馈了一个问题,运行程序弹了个框 这个只有程序第一次运行会出来,之后就不会了. 当然改个程序名字,又会弹出来. 强烈怀疑是写到了注册表,果然被我找到了. “HKEY_LOCAL_MACHINE ...