现代C++:函数对象和lambda:进入函数式编程
引用本讲我们将介绍函数对象尤其是匿名函数对象——lambda 表达式。今天的内容说难不难但可能跟你的日常思维方式有较大的区别建议你一定要试验一下文中的代码使用 xeus-cling 的同学要注意xeus-cling 似乎不太喜欢有 lambda 的代码遇到有问题时还是只能回到普通的编译执行方式了。C98 的函数对象函数对象function object[1] 自 C98 开始就已经被标准化了。从概念上来说函数对象是一个可以被当作函数来用的对象。它有时也会被叫做 functor但这个术语在范畴论里有着完全不同的含义还是不用为妙——否则玩函数式编程的人可能会朝着你大皱眉头的。下面的代码定义了一个简单的加 n 的函数对象类根据一般的惯例我们使用了 struct 关键字而不是 class 关键字struct adder { adder(int n) : n_(n) {} int operator()(int x) const { return x n_; } private: int n_; };它看起来相当普通唯一有点特别的地方就是定义了一个 operator()这个运算符允许我们像调用函数一样使用小括号的语法。随后我们可以定义一个实际的函数对象如 C11 形式的auto add_2 adder(2);或 C98 形式的adder add_2(2);得到的结果 add_2 就可以当作一个函数来用了。你如果写下 add_2(5) 的话就会得到结果 7。C98 里也定义了少数高阶函数你可以传递一个函数对象过去结果得到一个新的函数对象。最典型的也许是目前已经从 C17 标准里移除的 bind1st 和 bind2nd 了在 头文件中提供auto add_2 bind2nd(plusint(), 2);这样产生的 add_2 功能和前面相同是把参数 2 当作第二个参数绑定到函数对象 plus它的 operator() 需要两个参数上的结果。当然auto 在 C98 里是没有的结果要赋给一个变量就有点别扭了得写成binder2ndplusint add_2( plusint(), 2);因此在 C98 里我们通常会直接使用绑定的结果#include algorithm #include functional #include vector using namespace std; vector v{1, 2, 3, 4, 5}; transform(v.begin(), v.end(), v.begin(), bind2nd(plusint(), 2));上面的代码会将容器里的每一项数值都加上 2transform 函数模板在 头文件中提供。可以验证结果v{ 3, 4, 5, 6, 7 }函数的指针和引用除非你用一个引用模板参数来捕捉函数类型传递给一个函数的函数实参会退化成为一个函数指针。不管是函数指针还是函数引用你也都可以当成函数对象来用。假设我们有下面的函数定义int add_2(int x) { return x 2; };如果我们有下面的模板声明template typename T auto test1(T fn) { return fn(2); } template typename T auto test2(T fn) { return fn(2); } template typename T auto test3(T* fn) { return (*fn)(2); }当我们拿 add_2 去调用这三个函数模板时fn 的类型将分别被推导为 int (*)(int)、int ()(int) 和 int (*)(int)。不管我们得到的是指针还是引用我们都可以直接拿它当普通的函数用。当然在函数指针的情况下我们直接写 *value 也可以。因而上面三个函数拿 add_2 作为实参调用的结果都是 4。很多接收函数对象的地方也可以接收函数的指针或引用。但在个别情况下需要通过函数对象的类型来区分函数对象的时候就不能使用函数指针或引用了——原型相同的函数它们的类型也是相同的。Lambda 表达式Lambda 表达式 [2] 是一个源自阿隆佐·邱奇Alonzo Church——艾伦·图灵Alan Turing的老师——的术语。邱奇创立了 λ 演算 [3]后来被证明和图灵机是等价的。我们先不看数学上的 λ 表达式看一下上一节给出的代码在使用 lambda 表达式时可以如何简化。auto add_2 [](int x) { return x 2; };显然定义 add_2 不再需要定义一个额外的类型了我们可以直接写出它的定义。理解它只需要注意下面几点Lambda 表达式以一对中括号开始中括号中是可以有内容的稍后我们再说跟函数定义一样我们有参数列表跟正常的函数定义一样我们会有一个函数体里面会有 return 语句Lambda 表达式一般不需要说明返回值相当于 auto有特殊情况需要说明时则应使用箭头语法的方式参见[第 8 讲][](int x) - int { … }每个 lambda 表达式都有一个全局唯一的类型要精确捕捉 lambda 表达式到一个变量中只能通过 auto 声明的方式当然我们想要定义一个通用的 adder 也不难auto adder [](int n) { return [n](int x) { return x n; }; };这次我们直接返回了一个 lambda 表达式并且中括号中写了 n 来捕获变量 n 的数值。这个函数的实际效果和前面的 adder 函数对象完全一致。也就是说捕获 n 的效果相当于在一个函数对象中用成员变量存储其数值。纯粹为了满足你可能有的好奇心上面的 adder 相当于这样一个 λ 表达式如果你去学 Lisp 或 Scheme 的话你就会发现这些语言和 λ 表达式几乎是一一映射了。在 C 里表达虽然稍微啰嗦一点但也比较接近了。用我上面的 adder 就可以得到类似于函数式编程语言里的 currying [4] 的效果——把一个操作此处是加法分成几步来完成。没见过函数式编程的可能对下面的表达式感到奇怪吧auto seven adder(2)(5);不过最常见的情况是写匿名函数就是希望不需要起名字。以前面的把所有容器元素值加 2 的操作为例使用匿名函数可以得到更简洁可读的代码transform(v.begin(), v.end(), v.begin(), [](int x) { return x 2; });到了可以使用 ranges已在 C20 标准化的时候代码可以更短、更灵活。这个我们就留到后面再说了。一个 lambda 表达式除了没有名字之外还有一个特点是你可以立即进行求值。这就使得我们可以把一段独立的代码封装起来达到更干净、表意的效果。先看一个简单的例子[](int x) { return x * x; }(3)这个表达式的结果是 3 的平方 9。即使这个看似无聊的例子都是有意义的因为它免去了我们定义一个 constexpr 函数的必要。只要能满足 constexpr 函数的条件一个 lambda 表达式默认就是 constexpr 函数。另外一种用途是解决多重初始化路径的问题。假设你有这样的代码Obj obj; switch (init_mode) { case init_mode1: obj Obj(…); break; case init_mode2; obj Obj(…); break; … }这样的代码实际上是调用了默认构造函数、带参数的构造函数和移动赋值函数既可能有性能损失也对 Obj 提出了有默认构造函数的额外要求。对于这样的代码有一种重构意见是把这样的代码分离成独立的函数。不过有时候更直截了当的做法是用一个 lambda 表达式来进行改造既可以提升性能不需要默认函数或拷贝 / 移动又让初始化部分显得更清晰auto obj [init_mode]() { switch (init_mode) { case init_mode1: return Obj(…); break; case init_mode2: return Obj(…); break; … } }();变量捕获现在我们来细看一下 lambda 表达式中变量捕获的细节。变量捕获的开头是可选的默认捕获符 或 表示会自动按值或按引用捕获用到的本地变量然后后面可以跟逗号分隔从工程的角度大部分情况不推荐使用默认捕获符。更一般化的一条工程原则是显式的代码比隐式的代码更容易维护。当然在这条原则上走多远是需要权衡的你也不愿意写出非常啰嗦的代码吧否则的话大家就全部去写 C 了。一般而言按值捕获是比较安全的做法。按引用捕获时则需要更小心些必须能够确保被捕获的变量和 lambda 表达式的生命期至少一样长并在有下面需求之一时才使用需要在 lambda 表达式中修改这个变量并让外部观察到需要看到这个变量在外部被修改的结果这个变量的复制代价比较高如果希望以移动的方式来捕获某个变量的话则应考虑 变量名 表达式 的形式。表达式可以返回一个 prvalue 或 xvalue比如可以是 std::move(需移动捕获的变量)。上一节我们已经见过简单的按值捕获。下面是一些更多的演示变量捕获的例子。按引用捕获vectorint v1; vectorint v2; … auto push_data [](int n) { // 或使用 [v1, v2] 捕捉 v1.push_back(n); v2.push_back(n) }; push_data(2); push_data(3);这个例子很简单。我们按引用捕获 v1 和 v2因为我们需要修改它们的内容。按值捕获外围对象#include chrono #include iostream #include sstream #include string #include thread using namespace std; int get_count() { static int count 0; return count; } class task { public: task(int data) : data_(data) {} auto lazy_launch() { return [*this, count get_count()]() mutable { ostringstream oss; oss Done work data_ (No. count ) in thread this_thread::get_id() \n; msg_ oss.str(); calculate(); }; } void calculate() { this_thread::sleep_for(100ms); cout msg_; } private: int data_; string msg_; }; int main() { auto t task{37}; thread t1{t.lazy_launch()}; thread t2{t.lazy_launch()}; t1.join(); t2.join(); }这个例子稍复杂演示了好几个 lambda 表达式的特性mutable 标记使捕获的内容可更改缺省不可更改捕获的值相当于定义了 operator()(…) const[*this] 按值捕获外围对象task[count get_count()] 捕获表达式可以在生成 lambda 表达式时计算并存储等号后表达式的结果。这样多个线程复制了任务对象可以独立地进行计算。请自行运行一下代码并把 *this 改成 this看看输出会有什么不同。泛型 lambda 表达式函数的返回值可以 auto但参数还是要一一声明的。在 lambda 表达式里则更进一步在参数声明时就可以使用 auto包括 auto 等形式。不过它的功能也不那么神秘就是给你自动声明了模板而已。毕竟在 lambda 表达式的定义过程中是没法写 template 关键字的。还是拿例子说话template typename T1, typename T2 auto sum(T1 x, T2 y) { return x y; }跟上面的函数等价的 lambda 表达式是auto sum [](auto x, auto y) { return x y; }是不是反而更简单了你可能要问这么写有什么用呢问得好。简单来说答案是可组合性。上面这个 sum就跟标准库里的 plus 模板一样是可以传递给其他接受函数对象的函数的而 本身则不行。下面的例子虽然略有点无聊也可以演示一下#include array // std::array #include iostream // std::cout/endl #include numeric // std::accumulate using namespace std; int main() { array a{1, 2, 3, 4, 5}; auto s accumulate( a.begin(), a.end(), 0, [](auto x, auto y) { return x y; }); cout s endl; }虽然函数名字叫 accumulate——累加——但它的行为是通过第四个参数可修改的。我们把上面的加号 改成星号 *把初值 0 改成 1上面的计算就从从 1 加到 5 变成了算 5 的阶乘了。bind 模板我们上面提到了 bind1st 和 bind2nd 目前已经从 C 标准里移除。原因实际上有两个它的功能可以被 lambda 表达式替代有了一个更强大的 bind 模板拿我们之前给出的例子transform(v.begin(), v.end(), v.begin(), bind2nd(plusint(), 2));现在我们可以写成using namespace std:: placeholders; // for _1, _2... transform(v.begin(), v.end(), v.begin(), bind(plus(), _1, 2));原先我们只能把一个给定的参数绑定到第一个参数或第二个参数上现在则可以非常自由地适配各种更复杂的情况当然bind 的参数数量必须是第一个参数函数对象所需的参数数量加一。而 bind 的结果的参数数量则没有限制——你可以无聊地写出 bind(plus(), _1, _3)(1, 2, 3)而结果是 4完全忽略第二个参数。你可能会问它的功能是不是可以被 lambda 表达式替代呢。回答是“是”。对 bind 只需要稍微了解一下就好——在 C14 之后的年代里已经没有什么地方必须要使用 bind 了。function 模板每一个 lambda 表达式都是一个单独的类型所以只能使用 auto 或模板参数来接收结果。在很多情况下我们需要使用一个更方便的通用类型来接收这时我们就可以使用 function 模板。function 模板的参数就是函数的类型一个函数对象放到 function 里之后外界可以观察到的就只剩下它的参数、返回值类型和执行效果了。注意 function 对象的创建还是比较耗资源的所以请你只在用 auto 等方法解决不了问题的时候使用这个模板。下面是个简单的例子。mapstring, functionint(int, int) op_dict{ {, [](int x, int y) { return x y; }}, {-, [](int x, int y) { return x - y; }}, {*, [](int x, int y) { return x * y; }}, {/, [](int x, int y) { return x / y; }}, };这儿由于要把函数对象存到一个 map 里我们必须使用 function 模板。随后我们就可以用类似于 op_dict.at()(1, 6) 这样的方式来使用 function 对象。这种方式对表达式的解析处理可能会比较有用。内容小结在这一讲中我们了解了函数对象和 lambda 表达式的基本概念并简单介绍了 bind 模板和 function 模板。它们在泛型编程和函数式编程中都是重要的基础组成部分你应该熟练掌握。