Monads in Modern C++, What, Why, and How

教育   科技   2023-08-14 20:00   陕西  



今天这篇讲 Monads,对 C++ devs 来说是一个比较新的概念,也是一个比较难理解的概念。本文将概念和实践串起来讲,先讲抽象理论,后面讲具体实现和使用,以全面理解其定位。

Language

编程语言用于操作机器以辅助人类解决问题,主体是人和机器,目的是解决问题。人通过机器解决问题,编程语言是人和机器之间沟通的桥梁。
问题解决最基本的流程为定义问题、分析问题、寻找策略及选择策略。简单来说,你有一些东西,想通过一些行为,再得到一些东西。问题就是理想和现实之间的差距。
这其中最基本的构成要素就是东西和行为,也就是编程语言当中的数据和函数。一个大问题不可能通过一个行为就能解决,往往需要拆分为一个个小问题,再逐个击破,故根据问题复杂程度,数据和函数的数量也会随之变化。
主体虽分为人和机器,但却是由机器来完成具体工作。最初的语言,靠机器更近,尽管效率高,人和机器沟通起来却是多有不便;随后的语言,便靠人更近,使人和机器的沟通方式,更加接近人与人之间的沟通,这才简化了编程难度。
但常言道,月满则亏,水满则溢,凡事过犹不及。太靠近机器的语言,尽管效率高,但却难以理解;太靠近人的语言,虽说易理解,效率却颇低。因此,只有在明确仅注重效率或简易的情况下,才会使用这类语言,许多语言其实都处于中间,保持效率的同时也避免太过复杂。

Imperative versus Declarative

由机器到人,编程语言是把机器所能识别的具体指令,抽象为人所能理解的词句。

声明式语言相比命令式语言,离人更近,命令式更关注具体,声明式更关注抽象。抽象是把不变的都封装起来,从而避免去写一个个重复的指令,比如原本想要修改 std::vector 的元素,你需要使用 iffor 等等指令,这些再往上抽象一层就是过滤和变换两个步骤,直接使用 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 中的表示说明一下:

表示行为, 是输入类型,是输出类型。行为必须满足某种上下文,才能知道怎样处理,就指定了这个上下文。如果将其替换为代码,会更加容易理解:

1f(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++ 中使用的是 transformand_then,Haskell 中则使用的是 mapfmap

其次,为什么需要一些额外的操作呢?

看实际使用:

 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::optionalstd::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(05);
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,进一步加深理解,也使大家对标准中的这些相关特性有更加深入的认识。

如果读到这里,你还会产生“这不就是返回一个自身对象的引用嘛,我们早就用过”这种感想,那你只不过是在用旧概念理解新思想,相同的只是实现手段,不同的地方才是核心,理解不同的地方才算真正搞懂新概念。

更多内容,请见后续更新。


CppMore
Dive deep into the C++ core, and discover more!
 最新文章