函数指针、函数对象与 Lambda 表达式
在 C++ 中,函数属于 一等函数(first-class functions):你可以像对待普通变量那样使用函数,例如把它们作为参数传给其他函数、从函数中返回它们,或者把它们赋给变量。在这个语境中,经常会出现一个术语:回调(callback),也就是任何“可以被调用的东西”。它可以是函数指针,也可以是表现得像函数指针的对象,例如重载了 operator() 的对象,或者内联的 Lambda 表达式。重载了 operator() 的类,被称为 函数对象(function object),简称 functor。更方便的是,标准库还提供了一组类,用来创建回调对象,并把现有回调对象适配成不同形式。Lambda 表达式则允许你在真正需要的地方,直接写出一个简短的内联回调,从而提升代码的可读性与可维护性。现在是时候更深入地理解回调这一概念了,因为下一章会讲到的许多算法,都会通过这类回调来定制行为。
平时你通常不会去想函数在内存中的位置,但每个函数实际上都位于某个确定的地址上。在 C++ 中,你可以把 函数当作数据 来使用;换句话说,C++ 具有一等函数。也就是说,你可以取得一个函数的地址,并像使用变量一样使用它。
函数指针的类型,由其所兼容函数的参数类型与返回类型共同决定。下面这个例子定义了一个名为 fun 的变量,它可以指向“返回 bool、接收两个 int 参数”的函数:
bool (*fun)(int, int);不要忘记 *fun 周围的括号;否则,这条语句就不再是变量定义,而会被解释成一个函数原型:定义了一个名为 fun 的函数,接收两个 int,并返回一个指向 bool 的指针。这个 fun 函数指针还没有初始化。正如你已经知道的那样,应当避免未初始化的数据。你可以像下面这样把 fun 初始化为 nullptr:
bool (*fun)(int, int) { nullptr };使用函数指针实现 findMatches()
Section titled “使用函数指针实现 findMatches()”使用函数指针时,另一种常见做法是使用类型别名。类型别名可以为“具有某种共同特征的一族函数”起一个统一的类型名。例如,下面这行代码定义了一个名为 Matcher 的类型,它表示“指向任意一个接收两个 int 参数并返回 bool 的函数”的指针:
using Matcher = bool(*)(int, int);下面这个类型别名则定义了一个名为 MatchHandler 的类型,用来表示“接收一个 size_t 和两个 int 参数,并且没有返回值”的函数:
using MatchHandler = void(*)(size_t, int, int);有了这些类型之后,你就可以编写一个函数,把两个回调作为参数接收进来:一个 Matcher,一个 MatchHandler。接收其他函数作为参数,或者返回函数的函数,被称为 高阶函数(higher-order function)。例如,下面这个函数接收两个整数 span,再加上一个 Matcher 和一个 MatchHandler。它会并行遍历这两个 span,并对两边对应位置的元素调用 Matcher。如果 Matcher 返回 true,那么就调用 MatchHandler:第一个参数是匹配位置,第二和第三个参数则是让 Matcher 返回 true 的那两个值。注意,虽然 Matcher 和 MatchHandler 是以变量形式传进来的,但调用方式和普通函数完全一样:
void findMatches(span<const int> values1, span<const int> values2, Matcher matcher, MatchHandler handler){ if (values1.size() != values2.size()) { return; } // 大小必须一致。
for (size_t i { 0 }; i < values1.size(); ++i) { if (matcher(values1[i], values2[i])) { handler(i, values1[i], values2[i]); } }}注意,这个实现要求两个 span 拥有相同数量的元素。想要调用 findMatches(),你只需要准备任意一个满足 Matcher 定义的函数——也就是接收两个 int 并返回 bool 的函数——以及一个满足 MatchHandler 定义的函数即可。下面是一个 Matcher 的示例:当两个参数相等时返回 true:
bool intEqual(int value1, int value2) { return value1 == value2; }下面则是一个 MatchHandler 的示例,它只是简单把匹配结果打印出来:
void printMatch(size_t position, int value1, int value2){ println("在位置 {} 发现匹配 ({}, {})", position, value1, value2);}然后就可以把 intEqual() 和 printMatch() 作为参数传给 findMatches():
vector values1 { 2, 5, 6, 9, 10, 1, 1 };vector values2 { 4, 4, 2, 9, 0, 3, 1 };println("使用 intEqual() 调用 findMatches():");findMatches(values1, values2, &intEqual, &printMatch);这里通过取地址的方式,把回调函数传给 findMatches()。从技术上说,& 是可选的——即使你只写函数名,编译器也能理解你想取得它的地址。输出如下:
使用 intEqual() 调用 findMatches():在位置 3 发现匹配 (9, 9)在位置 6 发现匹配 (1, 1)函数指针的好处在于:findMatches() 本身是一个泛型函数,它负责比较两个 vector 中对应位置的值。前面的例子中,它按“是否相等”来比较;但由于它接收的是函数指针,因此你完全可以改用别的比较标准。比如,下面这个函数同样满足 Matcher 的定义:
bool bothOdd(int value1, int value2) { return value1 % 2 == 1 && value2 % 2 == 1; }下面这段代码展示了,bothOdd() 也可以用于调用 findMatches():
println("使用 bothOdd() 调用 findMatches():");findMatches(values1, values2, bothOdd, printMatch);输出如下:
使用 bothOdd() 调用 findMatches():在位置 3 发现匹配 (9, 9)在位置 5 发现匹配 (1, 3)在位置 6 发现匹配 (1, 1)通过函数指针,一个单独的 findMatches() 函数就能够根据传入的函数/回调,被定制成多种不同用途。
把 findMatches() 改写成函数模板
Section titled “把 findMatches() 改写成函数模板”其实,想让 findMatches() 接收回调参数,并不一定非得显式使用函数指针参数。相反,你可以把 findMatches() 改写成函数模板。所需改动只有两点:删除 Matcher 和 MatchHandler 这两个类型别名,并把 findMatches() 变成函数模板。改动如下所示:
template <typename Matcher, typename MatchHandler>void findMatches(span<const int> values1, span<const int> values2, Matcher matcher, MatchHandler handler){ /* … */ }更好的写法是下面这个带约束的函数模板。模板类型参数 Matcher 使用 predicate<int, int> 进行约束(见第 12 章“使用模板编写泛型代码”),以确保用户提供的是一个“能够用两个 int 参数调用并返回布尔值”的回调。类似地,MatchHandler 模板类型参数则被约束为“能够用一个 size_t 参数和两个 int 参数调用,并且不返回任何值”的回调。
template <predicate<int, int> Matcher, invocable<size_t, int, int> MatchHandler>void findMatches(span<const int> values1, span<const int> values2, Matcher matcher, MatchHandler handler){ /* … */ }这两种 findMatches() 实现都需要两个模板类型参数,分别对应 Matcher 与 MatchHandler 回调的类型;但借助函数模板实参推导,调用方式与前面那些版本完全一样。
如果使用缩写函数模板语法,那么 findMatches() 还可以写得更优雅。注意,这里已经不再需要显式写出 template<…> 头了:
void findMatches(span<const int> values1, span<const int> values2, auto matcher, auto handler){ /* … */ }同样地,matcher 和 handler 也依然可以加上约束:
void findMatches(span<const int> values1, span<const int> values2, predicate<int, int> auto matcher, invocable<size_t, int, int> auto handler){ /* … */ }到这里应该已经很清楚了:回调能让你写出非常泛化、可配置的代码。正是这种对回调的使用,让许多标准库算法(详见第 20 章“精通标准库算法”)变得如此强大。
Windows DLL 与函数指针
Section titled “Windows DLL 与函数指针”函数指针的一个常见使用场景,是取得动态链接库中某个函数的指针。下面这个例子展示了如何获得一个 Microsoft Windows 动态链接库(DLL)中的函数指针。DLL 本质上是一种由代码和数据构成的库,任何程序都可以使用它。一个具体的 Windows DLL 例子是 User32 DLL,它提供了大量功能,其中之一就是在屏幕上显示消息框。Windows DLL 的细节超出了这本聚焦平台无关 C++ 的书的范围,但它对 Windows 程序员来说实在太重要,因此值得简要讨论一下,而且它也是函数指针的一个很好的示例。
User32.dll 中用于显示消息框的一个函数名为 MessageBoxA()。假设你只想在确实需要显示消息框时,才去加载这个库。那么可以在运行时通过 Windows 的 LoadLibraryA() 函数来加载它(需要 <Windows.h>):
HMODULE lib { ::LoadLibraryA("User32.dll") };这个调用的结果是一个 库句柄(library handle);如果发生错误,它将是 NULL。在从库中加载函数之前,你需要先知道该函数的原型。MessageBoxA() 的原型如下:
int MessageBoxA(HWND, LPCSTR, LPCSTR, UINT);第一个参数是拥有该消息框的窗口(可以是 NULL),第二个参数是要显示的消息字符串,第三个参数是窗口标题,第四个参数则是消息框配置标志,比如显示哪些按钮、显示什么图标,等等。
现在,你可以定义一个名为 MessageBoxFunction 的类型别名,用来表示“指向具有该原型的函数”的指针:
using MessageBoxFunction = int(*)(HWND, LPCSTR, LPCSTR, UINT);成功加载库并定义好函数指针类型别名后,你就可以像下面这样取得库中该函数的指针:
MessageBoxFunction messageBox { (MessageBoxFunction)::GetProcAddress(lib, "MessageBoxA") };如果这一步失败,messageBox 将是 nullptr。如果成功,你就可以像下面这样调用这个已加载的函数。MB_OK 这个标志表示消息框里只显示一个 OK 按钮。
messageBox(NULL, "Hello World!", "ProC++", MB_OK);指向成员函数(和数据成员)的指针
Section titled “指向成员函数(和数据成员)的指针”上一节介绍了如何创建并使用指向普通函数的指针。你也知道,可以对普通变量使用指针。接下来考虑一下指向类数据成员和成员函数的指针。在 C++ 中,你完全可以取得类数据成员和成员函数的地址,从而得到指向它们的指针。不过,没有对象时,你无法访问非 static 数据成员,也无法调用非 static 成员函数。类数据成员和成员函数的核心意义,本来就是“它们依附于具体对象而存在”。因此,当你想通过指针调用成员函数,或访问数据成员时,必须在某个对象的语境下对这个指针进行解引用。下面是一个示例,它使用了第 1 章“C++ 与标准库速成”中介绍过的 Employee 类:
int (Employee::*functionPtr) () const { &Employee::getSalary };Employee employee { "John", "Doe" };println("{}", (employee.*functionPtr)());先别被这个语法吓到。第一行声明了一个名为 functionPtr 的变量,它的类型是“指向 Employee 类中某个非 static const 成员函数的指针”,该成员函数不接收参数并返回 int。同时,这一行还把它初始化为指向 Employee 类的 getSalary() 成员函数。这个语法和声明普通函数指针很像,只不过在 *functionPtr 前多了一个 Employee::。另外也要注意,这里 & 是必需的。
第三行通过 functionPtr 指针,在 employee 对象上调用了 getSalary() 成员函数。注意 employee.*functionPtr 周围的括号。之所以需要这些括号,是因为 operator() 的优先级高于 .*。
如果你手上拿的是对象指针,那么可以用 ->* 代替 .*,如下所示:
int (Employee::*functionPtr) () const { &Employee::getSalary };Employee johnD { "John", "Doe" };Employee* employee { &johnD };println("{}", (employee->*functionPtr)());借助类型别名,functionPtr 的定义还可以写得更易读:
using PtrToGet = int (Employee::*) () const;PtrToGet functionPtr { &Employee::getSalary };Employee employee { "John", "Doe" };println("{}", (employee.*functionPtr)());最后,借助 auto 还能再进一步简化:
auto functionPtr { &Employee::getSalary };Employee employee { "John", "Doe" };println("{}", (employee.*functionPtr)());在日常编程中,你并不会频繁用到“指向成员函数”或“指向数据成员”的指针。不过,有一点必须牢牢记住:没有对象,就无法解引用指向非 static 成员函数或数据成员的指针。你偶尔可能会想把“指向非 static 成员函数的指针”传给类似 qsort() 这种要求函数指针的函数,但那是行不通的。
你可以在类中重载函数调用运算符,这样该类的对象就可以像函数指针一样被使用。这类对象就叫做 函数对象(function object),简称 functor。与普通函数相比,函数对象的一个好处是:它可以在多次调用之间保留状态。
编写你的第一个函数对象
Section titled “编写你的第一个函数对象”正如第 15 章“重载 C++ 运算符”所解释的那样,想让某个类成为函数对象,你所需要做的只是为它提供函数调用运算符的重载。下面快速回顾一下:
class IsLargerThan{ public: explicit IsLargerThan(int value) : m_value { value } {} bool operator()(int value1, int value2) const { return value1 > m_value && value2 > m_value; } private: int m_value;};
int main(){ vector values1 { 2, 500, 6, 9, 10, 101, 1 }; vector values2 { 4, 4, 2, 9, 0, 300, 1 };
findMatches(values1, values2, IsLargerThan { 100 }, printMatch);}注意,IsLargerThan 类里重载的函数调用运算符被标记成了 const。在这个例子里这并非绝对必需,但正如下一章会解释的那样,对大多数标准库算法而言,谓词的函数调用运算符通常都必须是 const。
operator() 不需要访问函数对象里的任何非 static 数据成员和成员函数,那么它就可以被标记为 static。这样一来,编译器就可能对代码做出更好的优化。
标准库中的函数对象
Section titled “标准库中的函数对象”下一章会讨论许多标准库算法,例如 find_if()、accumulate() 等,它们都接收回调(比如函数指针和函数对象)作为参数,以便定制算法行为。C++ 在 <functional> 中提供了若干预定义的函数对象类,用于执行最常见的回调操作。本节对这些预定义函数对象做一个概览。
你的 <functional> 里也许还能看到 bind1st()、bind2nd()、mem_fun()、mem_fun_ref() 和 ptr_fun() 这类函数。它们从 C++17 起已被正式移除,因此本书不再展开讨论,你也应当避免使用它们。
算术函数对象
Section titled “算术函数对象”C++ 为五个二元算术运算符提供了函数对象类模板:plus、minus、multiplies、divides 和 modulus。此外还提供了一元的 negate。这些类都以操作数类型作为模板参数,本质上只是对实际运算符的一层包装。它们接收一个或两个该模板类型的参数,执行运算,并返回结果。下面是使用 plus 类模板的示例:
plus<int> myPlus;int res { myPlus(4, 5) };println("{}", res);这个例子当然有点“为了演示而演示”,因为如果只是做加法,直接使用 operator+ 就行,没必要专门用 plus 类模板。算术函数对象真正的价值在于:你可以把它们作为回调传给其他函数,而算术运算符本身无法被这样直接传递。例如,下面这段代码定义了一个带约束的 accumulateData() 函数模板,把 Operation 作为最后一个参数。geometricMean() 的实现,则把预定义的 multiplies 函数对象实例传给了 accumulateData():
template <input_iterator Iter, copy_constructible StartValue, invocable<const StartValue&, const StartValue&> Operation>auto accumulateData(Iter begin, Iter end, const StartValue& startValue, Operation op){ auto accumulated { startValue }; for (Iter iter { begin }; iter != end; ++iter) { accumulated = op(accumulated, *iter); } return accumulated;}
double geometricMean(span<const int> values){ auto mult { accumulateData(cbegin(values), cend(values), 1, multiplies<int>{}) }; return pow(mult, 1.0 / values.size()); // pow() 定义在 <cmath> 中}表达式 multiplies<int>{} 会创建一个新的 multiplies 函数对象实例,并把模板参数实例化为 int。
其他算术函数对象的行为与此类似。
这些算术函数对象本质上只是对算术运算符的包装。想把它们用于某种对象类型,你必须确保该类型实现了相应的运算,例如 operator* 或 operator+。
透明运算符函数对象
Section titled “透明运算符函数对象”C++ 支持 透明运算符函数对象(transparent operator functors),这让你可以省略模板类型实参。例如,你可以直接写 multiplies<>{}(也就是 multiplies<void>{} 的简写),而不必写成 multiplies<int>{}:
double geometricMeanTransparent(span<const int> values){ auto mult { accumulateData(cbegin(values), cend(values), 1, multiplies<>{}) }; return pow(mult, 1.0 / values.size());}这类透明运算符的一个重要特性是:它们是异构的(heterogeneous)。也就是说,它们不仅写法更简洁,还具有实打实的功能优势。举例来说,下面这段代码使用透明运算符函数对象 multiplies<>{},同时把 double 类型的 1.1 作为初始值,而 vector 里存的是整数。此时 accumulateData() 会以 double 计算结果,因此 result 将是 6.6。
vector<int> values { 1, 2, 3 };double result { accumulateData(cbegin(values), cend(values), 1.1, multiplies<>{}) };如果改用非透明运算符函数对象 multiplies<int>{},那么 accumulateData() 会按整数来计算结果,于是 result 将变成 6。编译这段代码时,编译器还会针对潜在的数据丢失给出警告。
vector<int> values { 1, 2, 3 };double result { accumulateData(cbegin(values), cend(values), 1.1, multiplies<int>{}) };最后,使用透明运算符而不是非透明版本,在性能上也可能更有优势;下一节会通过例子说明这一点。
比较函数对象
Section titled “比较函数对象”除了算术函数对象之外,所有标准比较操作也都提供了函数对象版本:equal_to、not_equal_to、less、greater、less_equal 和 greater_equal。你已经在第 18 章“标准库容器”里见过 less:它是 priority_queue、有序关联容器,以及 flat 关联容器适配器的默认比较器。现在,我们来看看如何改变这个比较标准。下面是一个使用默认比较器 std::less 的 priority_queue 示例:
priority_queue<int> myQueue;myQueue.push(3);myQueue.push(4);myQueue.push(2);myQueue.push(1);while (!myQueue.empty()) { print("{} ", myQueue.top()); myQueue.pop();}程序输出如下:
4 3 2 1正如你看到的那样,队列中的元素会按照 less 比较器所定义的规则,以降序被取出。你可以通过把比较器模板类型参数改成 greater,来改变这一行为。priority_queue 模板的定义如下:
template <typename T, typename Container = vector<T>, typename Compare = less<T>>;遗憾的是,Compare 类型参数排在最后,这意味着如果你要显式指定它,就必须连容器类型一起写出来。假如你想创建一个使用 greater、按升序排序元素的 priority_queue,那么前面示例中的定义就要改成下面这样:
priority_queue<int, vector<int>, greater<>> myQueue;此时输出变为:
1 2 3 4注意,myQueue 这里使用的是透明运算符 greater<>。事实上,对于所有接收比较器类型的标准库容器,都建议优先使用透明比较器。与非透明运算符相比,透明比较器往往能带来更好的性能。例如,如果一个 set<string> 使用的是非透明比较器(这也是默认情况),那么当你用字符串字面量查询某个 key 时,会产生一次不必要的拷贝,因为必须先从字符串字面量构造一个 string:
set<string> mySet;auto i1 { mySet.find("Key") }; // 构造 string,并分配内存!//auto i2 { mySet.find("Key"sv) }; // 编译错误!如果改用透明比较器,就可以避免这次拷贝。这就叫做 异构查找(heterogeneous lookups)。例如:
set<string, less<>> mySet;auto i1 { mySet.find("Key") }; // 不会构造 string,也不会分配内存。auto i2 { mySet.find("Key"sv) }; // 不会构造 string,也不会分配内存。类似地,C++23 还为 erase() 与 extract() 增加了 异构擦除与提取(heterogeneous erasure and extraction)的支持。
像 unordered_map、unordered_set 这样的无序关联容器,同样支持透明运算符。只不过在无序关联容器中使用它们,要比有序关联容器稍微麻烦一点。基本思路是:你需要实现一个自定义哈希函数对象,并在其中定义一个名为 is_transparent 的类型别名,令其等于 void:
class Hasher{ public: using is_transparent = void; size_t operator()(string_view sv) const { return hash<string_view>{}(sv); }};使用这个自定义哈希器时,还必须把透明的 equal_to<> 函数对象,指定为“键相等性比较”的模板类型参数。示例如下:
unordered_set<string, Hasher, equal_to<>> mySet;auto i1 { mySet.find("Key") }; // 不会构造 string,也不会分配内存。auto i2 { mySet.find("Key"sv) }; // 不会构造 string,也不会分配内存。逻辑函数对象
Section titled “逻辑函数对象”针对三个逻辑运算 operator!、&& 和 ||,C++ 提供了三个函数对象类:logical_not、logical_and 和 logical_or。这些逻辑操作只处理 true 与 false。与位级操作对应的函数对象,会在下一节介绍。
例如,可以用逻辑函数对象实现一个 allTrue() 函数,用来检查某个容器中的所有布尔标志是否都为 true:
bool allTrue(const vector<bool>& flags){ return accumulateData(begin(flags), end(flags), true, logical_and<>{});}类似地,logical_or 函数对象可以用来实现 anyTrue():只要容器中至少有一个布尔标志为 true,它就返回 true:
bool anyTrue(const vector<bool>& flags){ return accumulateData(begin(flags), end(flags), false, logical_or<>{});}位运算函数对象
Section titled “位运算函数对象”C++ 还提供 bit_and、bit_or、bit_xor 和 bit_not 这些函数对象,对应位运算 operator&、|、^ 和 ~。这些位运算函数对象可以与 transform() 算法(详见第 20 章)配合使用,对容器中的所有元素执行位运算。
适配函数对象
Section titled “适配函数对象”当你尝试使用标准库提供的基础函数对象时,常常会有一种“想把方钉硬塞进圆孔”的感觉。如果你想使用某个标准函数对象,但它的签名和你的实际需求不完全匹配,那么就可以尝试使用 适配函数对象(adapter function objects)来修正这种不匹配。它们可以适配函数对象、函数指针,基本上也就是适配任何可调用对象(callable)。适配器为 函数式组合(functional composition)提供了有限但实用的支持,也就是把多个函数组合起来,拼出你真正需要的行为。
绑定器(binder)可以把可调用对象的某些参数 绑定 到固定值上。第一个要介绍的绑定器是定义在 <functional> 中的 std::bind(),它允许你以非常灵活的方式绑定可调用对象的参数。你可以把参数绑定为固定值,甚至还可以把参数顺序重新排列。通过例子来看最清楚。假设你有一个接收两个参数的函数 func():
void func(int num, string_view str){ println("func({}, {})", num, str);}下面这段代码展示了如何用 bind() 把 func() 的第二个参数绑定到一个固定值 myString 上。绑定结果保存在 f1() 中。这里使用 auto,因为 bind() 的返回类型并没有被 C++ 标准规定下来,因此它是具体实现相关的。那些没有被绑定成固定值的参数,需要用 _1、_2、_3 等占位符表示,它们定义在 std::placeholders 命名空间中。在 f1() 的定义里,_1 指定了 f1() 的第一个参数,在调用 func() 时该落到哪个位置。最终效果是:f1() 只需要一个整数参数就可以被调用:
string myString { "abc" };auto f1 { bind(func, placeholders::_1, myString) };f1(16);输出如下:
func(16, abc)bind() 还可以用于调换参数顺序,下面的代码展示了这一点。这里的 _2 表示:f2() 的第二个参数,在调用 func() 时要放到前面去。换句话说,这个 f2() 绑定意味着:f2() 的第一个参数会变成 func() 的第二个参数,而 f2() 的第二个参数会变成 func() 的第一个参数:
auto f2 { bind(func, placeholders::_2, placeholders::_1) };f2("Test", 32);输出如下:
func(32, Test)正如第 18 章提到的,<functional> 还定义了 std::ref() 和 cref() 这两个辅助函数模板,它们分别用于绑定“非常量引用”和“常量引用”。例如,假设你有下面这个函数:
void increment(int& value) { ++value; }如果像下面这样调用它,那么 index 的值会变成 1:
int index { 0 };increment(index);但如果你改用 bind() 像下面这样调用,index 就不会递增,因为这里创建的是 index 的一个副本,而绑定到 increment() 第一个参数上的,其实是这份副本的引用:
auto incr { bind(increment, index) };incr();使用 std::ref() 传入真正的引用后,index 就能被正确递增:
auto incr { bind(increment, ref(index)) };incr();绑定参数时,还有一个小问题需要注意:它和重载函数组合在一起时会变得棘手。假设你有下面两个 overloaded() 函数:一个接收整数,一个接收浮点数:
void overloaded(int num) {}void overloaded(float f) {}如果你想把这组重载函数拿去做 bind(),那就必须显式指定到底要绑定哪一个重载。下面这种写法无法通过编译:
auto f3 { bind(overloaded, placeholders::_1) }; // 错误如果你要绑定的是那个接收浮点参数的重载,就需要写成下面这样:
auto f4 { bind((void(*)(float))overloaded, placeholders::_1) }; // OKbind() 的另一个示例,是把本章前面定义的 findMatches() 与某个类的成员函数配合使用,让成员函数充当 MatchHandler。例如,假设你有下面这个 Handler 类:
class Handler{ public: void handleMatch(size_t position, int value1, int value2) { println("在位置 {} 发现匹配 ({}, {})", position, value1, value2); }};那么,怎样把 handleMatch() 成员函数作为 findMatches() 的最后一个参数传进去呢?问题在于:成员函数必须始终在某个对象的语境中调用。从技术上说,类的每个成员函数都有一个隐式的第一个参数,它包含一个指向对象实例的指针,并在成员函数体内以 this 的名字访问。因此,这里会出现签名不匹配:我们的 MatchHandler 类型只接受三个参数——一个 size_t 和两个 int。解决办法,就是把这个隐式的第一个参数显式绑定好:
Handler handler;findMatches(values1, values2, intEqual, bind(&Handler::handleMatch, &handler, placeholders::_1, placeholders::_2, placeholders::_3));你也可以用 bind() 去绑定标准函数对象的参数。例如,可以把 greater_equal 的第二个参数绑定为固定值,让它始终拿输入值与 100 做比较:
auto greaterEqualTo100 { bind(greater_equal<>{}, placeholders::_1, 100) };标准库还提供了另外两个绑定器函数对象:std::bind_front() 和 bind_back()。后者是 C++23 新引入的。它们都会包装一个可调用对象 f。调用 bind_front() 生成的包装器时,f 的前 n 个参数会被绑定为给定值;而对 bind_back() 来说,被绑定的是最后 n 个参数。下面是两个例子:
auto f5 { bind_front(func, 42) };f5("Hello");
auto f6 { bind_back(func, "Hello") };f6(42);它会生成如下输出:
func(42, Hello)func(42, Hello)not_fn() 是一种 否定器(negator)。它和绑定器有些类似,但它的作用是对某个可调用对象的返回结果取反。例如,如果你想用 findMatches() 去找“不相等的值对”,就可以把 not_fn() 否定器应用到 intEqual() 的结果上:
findMatches(values1, values2, not_fn(intEqual), printMatch);not_fn() 这个函数对象会对其所包装的可调用对象的每一次调用结果取反。
调用成员函数
Section titled “调用成员函数”你可能会想把“指向类成员函数的指针”传给某个算法,作为回调。例如,假设你有下面这个算法:它会把容器中满足某个条件的 string 打印出来。模板类型参数 Matcher 使用 predicate<const string&> 做了约束,以确保用户提供的回调能够用一个 string 参数调用,并返回布尔值。
template <predicate<const string&> Matcher>void printMatchingStrings(const vector<string>& strings, Matcher matcher){ for (const auto& string : strings) { if (matcher(string)) { print("'{}' ", string); } }}你可以用这个算法来打印所有非空字符串,只要把 string 的 empty() 成员函数作为条件即可。然而,如果你只是简单地把 string::empty() 的指针直接作为第二个参数传给 printMatchingStrings(),算法并不知道它拿到的是“成员函数指针”,而不是普通函数指针或函数对象。调用成员函数指针的代码,与调用普通函数指针不同,因为前者必须在某个对象的语境下调用。
C++ 为此提供了一个转换函数 mem_fn()。在把成员函数指针传给算法之前,你可以先用 mem_fn() 包装它。下面的例子展示了这一点,并把它与 not_fn() 组合起来,对 mem_fn() 的结果取反。注意,这里成员函数指针必须写成 &string::empty;其中的 &string:: 不能省略。
vector<string> values { "Hello", "", "", "World", "!" };printMatchingStrings(values, not_fn(mem_fn(&string::empty)));输出如下:
'Hello' 'World' '!'not_fn(mem_fn()) 会生成一个函数对象,并作为 printMatchingStrings() 的回调。每次调用它时,它都会在参数对象上调用 empty() 成员函数,并把返回结果取反。
多态函数包装器
Section titled “多态函数包装器”C++ 标准库提供了 std::function 和 move_only_function。它们都属于 多态函数包装器(polymorphic function wrappers),也就是一种函数对象,能够包装任何可调用对象,例如函数、函数对象,或者本章后面将要讨论的 Lambda 表达式。
std::function
Section titled “std::function”std::function 函数对象定义在 <functional> 中。一个 std::function 实例既可以像函数指针那样被使用,也可以作为函数参数来实现回调;同时它还可以被存储、复制、移动,当然也可以执行。function 模板的模板参数写法,和大多数模板参数看起来有些不同。语法如下:
std::function<R(ArgTypes…)>其中,R 是返回类型,ArgTypes 则是一个以逗号分隔的参数类型列表。
下面这个例子演示了如何使用 std::function 来实现函数指针。它创建了一个函数指针 f1,使其指向函数 func()。定义好 f1 之后,就可以用它来调用 func():
void func(int num, string_view str) { println("func({}, {})", num, str); }
int main(){ function<void(int, string_view)> f1 { func }; f1(1, "test");}一个 function<R(ArgTypes…)> 可以存放“参数列表与 ArgTypes 完全匹配、返回类型正好是 R”的函数;它也可以存放其他函数,只要这些函数允许通过一组 ArgTypes 参数调用,并且其返回类型能转换为 R。例如,前面那个例子中的 func() 完全可以把第一个参数写成 const int&,而 main() 里什么都不用改:
void func(const int& num, string_view str) { println("func({}, {})", num, str); }借助类模板实参推导,你还可以把 f1 的创建进一步简化:
function f1 { func };当然,在前面的例子里,其实你也可以直接使用 auto,这样就不必显式写出 f1 的类型了。下面这种写法效果相同,而且更短;不过此时编译器推导出来的 f1 类型并不是 std::function,而是普通函数指针,也就是 void (*f1)(int, string_view):
auto f1 { func };由于 std::function 类型表现得就像函数指针一样,因此它们也可以传给那些接收回调的函数。本章前面最初版的 findMatches() 定义了两个函数指针类型别名。你完全可以把这两个类型别名改写成使用 std::function,而例子中的其余部分都不需要变:
// 一个类型别名:接收两个整数,// 如果两者匹配则返回 true,否则返回 false。using Matcher = function<bool(int, int)>;
// 一个类型别名:用于处理一次匹配。// 第一个参数是匹配位置,// 第二和第三个参数是匹配到的值。using MatchHandler = function<void(size_t, int, int)>;不过,正如本章前面提到的那样,实现 findMatches() 时,推荐的方式仍然是用函数模板,而不是函数指针或 std::function。
因此,看了这么多例子之后,你可能会觉得 std::function 好像也没那么有用;但事实上,当你需要把某个回调存成类的数据成员时,std::function 就会真正大放异彩。这正是本章习题中一个练习所要讨论的主题。
std::move_only_function
Section titled “ std::move_only_function”std::function 要求其内部存储的可调用对象必须是可复制的。为了解决这一限制,C++23 引入了“只能移动”的 std::move_only_function 包装器,它同样定义在 <functional> 中,可用于包装“只能移动、不能复制”的可调用对象。此外,move_only_function 还允许你显式把它的函数调用运算符标记为 const 和/或 noexcept。而 std::function 做不到这一点,因为它的函数调用运算符始终是 const。
下面这段代码演示了 move_only_function 的用法。假设 BigData 类内部保存着大量数据。函数对象 BigDataProcessor 负责处理一个 BigData 实例中的数据。为了避免复制,这个函数对象通过构造函数接收一个 unique_ptr<BigData>,并把它保存起来。这里为了演示,把函数调用运算符标记成了 const,并简单打印一些文字。main() 函数先创建一个指向 BigData 实例的 unique_ptr,再创建一个 const processor,最后调用其函数调用运算符。如果把这个例子中的 move_only_function 换成 function,就行不通了,因为 BigDataProcessor 是一个只能移动的类型。
class BigData {};
class BigDataProcessor{ public: explicit BigDataProcessor(unique_ptr<BigData> data) : m_data { move(data) } { } void operator()() const { println("正在处理 BigData 数据…"); } private: unique_ptr<BigData> m_data;};
int main(){ auto data { make_unique<BigData>() }; const move_only_function<void() const> processor { BigDataProcessor { move(data) } }; processor();}Lambda 表达式
Section titled “Lambda 表达式”为了一个本质上很简单的需求,却必须先写一个函数或函数对象类,给它取一个不会与其他名字冲突的名字,再去使用这个名字——这种做法实在有些笨重。此时,使用 Lambda 表达式 所代表的匿名(无名)函数,就方便得多了。Lambda 表达式允许你在原地写出匿名函数。它的语法更简洁,也能让代码更紧凑、更易读。Lambda 表达式特别适合定义那些“作为参数传给其他函数的小型回调”,这样你就不必另外在别处定义一个完整的函数对象类,再把回调逻辑写进其重载的函数调用运算符里。这样一来,逻辑会集中在一处,更容易理解,也更容易维护。Lambda 表达式既可以接收参数,也可以返回值、成为模板、访问外围作用域中的变量(按值或按引用),等等。它的灵活性很高。下面我们就一步一步搭建出 Lambda 表达式的语法。
先从一个最简单的 Lambda 表达式开始。下面这个例子定义了一个 Lambda 表达式,它只是向控制台写出一段字符串。Lambda 表达式以方括号 [] 开头,这一部分被称为 lambda introducer;后面跟着花括号 {},其中包含 Lambda 表达式的主体。这个 Lambda 表达式被赋值给使用 auto 推导类型的变量 basicLambda。第二行则通过普通函数调用语法来执行它。
auto basicLambda { []{ println("来自 Lambda 的问候"); } };basicLambda();输出如下:
来自 Lambda 的问候编译器会自动把任意一个 Lambda 表达式转换为一个函数对象,也叫做 lambda 闭包(lambda closure),并为它生成一个唯一的、由编译器决定的名字。对于前面的例子来说,这个 Lambda 表达式会被翻译成一个表现得大致如下的函数对象。注意,它的函数调用运算符是一个 const 成员函数,并且返回类型使用 auto,让编译器根据成员函数体自动推导返回类型。
class CompilerGeneratedName{ public: auto operator()() const { println("来自 Lambda 的问候"); }};这个由编译器生成的 Lambda 闭包类型名,可能会是像 __Lambda_17Za 这种很奇怪的名字。你既不需要知道它,也没办法可靠地写出它,好在你确实不需要知道。
Lambda 表达式可以接收参数。参数写在圆括号中,多个参数用逗号分隔,和普通函数完全一样。下面是一个带有单个参数 value 的示例:
auto parametersLambda { [](int value){ println("值是 {}", value); } };parametersLambda(42);如果 Lambda 表达式不接收参数,那么你既可以写一对空括号,也可以直接省略它们。
在编译器生成的函数对象中,这些参数会直接被翻译成函数调用运算符的参数:
class CompilerGeneratedName{ public: auto operator()(int value) const { println("值是 {}", value); }};Lambda 表达式也可以返回值。返回类型写在箭头之后,叫做 尾置返回类型(trailing return type)。下面这个例子定义了一个接收两个参数并返回它们之和的 Lambda 表达式:
auto sumLambda { [](int a, int b) -> int { return a + b; } };int sum { sumLambda(11, 22) };返回类型也可以省略,此时编译器会按照“函数返回类型推导”的同一套规则来推导 Lambda 表达式的返回类型(见第 1 章)。因此,前面的例子可以写成下面这样:
auto sumLambda { [](int a, int b){ return a + b; } };int sum { sumLambda(11, 22) };这个 Lambda 表达式对应的闭包,大致表现如下:
class CompilerGeneratedName{ public: auto operator()(int a, int b) const { return a + b; }};返回类型推导会去掉任何引用和 const 限定。例如,假设你有下面这个 Person 类:
class Person{ public: explicit Person(std::string name) : m_name { std::move(name) } { } const std::string& getName() const { return m_name; } private: std::string m_name;};在下面这段代码中,name1 的类型会被推导成 string;因此,即使 getName() 返回的是 const string&,这里依然会复制一份人的名字。关于 decltype(auto) 的讨论,请参见第 12 章。
Person p { "John Doe" };decltype(auto) name1 { [](const Person& person) { return person.getName(); }(p) };你可以把尾置返回类型和 decltype(auto) 结合起来,让推导结果与 getName() 的实际返回类型保持一致,也就是 const string&:
decltype(auto) name2 { [](const Person& person) -> decltype(auto) { return person.getName(); }(p) };到目前为止,本节中的 Lambda 表达式都属于 无状态(stateless)Lambda,因为它们没有从外围作用域捕获任何东西。Lambda 表达式也可以通过从外围作用域捕获变量,变成 有状态(stateful)的。例如,下面这个 Lambda 表达式捕获了变量 data,使它能够在主体中使用:
double data { 1.23 };auto capturingLambda { [data]{ println("Data = {}", data); } };这里的方括号部分就是 捕获块(capture block)。所谓 捕获 一个变量,就是让该变量在 Lambda 表达式主体内部变得可用。空捕获块 [] 表示不从外围作用域捕获任何变量。在前面的例子中,只写变量名 data,表示按值捕获它。
被捕获的变量会变成 Lambda 闭包的数据成员。按值捕获的变量会被复制到函数对象的数据成员中。这些数据成员的 const 性,与原始被捕获变量一致。前面 capturingLambda 的例子中,函数对象会得到一个名为 data 的、非 const 的数据成员,因为被捕获的变量 data 本身就是非 const。编译器生成的函数对象大致如下:
class CompilerGeneratedName{ public: CompilerGeneratedName(const double& d) : data { d } {} auto operator()() const { println("Data = {}", data); } private: double data;};在下面这个例子里,由于被捕获的变量是 const,所以函数对象中得到的也是一个 const 数据成员:
const double data { 1.23 };auto capturingLambda { [data]{ println("Data = {}", data); } };前面提到过,Lambda 闭包的函数调用运算符默认会被标记为 const。这意味着,即便你按值捕获的是一个非 const 变量,Lambda 表达式仍然无法修改这份拷贝。你可以通过把 Lambda 表达式声明为 mutable,把函数调用运算符改成非 const,如下所示:
double data { 1.23 };auto capturingLambda { [data] () mutable { data *= 2; println("Data = {}", data); } };在这个例子中,非 const 变量 data 被按值捕获,因此函数对象拥有一个非 const 数据成员,它是 data 的一份拷贝。由于使用了 mutable 关键字,函数调用运算符是非 const 的,所以 Lambda 表达式主体可以修改它所持有的那份 拷贝。
你也可以在变量名前面加上 &,表示按引用捕获。下面这个例子通过引用捕获变量 data,因此 Lambda 表达式会直接修改外围作用域中的 data:
double data { 1.23 };auto capturingLambda { [&data]{ data *= 2; } };对于这个 Lambda 表达式,编译器生成的函数对象中,会有一个名为 data 的“引用到 double”类型数据成员。当你按引用捕获变量时,必须确保在 Lambda 表达式实际执行时,这个引用依然有效。
从外围作用域捕获“所有变量”的方式有两种,称为 默认捕获(capture defaults):
[=]:默认按值捕获所有变量。[&]:默认按引用捕获所有变量。
你也可以通过 捕获列表(capture list)并结合可选的 默认捕获,来精确决定捕获哪些变量,以及如何捕获。带 & 前缀的变量按引用捕获;没有前缀的变量按值捕获。如果存在默认捕获,它必须是捕获列表中的第一个元素,并且只能是 & 或 =。下面是一些捕获块示例:
[&x]:只按引用捕获x,不捕获其他任何变量。[x]:只按值捕获x,不捕获其他任何变量。[=,&x,&y]:默认按值捕获,但变量x与y按引用捕获。[&,x]:默认按引用捕获,但变量x按值捕获。[&x,&x]:非法,因为标识符不能重复出现。
当 Lambda 表达式创建于某个对象的作用域内——例如类的成员函数内部——那么你还可以通过几种方式捕获 this:
[this]:捕获当前对象。在 Lambda 表达式主体中,你可以访问这个对象,甚至可以不显式写this->。不过你必须保证,被this指向的对象,直到 Lambda 最后一次执行时都仍然活着。[*this]:捕获当前对象的一份副本。这在原始对象可能在 Lambda 执行前就已经销毁的场景下很有用。[=,this]:默认按值捕获所有内容,并显式捕获this指针。在 C++20 之前,[=]会隐式捕获this。从 C++20 起,这种做法已被弃用;如果你需要this,就必须显式捕获它。
关于捕获块,还有几点需要说明:
- 如果已经指定了“按值(
=)”或“按引用(&)”的默认捕获,那么就不允许再额外用相同方式去捕获某个具体变量。例如,[=,x]与[&,&x]都是无效的。 - 对象的数据成员本身不能被直接捕获,除非使用本章后面会讨论的 Lambda 捕获表达式。
- 当捕获
this时——无论是复制this指针([this]),还是复制当前对象([*this])——Lambda 表达式都可以访问该对象的全部public、protected和private数据成员与成员函数。
虽然默认捕获只会捕获 Lambda 主体中真正使用到的变量,但仍然不推荐使用默认捕获。使用 = 默认捕获时,你可能会不小心引入昂贵的复制;使用 & 默认捕获时,你又可能意外修改外围作用域中的变量。我建议你始终显式写出要捕获哪些变量,以及捕获方式。
全局变量始终按引用捕获,即使你要求“按值捕获”也是如此!例如,即使你使用默认捕获,把“一切都按值捕获”,全局变量 global 仍然会以引用方式被捕获,因此在 Lambda 执行后,它的值会被改变。
Lambda 表达式的完整语法如下:
[capture_block] attributes1 (parameters) specifiers noexcept_specifier attributes2 -> return_type requires1 {body}或者:
[capture_block] <template_params> requires1 attributes1 (parameters) specifiers noexcept_specifier attributes2 -> return_type requires2 {body}除了捕获块与主体以外,其他部分全都是可选的:
- 捕获块: 也就是 lambda introducer,它决定如何捕获外围作用域中的变量,并让这些变量在 Lambda 主体中可用。
- 模板参数: 允许你编写带参数化能力的 Lambda 表达式,本章稍后会讨论。
- 参数: Lambda 表达式的参数列表。如果 Lambda 表达式不需要任何参数,你可以省略这一对括号,或者显式写出一对空括号
()。1 参数列表与普通函数的参数列表类似。 - 限定符: 可用的限定符包括:
mutable: 把 Lambda 闭包的函数调用运算符标记为可变;前面已经给出过示例。constexpr: 把 Lambda 闭包的函数调用运算符标记为constexpr,使其可在编译期求值。即使省略不写,只要满足constexpr函数的全部限制,它也会隐式成为constexpr。consteval: 把 Lambda 闭包的函数调用运算符标记为consteval,使其成为必须在编译期求值的立即函数;详见第 9 章“精通类与对象”。constexpr与consteval不能同时使用。static(C++23): 把 Lambda 闭包的函数调用运算符标记为static。这一点只能用于无状态 Lambda 表达式,也就是捕获块为空的 Lambda!加上这个限定符后,编译器往往能更好地优化生成代码,尤其是当这类无状态 Lambda 被存进std::function或move_only_function包装器时。
noexcept指定符: 为 Lambda 闭包的函数调用运算符指定noexcept子句,方式与普通函数类似。属性 1(C++23): 为 Lambda 闭包的函数调用运算符指定属性,例如 [[nodiscard]]。属性见第 1 章。- 属性 2: 为 Lambda 闭包本身指定属性。
- 返回类型: 返回值的类型。如果省略,编译器会按函数返回类型推导的规则进行推导;详见第 1 章。
- Requires 子句 1 和 2: 为 Lambda 闭包的函数调用运算符指定模板类型约束。第 12 章“使用模板编写泛型代码”解释了如何编写这类约束。
作为参数的 Lambda 表达式
Section titled “作为参数的 Lambda 表达式”把 Lambda 表达式作为函数参数传递,有两种常见方式。一种是把函数参数类型写成与 Lambda 签名匹配的 std::function;另一种则是使用模板类型参数。
例如,你就可以把 Lambda 表达式传给本章前面定义过的 findMatches():
vector values1 { 2, 5, 6, 9, 10, 1, 1 };vector values2 { 4, 4, 2, 9, 0, 3, 1 };findMatches(values1, values2, [](int value1, int value2) { return value1 == value2; }, printMatch);泛型 Lambda 表达式
Section titled “泛型 Lambda 表达式”对于 Lambda 表达式的参数,你可以像函数模板那样使用 auto 类型推导,而不是显式写出具体类型。要对某个参数启用自动类型推导,只需把类型写成 auto、auto& 或 auto*。它的类型推导规则与模板实参推导完全相同。
下面这个例子定义了一个名为 areEqual 的泛型 Lambda 表达式。这个 Lambda 表达式会作为回调,用在本章前面介绍过的 findMatches() 上:
// 定义一个用于判断值是否相等的泛型 Lambda 表达式。auto areEqual { [](const auto& value1, const auto& value2) { return value1 == value2; } };// 在调用 findMatches() 时使用这个泛型 Lambda 表达式。vector values1 { 2, 5, 6, 9, 10, 1, 1 };vector values2 { 4, 4, 2, 9, 0, 3, 1 };findMatches(values1, values2, areEqual, printMatch);编译器为这个泛型 Lambda 表达式生成的函数对象,大致表现如下:
class CompilerGeneratedName{ public: template <typename T1, typename T2> auto operator()(const T1& value1, const T2& value2) const { return value1 == value2; }};如果你把 findMatches() 修改为不只支持 int 的 span,而是还能支持其他类型,那么这个泛型 Lambda 表达式 areEqual 依然可以直接复用,无须做任何改动。
Lambda 捕获表达式
Section titled “Lambda 捕获表达式”Lambda 捕获表达式(lambda capture expressions)允许你用任意表达式去初始化捕获变量。它还可以用来在 Lambda 表达式中引入那些并非从外围作用域捕获而来的变量。例如,下面这段代码创建了一个 Lambda 表达式,其捕获块中包含两个变量:一个名为 myCapture,通过 Lambda 捕获表达式用字符串 "Pi: " 初始化;另一个名为 pi,它是从外围作用域按值捕获进来的。注意,像 myCapture 这种“通过捕获初始化器创建、且不是引用”的捕获变量,会以拷贝构造的方式初始化,因此其 const 限定会被去掉。
double pi { 3.1415 };auto myLambda { [myCapture = "Pi: ", pi]{ println("{}{}", myCapture, pi); } };Lambda 捕获变量可以用任意表达式初始化,因此当然也可以用 std::move()。这一点对那些“不能复制、只能移动”的对象非常重要,例如 unique_ptr。默认的按值捕获使用的是复制语义,因此你无法直接按值捕获一个 unique_ptr。借助 Lambda 捕获表达式,你就可以通过移动的方式来捕获它,如下所示:
auto myPtr { make_unique<double>(3.1415) };auto myLambda { [p = move(myPtr)]{ println("{}", *p); } };理论上,捕获变量和外围作用域中的名字可以重名,虽然不推荐这么做。前面的例子也可以写成下面这样:
auto myPtr { make_unique<double>(3.1415) };auto myLambda { [myPtr = move(myPtr)]{ println("{}", *myPtr); } };模板化 Lambda 表达式
Section titled “模板化 Lambda 表达式”模板化 Lambda 表达式(templated lambda expressions)让你更容易访问泛型 Lambda 参数的类型信息。例如,假设你有一个 Lambda 表达式,它要求传入的是 vector,但 vector 中的元素类型可以是任意的,因此它是一个使用 auto 参数的泛型 Lambda。现在,Lambda 主体中希望知道这个 vector 的元素类型是什么。如果没有模板化 Lambda,你可以用 decltype() 和 std::decay_t 类型萃取来做到这一点。类型萃取会在第 26 章“高级模板”中详细讨论,但此处并不需要深入这些细节。你只要知道:decay_t 会移除类型上的 const、引用等修饰即可。下面是这个泛型 Lambda:
auto lambda { [](const auto& values) { using V = decay_t<decltype(values)>; // vector 的真实类型。 using T = typename V::value_type; // vector 元素的类型。 T someValue { };} };你可以像下面这样调用它:
vector values { 1, 2, 100, 5, 6 };lambda(values);使用 decltype() 与 decay_t 会显得有些绕。模板化 Lambda 能把这件事做得更简单。下面这个 Lambda 表达式强制其参数必须是一个 vector,但同时仍然用模板类型参数来表示 vector 的元素类型:
auto lambda { [] <typename T> (const vector<T>& values) { T someValue { };} };模板化 Lambda 的另一个用途,是给泛型 Lambda 加上额外限制。例如,假设你有下面这个泛型 Lambda:
[](const auto& value1, const auto& value2) { /* … */ }这个 Lambda 接收两个参数,编译器会分别为它们自动推导类型。由于两个参数的类型是分开推导的,因此 value1 与 value2 的类型可能不同。如果你想限制它们必须是同一种类型,就可以把它改写成模板化 Lambda:
[] <typename T> (const T& value1, const T& value2) { /* … */ }你还可以通过添加 requires 子句,给这些模板类型加上约束;这在第 12 章中已经讨论过。示例如下:
[] <typename T> (const T& value1, const T& value2) requires integral<T> { /* … */ }把 Lambda 表达式作为返回值
Section titled “把 Lambda 表达式作为返回值”借助本章前面介绍过的 std::function,函数也可以返回 Lambda 表达式。先看下面这个定义:
function<int(void)> multiplyBy2Lambda(int x){ return [x]{ return 2 * x; };}这个函数体中创建了一个 Lambda 表达式,它按值捕获外围作用域中的变量 x,并返回传给 multiplyBy2Lambda() 的那个值的两倍。multiplyBy2Lambda() 的返回类型是 function<int(void)>,也就是“一个不接收任何参数并返回整数的函数”。函数体中定义的 Lambda 表达式与这一原型完全匹配。变量 x 通过按值捕获进入 Lambda,因此在 Lambda 被返回之前,x 的值就已经复制到了 Lambda 表达式内部。这个函数可以像下面这样调用:
function<int(void)> fn { multiplyBy2Lambda(5) };println("{}", fn());你也可以用 auto 让写法更轻松:
auto fn { multiplyBy2Lambda(5) };println("{}", fn());输出是 10。
借助函数返回类型推导(见第 1 章),multiplyBy2Lambda() 还可以写得更优雅:
auto multiplyBy2Lambda(int x){ return [x]{ return 2 * x; };}上面这个 multiplyBy2Lambda() 按值捕获变量 x,也就是 [x]。假设你把它改写成按引用捕获 [&x],如下所示。这种写法是错误的,因为 Lambda 表达式会在程序稍后的时刻才被执行,而那时它已经不再处于 multiplyBy2Lambda() 的作用域之内,引用到的 x 早就失效了。
auto multiplyBy2Lambda(int x){ return [&x]{ return 2 * x; }; // BUG!}不求值语境中的 Lambda 表达式
Section titled “不求值语境中的 Lambda 表达式”Lambda 表达式可以用于 不求值语境(unevaluated contexts)。例如,传给 decltype() 的参数只在编译期使用,不会在运行时真正求值。下面就是一个在 decltype() 中使用 Lambda 表达式的例子:
using LambdaType = decltype([](int a, int b) { return a + b; });默认构造、复制与赋值
Section titled “默认构造、复制与赋值”无状态 Lambda 表达式可以被默认构造、复制和赋值。下面是一个简单示例:
auto lambda { [](int a, int b) { return a + b; } }; // 一个无状态 Lambda。decltype(lambda) lambda2; // 默认构造。auto copy { lambda }; // 拷贝构造。copy = lambda2; // 拷贝赋值。把“不求值语境中的 Lambda”与这些特性结合起来,就会得到下面这种完全合法的代码:
using LambdaType = decltype([](int a, int b) { return a + b; }); // 不求值语境。LambdaType getLambda() { return LambdaType{}; /* 默认构造。 */ }你可以像下面这样测试这个函数:
println("{}", getLambda()(1, 2)); 递归 Lambda 表达式
Section titled “ 递归 Lambda 表达式”对于普通 Lambda 表达式来说,即使你把它存进了一个有名字的变量里,想让它在主体内部调用自己,仍然并不直观。例如,下面这个名为 fibonacci 的 Lambda,在第二个 return 语句里尝试两次调用自己;但它实际上无法通过编译。
auto fibonacci = [](int n) { if (n < 2) { return n; } return fibonacci(n - 1) + fibonacci(n - 2); // 错误:无法编译!};借助 C++23 的显式对象参数特性(详见第 8 章“精通类与对象”),这件事终于可以直接做到了,也因此可以写出 递归 Lambda 表达式(recursive lambda expressions)。下面这段代码就演示了这样一个递归 Lambda。它使用了一个名为 self 的显式对象参数,并在第二个 return 语句中递归调用自己两次。
auto fibonacci = [](this auto& self, int n) { if (n < 2) { return n; } return self(n - 1) + self(n - 2);};这个递归 Lambda 表达式可以像下面这样测试:
println("前 20 个 Fibonacci 数:");for (int i { 0 }; i < 20; ++i) { print("{} ", fibonacci(i)); }输出正如预期:
前 20 个 Fibonacci 数:0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181定义在 <functional> 中的 std::invoke(),可以用一组参数调用任意可调用对象。下面这个例子一共使用了三次 invoke():一次调用普通函数,一次调用 Lambda 表达式,一次在 string 实例上调用成员函数:
void printMessage(string_view message) { println("{}", message); }
int main(){ invoke(printMessage, "Hello invoke."); invoke([](const auto& msg) { println("{}", msg); }, "Hello invoke."); string msg { "Hello invoke." }; println("{}", invoke(&string::size, msg));}这段代码的输出如下:
Hello invoke.Hello invoke.13<functional> 中的 std::invoke_r(),它允许你显式指定返回类型。示例如下:
int sum(int a, int b) { return a + b; }
int main(){ auto res1 { invoke(sum, 11, 22) }; // res1 的类型是 int。 auto res2 { invoke_r<double>(sum, 11, 22) }; // res2 的类型是 double。}本章介绍了回调这一概念:回调就是被传给其他函数、用于定制其行为的函数。你已经看到,回调既可以是函数指针,也可以是函数对象,或者 Lambda 表达式。你还学到,相比把各种函数对象和适配函数对象层层组合起来,Lambda 表达式往往能写出更易读的代码。请记住:写出易读的代码,与写出能工作的代码同样重要,甚至往往更重要。因此,即使某个 Lambda 表达式比“适配过的函数对象”稍微长一点,它通常也更易读,因此也更容易维护。
既然你现在已经能够熟练使用回调,那么就该进入标准库真正强大的领域——泛型算法了。
通过完成下面这些练习,你可以巩固本章讨论过的内容。所有练习的解答,都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某个练习上卡住了,建议先回过头重新阅读本章相关部分,尽量自己找到答案,再去看网站上的解答。
- 练习 19-1: 把本章中的
IsLargerThan函数对象示例改写成 Lambda 表达式。代码可在可下载源码包中的Ch19\03_FunctionObjects\01_IsLargerThan.cpp找到。 - 练习 19-2: 把本章给出的
bind()示例改写成使用 Lambda 表达式。代码可在可下载源码包中的Ch19\03_FunctionObjects\07_bind.cpp找到。 - 练习 19-3: 把“绑定类成员函数
Handler::handleMatch()”的示例改写成使用 Lambda 表达式。代码可在可下载源码包中的Ch19\03_FunctionObjects\10_FindMatchesWithMemberFunctionPointer.cpp找到。 - 练习 19-4: 第 18 章介绍了
std::erase_if(),它可以根据某个谓词返回true的条件,从容器中移除元素。现在你已经掌握了回调相关的一切知识,请编写一个小程序,创建一个整数vector,然后使用erase_if()删除其中所有奇数值。你传给erase_if()的谓词应当接收一个值,并返回布尔值。 - 练习 19-5: 实现一个名为
Processor的类。构造函数应接收一个“接收单个整数并返回整数”的回调,并把这个回调存进类的数据成员中。然后,为该类添加一个函数调用运算符重载:它接收一个整数并返回一个整数,实现仅仅是把工作转发给已保存的回调。最后,用不同回调来测试你的类。 - 练习 19-6: 编写一个递归 Lambda 表达式,用来计算某个数的幂。例如,4 的 3 次方,写作 4^3,等于 4×4×4。请确保它也能处理负指数;举例来说,4^-3 等于 1/(4^3)。任何数的 0 次方都等于 1。请通过生成 -10 到 10 之间所有指数对应的 2 的幂,来测试你的 Lambda 表达式。
Footnotes
Section titled “Footnotes”-
在 C++23 之前,只有在“不需要任何参数,并且没有指定
mutable、constexpr、consteval、noexcept指定符、属性、返回类型或requires子句”的情况下,才可以省略那对空括号。 ↩