Monads in Modern C++, What, Why, and How
今天这篇讲 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.
用一个公式说明一下:
m a \rarr (a \to m b) \rarr m b
(a \rarr m b)
表示行为,a
是输入类型,b
是输出类型。行为必须满足某种上下文,才能知道怎样处理,m
就指定了这个上下文。如果将其替换为代码,会更加容易理解:
b f(a);
因此,其实说的就是有一个输入 a
,通过一个函数,得到一个输出 b
。m
表示的就是 Monad,a
和 b
前面都有 m
,表示它们必须处于一定的上下文才能调用这个函数。
我们为它提供一下上下文,变为:
template <typename A, typename B>
Functor<B> transform(Functor<A>, std::function<B(A)>);
// b f(a); -->
Functor<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()
作为成员出现。
template <typename A>
struct Functor {
template <typename B>
Functor<B> transform(std::function<B(A)>);
};
只有 transform()
这一个成员的时候,一般称之为 Functor;若是再增加一些额外操作,一般称之为 Monad。
template <typename A>
struct Monad {
template <typename B>
Monad<B> transform(std::function<B(A)>);
template <typename B>
Monad<B> and_then(std::function<B(A)>);
};
首先,函数名称不是随便起的,一般一个语言里面都有一些共识,比如 C++ 中使用的是 transform
和 and_then
,Haskell 中则使用的是 map
和 fmap
。
其次,为什么需要一些额外的操作呢?
看实际使用:
template <typename A>
struct Monad {
A val;
Monad(A val) : val{ std::move(val) } {}
template <typename F>
auto transform(F call) -> Monad<decltype(std::invoke(call, val))> {
return std::invoke(call, val);
}
void print() {
std::cout << " val: " << val << "\n";
}
};
int main() {
auto stoi = [](std::string& s) -> int {
return std::stoi(s);
};
auto doubled = [](int val) {
return val * 2;
};
Monad<std::string> m("42");
m.transform(stoi).transform(doubled).print(); // 84
}
借由 Monad
,我们得以组合多个函数,这是正常情况。如果所要组合的函数返回类型本身就是 Monad
,此时就会发生错误。
int main() {
auto stoi = [](std::string& s) -> Monad<int> {
return std::stoi(s);
};
auto doubled = [](int val) {
return val * 2;
};
Monad<std::string> m("42");
// Error
m.transform(stoi).transform(doubled).print();
}
发生了什么?stoi
的返回类型由 int
变为了 Monad<int>
,于是第一次调用 transform()
的返回类型变成了 Monad<Monad<int>>
;第二次调用 transform()
时,doubled()
传入的参数类型为 Monad<int>
,与实际类型 int
并不匹配,产生错误。
此时就需要一个额外的工具来解决这个问题,每次都将 Monad<Monad<T>>
变为 Monad<T>
,这个函数一般称为 join()
。实现为:
template <typename A>
struct Monad {
// ...
auto join() {
auto _join = [] <typename T> (const Monad<Monad<T>>& m) -> Monad<T> {
return m.val;
};
return _join(*this);
}
// ...
};
int main() {
// ...
Monad<std::string> m("42");
m.transform(stoi).join().transform(doubled).print();
}
但是这样使用起来有点麻烦,打破了函数组合的连贯性,一般来讲要再封装一层,直接把 transform()
和 join()
这两个调用合并成一个步骤,一般称为 Monadic bind 或是 mbind。在 C++ 中,被命名为 and_then()
。下面是完整实现:
template <typename A>
struct Monad {
A val;
Monad(A val) : val{ std::move(val) } {}
template <typename F>
auto transform(F call) -> Monad<decltype(std::invoke(call, val))> {
return std::invoke(call, val);
}
template <typename F>
auto and_then(F call) {
auto nest_monad = transform(std::move(call));
return join(nest_monad);
}
template <typename T>
auto join(const Monad<Monad<T>>& m) -> Monad<T> {
return m.val;
}
void print() {
std::cout << " val: " << val << "\n";
}
};
int main() {
auto stoi = [](std::string& s) -> Monad<int> {
return std::stoi(s);
};
auto doubled = [](int val) {
return val * 2;
};
Monad<std::string> m("42");
m.and_then(stoi).transform(doubled).print();
}
在 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
前后的操作。
auto to_int = [](std::string& s) -> std::optional<int> {
return std::stoi(s);
};
// Monadic std::optional
std::optional<std::string> ostr("42");
auto out = ostr
.and_then(to_int)
.transform([](auto i) { return i * 2; } );
// Non-monadic std::optional
std::optional<std::string> ostr("42");
std::optional<int> out{};
if (ostr.has_value()) {
out = to_int(ostr.value());
}
std::optional<int> final_out{};
if (out.has_value()) {
final_out = out.value() * 2;
}
可以看到,Monadic std::optional
消除了大量重复的检查代码,让我们直接专注于程序逻辑,而不必再被那些次要的细枝末节分散注意力。
C++23 Monadic std::expected
std::expected
也是一个 Monad,它是 std::optional
和 std::variant
的结合体,接口和 std::optional
非常相似。
使用 Monadic operations 的一个例子:
enum class Status {
Ok = 1,
connection_error,
no_authority,
format_error,
invalid_content
};
bool connected() {
return true;
}
bool has_authority() {
return true;
}
bool format() {
return true;
}
std::expected<std::string, Status> read_data() {
if (!connected())
return std::unexpected<Status> { Status::connection_error };
if (!has_authority())
return std::unexpected<Status> { Status::no_authority };
if (!format())
return std::unexpected<Status> { Status::format_error };
return {"cat-dog-duck"};
}
int main() {
using namespace std::literals;
auto to_comma = [](const std::string& s) -> std::expected<std::string, Status> {
auto animals = s
| std::views::split('-')
| std::views::transform([](auto&& str) {
return std::string_view(&*str.begin(), std::ranges::distance(str));
});
auto results = fmt::format("{}", fmt::join(animals, ","));
if (results.empty())
return std::unexpected<Status> { Status::invalid_content };
else
return results;
};
auto handle_error = [](Status e) -> std::expected<std::string, Status> {
auto err_msg = fmt::format("error code: {}\n", std::to_underlying(e));
return err_msg;
};
auto result = read_data();
result
// if expected is unexpected value handle it
.or_else(handle_error)
// flatmap from "cat-dog-duck" to "cat,dog,duck"
.and_then(to_comma)
// if expected is unexpected value handle it
.or_else(handle_error)
// output "cat,dog,duck"
.transform([](const std::string& s) { std::cout << s; });
}
例子中先读取数据,然后将 "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,函数组合起来易读性更好。
一个例子:
int main()
{
auto fractal = [](int val) -> std::vector<int> {
return { val, val + 1 };
};
auto even = [](int i) { return 0 == i % 2; };
auto square = [](int i) { return i * i; };
// {0, 1, 2, 3, 4}
auto ints = std::views::iota(0, 5);
auto results = ints
// {{0, 1}, {1, 2}, {2, 3}, {3, 4}, {4, 5}}
| std::views::transform(fractal)
// {0, 1, 1, 2, 2, 3, 3, 4, 4, 5}
| std::views::join
// {0, 2, 2, 4, 4}
| std::views::filter(even)
// {0, 4, 4, 16, 16}
| std::views::transform(square)
| std::ranges::to<std::vector>();
}
以前编写类似的逻辑,需要不断遍历、判断,编写非常多的细节,借助 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,进一步加深理解,也使大家对标准中的这些相关特性有更加深入的认识。
如果读到这里,你还会产生“这不就是返回一个自身对象的引用嘛,我们早就用过”这种感想,那你只不过是在用旧概念理解新思想,相同的只是实现手段,不同的地方才是核心,理解不同的地方才算真正搞懂新概念。
更多内容,请见后续更新。
好文。不过 and_then 从实现上似乎违反了单一职责,同时做了拆包和 向下传递的事情,名字上也没有 flatmap 的概念好理解..