Monad 简介






4.94/5 (10投票s)
一篇为具有 C#、Java、Python 等非纯函数式编程语言背景的开发人员准备的 Monad 简单分步介绍。
目录
- 引言
- 一个简单的问题
- 一个简单的 Java 解决方案
- 只有函数!
- 函数组合
- 有错误,但没有异常
- “bind” 函数
- 终于,一个 Monad!
- 最大化可重用性
- 一个面向对象的 Bind
- 摘要
- 结束语
- 历史
引言
Monad 在大多数函数式编程语言中被大量使用。例如,在 Haskell 中,它们至关重要,并且无处不在,出现在各种应用程序和库中。
另一方面,Monad 在流行的非纯函数式编程语言(如 C#、Java、Python 等)中很少使用。
为什么会有这么大的差异呢?
要找到答案,我们首先必须知道
-
什么是 Monad?
-
为什么它们在函数式语言中使用?它们解决了什么问题?
-
我们如何在 C#、Java、Python 等语言中使用 Monad?我们应该这样做吗?
读完本文后,你将有望毫不犹豫地回答这些问题。
注释
本文的目标读者是具有良好非纯函数式编程语言背景的软件开发人员。
本文中的示例是用 Java 展示的。但读者不需要是 Java 专家,因为只使用了基础的 Java。这些示例在 C# 中看起来会非常相似,并且可以轻松地用支持泛型类型(类型参数)和高阶函数(可以接受函数作为输入参数并返回函数的函数)的其他语言重写。
完整源代码可在 Gitlab 上找到。
一个简单的问题
假设我们必须编写一个简单的函数来“增强”一个句子。该函数应该通过如下方式转换输入字符串
-
移除前导和尾随空格
-
将所有字母转换为大写
-
追加一个感叹号
例如,向函数输入“ Hello bob
”应该返回“HELLO BOB!
”。
一个简单的 Java 解决方案
在大多数编程语言中,这很容易做到。以下是 Java 中的一个解决方案
static String enthuse ( String sentence ) {
return sentence.trim().toUpperCase().concat ( "!" );
}
要编写一个完整的 Java “应用程序”,包括一个基本的测试,我们可以将下面的代码放在文件 MonadTest_01.java 中
public class MonadTest_01 {
static String enthuse ( String sentence ) {
return sentence.trim().toUpperCase().concat ( "!" );
}
public static void main ( String[] args ) {
System.out.println ( enthuse ( " Hello bob " ) );
}
}
然后我们可以用以下命令编译并运行程序
javac MonadTest_01.java
java MonadTest_01
输出结果符合预期
HELLO BOB!
到目前为止一切顺利。
只有函数!
在前面的 Java 示例中,我们使用了对象方法(例如,sentence.trim()
)。然而,由于本文是关于 Monad 的,我们必须意识到纯函数式编程语言没有在对象上执行的方法(函数)。纯函数式编程语言(基于 lambda 演算)只有无副作用的函数,它们接受输入并返回结果。
因此,让我们(仍然在 Java 中)通过只使用纯函数来重写前面的代码。这很重要,因为我们必须使用函数才能最终理解为什么 Monad 会被发明出来。
这是新的代码
static String trim ( String string ) {
return string.trim();
}
static String toUpperCase ( String string ) {
return string.toUpperCase();
}
static String appendExclam ( String string ) {
return string.concat ( "!" );
}
static String enthuse ( String sentence ) {
return appendExclam ( toUpperCase ( trim ( sentence ) ) );
}
public static void test() {
System.out.println ( enthuse ( " Hello bob " ) );
}
代码以三个纯函数(trim
、toUpperCase
和 appendExclam
)开始,它们都接受一个 string
作为输入,并返回一个 string
作为结果。请注意,我稍微作弊了,因为我仍然在函数体中使用了对象方法(例如,string.trim()
)。但这在这里不重要,因为在这个练习中,我们不关心这三个函数的实现 —— 我们关心的是它们的签名。
有趣的部分是 enthuse 函数的主体
return appendExclam ( toUpperCase ( trim ( sentence ) ) );
我们可以看到只有函数调用(就像在函数式编程语言中一样)。这些调用是嵌套的,并按如下方式执行
-
步骤 1:执行
trim ( sentence )
。 -
步骤 2:步骤 1 的结果被输入到
toUpperCase
。 -
步骤 3:步骤 2 的结果被输入到
appendExclam
。 -
最后,步骤 3 的结果作为
enthuse
函数的结果返回。
下图说明了初始输入如何通过链接三个函数进行转换
为了看看一切是否仍然正常工作,我们可以执行 test 函数。结果保持不变
HELLO BOB!
函数组合
在函数式编程语言中,嵌套的函数调用(比如我们的 appendExclam ( toUpperCase ( trim ( sentence ) ) )
)被称为函数组合。
重要
函数组合是函数式编程语言的基础。在 lambda 演算中,函数的主体是单个表达式。复杂的表达式可以通过组合函数来创建。
正如我们稍后将看到的,Monad 为我们组合函数时可能出现的一个问题提供了解决方案。在继续之前,让我们先看看在不同环境中使用函数组合的变体。熟悉这个重要概念的读者可以跳转到下一章。
Unix 管道
首先,有趣的是,函数组合的思想与 Unix/Linux 中的管道相似。第一个命令的输出作为输入送入第二个命令。然后第二个命令的输出作为输入送入第三个命令,依此类推。在 Unix/Linux 中,符号 | 用于管道连接命令。这里是一个管道的例子,它计算名称中包含 "page" 的文件数量(例子借用自 如何在 Linux 上使用管道)
ls - | grep "page" | wc -l
管道操作符
因为管道在许多上下文中都很有用,一些编程语言有一个专门的管道操作符。例如,F# 使用 |> 来链接函数调用。如果 Java 有这个操作符,那么函数 enthuse
可以写成
static String enthuse ( String sentence ) {
return trim ( sentence ) |> toUpperCase |> appendExclam;
}
……这在语义上是相同的,但比使用嵌套函数调用的真实 Java 更易读
static String enthuse ( String sentence ) {
return appendExclam ( toUpperCase ( trim ( sentence ) ) );
}
函数组合操作符
由于函数组合至关重要,大多数函数式编程语言都有一个专门的函数组合操作符,使得组合函数变得非常简单。
例如,在 Haskell 中,点(.)用于组合函数(源于数学中使用的环操作符符号 ∘)。点本身就是一个函数,其签名定义如下
(.) :: (b -> c) -> (a -> b) -> a -> c
这告诉我们,该函数接受两个函数作为输入(b -> c
和 a -> b
),并返回另一个函数(a -> c
),即两个输入函数的组合。
因此,要说明函数 h
是函数 f
和 g
的组合,在 Haskell 中你可以简单地写
h = f . g
注意点操作符在 Haskell 和面向对象语言(如 C#、Java 等)中的语义完全不同。在 Java 中,f.g
意味着在对象 f 上应用 g
(例如 person.name
)。在 Haskell 中,它意味着组合函数 f
和 g
。
F# 使用 >>
来组合函数。它的定义如下
let (>>) f g x = g(f(x))
它的使用方式如下
let h = f >> g
注意
F# 的 >>
操作符不能与 Haskell 中的Monad 序列操作符混淆,后者也使用符号 >>
。
如果 Java 有类似 F# 的函数组合语法,那么函数 enthuse
就可以简单地写成如下形式
static String enthuse ( String sentence ) = trim >> toUpperCase >> appendExclam;
有错误,但没有异常
为了本教程的目的,假设我们的函数可能会以下列方式失败
-
如果输入字符串为空或只包含空格(即结果不能为空字符串),函数
trim
会失败。 -
如果输入字符串为空或包含除字母或空格之外的字符,函数
toUpperCase
会失败。 -
如果输入字符串长度超过 20 个字符,函数
appendExclam
会失败。
在惯用的 Java 中,会抛出异常来指示错误。但纯函数式编程语言不支持异常,因为函数不能有副作用。一个可能失败的函数必须将错误信息作为函数返回结果的一部分返回。例如,函数在成功时返回一个 string
,在出错时返回错误数据。
那么,让我们在 Java 中这样做。
首先,我们定义一个简单的错误类,带有一个描述错误的 info 字段
public class SimpleError {
private final String info;
public SimpleError ( String info ) {
this.info = info;
}
public String getInfo() { return info; }
public String toString() { return info; }
}
如前所述,函数必须能够在成功时返回一个 string
,否则返回一个错误对象。为了实现这一点,我们可以定义类 ResultOrError
public class ResultOrError {
private final String result;
private final SimpleError error;
public ResultOrError ( String result ) {
this.result = result;
this.error = null;
}
public ResultOrError ( SimpleError error ) {
this.result = null;
this.error = error;
}
public String getResult() { return result; }
public SimpleError getError() { return error; }
public boolean isResult() { return error == null; }
public boolean isError() { return error != null; }
public String toString() {
if ( isResult() ) {
return "Result: " + result;
} else {
return "Error: " + error.getInfo();
}
}
}
正如我们所见
-
该类有两个不可变字段,用于存放结果或错误。
-
有两个构造函数。
第一个用于成功的情况(例如,
return new ResultOrError ( "hello");
)。第二个构造函数用于失败的情况(例如,
return new ResultOrError ( new Error ( "Something went wrong") );
)。 -
isResult
和isError
是工具函数。 -
为了调试目的,重写了
toString
。
现在我们可以重写这三个工具函数以包含错误处理
public class StringFunctions {
public static ResultOrError trim ( String string ) {
String result = string.trim();
if ( result.isEmpty() ) {
return new ResultOrError ( new SimpleError (
"String must contain non-space characters." ) );
}
return new ResultOrError ( result );
}
public static ResultOrError toUpperCase ( String string ) {
if ( ! string.matches ( "[a-zA-Z ]+" ) ) {
return new ResultOrError ( new SimpleError (
"String must contain only letters and spaces." ) );
}
return new ResultOrError ( string.toUpperCase() );
}
public static ResultOrError appendExclam ( String string ) {
if ( string.length() > 20 ) {
return new ResultOrError ( new SimpleError (
"String must not exceed 20 characters." ) );
}
return new ResultOrError ( string.concat ( "!" ) );
}
}
注意
为了保持这个练习代码的简单性,我们不检查和处理 null
值(就像我们在生产代码中会做的那样)。例如,如果一个函数被调用时输入为 null
,我们简单地接受抛出 NullPointerException
。
重要的是,之前返回 string
的三个函数,现在返回一个 ResultOrError
对象。
因此,之前定义为如下的函数 enthuse
static String enthuse ( String sentence ) {
return appendExclam ( toUpperCase ( trim ( sentence ) ) );
}
……不再起作用了。
不幸的是,函数组合现在是无效的,因为函数现在返回一个 ResultOrError
对象,但需要一个 string
作为输入。输出/输入类型不再匹配。函数不能再被链接了。
在之前的版本中,当函数返回 string
时,一个函数的输出可以作为下一个函数的输入
但现在这已经行不通了
然而,我们仍然可以在 Java 中像这样实现 enthuse
static ResultOrError enthuse ( String sentence ) {
ResultOrError trimmed = trim ( sentence );
if ( trimmed.isResult() ) {
ResultOrError upperCased = toUpperCase ( trimmed.getResult() );
if ( upperCased.isResult() ) {
return appendExclam ( upperCased.getResult() );
} else {
return upperCased;
}
} else {
return trimmed;
}
}
不好!最初简单的一行代码变成了一个丑陋的怪物。
我们可以稍微改进一下
static ResultOrError enthuse_2 ( String sentence ) {
ResultOrError trimmed = trim ( sentence );
if ( trimmed.isError() ) return trimmed;
ResultOrError upperCased = toUpperCase ( trimmed.getResult() );
if ( upperCased.isError() ) return upperCased;
return appendExclam ( upperCased.getResult() );
}
这种代码在 Java 和许多其他编程语言中都可行。但这肯定不是我们想一遍又一遍写的代码。错误处理代码与正常流程混杂在一起,使得代码难以阅读、编写和维护。
更重要的是,我们根本不能在纯函数式编程语言中写这样的代码。请记住:一个函数返回的表达式是由函数组合构成的。
很容易想象其他情况也会导致同样的困境。在同样的问题以多种变体出现的情况下,我们应该怎么办?是的,我们应该尝试找到一个通用的解决方案,可以用于最大多数的情况。
Monad 来拯救!
Monad 为这类问题提供了一个通用的解决方案,而且它们还有其他好处。正如我们稍后将看到的,Monad 为所谓的 monadic 函数实现了函数组合,这些函数因为类型不兼容而无法直接组合。
有人说:“如果 Monad 不存在,你本可以发明它们。”(例如,Brian Beckman 在他出色的演讲 不要害怕 Monad 中所说)
这是真的!
那么,让我们自己尝试找到一个解决方案,暂时忽略 Monad 可以解决我们问题的事实。
“bind” 函数
在函数式编程语言中,一切都是用函数完成的。所以我们已经知道,我们必须创建一个函数来解决我们的问题。
我们称这个函数为 bind
,因为它的作用是绑定两个不能直接组合的函数。
接下来我们必须决定 bind 的输入应该是什么,以及它应该返回什么。让我们考虑链接函数 trim
和 toUppercase
的情况
要实现的逻辑必须如下工作
-
如果 trim 返回一个
string
,那么就可以调用toUppercase
,因为它接受一个string
作为输入。所以最终的输出将是toUppercase
的输出 -
如果 trim 返回一个错误,那么就不能调用
toUppercase
,错误必须被简单地转发。所以最终的输出将是trim
的输出
我们可以推断出 bind 需要两个输入参数
-
trim
的结果,类型为ResultOrError
-
函数
toUppercase
,因为如果trim
返回一个string
,那么 bind 必须调用toUppercase
bind
的输出类型很容易确定。如果 trim
返回一个 string
,那么 bind
的输出就是 toUppercase
的输出,类型为 ResultOrError
。如果 trim 失败,那么 bind 的输出就是 trim
的输出,类型也是 ResultOrError
。因为两种情况下的输出类型都是 ResultOrError
,所以 bind 的输出类型必须是 ResultOrError
。
所以现在,我们知道了 bind 的签名
在 Java 中,这可以写成
ResultOrError bind ( ResultOrError value, Function<String, ResultOrError> function )
实现 bind 很容易,因为我们确切地知道需要做什么
static ResultOrError bind ( ResultOrError value, Function<String, ResultOrError> function ) {
if ( value.isResult() ) {
return function.apply ( value.getResult() );
} else {
return value;
}
}
函数 enthuse
现在可以重写如下
static ResultOrError enthuse ( String sentence ) {
ResultOrError trimmed = trim ( sentence );
ResultOrError upperCased = bind ( trimmed, StringFunctions::toUpperCase );
// alternative:
// ResultOrError upperCased = bind ( trimmed, string -> toUpperCase(string) );
ResultOrError result = bind ( upperCased, StringFunctions::appendExclam );
return result;
}
但这仍然是命令式代码(一系列语句)。如果我们做得好,那么我们必须能够仅通过使用函数组合来重写 enthuse
。事实上,我们可以这样做
static ResultOrError enthuse_2 ( String sentence ) {
return bind ( bind ( trim ( sentence ), StringFunctions::toUpperCase ),
StringFunctions::appendExclam );
}
bind
的主体乍一看可能有点令人困惑。我们稍后会改变它。关键是我们在 enthuse
的主体中只使用函数组合。
注释
如果你以前从未见过这种代码,请花点时间消化并完全理解这里发生了什么。
理解 bind
是理解 Monad 的关键!
bind
是 Haskell 中使用的函数名。还有一些替代名称,例如:flatMap
、chain
、andThen
。
它能正常工作吗?我们来测试一下。这是一个包含 bind
、enthuse
的两个变体以及一些覆盖成功和所有错误路径的简单测试的类
public class MonadTest_04 {
static ResultOrError bind
( ResultOrError value, Function<String, ResultOrError> function ) {
if ( value.isResult() ) {
return function.apply ( value.getResult() );
} else {
return value;
}
}
static ResultOrError enthuse ( String sentence ) {
ResultOrError trimmed = trim ( sentence );
ResultOrError upperCased = bind ( trimmed, StringFunctions::toUpperCase );
// alternative:
// ResultOrError upperCased = bind ( trimmed, string -> toUpperCase(string) );
ResultOrError result = bind ( upperCased, StringFunctions::appendExclam );
return result;
}
static ResultOrError enthuse_2 ( String sentence ) {
return bind ( bind ( trim ( sentence ), StringFunctions::toUpperCase ),
StringFunctions::appendExclam );
}
private static void test ( String sentence ) {
System.out.println ( enthuse ( sentence ) );
System.out.println ( enthuse_2 ( sentence ) );
}
public static void tests() {
test ( " Hello bob " );
test ( " " );
test ( "hello 123" );
test ( "Krungthepmahanakhon is the capital of Thailand" );
}
}
运行函数 tests
输出
Result: HELLO BOB!
Result: HELLO BOB!
Error: String must contain non-space characters.
Error: String must contain non-space characters.
Error: String must contain only letters and spaces.
Error: String must contain only letters and spaces.
Error: String must not exceed 20 characters.
Error: String must not exceed 20 characters.
上面定义的 bind
函数服务于我们的特定问题。但要使其成为 Monad 的一部分,我们必须使其更通用。我们很快就会这样做。
注意
如上所示使用 bind
是解决我们函数组合问题的常用方法。但它不是唯一的方法。一种替代方法被称为Kleisli 组合(超出本文范围)。
终于,一个 Monad!
现在我们有了 bind
,通往 Monad 的剩余步骤就很容易了。我们只需要做一些改进,以便有一个更通用的解决方案,也可以应用于其他情况。
我们在本章的目标很明确:看到模式并改进 ResultOrError
,这样我们最终就可以赞叹
“一个 MONAD!”
第一个改进
在上一章中,我们将 bind
定义为一个满足我们特定需求的独立函数。第一个改进是将 bind
移动到 ResultOrError
类中。函数 bind
必须是我们 Monad 类的一部分。原因是 bind
的实现取决于使用 bind
的 Monad。虽然 bind
的签名总是相同的,但不同类型的 Monad 使用不同的实现。
第二个改进
在我们的示例代码中,组合的函数都接受一个 string
作为输入,并返回一个 string
或一个错误。如果我们需要组合接受一个整数并返回一个整数或错误的函数怎么办?我们能改进 ResultOrError
使其适用于任何类型的结果吗?是的,我们可以。我们只需要向 ResultOrError
添加一个类型参数。
在将 bind
移入类并添加类型参数后,新版本现在变成
public class ResultOrErrorMona<R> {
private final R result;
private final SimpleError error;
public ResultOrErrorMona ( R result ) {
this.result = result;
this.error = null;
}
public ResultOrErrorMona ( SimpleError error ) {
this.result = null;
this.error = error;
}
public R getResult() { return result; }
public SimpleError getError() { return error; }
public boolean isResult() { return error == null; }
public boolean isError() { return error != null; }
static <R> ResultOrErrorMona<R> bind
( ResultOrErrorMona<R> value, Function<R, ResultOrErrorMona<R>> function ) {
if ( value.isResult() ) {
return function.apply ( value.getResult() );
} else {
return value;
}
}
public String toString() {
if ( isResult() ) {
return "Result: " + result;
} else {
return "Error: " + error.getInfo();
}
}
}
注意类名:ResultOrErrorMona
。这不是打字错误。这个类还不是一个 Monad,所以我称它为 mona(只是为了好玩)。
第三个改进
假设我们必须链接以下两个函数
ResultOrError<Integer> f1 ( Integer value )
ResultOrError<String> f2 ( Integer value )
这是一张图来说明这一点
我们当前的 bind
函数无法处理这种情况,因为两个函数的输出类型不同(ResultOrError<Integer>
和 ResultOrError<String>
)。我们必须使 bind 更通用,以便可以链接不同值类型的函数。bind 的签名必须从
static <R> Monad<R> bind ( Monad<R> monad, Function<R, Monad<R>> function )
... 改为
static <R1, R2> Monad<R2> bind ( Monad<R1> monad, Function<R1, Monad<R2>> function )
bind
的实现也必须相应调整。这是新的类
public class ResultOrErrorMonad<R> {
private final R result;
private final SimpleError error;
public ResultOrErrorMonad ( R result ) {
this.result = result;
this.error = null;
}
public ResultOrErrorMonad( SimpleError error ) {
this.result = null;
this.error = error;
}
public R getResult() { return result; }
public SimpleError getError() { return error; }
public boolean isResult() { return error == null; }
public boolean isError() { return error != null; }
static <R1, R2> ResultOrErrorMonad<R2> bind
( ResultOrErrorMonad<R1> value, Function<R1, ResultOrErrorMonad<R2>> function ) {
if ( value.isResult() ) {
return function.apply ( value.result );
} else {
return new ResultOrErrorMonad<R2> ( value.error );
}
}
public String toString() {
if ( isResult() ) {
return "Result: " + result.toString();
} else {
return "Error: " + error.toString();
}
}
}
再次注意类名:ResultOrErrorMonad
。
是的,现在它是一个 Monad 了。
注意
在现实世界中,我们不会为是 Monad 的类型添加 "monad" 后缀。我将这个类命名为 ResultOrErrorMonad
(而不是简单地命名为 ResultOrError
),是为了明确这个类是一个 Monad。
我们如何确定这个类确实是一个 Monad?
虽然“monad”这个术语在数学中有非常精确的定义(就像数学中的所有东西一样),但在编程语言的世界里,这个术语还没有明确的定义。然而,维基百科给出了一个通用的定义。一个 monad 由三部分组成
-
一个类型构造器
M
,用于构建一个 monadic 类型M T
。换句话说,monad 中包含的值有一个类型参数。
在我们的例子中,它是类声明中的类型参数
R
class ResultOrErrorMonad<R>
-
一个类型转换器,通常称为
unit
或return
,它将一个对象 x 嵌入到 monad 中:unit(x) : T → M T
在 Haskell 中,类型转换器定义为:
return :: a -> m a
在类 Java 语言中,这意味着必须有一个构造函数,它接受一个
R
类型的值,并返回一个包含该值的 monadM<R>
。在我们的具体案例中,它是
ResultOrErrorMonad
类的构造函数public ResultOrErrorMonad ( R result )
-
一个组合子,通常称为
bind
(意为绑定一个变量)并用中缀操作符>>=
表示,它解包一个 monadic 变量,然后将其插入到一个 monadic 函数/表达式中,从而产生一个新的 monadic 值:(mx >>= f) : (M T, T → M U) → M U
在 Haskell 中,bind 被定义为:
(>>=) :: m a -> (a -> m b) -> m b
在我们的例子中,它是
bind
函数<R1, R2> ResultOrErrorMonad<R2> bind ( ResultOrErrorMonad<R1> value, Function<R1, ResultOrErrorMonad<R2>> function )
维基百科接着说:“要完全符合 Monad 的资格,这三个部分还必须遵守一些定律:……”
在 Haskell 中,这三条定律定义如下
-
return a >>= k = k a
-
m >>= return = m
-
m >>= (\x -> k x >>= h) = (m >>= k) >>= h
讨论这些定律超出了本文的范围(这是一篇 Monad 的介绍)。这些定律确保 Monad 在所有情况下都能良好地运行。违反它们可能导致细微而痛苦的错误,如这里、这里和这里所解释的。据我所知,目前还没有编译器能够强制执行 Monad 定律。因此,开发者有责任验证 Monad 定律是否被遵守。可以说,上面的 ResultOrErrorMonad
满足 Monad 定律。
虽然我们已经完成了,但仍有改进的空间。
最大化可重用性
除了为结果值设置一个类型参数外,我们还可以为错误值添加一个类型参数。这使得 Monad 更具可重用性,因为 Monad 的用户现在可以自由决定他们想要使用哪种类型的错误。例如,你可以看看 F# 的 Result 类型。
最后,我们可以通过让用户定义这两个值的含义来使 Monad 更具可重用性。在我们的例子中,一个值代表结果,另一个代表错误。但我们可以更抽象。我们可以创建一个 Monad,它只持有两个可能值中的一个——要么是 value_1
要么是 value_2
。并且每个值的类型都可以由一个类型参数自由定义。这确实是一些函数式编程语言支持的标准 Monad。在 Haskell 中,它被称为 Either
。它的构造函数定义如下
data Either a b = Left a | Right b
以我们的 ResultOrErrorMonad
类为起点,在 Java 中创建一个 Either
monad 会很容易。
注意
一些项目使用 Either
monad 来处理可能失败的函数。在我看来,使用更具体的 ResultOrError
类型是一个更好、更不容易出错的选择(原因在此不作解释)。
一个面向对象的 Bind
现在我们知道了 Monad 在函数式编程语言中是如何工作的,让我们回到 OOP(面向对象编程)的世界。我们能创造出类似 OO-monad 的东西吗?
如果我们看一下 <a>ResultOrErrorMonad</a>
类,我们可以看到这个类中的所有东西都已经是标准的 Java,只有一个例外:函数 bind
是该类的静态成员。这意味着我们不能对 bind 使用对象方法的点语法。目前调用 bind 的语法是 bind ( v, f )
。但如果 bind 是该类的非静态成员,我们就可以写成 v.bind ( f )
。这将使嵌套函数调用的语法更具可读性。
幸运的是,将 bind
改为非静态很容易。
为了使 Monad 更通用一些,我们再为错误值引入第二个类型参数。这样用户就不必非得使用 SimpleError
—— 他们可以使用自己的错误类。
这是一个面向对象风格的 ResultOrError
monad 的代码
public class ResultOrError<R, E> {
private final R result;
private final E error;
private ResultOrError ( R result, E error ) {
this.result = result;
this.error = error;
}
public static <R, E> ResultOrError<R, E> createResult ( R result ) {
return new ResultOrError<R, E> ( result, null );
}
public static <R, E> ResultOrError<R, E> createError ( E error ) {
return new ResultOrError<R, E> ( null, error );
}
public R getResult() { return result; }
public E getError() { return error; }
public boolean isResult() { return error == null; }
public boolean isError() { return error != null; }
public <R2> ResultOrError<R2,E> bind ( Function<R, ResultOrError<R2,E>> function ) {
if ( isResult() ) {
return function.apply ( result );
} else {
return createError ( error );
}
}
public String toString() {
if ( isResult() ) {
return "Result: " + result.toString();
} else {
return "Error: " + error.toString();
}
}
}
现在,在函数 enthuse
的主体中使用 bind 的代码变得更易读了。我们不再需要写
return bind ( bind ( trim ( sentence ), v -> toUpperCase(v) ), v -> appendExclam(v) );
……我们可以避免嵌套,写成
return trim ( sentence ).bind ( v -> toUpperCase(v) ).bind ( v -> appendExclam(v) );
那么,Monad 在现实世界的 OOP 环境中有用吗?
是的,它们可以有用。
但“可以”这个词需要强调,因为它(像往常一样)取决于我们想要实现什么。举个例子,假设我们有一些很好的理由“不使用异常”来进行错误处理。
还记得我们在有错误,但没有异常一章中不得不写的丑陋的错误处理代码吗?
static ResultOrError enthuse ( String sentence ) {
ResultOrError trimmed = trim ( sentence );
if ( trimmed.isResult() ) {
ResultOrError upperCased = toUpperCase ( trimmed.getResult() );
if ( upperCased.isResult() ) {
return appendExclam ( upperCased.getResult() );
} else {
return upperCased;
}
} else {
return trimmed;
}
}
使用 Monad 可以消除样板代码
static ResultOrError enthuse ( String sentence ) {
return trim ( sentence ).bind ( v -> toUpperCase(v) ).bind ( v -> appendExclam(v) );
}
好!
摘要
理解 Monad 的关键是理解 bind
(也称为 chain
、andThen
等)。函数 bind
用于组合两个 monadic 函数。一个 monadic 函数是接受一个 T
类型的值并返回一个包含该值的对象的函数(a -> m a
)。Monadic 函数不能直接组合,因为第一个被调用的函数的输出类型与第二个函数的输入类型不兼容。bind
解决了这个问题。
函数 bind
本身就很有用。但它只是 Monad 的一部分。
在类 Java 的世界里,一个 Monad 是一个类(类型)M
,具有
-
一个类型参数
T
,定义了存储在 Monad 中的值的类型(例如M<T>
) -
一个构造函数,它接受一个
T
类型的值,并返回一个包含该值的 MonadM<T>
-
类 Java 语言
M<T> create ( T value )
-
Haskell
return :: a -> m a
-
-
一个用于组合两个 monadic 函数的
bind
函数-
类 Java 语言
M<T2> bind ( M<T1> monad, Function<T1, M<T2>> function )
-
Haskell
(>>=) :: m a -> (a -> m b) -> m b
-
一个 Monad 必须遵守三个 Monad 定律。这些定律确保 Monad 在所有情况下都能良好地运行。
Monad 主要用于函数式编程语言,因为这些语言依赖于函数组合。但它们在其他范式的上下文中也可能有用,例如支持泛型类型和高阶函数的面向对象编程语言。
结束语
正如其标题所暗示的,本文是对 Monad 的介绍。它没有涵盖 Monad 的全部范围,没有展示其他有用的 Monad 示例(Maybe monad、IO monad、state monad 等),并且完全忽略了“范畴论”——Monad 的数学背景。对于那些想了解更多的人,网上已经有大量的信息。
希望本文能帮助你抓住 Monad 的精髓,看到它们的美,并理解它们如何能改善代码和简化生活。
“快乐地使用 MONAD 吧!”
注意
本文使用 PML (Practical Markup Language) 编写。你可以在 Gitlab 上查看 PML 代码。
本文中使用的源代码示例存储在 Gitlab 上。
历史
- 2021年1月7日:初始版本