今天这篇讲 Monads,对 C++ devs 来说是一个比较新的概念,也是一个比较难理解的概念。本文将概念和实践串起来讲,先讲抽象理论,后面讲具体实现和使用,以全面理解其定位。
Language
Imperative versus Declarative
由机器到人,编程语言是把机器所能识别的具体指令,抽象为人所能理解的词句。
声明式语言相比命令式语言,离人更近,命令式更关注具体,声明式更关注抽象。抽象是把不变的都封装起来,从而避免去写一个个重复的指令,比如原本想要修改 std::vector
的元素,你需要使用 if
、for
等等指令,这些再往上抽象一层就是过滤和变换两个步骤,直接使用 v | filter | transform
则更加言简意赅。因此,声明式用起来要更加简洁,使人更加专注于问题解决步骤。
具体中包含了变与不变,抽象中提取了不变的部分。具体就像是白话文,抽象就像是文言文,前者通俗易懂,后者简洁精炼。命令式让人更加专注于行为的细枝末节,事无具细,一切尽在掌握之中;声明式让人更加关注于行为步骤,抽离细节,一切环环相扣。
也可以说命令式注重问题解决过程,而声明式注重问题解决结果。不关心过程,只想要结果,展现在语言上就是只调用接口,不关心实现。因此,从视角上来看,命令式更偏向于微观,声明式更偏向于宏观。宏观适合思考问题,微观适合解决问题,前者像是将军,只谋战略,后者则像是小兵,具体实施。
将无兵而不行,兵无将而不动。抽象与具体当中,虽然真正起作用的是具体的指令,但使用的却是抽象的接口。接口使来结构清晰,能更加专注于问题逻辑,跳出问题细节。抽象与具体紧密连接,只要抽象,抽象从何来?只要具体,具体何以用?
是以范式不同,本质是解决问题思维的不同,是侧重点的不同。
What is Monads
C++ 有许多范式,面向过程、面向对象、泛型编程、函数式编程…… 许多人发现 Modern C++ 写来越发简单,这种简单写法就谓之 Modern,但这种简洁从何而来?本质就是范式的转变。
C++11 至今,C++ 已逐步从函数式编程中汲取了许多特性,Monads 就是其中非常重要的一个特性。C++23 中也加入不少 Monads,简化了一些原本较为繁琐的操作。
那么,什么是 Monads?
这个概念来自范畴论,称为单子,牵扯概念颇多,且不易理解,因此我们结合 C++ 来讲。函数式编程当中使用 Monads 表示一个抽象数据类型,其目的在于表示计算过程。
既然是函数式编程的概念,它自然属于声明式,所以通过 Monads 能够减少重复代码,抽象处理逻辑,简化组合流程。
为了精确,这里还是提供一下 Monads 的定义:
A monad is just a monoid in the category of endofunctors.
用下面 Haskell 中的表示说明一下:
表示行为, 是输入类型,是输出类型。行为必须满足某种上下文,才能知道怎样处理,就指定了这个上下文。如果将其替换为代码,会更加容易理解:
1b f(a);
因此,其实说的就是有一个输入,通过一个函数,得到一个输出。表示的就是 Monad,和前面都有,表示它们必须处于一定的上下文才能调用这个函数。
我们为它提供一下上下文,变为:
1template <typename A, typename B>
2Functor<B> transform(Functor<A>, std::function<B(A)>);
3
4// b f(a); -->
5Functor<B> transform(Functor<A>, f);
这里的 Functor 并非 C++ 中的仿函数,而是范畴论当中的术语,称为函子。范畴论中,我们的输入数据和输出数据就称为范畴,Functor 是将一个范畴转换为另一个范畴的方式,它是比 Function 更高阶的函数。代码中 Functor 就提供了所谓的上下文,能够将 A 作为输入,B 作为输出,因为输入输出都是 Functor,所以就具备了组合多个函数的作为。
若是输入类型与输出类型相同,即源范畴与目的范畴相同,就称为幺元,此时函子就称为自函子(endofunctors),它是 end of functors 的组合词。幺元构成了一个幺半群,就是概念中所说的 Monoid,我们的类型只要满足这些约束,就称为一个 Monad。代码中的输入输出类型都是 Functor,所以精确点说它是 endofunctors,也是 Monad。
若类型只有 transform()
这一个函数,就称为一个 Functor,表示将一个范畴转换为另一个范畴。若输入输出类型还相同,就称为 endofunctor,但一般还是称为 Functor,要注意此时实际上指的是 endofunctor,也是 Monads。但因为 Functor 只有 transform()
这一个函数,所以它只会进行转换操作,输入元素和输出元素的大小是相同的,而 Monad 除 transform()
外,还有一些其他函数,比如过滤函数,此时输入元素和输出元素的大小可以是不同的。因此,需要分清二者,否则极易混淆 Functor 和 Monads。
为什么要保证输入输出类型一致呢?范畴的基本本质是组合,只要类型相同,就能够将许多行为串起来,比如 type.transform(f).transform(g).transform(h)
,类型不同就缺失了上下文,无法组合函数。
Monads in C++
C++ 中通常是将 Functor 定义成类,transform()
作为成员出现。
1template <typename A>
2struct Functor {
3 template <typename B>
4 Functor<B> transform(std::function<B(A)>);
5};
只有 transform()
这一个成员的时候,一般称之为 Functor;若是再增加一些额外操作,则称之为 Monad。
1template <typename A>
2struct Monad {
3 template <typename B>
4 Monad<B> transform(std::function<B(A)>);
5
6 template <typename B>
7 Monad<B> and_then(std::function<B(A)>);
8};
首先,函数名称不是随便起的,一般一个语言里面都有一些共识,比如 C++ 中使用的是 transform
和 and_then
,Haskell 中则使用的是 map
和 fmap
。
其次,为什么需要一些额外的操作呢?
看实际使用:
1template <typename A>
2struct Monad {
3 A val;
4
5 Monad(A val) : val{ std::move(val) } {}
6
7 template <typename F>
8 auto transform(F call) -> Monad<decltype(std::invoke(call, val))> {
9 return std::invoke(call, val);
10 }
11
12 void print() {
13 std::cout << " val: " << val << "\n";
14 }
15};
16
17
18int main() {
19 auto stoi = [](std::string& s) -> int {
20 return std::stoi(s);
21 };
22
23 auto doubled = [](int val) {
24 return val * 2;
25 };
26
27 Monad<std::string> m("42");
28 m.transform(stoi).transform(doubled).print(); // 84
29}
借由 Monad
,我们得以组合多个函数,这是正常情况。如果所要组合的函数返回类型本身就是 Monad
,此时就会发生错误。
1int main() {
2 auto stoi = [](std::string& s) -> Monad<int> {
3 return std::stoi(s);
4 };
5
6 auto doubled = [](int val) {
7 return val * 2;
8 };
9
10 Monad<std::string> m("42");
11 // Error
12 m.transform(stoi).transform(doubled).print();
13}
发生了什么?stoi
的返回类型由 int
变为了 Monad<int>
,于是第一次调用 transform()
的返回类型变成了 Monad<Monad<int>>
;第二次调用 transform()
时,doubled()
传入的参数类型为 Monad<int>
,与实际类型 int
并不匹配,产生错误。
此时就需要一个额外的工具来解决这个问题,每次都将 Monad<Monad<T>>
变为 Monad<T>
,这个函数一般称为 join()
。实现为:
1template <typename A>
2struct Monad {
3 // ...
4
5 auto join() {
6 auto _join = [] <typename T> (const Monad<Monad<T>>& m) -> Monad<T> {
7 return m.val;
8 };
9
10 return _join(*this);
11 }
12
13 // ...
14};
15
16
17int main() {
18 // ...
19
20 Monad<std::string> m("42");
21 m.transform(stoi).join().transform(doubled).print();
22}
但是这样使用起来有点麻烦,打破了函数组合的连贯性,一般来讲要再封装一层,直接把 transform()
和 join()
这两个调用合并成一个步骤,一般称为 Monadic bind 或是 mbind。在 C++ 中,被命名为 and_then()
。下面是完整实现:
1template <typename A>
2struct Monad {
3 A val;
4
5 Monad(A val) : val{ std::move(val) } {}
6
7 template <typename F>
8 auto transform(F call) -> Monad<decltype(std::invoke(call, val))> {
9 return std::invoke(call, val);
10 }
11
12 template <typename F>
13 auto and_then(F call) {
14 auto nest_monad = transform(std::move(call));
15 return join(nest_monad);
16 }
17
18 template <typename T>
19 auto join(const Monad<Monad<T>>& m) -> Monad<T> {
20 return m.val;
21 }
22
23 void print() {
24 std::cout << " val: " << val << "\n";
25 }
26};
27
28
29int main() {
30 auto stoi = [](std::string& s) -> Monad<int> {
31 return std::stoi(s);
32 };
33
34 auto doubled = [](int val) {
35 return val * 2;
36 };
37
38 Monad<std::string> m("42");
39 m.and_then(stoi).transform(doubled).print();
40}
在 and_then()
之中,除了解决调用链连贯性问题,还可以增加一些额外的检查,比如值是否有效,有效则执行调用后将 Monad<Monad<T>>
变为 Monad<T>
,无效则不执行本次调用,直接返回一个空的 Monad
。因此,and_then()
这种命名表面上表达了检查再执行的意思,实际上主要目的还是在于调用的连贯性。
也因此,and_then()
所调用的函数必须返回一个 Monad<T>
,这样实际调用的才是 Monad<Monad<T>>
,否则函数调用不匹配。
最后,这就是 C++ 中所要掌握的 Monads 实现技术,更多深入内容以后再扩展。
Monads, Monads, Monads Everywhere
在大家都没意识到时,Monads 已在 Modern C++ 中遍地开花。本节看 C++ 中的 Monads 类型。
C++23 Monadic std::optional
首先是 std::optional
,看看 C++23 新加的三个成员:
transform()
and_then()
or_else()
想必也无需我再多加介绍,这里的 or_else()
是额外添加的另一个 Monadic 函数,用于处理错误,如果 std::optional
为空,能够调用传入的函数处理,若非空,则什么也不做。
std::optional
类型对应 Haskell 中的 Maybe
类型。
来对比一下 Monadic std::optional
前后的操作。
1auto to_int = [](std::string& s) -> std::optional<int> {
2 return std::stoi(s);
3};
4
5// Monadic std::optional
6std::optional<std::string> ostr("42");
7auto out = ostr
8 .and_then(to_int)
9 .transform([](auto i) { return i * 2; } );
10
11// Non-monadic std::optional
12std::optional<std::string> ostr("42");
13std::optional<int> out{};
14if (ostr.has_value()) {
15 out = to_int(ostr.value());
16}
17std::optional<int> final_out{};
18if (out.has_value()) {
19 final_out = out.value() * 2;
20}
可以看到,Monadic std::optional
消除了大量重复的检查代码,让我们直接专注于程序逻辑,而不必再被那些次要的细枝末节分散注意力。
C++23 Monadic std::expected
std::expected
也是一个 Monad,它是 std::optional
和 std::variant
的结合体,接口和 std::optional
非常相似。
使用 Monadic operations 的一个例子:
1enum class Status {
2 Ok = 1,
3 connection_error,
4 no_authority,
5 format_error,
6 invalid_content
7};
8
9bool connected() {
10 return true;
11}
12
13bool has_authority() {
14 return true;
15}
16
17bool format() {
18 return true;
19}
20
21std::expected<std::string, Status> read_data() {
22 if (!connected())
23 return std::unexpected<Status> { Status::connection_error };
24 if (!has_authority())
25 return std::unexpected<Status> { Status::no_authority };
26 if (!format())
27 return std::unexpected<Status> { Status::format_error };
28
29 return {"cat-dog-duck"};
30 }
31
32
33int main() {
34 using namespace std::literals;
35
36 auto to_comma = [](const std::string& s) -> std::expected<std::string, Status> {
37 auto animals = s
38 | std::views::split('-')
39 | std::views::transform([](auto&& str) {
40 return std::string_view(&*str.begin(), std::ranges::distance(str));
41 });
42
43 auto results = fmt::format("{}", fmt::join(animals, ","));
44
45 if (results.empty())
46 return std::unexpected<Status> { Status::invalid_content };
47 else
48 return results;
49 };
50
51 auto handle_error = [](Status e) -> std::expected<std::string, Status> {
52 auto err_msg = fmt::format("error code: {}\n", std::to_underlying(e));
53 return err_msg;
54 };
55
56 auto result = read_data();
57 result
58 // if expected is unexpected value handle it
59 .or_else(handle_error)
60 // flatmap from "cat-dog-duck" to "cat,dog,duck"
61 .and_then(to_comma)
62 // if expected is unexpected value handle it
63 .or_else(handle_error)
64 // output "cat,dog,duck"
65 .transform([](const std::string& s) { std::cout << s; });
66}
例子中先读取数据,然后将 "cat-dog-duck" 转换为 "cat,dog,duck",期间通过 or_else()
处理错误,通过 and_then()
完成实际转换,通过 transform()
完成最终输出。
如果你的函数不想返回任何值,那么只能调用 transform()
,and_then()
必须返回 std::expected
类型。
List Monads
Monads 有多种类型,像前面使用的 std::optional
用于处理可选值,std::expected
用于处理错误,还有一种广泛使用的是 List Monads。
List Monads 就是 C++20 Ranges 为容器所带来的 Monadic 操作,它一般配备有 pipe,函数组合起来易读性更好。
一个例子:
1int main()
2{
3 auto fractal = [](int val) -> std::vector<int> {
4 return { val, val + 1 };
5 };
6 auto even = [](int i) { return 0 == i % 2; };
7 auto square = [](int i) { return i * i; };
8
9 // {0, 1, 2, 3, 4}
10 auto ints = std::views::iota(0, 5);
11 auto results = ints
12 // {{0, 1}, {1, 2}, {2, 3}, {3, 4}, {4, 5}}
13 | std::views::transform(fractal)
14 // {0, 1, 1, 2, 2, 3, 3, 4, 4, 5}
15 | std::views::join
16 // {0, 2, 2, 4, 4}
17 | std::views::filter(even)
18 // {0, 4, 4, 16, 16}
19 | std::views::transform(square)
20 | std::ranges::to<std::vector>();
21
22}
以前编写类似的逻辑,需要不断遍历、判断,编写非常多的细节,借助 List Monads,只需关注算法步骤,将它们一个一个组合起来,一目了然。
其中,transform()
和 join()
已经是大家非常熟悉的 Monadic 操作,其他的是一些额外操作,根据 Monads 类型,也可以自行添加。借助 C++23 ranges::to
,可以非常容易地将 Views 转换成任意容器,操作起来更加顺畅。
Conclusion
关于 C++ Monads 的文章很少,它是属于函数式编程的概念,这方面的特性也都很新。本文从底层思维着手,讲解了 Modern C++ 近些年特性的原理,利于深入理解和运用这方面的特性。
同时,在可以观望的未来,C++26/29 还会再引入一些新的 Monads,已经存在不少相关提案,异步代码中也将充斥着大量 Monads。
Monads 并不容易讲解,何况是 C++ 中的 Monads。本文由语言及手,牵出命令式和声明式,再引出 Monads 的概念,次再由 C++ 代码实现一个基本的 Monads,以具象化这些抽象概念,降低理解难度,最后再展示标准中的 Monads,进一步加深理解,也使大家对标准中的这些相关特性有更加深入的认识。
如果读到这里,你还会产生“这不就是返回一个自身对象的引用嘛,我们早就用过”这种感想,那你只不过是在用旧概念理解新思想,相同的只是实现手段,不同的地方才是核心,理解不同的地方才算真正搞懂新概念。
更多内容,请见后续更新。