Functors, Applicatives and Monads In Pictures
封面来源:由博主个人绘制,如需使用请联系博主。
本文是 skopylov58/monads-in-pictures-in-java 中文译文,最初的原文是 Haskell 版本的 Functors, Applicatives, And Monads In Pictures,希望本文能对不懂 Haskell 的中文读者有所帮助。
本文并未进行逐词逐句地翻译,而是在尽量保证语义不发生变化的情况下,使其更符合中文读者的阅读习惯。如有错误,希望各位看官不吝赐教,批评指正。
本文将使用 JShell 来演示代码片段:
1
2
3
4
5 C:\Users\Mofan>jshell
| 欢迎使用 JShell -- 版本 21.0.7
| 要大致了解该版本, 请键入: /help intro
jshell>注意:JShell 是 Java 9 新增的交互式脚本工具,请保证使用的 Java 版本符合要求。
这是一个简单的值:
我们知道如何将一个「函数」应用到这个值:
在 JShell 中,它看起来像这样:
1 | jshell> Function<Integer, Integer> add3 = x -> x + 3 |
这很简单。
我们可以将其扩展为:任何值都可以出现在「上下文」(Context)中。目前你可以将「上下文」想象成一个可以放入值的盒子:
当你将一个函数应用于这个值时,你将 依赖于上下文 而得到不同的值。Functors、Applicatives、Monads,Arrows 等概念都是基于这种思想。
Maybe
数据类型定义了两种相关的上下文:
Java 中没有 Maybe
数据类型,可以用 Optional<T>
来代替它,用于模拟值 T
的缺失。
可以参考下表将 Haskell 中的一些概念用 Java 类进行替代:
Haskell | Java |
---|---|
Maybe |
Optional<T> |
Just 2 |
Optional.of(2) |
Nothing |
Optional.empty() |
1 | jshell> var just2 = Optional.of(2) |
稍后我们将看到当某物是 Just a
而不是 Nothing
时,应用一个函数会有怎样的不同。首先让我们来聊聊 Functors!
1. Functors
Functor,函子。
当值被封装到上下文中时,不能对其应用一个普通函数:
这就是 map
发挥作用的地方(在 Haskell 中是 fmap
)。
map
是混江湖的,map
对上下文门儿清(感谢 DeepSeek 给出的译文参考)。它懂得如何将函数应用于封装在上下文中的值。
假设你想将 add3
应用于 just2
,使用 map
吧:
1 | jshell> var just5 = just2.map(add3) |
哇哦!map
向我们展示了它是如何做到的!但 map
是怎么知道应该怎么应用这个函数的呢?
2. 究竟什么是 Functor?
Functor 是一个抽象基类(在 Haskell 中叫 typeclass)。
Haskell 中的定义如下:
在 Java 中,它可以是这样的一个函数式接口:
1 |
|
Functor 是一个定义了 map
如何应用于他的数据类型。
Haskell 中 fmap
的工作原理如下:
所以在 Java 里可以这样做:
1 | jshell> just2.map(x -> x + 3) |
map
神奇地应用了这个函数,因为 Optional
是一个 Functor。它指定 了 map
如何应用于空的和非空的 Optional
。
当我们写下 just2.map(x -> x + 3)
时,背后发生的事情是这样的:
所以你可能会想,好吧 map
,请将 x -> x + 3
应用到 Nothing
:
1 | jshell> nothing.map(x -> x + 3) |
比尔・奥莱利(Bill O’Reilly )对 Maybe 函子一无所知
就像《黑客帝国》中的墨菲斯[1]一样,map
知道该怎么做;从 Nothing
开始,最后得到的也是 Nothing
。map
颇具禅意。现在明白了为什么会存在 Optional
数据类型。例如,在没有 Optional
数据类型的语言中,你会这样处理数据库记录:
1 | var post = Post.find_by_id(1); |
但在 Java 中使用 Optional
:
1 | return Optional.ofNullable(Post.find_by_id(1)) |
如果 find_by_id()
返回了一个帖子,我们将用 getTitle()
获取其标题;如果它返回 Nothing
,我们也将返回 Nothing
。挺酷的,对吧?
这里还有另一个例子:当你将一个函数应用于一个列表会发生什么呢?
在 Java 中,可以使用 List#stream()
方法将 List
对象转换为 Stream
对象,也可以直接构建包含指定元素的 Stream
对象。
Stream
也是函子,在 Java 中有:
1 | jshell> Stream.of(2,4,6).map(add3).toList() |
好的,好的,再举一个例子:当你把一个函数应用到另一个函数上时又会发生什么呢?
这是一个函数:
然后将一个函数应用到另一个函数上:
结果依旧是一个函数!
1 | jshell> var add5 = add3.andThen(x -> x + 2) |
因此函数也是函子!
3. Applicatives
Applicative,适用函子(应用函子)。
它是 Functor 的扩展类型,除了具备 Functor 的功能外,还能处理「函数位于上下文中」的情况,支持将封装在上下文中的函数应用到封装的值上。
使用 Applicative 时,我们的值会被封装在一个上下文中,就像 Functor 一样:
但我们的函数也能被封装在一个上下文中!
好的,先让这个概念沉淀一下。Applicatives 不会开玩笑。它知道如何将一个封装在上下文中的函数应用到一个封装在上下文中的值:
Java 中的 Optional
并不是 Applicatives,但我们可以为它编写一个 aplicative()
函数:
1 | <T, R> Optional<R> applicative(Optional<Function<T, R>> func, Optional<T> value) { |
1 | jshell> <T, R> Optional<R> applicative(Optional<Function<T, R>> func, Optional<T> value) { |
让我们来测试一下:
1 | jshell> applicative(Optional.of(add3), just2) |
在 Haskell 中使用 <*>
可能会导致一些有趣的情况,比如:
Java 里并没有 <*>
运算符,但可以使用以下方式实现相同的效果:
1 | jshell> Function<Integer, Integer> mult2 = x -> 2 * x |
这里有一些可以用 Applicatives 做到、但用 Functors 做不到的事情。如何将一个接收两个参数的函数应用于两个被封装的值?
Applicatives 将 Functors 推到一边,「大男孩可以使用任意参数数量的函数」,它说,「凭借 Haskell 中的 <$>
和 <*>
,我可以处理接收任意数量未封装值的任意函数,然后将所有封装的值都传递给这个函数,最后得到一个封装值!哈哈哈哈!」。
而且还有一个名为 lift_a2
的方法也能做到相同的事(Java 无法处理 aplicative 调用中任意数量的参数,但我们可以创建一个类似的 lift_a2
方法):
1 | <A, B, C> Optional<C> lift_a2(Optional<A> optA, Optional<B> optB, BiFunction<A, B, C> bifunc) { |
该方法接收两个封装的值(使用 Optional
封装),还有一个接收两个未封装值的 bifunc
函数:
1 | jshell> <A, B, C> Optional<C> lift_a2(Optional<A> optA, Optional<B> optB, BiFunction<A, B, C> bifunc) { |
让我们测试一下:
1 | jshell> lift_a2(just2, Optional.of(3), (x, y) -> x * y) |
4. Monads
Monad,单子。
它进一步拓展了 Applicative,用于处理嵌套上下文。
如何学习 Monad:
- 获得计算机科学博士学位。
- 扔掉它,因为你不需要它来学习这一部分!
Monad 增加了新的变化。
Functor 能够将一个函数应用于被封装的值:
Applicative 能够将一个被封装的函数应用于一个被封装的值:
Monad 则能够将一个返回封装的值的函数应用于一个被封装的值。Monad 有一个 flatMap
函数(在 Haskell 中是 >>=
,发音为 bind
)来完成这个操作。
让我们来看一个例子。经典的 Maybe
数据类型就一个 Monad:
假设 half
是一个仅适用于偶数的函数:
1 | Function<Integer, Optional<Integer>> half = i -> i % 2 == 0 ? Optional.of(i / 2) : Optional.empty(); |
1 | jshell> Function<Integer, Optional<Integer>> half = i -> i % 2 == 0 ? Optional.of(i / 2) : Optional. |
当传入 half
函数的参数是偶数时,返回由 Optional
包装的一半的值,否则返回 Optional.empty()
,即 Nothing
。
如果传入 half
函数的参数是一个被封装的值:
half
函数无法正常工作。
我们需要使用 flatMap
(在 Haskell 中是 >>=
)将封装后的值传入函数中。这是 flatMap
的照片 🤣:
它的工作原理如下:
1 | jshell> Optional.of(4).flatMap(half) |
这里面发生了什么?Monad 是另一个抽象基类(在 Haskell 中,它是另一个 typeclass)。这是它在 Java 中的部分定义:
1 | public interface Monad<T> { |
bind
(在 Java 中是 flatMap
,在 Haskell 中是 >>=
)的含义是:
因此 Java 中的 Optional<T>
实际上是一个 Monad。
用 Just 3
来演示下使用示例:
1 | jshell> Optional.of(3).flatMap(half) |
如果你传入一个 Nothing
,那就更简单了:
1 | jshell> nothing.flatMap(half) |
你也可以链式调用它们:
1 | jshell> Optional.of(20).flatMap(half).flatMap(half).flatMap(half) |
酷毙了!
在 Haskell 中,Maybe
既是一个 Functor
,也是一个 Applicative
,还是一个 Monad
。
但在 Java 中,Optional
只是一个 Functor
和 Monad
,它不是 Applicative
,但如果需要,可以提供一个 applicative
函数。
5. IO Monad
现在让我们来看另一个例子,IO Monad:
Java 标准库中并没有 IO Monad,为了翻译,我们需要完成一个最小的 IO Monad 实现:
1
2
3
4
5
6
7
8 public interface IO<T> {
T run();
default <R> IO<R> flatMap(Function<T, IO<R>> mapper) {
return () -> mapper.apply(run()).run();
}
}它和
Supplier<T>
很类似,内部增加了一个flatMap
方法。
具体来说有三个函数。getLine
不接受任何参数,直接获取用户的输入:
1 | IO<String> getLine() { |
readFile
接收一个字符串(文件名)并返回该文件的内容:
1 | IO<String> readFile(String fileName) { |
putStrLn
接收一个字符串并打印它:
1 | IO<Void> printLine(String text) { |
这三个函数都接收一个普通值(或者不接收值),并返回一个分装的值。我们可以使用 flatMap
将它们串联起来:
1 | var io = getLine().flatMap(s -> readFile(s)).flatMap(s -> printLine(s)); |
哇哦!前排的各位请看 Monad 的表演!
Haskell 还为我们提供了一些关于 Monad 的语法糖,称为 do
表示法:
1 | foo = do |
可惜,这在 Java 中并没有。
6. 总结
- 一个 functor 是一种实现了
Functor
抽象基类的数据类型。 - 一个 applicative 是一种实现了
Applicative
抽象基类的数据类型。 - 一个 monad 是一种实现了
Monad
抽象基类的数据类型。 - Haskell 中的
Maybe
实现了全部三个,因此它既是 functor,也是 applicative,还是 monad;但 Java 中的Optional<T>
只实现了Functor
和Monad
。
这三个有什么区别呢?
functors
:使用map
将一个函数应用于一个被封装的值;applicatives
: 使用applicative
将一个被封装的函数应用于一个被封装的值;monads
:使用flatMap
将一个返回被封装的值的函数应用于一个被封装的值。
墨菲斯清楚虚拟与现实的规则,能精准指引尼奥应对未知。
map
也是这样,它知道如何处理Optional
,有值就处理值,没值就保持原样,就像墨菲斯看穿规则一样,map
也看穿了Optional
的操作逻辑,能够给出对应的应对方案。 ↩︎