CppCon 2015 学习:Functional programming: functors and monads
发现模式并将其转化为有用的抽象
这个想法围绕着 发现现有代码或系统中的模式,然后将其 泛化 成可重用的抽象,而不是强行将抽象应用于特定的类型或问题。
关键概念:
- 类型之间的共通操作:
- 类型像
智能指针
、optional
和future
都有一个共同点:它们封装了值。尽管这些类型的用途不同(智能指针用于内存管理,optional用于可选值,future用于异步操作),它们都封装了一个可以访问或修改的值。 - 认识到这种 共通行为 可以让我们创建一个通用的抽象,这个抽象可以应用于所有这些类型。
- 类型像
- 泛型编程(Genericity):
- 泛型编程 的概念是写出能够处理任何类型的代码,只要该类型符合某些约束或具有共同的操作。
- 在 C++ 中,这通常是通过 模板 和类型特性来实现的。与其为每种类型编写不同的代码(例如,为
smart_ptr
或optional
写一个专门的函数),我们写一个 通用的代码,它可以处理任何支持某些操作的类型(比如解引用或访问值)。
例子:
template <typename T> void print_value(const T& value) {std::cout << value << std::endl; } // 这适用于任何类型,不管是普通类型、智能指针还是 optional。 print_value(42); // 输出 42 print_value(std::make_optional(42)); // 输出 42
- 设计抽象与发现抽象:
- 很多设计模式(例如 Gang of Four 中的设计模式)是通过 设计抽象 来通用化常见问题的解决方案。这些模式很有用,但通常是基于直觉或经验设计出来的。
- 然而,真正 有用的抽象 是通过 发现 类型或操作的行为模式来实现的。通过识别现有的模式,我们可以创建 优雅、简洁且自然的抽象。
- 例如,迭代器模式(Iterator Pattern)并不是为了满足每个场景的需要而发明的,而是通过观察常见的序列操作(如列表、数组和其他容器)可以如何被抽象成一种统一的方式,从而 发现 的。
- 强迫模式与自然抽象:
- 开发者或设计人员往往试图将某些模式强加给特定类型或问题。这在很多设计模式中都能看到,比如 单例模式(Singleton)或 工厂模式(Factory Pattern),这些结构往往是在没有真正了解问题的情况下设计出来的。
- 当你将一个抽象强行应用到一个问题上时,可能会导致 过度设计 或不必要的复杂性。
- 相反,通过 发现现有系统中的模式,我们可以创建与问题 自然契合 的抽象,从而提高代码的清晰性、可维护性和可扩展性。
编程中的实际应用:
- 识别现有代码中的抽象:
- 与其设计一个新的抽象,不如去看现有代码中是否有反复出现的模式。例如:
- C++ 中的许多容器(如
std::vector
、std::list
、std::map
)都有相似的操作(插入、删除、访问)。通过识别这一点,你可以设计一个适用于所有容器的抽象。 - 同样,许多类型都在封装一个值或资源。识别这一点可以让你设计适用于许多不同类型的抽象(例如
std::optional<T>
、std::shared_ptr<T>
、std::future<T>
)。
- C++ 中的许多容器(如
- 与其设计一个新的抽象,不如去看现有代码中是否有反复出现的模式。例如:
- 使用现有的模式:
- 与其 重新发明轮子,不如寻找 已建立的模式 和 框架。在 C++ 中,你可以找到已经存在的抽象,这些抽象已经在 标准库 和其他库中被发现并广泛使用。这些抽象是通过 发现 常见的类型操作模式而产生的。
- 创建更通用的解决方案:
- 一旦你识别了共通的模式,就可以创建 更通用的解决方案。例如,之前提到的
print_value
函数可以处理多种输入类型,类似地,你也可以创建一个 统一的操作,比如map()
,它可以适用于所有类型的容器或封装类型。
- 一旦你识别了共通的模式,就可以创建 更通用的解决方案。例如,之前提到的
示例:识别包装模式
封装一个值(无论是为了内存管理、可选性还是异步操作)是一个反复出现的模式。我们可以识别出 C++ 中许多类型符合这一模式:
std::unique_ptr<T>
:封装一个动态分配的对象。std::shared_ptr<T>
:封装一个具有共享所有权的对象。std::optional<T>
:封装一个可能不存在的值。std::future<T>
:封装一个异步结果,稍后会有值。
通过识别这个模式,我们可以创建适用于任何封装值类型的抽象。
例如,一个处理封装值的通用函数可能是这样的:
template <typename T>
void process_wrapped_value(const T& value) {if constexpr (std::is_same<T, std::optional<int>>::value) {// 针对 std::optional<int> 的特殊逻辑}else if constexpr (std::is_same<T, std::shared_ptr<int>>::value) {// 针对 std::shared_ptr<int> 的特殊逻辑}else {// 针对其他类型的通用逻辑}
}
总结
- 识别模式 在现有系统和类型中是创建抽象的更有效方式,而不是强行将抽象应用于特定问题。
- 通过观察共同的操作(如包装值、遍历集合等),我们可以 泛化 解决方案,写出更加通用和可维护的代码。
- 有效的抽象并不是 设计 出来的,而是通过 发现 现有的模式,创建适合问题的自然抽象。
这个类比在解释编程中的 函子(Functor) 时是有帮助的,尤其是在函数式编程中。函子可以看作是一个 装有值的盒子,这个盒子有一些特性。我们可以一步步解析这个类比,理解其中的概念:
函子是一个装有值的盒子
- 函子是一个包含值的盒子:
- 函子 就像一个 容器 或者 盒子,它包含一个值(或多个值)。通过这个盒子,我们可以操作盒子里面的值,而无需直接接触它。这就像你不需要直接操作盒子里的内容,而是通过盒子的接口来操作里面的值。
- C++ 示例:
std::optional<int> box = 5; // 一个装有 int 值的盒子
- 或没有值:
- 有些函子可能不包含任何值。例如,optional 可能是空的,或者 future 还没有计算结果。这个“空”状态就代表了“没有值”。
- C++ 示例:
std::optional<int> box; // 一个没有值的盒子(空的)
- 或包含多个值:
- 函子不仅可以包含一个值,它还可以包含多个值。比如我们常见的 list、vector、tuple 等,它们可以作为一个函子来容纳多个值。
- C++ 示例:
std::vector<int> box = {1, 2, 3, 4}; // 一个包含多个值的盒子(列表)
- 多个值是否有相同的类型?:
- 盒子里的值通常是 相同类型 的,但有时也可以是 不同类型 的。比如 tuple 就可以包含多种类型的值。
- C++ 示例:
std::tuple<int, double, std::string> box = {1, 3.14, "hello"}; // 一个包含不同类型值的盒子
- 盒子允许你查看里面的内容并对其调用函数:
- 函子的关键特性是,它允许你对里面的值应用一个 函数(或者是变换)。这通常通过一个方法来完成,通常叫做
map
或transform
。 - C++ 示例(
std::optional
):std::optional<int> box = 5; auto result = box.map([](int value) { return value * 2; }); // 对盒子里的值应用一个函数
- 函子的关键特性是,它允许你对里面的值应用一个 函数(或者是变换)。这通常通过一个方法来完成,通常叫做
- 或者当盒子为空时不调用任何函数:
- 如果盒子是空的(即里面没有值),那么函数不会被调用。例如,在 C++ 中的
std::optional
,如果盒子没有值,调用函数就不会做任何事情。 - C++ 示例(
std::optional
):std::optional<int> emptyBox; auto result = emptyBox.map([](int value) { return value * 2; }); // 不会做任何事情,因为盒子是空的
- 如果盒子是空的(即里面没有值),那么函数不会被调用。例如,在 C++ 中的
- 或者对多个值调用函数:
- 有些函子,比如 list 或 vector,可以对里面的每个值都调用一次函数。这是因为它们包含多个值。
- C++ 示例(
std::vector
):std::vector<int> box = {1, 2, 3}; std::transform(box.begin(), box.end(), box.begin(), [](int value) { return value * 2; }); // 对 vector 中的每个元素都应用函数
- 或者对不同类型的值调用不同的函数:
- 如果函子包含不同类型的值,我们可以根据类型来调用不同的函数。这通常通过 模式匹配 或 类型分发 来实现。
- C++ 示例(
std::variant
):std::variant<int, double> box = 3.14; std::visit([](auto&& arg) { std::cout << arg; }, box); // 根据类型来应用不同的函数
总结:
将 函子 比作一个 盒子 是一个简化的类比,帮助我们理解其概念:
- 函子是一个容器(或盒子),它可以装一个值或多个值。
- 它允许我们 对盒子中的值应用函数(如果有值)。
- 它可以是 空的、包含多个值,甚至是 包含不同类型的值。
- 函子的主要特性是,它提供了一种 统一的方式 来操作盒子中的值,这就是函数式编程中 map 或 transform 操作的核心。
“单子是一个墨西哥卷饼” 这样的比喻,和类似的比喻比如“函子是一个盒子”,通常被用来简化或让复杂的概念更容易理解,但它们往往会带来更多的误导而非帮助。让我们分解一下这些概念,了解为什么这些比喻不太有用,并如何更好地理解这些概念:
单子和墨西哥卷饼:为什么比喻不起作用
- 过度简化:
- 比如“单子是一个墨西哥卷饼”这样的比喻,虽然乍一听可能让人觉得容易理解,但它过于简化了单子的概念,反而让人更迷惑。
- 单子的核心思想是提供一种结构化的方式来处理可能涉及副作用(例如状态、IO、异常等)的计算。它是为了组合操作,同时保持可预测的上下文(例如处理副作用或空值)。
- 单子是组合模式,而非对象:
- 单子不是一个墨西哥卷饼,因为单子的概念本质上是关于组合和如何链式地组织计算,而不是一个简单的容器(比如墨西哥卷饼装满某些东西)。
- 它更应该被理解为一个设计模式,用于以特定的方式处理计算,而不仅仅是一个容器,里面装着某个值。
为什么“函子是盒子”也有误导性
- 函子是盒子这个比喻也没有很好地解释函子的含义。
- 函子是一个类型类(在Haskell或类似语言中),它允许对一个包含的值应用映射操作。它是一个容器,允许对容器内的值应用函数,但这里有更多的内容:
- 函子不仅仅是一个容器,它更重要的是一个对结构进行映射的方式,保持结构的形状不变,而这个映射的本质是变换。因此,单纯的“盒子”比喻无法传达函子的变换性质。
如何更好地理解单子和函子
- 单子:
- 单子是一个设计模式,用于以特定的方式链式地处理计算,同时保持上下文(例如,处理副作用或失败的计算)。它使得你能够在保持一致性和控制副作用的同时,进行复杂的计算。
- 例如,在Haskell中,Maybe单子可以用于处理可能失败的计算(例如返回
Nothing
)。单子操作符(bind
或>>=
)帮助我们传递失败的上下文,确保我们不需要在每一步中手动检查空值。
- 函子:
- 函子是一个实现了
map
操作的类型,允许你对结构内的值应用函数,而不改变结构本身。 - 例如,C++中的
std::optional<T>
就是一个函子,你可以在其中存在值时应用映射函数,而不改变optional
容器本身。
- 函子是一个实现了
为什么比喻常常不准确
- 比喻可以帮助初步理解,但由于它们的过度简化,往往会限制对深层概念的理解。
- 单子和函子是抽象的数学概念,它们的作用是提供通用的计算结构,但这种抽象很难通过物理对象的直观比喻来捕捉。
更数学化的理解方式
- 函子:函子是范畴论中的一个概念,它定义了从一个范畴到另一个范畴的映射。这个映射作用于对象(比如值),并且保持它们之间关系的结构。
- 单子:单子是一个组合工具,它为链式应用函数提供了操作,并保持计算的上下文(例如,管理状态、副作用、错误等)。
总结:
- 与其依赖过度简化的比喻,不如专注于数学原理来理解单子和函子。
- 单子是关于以可组合的方式结构化计算,函子则是将函数应用于容器内的值。
- 虽然像“单子是墨西哥卷饼”这样的比喻看似有趣,但它们通常导致误解,掩盖了核心思想。更好的方法是通过实际的例子、代码和范畴论的学习来深入理解这些概念。
optional<T>
是 C++ 中用于表示可能存在或不存在的值的容器类型。它要么包含一个值,要么为空。这种类型非常有用,尤其是在函数的返回值可能没有有效结果时。
optional<T>
的主要概念:
- 包含零个或一个值:
optional<T>
要么包含一个类型为T
的值,要么不包含任何值(即为空)。- 这对于可能不返回有效结果的函数非常有用(例如,由于错误或某些条件下值不存在)。
- 检查值是否存在:
- 可以使用
has_value()
或operator bool()
来检查optional<T>
是否包含值。std::optional<int> opt = 42; if (opt) { // 或者 if (opt.has_value())std::cout << "Value: " << *opt << std::endl; // 解引用获取值 }
- 可以使用
- 创建空的或包含值的
optional
:- 可以使用
std::nullopt
(或 Boost 中的boost::none
)来创建一个空的optional
。std::optional<int> opt1 = std::nullopt; // 空的 std::optional<int> opt2 = 42; // 包含值 42
- 可以使用
- 有条件地应用函数:
- 可以在
optional
内部的值上应用函数,但仅在它包含有效值时。通常的方法是先检查值是否存在,然后再有条件地应用函数。 - 典型的模式是使用 三元运算符 或
std::transform
/std::map
:
在这段代码中:auto transformed = opt ? boost::make_optional(f(*opt)) : boost::none;
*opt
解引用optional
来获取其内部的值。boost::make_optional(f(*opt))
对值应用函数f
,并将结果封装到一个新的optional
中。boost::none
表示如果原optional
为空,则返回空的optional
。
- 可以在
实际示例:
假设我们有一个可能返回值的函数,但如果分母为零,它也可能不返回值:
std::optional<double> safeDivide(double numerator, double denominator) {if (denominator == 0) {return std::nullopt; // 如果分母为零,返回空的 optional} else {return numerator / denominator; // 返回一个有效的结果}
}
int main() {auto result = safeDivide(10, 2);if (result) { // 检查是否有有效的结果std::cout << "Result: " << *result << std::endl;} else {std::cout << "Division by zero!" << std::endl;}auto emptyResult = safeDivide(10, 0); // 分母为零if (!emptyResult) { // 检查没有结果的情况std::cout << "No result!" << std::endl;}
}
在这个例子中:
- 如果除法成功,
optional
包含结果。 - 如果有错误(如分母为零),则返回空的
optional
。
转换 optional
内部的值:
可以使用函数来转换 optional
中的值。假设你想对一个 optional
中的值进行两倍操作(如果值存在):
std::optional<int> opt = 10;
auto doubled = opt ? boost::make_optional(*opt * 2) : boost::none;
if (doubled) {std::cout << "Doubled: " << *doubled << std::endl;
}
这里:
*opt
解引用optional
获取值。- 三元运算符检查
optional
是否为空,并对其进行转换。
为什么使用 optional<T>
?
- 避免空指针异常:相比于使用指针来表示可选值,
optional<T>
更安全,因为你不需要手动检查指针是否为nullptr
。 - 更好的语义表达:
optional<T>
明确表达了值可能存在或不存在,提升了代码的可读性和理解性。 - 表示缺失数据:它是表示数据缺失的绝佳工具(类似于指针中的
NULL
或nullptr
),但提供了更好的安全性和清晰度。
C++17 及以后版本(std::optional
):
- 在 C++17 中,
std::optional<T>
已经成为标准库的一部分,提供与 Boost 的optional
相同的功能。它可以像以下代码一样使用:std::optional<int> opt = 42; auto transformed = opt ? std::make_optional(*opt * 2) : std::nullopt;
总结:
optional<T>
是一种容器类型,可以包含一个T
类型的值,也可以为空,非常适合处理可能没有有效结果的计算。- 你可以创建空的或包含值的
optional
,检查其是否包含值,并有条件地对其内容应用函数。 - 上述代码示例展示了如何在
optional
内部的值存在时应用转换操作,并使用boost::none
表示空值。
std::vector<T>
的理解
std::vector<T>
是 C++ 标准库中的一个容器类型,用于存储零个或多个相同类型(T
)的元素。它是一个动态数组,能够在程序运行时根据需要自动调整其大小。
std::vector<T>
的主要特点:
- 包含零个或多个值:
std::vector<T>
可以存储任意数量的元素(取决于可用的内存),甚至是零个元素。- 这是一个动态大小的容器,可以在运行时根据需要增加或减少元素数量。
- 检查元素数量:
- 你可以使用
size()
方法来检查vector
中的元素个数。std::vector<int> vec = {1, 2, 3}; std::cout << "Size of vector: " << vec.size() << std::endl; // 输出 3
- 你可以使用
- 创建包含任意数量元素的
vector
:vector
容器的大小是动态可变的,可以随时增加或减少元素的数量。它的最大大小仅受可用内存的限制。std::vector<int> vec; // 空 vector vec.push_back(10); // 向 vector 添加元素 vec.push_back(20);
- 对所有值调用函数:
std::vector<T>
提供了一个高效的方式来遍历所有元素并对它们执行操作。你可以使用算法如std::transform
来将函数应用到所有元素。- 在语义上,虽然
std::transform
允许你修改或转换vector
中的元素,但是这种方式可能不是最优的,因为它会在每次迭代时创建一个新的容器来存储结果。
示例:如何在 vector
中应用函数
假设你有一个整数 vector
,并且你想将每个元素乘以 2。使用 std::transform
可以轻松实现:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {// 创建一个包含元素的 vectorstd::vector<int> vec = {1, 2, 3, 4};// 准备一个 transformed vector 来存储转换后的结果std::vector<int> transformed;transformed.reserve(vec.size()); // 预分配空间以提高效率// 使用 std::transform 将每个元素乘以 2std::transform(vec.begin(), vec.end(), std::back_inserter(transformed), [](int x) {return x * 2;});// 输出 transformed vectorfor (int x : transformed) {std::cout << x << " "; // 输出:2 4 6 8}return 0;
}
代码分析:
- 创建
transformed
向量:transformed.reserve(vec.size())
预先为transformed
向量分配足够的内存空间,以避免每次插入新元素时都进行内存重新分配。 std::transform
:该函数将一个函数f
应用于vec
中的每个元素,并将结果插入到transformed
中。我们用std::back_inserter
来自动将结果插入到transformed
向量中。
std::transform
的原型是:std::transform(begin, end, output_begin, func);
begin
和end
是原始容器的迭代器(在此为vec.begin()
和vec.end()
)。output_begin
是目标容器的插入点(在此是std::back_inserter(transformed)
)。func
是我们对每个元素应用的操作(在此是乘以 2)。
性能优化:
- 避免不必要的拷贝:通过
reserve
方法来提前为transformed
分配空间,可以避免每次插入时发生重复的内存分配和拷贝。 std::transform
语义:std::transform
是一个强大的算法,它可以在一个容器的范围内对每个元素应用函数,并将结果写入另一个容器。虽然它在语义上直观且高效,但它会创建一个新的容器来存储转换后的结果。如果你希望就地修改原始vector
中的元素,可以直接使用std::for_each
或std::transform
的原地修改版本。
总结:
std::vector<T>
是一个非常灵活且高效的容器,用于存储多个相同类型的元素。- 使用
std::transform
可以方便地对vector
中的所有元素应用函数,并生成一个新的容器来存储转换后的结果。 - 在实际编程中,通过合理使用
reserve
和std::back_inserter
等技巧,能够提高vector
操作的性能。
std::future<T>
的理解
std::future<T>
是 C++ 标准库中的一个类模板,用于表示某个操作的结果,这个结果可能会在未来某个时刻可用,或者根本不可用。它通常与 std::promise<T>
配合使用,以便在异步操作完成后提供结果。
std::future<T>
的主要特点:
- 包含将来可能出现的值(或根本没有值):
std::future<T>
表示一个值,这个值将在未来某个时刻变得可用,或者可能根本不会有值(例如,如果计算失败或者被取消)。- 它通常用于异步编程中,表示一个异步操作的结果。
- 检查值是否存在:
- 你可以使用
std::future<T>::valid()
来检查future
是否持有有效的值。 - 你还可以使用
std::future<T>::get()
来获取结果。如果结果尚未准备好,它会阻塞当前线程直到值可用。
- 你可以使用
- 创建已准备好的
future
:- 你可以通过
std::promise<T>
或std::make_ready_future()
来创建一个已准备好的future
,即表示操作已经完成并且结果已经存在。 - 你还可以通过类似
std::make_exceptional_future()
创建一个包含异常的future
。
- 你可以通过
- 调用函数处理值:
std::future<T>
提供了.then()
方法(在某些实现中可能是类似的),可以用来在异步操作完成后对结果进行处理。- 如果没有
.then()
,可以通过创建一个线程并等待future
结果的方式来处理它,这样做虽然有效,但通常不推荐。
示例:如何使用 std::future<T>
进行异步操作
假设你正在进行一个耗时的计算,并且希望在计算完成后执行某些操作。你可以通过 std::future<T>
和 std::promise<T>
来实现。
#include <iostream>
#include <future>
#include <thread>
// 一个简单的函数,模拟异步计算
int calculate_square(int x) {std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟延时return x * x;
}
int main() {// 创建一个 promise 和 futurestd::promise<int> promise;std::future<int> fut = promise.get_future();// 启动一个线程来执行计算,并通过 promise 设置结果std::thread t([&promise] {int result = calculate_square(10); // 计算 10 的平方promise.set_value(result); // 设置计算结果});// 在 main 线程中使用 future 获取计算结果std::cout << "Waiting for result...\n";int result = fut.get(); // 阻塞直到 future 获取到值std::cout << "Result: " << result << std::endl;// 等待线程完成t.join();return 0;
}
代码分析:
std::promise<int> promise;
:std::promise
用于设置一个未来值。在此示例中,我们创建了一个类型为int
的promise
,表示将来会提供一个整数值。
std::future<int> fut = promise.get_future();
:- 通过
promise.get_future()
获取一个与promise
相关联的future
。这个future
会在将来被填充上结果。
- 通过
- 异步计算和设置值:
- 我们创建了一个新线程,在线程内执行
calculate_square(10)
计算 10 的平方,并使用promise.set_value(result)
设置计算结果。
- 我们创建了一个新线程,在线程内执行
- 阻塞直到结果可用:
- 在主线程中,使用
fut.get()
阻塞并等待直到future
中有了结果。一旦结果准备好,fut.get()
会返回该值。
- 在主线程中,使用
t.join();
:- 使用
join()
等待线程执行完成,确保主线程在退出前等待异步线程结束。
- 使用
示例:如何使用 .then()
(假设支持)
在某些 C++ 实现中(例如 C++20 及以后),你可能会看到 std::future
支持 .then()
方法,它可以让你在异步操作完成后直接应用一个函数:
auto transformed = fut.then([](std::future<int> fut) {return fut.get() * 2; // 获取值并对结果进行转换
});
std::cout << "Transformed result: " << transformed.get() << std::endl;
在这种情况下,.then()
会返回一个新的 future
,你可以链式调用它,等待异步操作的结果并继续操作。
性能和注意事项:
- 阻塞:
fut.get()
会阻塞当前线程,直到异步操作完成。虽然阻塞是必要的,但在某些情况下,可能会有性能影响。你可以使用std::async
等更复杂的机制来避免完全阻塞。 - 异常处理:如果异步操作抛出异常,你可以在
fut.get()
上捕获该异常,或使用.then()
来处理。 - 线程安全:
std::future<T>
是线程安全的,但你只能在一个线程中调用get()
方法。如果在多个线程中调用get()
,会导致未定义的行为。
总结:
std::future<T>
是一个用于表示将来可能会出现的值的类模板,通常与std::promise<T>
配合使用。- 它可以用来处理异步操作的结果,通过
.get()
阻塞直到结果可用,或使用.then()
等方法来处理异步操作的结果。 std::future
在多线程编程中非常有用,能够避免线程之间的直接同步和等待问题。
Functors(函子)
在编程中,函子是一个类型,它封装了另一个类型(或多个类型),并提供一个函数来将某个函数应用于该类型中包含的值。
函子的定义
函子这个概念来源于范畴论,但在编程中我们并不深入探讨范畴论的数学背景。可以简单地理解为,函子是一个容器(或包装器),它包含一个值(或者多个值)。这个容器提供了一个叫做 map
(或者在 Haskell 中叫做 fmap
)的函数,允许你将一个函数应用于容器内的值,并生成一个新的容器。
函子的类型签名
在 Haskell 中,fmap
的签名如下:
fmap :: (a -> b) -> f a -> f b
这意味着 fmap
接受一个从 a
到 b
的函数,并且接受一个包含 a
的函子,返回一个包含 b
的函子。
a
和b
是函子内部值的类型。f
是函子本身,它可以是类似Maybe
、List
、Optional
等的容器类型。fmap
将函数应用于函子中的值,返回一个包含新值的函子。
Haskell 中的 fmap
示例
这里是一个 Haskell 中的例子:
data Maybe a = Nothing | Just a
instance Functor Maybe wherefmap _ Nothing = Nothingfmap f (Just x) = Just (f x)
- 如果你对
Just 5
使用fmap
并应用(*2)
函数,结果将是Just 10
。 - 如果你对
Nothing
使用fmap
,它仍然是Nothing
,因为里面没有值可以应用函数。
C++ 中的函子
在 C++ 中,创建一个函子比在像 Haskell 这样支持函数式编程的语言要复杂一些。但你仍然可以通过模板、类和Lambda 函数来实现函子。
C++ 中的函子示例
#include <iostream>
#include <vector>
#include <algorithm>
// 定义一个函子类型
template<typename T>
class MyFunctor {
public:// 函子的 map 函数(在 C++ 中用 operator() 来实现)T operator()(const T& x) const {return x * 2; // 示例:将值翻倍}
};
int main() {std::vector<int> numbers = {1, 2, 3, 4, 5};// 创建函子实例MyFunctor<int> fmap;// 使用 std::transform 对 vector 中的每个元素应用函子std::transform(numbers.begin(), numbers.end(), numbers.begin(), fmap);// 输出转换后的值for (int num : numbers) {std::cout << num << " "; // 输出: 2 4 6 8 10}return 0;
}
解释:
MyFunctor
: 这是一个将输入整数值翻倍的函子,它通过operator()
方法来实现。std::transform
: 这个标准算法将函子应用于容器中的每个元素(在这个例子中是一个std::vector<int>
)。- 结果是一个修改后的
vector
,其中每个元素都被函子转换了(翻倍)。
函子的应用场景:
- 容器: 函子可以封装数据类型(例如容器),并允许你对容器中的每个元素应用函数。
std::optional
,std::vector
,std::future
: 这些类型可以看作是函子,因为它们封装了值,并且允许你对值进行转换(例如map
或transform
)。- C++ 中的函数式编程: 尽管 C++ 不是一个纯粹的函数式编程语言,但它支持函子等函数式编程范式,这帮助我们更声明式地编写代码。
总结:
- 函子(Functor): 是一个类型,它封装一个值或一组值,并允许你应用一个函数来转换这些值。
map
或fmap
(在 Haskell 中)是应用于函子中的值并返回一个新值的函数。- C++ 中的函子:C++ 中的函子通常是实现了
operator()
方法的类型,这使得它们在应用到值时表现得像函数一样。它们通常与std::transform
等算法或容器类型(如std::vector
和std::optional
)一起使用。
Functors pt. 2
在这一部分,我们继续探讨 函子 的概念,并且深入理解它在不同类型中的应用。
我们已经见过 fmap
!
在之前的幻灯片中,实际上我们已经接触到过 fmap
(即映射函数)。我们讨论的许多代码片段,实际上都在实现 fmap
。例如,针对不同类型(如 std::optional
、std::vector
等)使用了 map
或 transform
操作,这些都可以视作是 函子 的应用。
例子
对于 std::optional
类型,fmap
会将一个函数应用于容器内部的值(如果有的话),并返回一个新的 optional
容器。
#include <iostream>
#include <boost/optional.hpp>
int main() {boost::optional<int> opt = 10;auto transformed = opt ? boost::make_optional(*opt + 5) : boost::none;if (transformed) {std::cout << "Transformed value: " << *transformed << std::endl; // 输出: Transformed value: 15} else {std::cout << "No value!" << std::endl;}return 0;
}
在这个例子中,boost::optional
就是一个函子,我们使用 fmap
操作符将值转换为另一个类型。
Variant 类型作为 vector 的应用
我们可以将 boost::variant
也看作是一个特殊的容器类型(类似于 std::vector
),因为它允许将一个函数应用于其包含的多种类型的值。
std::variant
和boost::variant
是容器类型,它们的内部可以包含不同类型的值。- 对这种类型的函数对象进行
fmap
操作时,可以根据类型不同对每个值应用不同的操作。
这种方式在 Haskell 中并不容易实现,因为 Haskell 更加严格地要求类型的统一,而 C++ 通过模板和多态(尤其是函数重载)提供了更灵活的方式来处理这种情况。
Haskell 中的 Bifunctor
在 Haskell 中,除了标准的 Functor 类型类,还存在一个名为 Bifunctor 的类型类,它允许你对 双值 的容器(例如包含两个值的容器)进行操作。
bimap
是 Bifunctor 中的映射函数,它允许你对容器中的两个值分别应用不同的函数。
bimap
的类型签名
bimap :: (a -> b) -> (c -> d) -> f a c -> f b d
bimap
接受两个函数:- 第一个函数
a -> b
将第一个值从类型a
转换为类型b
。 - 第二个函数
c -> d
将第二个值从类型c
转换为类型d
。
- 第一个函数
f a c
是一个包含两个值的容器(如一个二元组)。f b d
是转换后的容器,包含转换后的值。
示例:Bifunctor 使用
import Data.Bifunctor
-- 定义一个二元组类型,Bifunctor 实现
instance Bifunctor (,) wherebimap f g (x, y) = (f x, g y)
-- 使用 bimap 函数
main = print $ bimap (+1) (*2) (3, 4)
-- 输出: (4, 8)
- 在这个例子中,
bimap
将(3, 4)
转换成了(4, 8)
,即对第一个值应用(+1)
,对第二个值应用(*2)
。
总结
fmap
在 C++ 中常见的应用方式,如std::optional
和std::vector
类型,都可以看作是函子的实现,它们通过map
将函数应用于其包含的值。std::variant
在 C++ 中也表现出类似函子的特性,通过重载函数来处理其内部不同类型的值。- Haskell 中的
bimap
允许你同时对双值容器进行操作,并且将两个不同类型的函数应用到容器的不同值上。
函子的应用场景
- 封装和转换:我们可以用函子对不同类型的数据进行统一的操作。
- 类型安全:通过封装类型和转换逻辑,函子为我们提供了类型安全的操作方式。
- 函数式编程:C++ 和其他语言通过支持函子,增强了函数式编程的能力,使得我们能够更高效、更灵活地处理数据。
Functors pt. 3
这一部分继续讨论 函子 的概念,重点讲解了 fmap 的特性及其在函数式编程中的作用。
fmap 是一个伟大的工具
fmap
是一个非常有用的工具,它允许我们对一个函子中的值进行轻松的函数调用。它遵循 值语义(value semantics),即使容器中的值被封装在容器里,我们仍然可以以一致的方式对其进行操作。
fmap 定义
fmap
的定义有一个关键的特性:它返回一个同类型的 函子。也就是说,应用fmap
后,得到的结果还是同一种类型的容器,内部包含经过函数转换后的值。
举个例子,假设我们有一个std::optional<int>
类型,如果我们对其应用fmap
,那么它依然是一个std::optional<int>
类型的容器,只不过其中的值已经被应用了某个函数(比如加一)。
Endofunctors(自函子)
- 由于
fmap
保证了返回的函子是同类型的容器,所以它们被称为 endofunctors(自函子)。Endofunctor 是指那些能够映射到它们自身的函子。- 简单来说,endofunctor 就是一个能够接受自己类型作为输入并返回自己类型的函子。对容器应用
fmap
后,得到的还是同类型的容器(即类型保持不变)。
- 简单来说,endofunctor 就是一个能够接受自己类型作为输入并返回自己类型的函子。对容器应用
为什么 endofunctor 很有用?
- endofunctor 这个概念在很多领域都非常重要,尤其是在函数式编程中,它为我们提供了一种一致的方式来操作容器中的值,同时保持容器的类型不变。
- 这个概念非常有用,尤其是在组合复杂操作时,可以确保容器的类型始终保持一致,并且可以通过不同的函数组合构造出新的操作。
举个简单的例子:
#include <iostream>
#include <optional>
int main() {std::optional<int> opt = 10;// 应用 fmap(通过lambda将值加1)auto transformed = opt ? std::make_optional(*opt + 1) : std::nullopt;if (transformed) {std::cout << "Transformed value: " << *transformed << std::endl; // 输出: 11} else {std::cout << "No value!" << std::endl;}return 0;
}
在这个例子中,std::optional
是一个 endofunctor,因为应用 fmap
后,它的类型 std::optional<int>
并没有变化。
总结
fmap
是一个非常有用的工具,它遵循值语义并允许对容器中的值进行函数调用。- endofunctors 是一种特殊的函子,它映射到自己本身,即操作后的容器仍然是同类型的。
- 函子使得我们可以对不同容器进行一致的操作,同时保持类型安全,并且方便地组合不同的操作。
A Quick Shortcut without Mentioning [ … ]
在这一部分,讲解了 Applicative Functors 的概念,并澄清了之前的解释。接下来,我们将会讨论 Applicative Functors 和它们在 C++ 中的一些实际应用。
I lied again
讲者在这里自嘲,之前的解释可能并不完全准确,因为 Applicative Functors 其实是比普通的 Functor 更强大的工具。通过 Applicative Functors,我们能够将函数本身也包装成一个 Functor,这给我们提供了更多的功能和灵活性。
Applicative Functors 允许你将函数本身包装成 Functor
- Applicative Functors 的一个重要特点是,你可以将一个 函数 本身也包装成一个 Functor。这意味着你可以像对待普通值一样对待函数,即使这个函数是可选的(如
std::optional
),这就为组合函数和容器提供了更多的灵活性。
使用场景:可选的函数和可选的值
举个简单的例子:假设我们有一个 可选函数(比如一个返回 std::optional
的函数)和一个 可选值(std::optional<int>
),我们可以通过 Applicative Functors 来应用这个函数:
std::optional<int> value = 10;
std::optional<std::function<int(int)>> function = [](int x) { return x + 5; };
// 使用 Applicative Functor 来应用函数
auto result = (function && value) ? std::make_optional((*function)(*value)) : std::nullopt;
在上面的代码中,function
和 value
都是 std::optional
类型的容器。应用 Applicative Functor 后,如果它们都包含有效值,我们就能将函数应用于这个值。否则,结果就是 std::nullopt
。
从这次讨论的角度来看,Applicative Functors 并没有那么有趣
- 讲者在这里表示,尽管 Applicative Functors 很强大,但在本次讨论的上下文中,它们可能没有 Functor 那么引人注目,因此没有深入探讨其细节。
总结
- Applicative Functors 是一种更强大的 Functor 类型,它允许我们将函数本身也包装成 Functor,从而支持对可选的函数和值进行组合操作。
- 在实际应用中,Applicative Functors 非常有用,尤其是在处理 可选值(如
std::optional
)时,可以轻松组合函数和容器。
Monads: join
在这部分,讲者介绍了 Monads 中的一个重要概念:join。我们先来看一下这个概念和代码示例的含义。
什么是 join
?
在某些情况下,我们可以“扁平化”(flatten)一个 Functor,这就是所谓的 join 操作。对于一些容器类型,如 std::optional
,我们有一个嵌套的容器(即一个容器中的元素本身也是一个容器)。通过 join
操作,我们可以将这种嵌套的结构“合并”为一个单一的容器。
换句话说,join
的作用是将 容器中的容器 展开成 单一的容器,从而简化结构。
示例代码:
template<typename T>
boost::optional<T> join(boost::optional<boost::optional<T>> opt)
{return opt ? std::move(*opt) : boost::none;
}
在这段代码中,join
函数的作用是:
- 输入:一个
boost::optional<boost::optional<T>>
类型,即一个嵌套的optional
。 - 输出:一个
boost::optional<T>
类型,即扁平化后的optional
。
如果外层的optional
包含值(即opt
为真),那么join
就会提取出内层的optional
并返回它的值。如果外层optional
是空的(即opt
为boost::none
),那么函数返回一个空的optional
(boost::none
)。
理解这个例子的关键点:
- “扁平化”:通过 join,我们将一个嵌套的容器(例如
boost::optional<boost::optional<T>>
)转变成一个单一的容器(boost::optional<T>
)。这就像把多个层次的包装去掉,只留下真正的内容。 join
与Monad
:在 Monad 的上下文中,join
是一个非常重要的操作,它用于将嵌套的Monad
扁平化。通过join
,我们能够将多个层次的 容器(比如optional
、future
等)简化成一个层次,使得我们可以更轻松地进行链式操作。
总结:
join
是一个操作,它将嵌套的容器结构“合并”或“扁平化”,使得我们能够更直接地访问内层的值。- 这个操作在 Monads 中非常常见,它简化了多个层级的结构,使得处理变得更加方便。
Monads: join
for std::vector
这段代码演示了如何为 std::vector
类型实现类似于 join 的操作,使得我们能够“扁平化”一个嵌套的 vector。
join
的作用:
- 在这个例子中,
join
的作用是将一个嵌套的std::vector<std::vector<T>>
(即一个包含多个std::vector
的vector
)展平,变成一个单一的std::vector<T>
,使得原来嵌套的结构成为一维的。 - 这意味着,原来每个子
vector
中的元素都会被提取出来,全部放入到一个大的vector
中。
代码分析:
template<typename T>
std::vector<T> join(std::vector<std::vector<T>> vec)
{std::vector<T> ret;// reserve enough space for all elementsfor (auto && v : vec){std::move(v.begin(), v.end(), std::back_inserter(ret));}return ret;
}
- 输入:
vec
是一个std::vector<std::vector<T>>
,即一个包含多个std::vector<T>
的容器。
- 输出:
ret
是一个扁平化后的std::vector<T>
,它包含了所有子vector
中的元素。
std::move
和std::back_inserter
:std::move(v.begin(), v.end(), std::back_inserter(ret))
通过std::move
将每个子vector
中的元素移动到ret
中,而不是复制它们。这是为了提高性能,避免不必要的复制。std::back_inserter(ret)
将元素插入到ret
的末尾。
reserve
:- 代码中提到的
reserve
(通过注释)表示我们可以预先为ret
分配足够的空间来容纳所有的元素,这样可以避免在插入过程中进行多次内存分配,提高效率。
- 代码中提到的
return
:- 最后,
join
返回一个扁平化后的std::vector<T>
,包含了所有子vector
中的元素。
- 最后,
示例:
假设我们有以下嵌套的 vector
:
std::vector<std::vector<int>> vec = {{1, 2, 3},{4, 5},{6, 7, 8}
};
通过调用 join
:
auto result = join(vec);
result
将会是一个扁平化的 std::vector<int>
:
{1, 2, 3, 4, 5, 6, 7, 8}
总结:
join
操作不仅适用于optional
类型,也适用于std::vector
类型,它的作用是将嵌套的容器扁平化。- 在这个例子中,
join
通过移动嵌套vector
中的元素来创建一个新的、扁平的vector
。 - 这种方式有助于简化数据结构,使得后续的操作变得更加直观和高效。
Monads: join
详解
概念
join
操作的基本概念是:将一个嵌套的m (m a)
类型(即一个包含m a
的容器)“展平”成一个单一的m a
类型。- 形式化定义:
join :: m (m a) -> m a
m
代表一个容器(例如optional
,vector
等),而a
是容器内部的元素类型。- 形式化定义:
join
的作用
join
的主要目的是将一个嵌套容器展平到单一容器。这个操作在许多情况中都非常有用,比如:- 对于
optional<optional<T>>
类型,调用join
可以将其展平为optional<T>
。 - 对于
vector<vector<T>>
类型,调用join
可以将其展平为vector<T>
。
- 对于
不同容器的 join
实现
- 对于不同类型的容器,
join
执行的操作可能有所不同,并且对于某些类型,join
可能并没有太大意义。
例如:- 对于
optional
类型,join
会消除嵌套的optional
,将optional<optional<T>>
转换为optional<T>
,这是有意义的。 - 对于某些其他容器类型,
join
可能不那么直观或不具备实用意义。
- 对于
如何使用 join
和 fmap
假设我们有一系列的计算步骤,每个步骤返回的是一个 optional
类型,形成了嵌套的 optional<optional<T>>
:
fmap
操作:首先,使用fmap
将函数应用到optional
中的值。此时,返回的将是optional<optional<T>>
。boost::optional<boost::optional<int>> opt1 = ...; auto result = opt1 ? boost::make_optional(boost::make_optional(f(*opt1))) : boost::none;
join
操作:随后,可以使用join
操作将嵌套的optional
展平为optional<T>
,使得结构更加简单。boost::optional<int> flatOpt = join(opt1);
- 再次应用
fmap
:最后,您可以在展平后的optional<T>
上再次应用fmap
,进行进一步的操作。auto transformed = flatOpt ? boost::make_optional(f(*flatOpt)) : boost::none;
例子说明
假设我们有一个计算步骤 f
,它接受一个整数并返回一个 optional<int>
:
auto f = [](int x) {return x > 0 ? boost::make_optional(x * 2) : boost::none;
};
我们先执行 f
,然后使用 join
操作将返回的 optional<optional<int>>
展平为 optional<int>
:
boost::optional<boost::optional<int>> opt1 = boost::make_optional(boost::make_optional(10));
auto flattened = join(opt1); // Now flattened is boost::optional<int> with value 10
auto transformed = flattened ? boost::make_optional(f(*flattened)) : boost::none; // f(10) = 20
通过这种方式,您可以轻松地处理包含多个嵌套层次的容器,最终得到一个简单的结构。
总结
join
操作的目的是将嵌套的容器展平,使得后续的操作更加简洁。join
和fmap
配合使用,可以实现类似于链式计算的过程,每一步都返回一个容器类型,最终通过join
展平嵌套结构。- 对于不同的容器类型,
join
的具体实现和意义可能有所不同,但它总是帮助我们将容器中的元素提取并简化。
Monads: mbind
详解
mbind
的基本概念
mbind
是 monad 中的一个操作符,在 Haskell 中它通常表示为 >>=
,其基本功能是将一个 monad 类型的值传递给一个返回 monad 的函数,并返回最终的 monad。
- Haskell 版本:
这里的(>>=) :: m a -> (a -> m b) -> m b x >>= f = join (fmap f x)
x
是一个包含类型a
的 monad,而f
是一个函数,它接受一个a
类型的值并返回一个m b
类型的 monad。
如何理解 mbind
- 通过
>>=
,我们能够将一个 monad 的值传递给一个函数,函数的返回值本身也是一个 monad。通过join
和fmap
结合使用,我们可以将嵌套的 monad 展平并获得最终的结果。
例如,fmap f x
会将x
中的值传递给f
,这会产生一个新的 monad(通常是嵌套的)。接着,我们通过join
将嵌套的 monad 展平,从而得到最终的 monadic 值。
在 C++ 中实现 mbind
- 在 C++ 中,
mbind
(即>>=
)的行为和 Haskell 中的实现类似,但存在一个问题:C++ 中的操作符>>=
的结合性与 Haskell 的行为不一样。
在 C++ 中,如果你写a >>= b >>= c
,它的执行顺序是(a >>= b) >>= c
,而不是a >>= (b >>= c)
,这会导致不同的结果。由于 C++ 中的结合性问题,可能需要手动调整括号或修改实现来避免错误。- 示例:
auto x = a >>= b; // This is (a >>= b), not a >>= (b >>= c)
- 示例:
mbind
与 join
的关系
mbind
与join
:有时,我们需要实现mbind
而不是join
,因为join
实际上是将嵌套的 monad 展平,而mbind
允许我们通过应用一个函数来链接 monadic 操作。如果你选择实现mbind
,那么join
实际上可以通过将恒等函数传递给mbind
来间接实现:template <typename T> auto join(const std::optional<std::optional<T>>& opt) {return opt ? *opt : std::nullopt; } // Equivalent to: mbind(x) = join(fmap(identity, x))
- 在这里,
identity
函数就是一个将输入值原样返回的函数,fmap
将其应用到x
上,而join
则展平嵌套的结果。
- 在这里,
C++ 中的实现示例
假设我们有一个 std::optional<T>
类型的 monad,想要实现类似 Haskell 中 >>=
的功能:
template<typename T, typename F>
auto mbind(const std::optional<T>& opt, F&& f) {if (opt) {return f(*opt); // f returns an std::optional<U>} else {return std::optional<decltype(f(*opt))>(); // Return an empty std::optional}
}
这个 mbind
操作符将一个 std::optional<T>
传递给一个返回 std::optional<U>
类型的函数 f
,并返回最终的结果。
总结
mbind
(>>=
) 是 monad 中的一个重要操作符,用于将一个值(可能包含在 monad 中)传递给一个返回 monad 的函数。它依赖于join
和fmap
的组合。- 在 C++ 中实现
mbind
时,需要特别注意操作符的结合性问题,可能需要调整实现或使用额外的括号来确保正确的计算顺序。 - 有时,使用
mbind
比join
更加直接和清晰,因为它允许我们在 monad 内部进行函数应用,同时保持 monadic 结构不变。
Monads 解析
Monads 是 Functors
- Functors 是一种可以在其中应用函数的容器,而 Monads 是 Functors 的一种特殊形式。与 Functors 一样,Monads 也能封装一个值并提供一种将函数应用于该值的方式,但它们还提供了更强大的组合和控制机制。
Monads 表示计算的步骤序列
- Monads 用于描述一系列计算步骤,每一步都可能依赖于前一步的结果。它们能够将计算过程“链式”地连接起来,每个步骤的结果都被封装在一个 monadic 容器中。通过这种方式,Monads 使得复杂的计算变得更加易于组合与管理,尤其是在涉及副作用或异步操作的场景中。
Monads 是“可编程的分号”
- 这个比喻说明了 Monads 在控制流中的作用。就像分号(
;
)在程序中表示一个语句的结束和下一个语句的开始一样,Monads 也能表示计算流程中的步骤。具体来说,Monads 能够管理和控制这些计算步骤的顺序,尤其是涉及副作用(比如输入输出操作、状态修改等)时。它们不仅是计算流的“分隔符”,还可以在其中插入特定的逻辑,比如错误处理、状态传递等。
Monads 的关键特点
- 封装计算:每个计算步骤的结果都被封装在 monadic 类型中(如
Optional
,Future
等),确保计算的一致性和可靠性。 - 顺序化计算:通过
bind
(Haskell 中是>>=
操作符),Monads 可以将多个计算步骤串联起来,允许后续步骤依赖于前一步的结果。 - 处理副作用:Monads 特别适合处理有副作用的计算,如 IO 操作、状态修改、错误处理等。例如,
IO
Monad 允许我们在一个函数式程序中处理输入输出。 - 统一接口:Monads 提供了统一的接口(如
fmap
、bind
、join
等),使得不同类型的计算(无论是同步的、异步的、包含副作用的等)都可以通过类似的方式进行组合。
总结
- Monads 是 Functors 的一种扩展,提供了更强的功能,允许我们定义和组合复杂的计算步骤。
- Monads 描述计算过程中的每个步骤,它们能够链式地组织计算,保证每个步骤的输出可以作为下一个步骤的输入。
- Monads 是“可编程的分号”,它们帮助我们控制计算流程,特别是在处理副作用时,让程序更加可控和可组合。
do-notation 解析
do-notation 是 monadic bind 的简写
- do-notation 是一种用于简化 monadic bind(
>>=
)的语法。它允许你以更直观和易读的方式编写基于 monads 的计算序列,特别是在函数式编程语言中,像 Haskell 就广泛使用 do-notation 来简化代码结构。
如何使用 do-notation
do
语法可以帮助你编写多个 monadic 操作,而无需显式地使用>>=
。它的工作方式是,将一个步骤的结果(如something <- foo
)解包并传递到后续的计算中。- 例如,下面是 do-notation 的使用示例:
dosomething <- foobarbaz something
- 上述代码可以转换成标准的 monadic bind 形式:
foo >>= \something -> bar >>= \_ -> baz something
解释
- 这里的
foo
是一个可能返回Maybe
(类似于 C++ 中的Optional
)的操作。如果foo
成功返回一个值,则将该值绑定到something
并继续执行bar
操作。如果bar
成功,那么接着执行baz something
。 do-notation
就是简化了这些重复的>>=
链接,使得代码更加易于阅读。
举个具体例子
- 假设我们有一个
Maybe
monad(类似于 C++ 的Optional
类型),并且我们想要按照顺序执行多个可能失败的操作:
dox <- foo -- foo 返回 Maybey <- bar -- bar 返回 Maybereturn (x + y) -- 如果 foo 和 bar 都成功,返回它们的和
这相当于:
foo >>= \x ->bar >>= \y ->return (x + y)
为什么它看起来很熟悉?
- 其实在 C++ 中,我们也有类似的操作——例如,
std::optional
和std::future
都可以类似地链式调用多个操作。虽然 C++ 没有直接的do-notation
,但是我们也可以通过嵌套的函数调用来处理这种依赖计算:
auto result = foo().then([](auto x) {return bar().then([x](auto y) {return x + y;});
});
- do-notation 的优势在于,它让你可以用更简洁、可读的方式表达这些连续的、依赖的操作,而不必明确每一步的
bind
操作。
总结
- do-notation 是一种简化 monadic bind 的语法,使得我们在进行多个依赖计算时更加高效和清晰地表达逻辑。
- 它可以看作是将一系列
>>=
操作包装起来,提供一个更自然的结构,特别适用于处理可能失败或有副作用的计算(如Maybe
或Future
)。
do-notation 和 C++ 代码的对比
do-notation 和 C++ try-catch 的相似性
- do-notation 本质上是一种用于表达一系列依赖操作的语法,它类似于 C++ 中的异常处理机制,尤其是
try-catch
语句。它们都强调“顺序执行”多个操作,并在某些情况下中断执行。 - 在 Haskell 或其他函数式编程语言中,
do-notation
让我们以更直观的方式写出具有依赖关系的计算序列。如果某一步失败(比如值为Nothing
或Failure
),则后续的操作将不会继续执行。 - 这与 C++ 中的
try-catch
机制非常相似,后者也会在遇到异常时停止当前的代码执行,转而执行异常处理逻辑。
对比示例
假设你有一系列操作,需要检查每个操作是否成功:
Haskell 代码 (使用 do-notation
):
dosomething <- foobarbaz something
这在 C++ 中类似于:
C++ 代码 (使用 try-catch
):
try {auto something = foo();bar();baz(something);
} catch ( /* ... */ ) {// 处理错误
}
- Haskell 中的
do
语法将多个操作链接在一起。如果任何一个操作失败,后续的操作就不会被执行,就像是抛出了一个“失败”信号。 - C++ 中的
try-catch
也有类似的效果:如果foo()
或bar()
发生异常,那么程序会跳到catch
语句中,而不执行baz(something)
。
幽默:Do or Do Not; There Is No Try
- 这句话出自《星球大战》中的尤达大师,他在教卢克如何使用原力时说:“Do or do not; there is no try”(要么做,要么不做,别说‘试试’)。这正是对
do-notation
的一个有趣比喻:在do-notation
中,我们没有“试试”,我们只做了。如果某个操作失败了,它就不会继续执行下去。