第 3 章 语言运行期的强化

3.1 Lambda 表达式

Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性, 而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多, 所以匿名函数几乎是现代编程语言的标配。

基础

Lambda 表达式的基本语法如下:

  1. [捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
  2. // 函数体
  3. }

上面的语法规则除了 [捕获列表] 内的东西外,其他部分都很好理解,只是一般函数的函数名被略去, 返回值使用了一个 -> 的形式进行(我们在上一节前面的尾返回类型已经提到过这种写法了)。

所谓捕获列表,其实可以理解为参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的, 这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:

1. 值捕获

与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 lambda 表达式被创建时拷贝, 而非调用时才拷贝:

  1. void lambda_value_capture() {
  2. int value = 1;
  3. auto copy_value = [value] {
  4. return value;
  5. };
  6. value = 100;
  7. auto stored_value = copy_value();
  8. std::cout << "stored_value = " << stored_value << std::endl;
  9. // 这时, stored_value == 1, 而 value == 100.
  10. // 因为 copy_value 在创建时就保存了一份 value 的拷贝
  11. }

2. 引用捕获

与引用传参类似,引用捕获保存的是引用,值会发生变化。

  1. void lambda_reference_capture() {
  2. int value = 1;
  3. auto copy_value = [&value] {
  4. return value;
  5. };
  6. value = 100;
  7. auto stored_value = copy_value();
  8. std::cout << "stored_value = " << stored_value << std::endl;
  9. // 这时, stored_value == 100, value == 100.
  10. // 因为 copy_value 保存的是引用
  11. }

3. 隐式捕获

手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 &= 向编译器声明采用引用捕获或者值捕获.

总结一下,捕获提供了lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:

  • [] 空捕获列表
  • [name1, name2, …] 捕获一系列变量
  • [&] 引用捕获, 让编译器自行推导捕获列表
  • [=] 值捕获, 让编译器执行推导引用列表

4. 表达式捕获

这部分内容需要了解后面马上要提到的右值引用以及智能指针

上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。

C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获, 被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的:

  1. #include <iostream>
  2. #include <utility>
  3. int main() {
  4. auto important = std::make_unique<int>(1);
  5. auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
  6. return x+y+v1+(*v2);
  7. };
  8. std::cout << add(3,4) << std::endl;
  9. return 0;
  10. }

在上面的代码中,important 是一个独占指针,是不能够被捕获到的,这时候我们需要将其转移为右值, 在表达式中初始化。

泛型 Lambda

上一节中我们提到了 auto 关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。 但是 Lambda 表达式并不是普通函数,所以 Lambda 表达式并不能够模板化。 这就为我们造成了一定程度上的麻烦:参数表不能够泛化,必须明确参数表类型。

幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始, Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型:

  1. auto add = [](auto x, auto y) {
  2. return x+y;
  3. };
  4. add(1, 2);
  5. add(1.1, 2.2);

3.2 函数对象包装器

这部分内容虽然属于标准库的一部分,但是从本质上来看,它却增强了 C++ 语言运行时的能力, 这部分内容也相当重要,所以放到这里来进行介绍。

std::function

Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象), 当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:

  1. #include <iostream>
  2. using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
  3. void functional(foo f) { // 定义在参数列表中的函数类型 foo 被视为退化后的函数指针类型 foo*
  4. f(1); // 通过函数指针调用函数
  5. }
  6. int main() {
  7. auto f = [](int value) {
  8. std::cout << value << std::endl;
  9. };
  10. functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
  11. f(1); // lambda 表达式调用
  12. return 0;
  13. }

上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用, 而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型, 统一称之为可调用类型。而这种类型,便是通过 std::function 引入的。

C++11 std::function 是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如:

  1. #include <functional>
  2. #include <iostream>
  3. int foo(int para) {
  4. return para;
  5. }
  6. int main() {
  7. // std::function 包装了一个返回值为 int, 参数为 int 的函数
  8. std::function<int(int)> func = foo;
  9. int important = 10;
  10. std::function<int(int)> func2 = [&](int value) -> int {
  11. return 1+value+important;
  12. };
  13. std::cout << func(10) << std::endl;
  14. std::cout << func2(10) << std::endl;
  15. }

std::bindstd::placeholder

std::bind 则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。 例如:

  1. int foo(int a, int b, int c) {
  2. ;
  3. }
  4. int main() {
  5. // 将参数1,2绑定到函数 foo 上,但是使用 std::placeholders::_1 来对第一个参数进行占位
  6. auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
  7. // 这时调用 bindFoo 时,只需要提供第一个参数即可
  8. bindFoo(1);
  9. }

提示:注意 auto 关键字的妙用。有时候我们可能不太熟悉一个函数的返回值类型, 但是我们却可以通过 auto 的使用来规避这一问题的出现。

3.3 右值引用

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vectorstd::string 之类的额外开销, 也才使得函数对象容器 std::function 成为了可能。

左值、右值的纯右值、将亡值、右值

要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。

左值(lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值(rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值(prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、 原始字面量、Lambda 表达式都属于纯右值。

将亡值(xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++中, 纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。

将亡值可能稍有些难以理解,我们来看这样的代码:

  1. std::vector<int> foo() {
  2. std::vector<int> temp = {1, 2, 3, 4};
  3. return temp;
  4. }
  5. std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v, 然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到, 而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

右值引用和左值引用

需要拿到一个将亡值,就需要用到右值引用的申明:T &&,其中 T 是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:

  1. #include <iostream>
  2. #include <string>
  3. void reference(std::string& str) {
  4. std::cout << "左值" << std::endl;
  5. }
  6. void reference(std::string&& str) {
  7. std::cout << "右值" << std::endl;
  8. }
  9. int main()
  10. {
  11. std::string lv1 = "string,"; // lv1 是一个左值
  12. // std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
  13. std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
  14. std::cout << rv1 << std::endl; // string,
  15. const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
  16. // lv2 += "Test"; // 非法, 常量引用无法被修改
  17. std::cout << lv2 << std::endl; // string,string
  18. std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
  19. rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
  20. std::cout << rv2 << std::endl; // string,string,string,Test
  21. reference(rv2); // 输出左值
  22. return 0;
  23. }

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:

  1. #include <iostream>
  2. int main() {
  3. // int &a = std::move(1); // 不合法,非常量左引用无法引用右值
  4. const int &b = std::move(1); // 合法, 常量左引用允许引用右值
  5. std::cout << a << b << std::endl;
  6. }

第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:

  1. void increase(int & v) {
  2. v++;
  3. }
  4. void foo() {
  5. double s = 1;
  6. increase(s);
  7. }

由于 int& 不能引用 double 类型的参数,因此必须产生一个临时值来保存 s 的值, 从而当 increase() 修改这个临时值时,从而调用完成后 s 本身并没有被修改。

第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要。

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,例如:

  1. #include <iostream>
  2. class A {
  3. public:
  4. int *pointer;
  5. A():pointer(new int(1)) {
  6. std::cout << "构造" << pointer << std::endl;
  7. }
  8. A(A& a):pointer(new int(*a.pointer)) {
  9. std::cout << "拷贝" << pointer << std::endl;
  10. } // 无意义的对象拷贝
  11. A(A&& a):pointer(a.pointer) {
  12. a.pointer = nullptr;
  13. std::cout << "移动" << pointer << std::endl;
  14. }
  15. ~A(){
  16. std::cout << "析构" << pointer << std::endl;
  17. delete pointer;
  18. }
  19. };
  20. // 防止编译器优化
  21. A return_rvalue(bool test) {
  22. A a,b;
  23. if(test) return a; // 等价于 static_cast<A&&>(a);
  24. else return b; // 等价于 static_cast<A&&>(b);
  25. }
  26. int main() {
  27. A obj = return_rvalue(false);
  28. std::cout << "obj:" << std::endl;
  29. std::cout << obj.pointer << std::endl;
  30. std::cout << *obj.pointer << std::endl;
  31. return 0;
  32. }

在上面的代码中:

  1. 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
  2. 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:

  1. #include <iostream> // std::cout
  2. #include <utility> // std::move
  3. #include <vector> // std::vector
  4. #include <string> // std::string
  5. int main() {
  6. std::string str = "Hello world.";
  7. std::vector<std::string> v;
  8. // 将使用 push_back(const T&), 即产生拷贝行为
  9. v.push_back(str);
  10. // 将输出 "str: Hello world."
  11. std::cout << "str: " << str << std::endl;
  12. // 将使用 push_back(const T&&), 不会出现拷贝行为
  13. // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
  14. // 这步操作后, str 中的值会变为空
  15. v.push_back(std::move(str));
  16. // 将输出 "str: "
  17. std::cout << "str: " << str << std::endl;
  18. return 0;
  19. }

完美转发

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

  1. void reference(int& v) {
  2. std::cout << "左值" << std::endl;
  3. }
  4. void reference(int&& v) {
  5. std::cout << "右值" << std::endl;
  6. }
  7. template <typename T>
  8. void pass(T&& v) {
  9. std::cout << "普通传参:";
  10. reference(v); // 始终调用 reference(int&)
  11. }
  12. int main() {
  13. std::cout << "传递右值:" << std::endl;
  14. pass(1); // 1是右值, 但输出是左值
  15. std::cout << "传递左值:" << std::endl;
  16. int l = 1;
  17. pass(l); // l 是左值, 输出左值
  18. return 0;
  19. }

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。 而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

  1. #include <iostream>
  2. #include <utility>
  3. void reference(int& v) {
  4. std::cout << "左值引用" << std::endl;
  5. }
  6. void reference(int&& v) {
  7. std::cout << "右值引用" << std::endl;
  8. }
  9. template <typename T>
  10. void pass(T&& v) {
  11. std::cout << " 普通传参: ";
  12. reference(v);
  13. std::cout << " std::move 传参: ";
  14. reference(std::move(v));
  15. std::cout << " std::forward 传参: ";
  16. reference(std::forward<T>(v));
  17. std::cout << "static_cast<T&&> 传参: ";
  18. reference(std::forward<T>(v));
  19. }
  20. int main() {
  21. std::cout << "传递右值:" << std::endl;
  22. pass(1);
  23. std::cout << "传递左值:" << std::endl;
  24. int v = 1;
  25. pass(v);
  26. return 0;
  27. }

输出结果为:

  1. 传递右值:
  2. 普通传参: 左值引用
  3. std::move 传参: 右值引用
  4. std::forward 传参: 右值引用
  5. static_cast<T&&> 传参: 右值引用
  6. 传递左值:
  7. 普通传参: 左值引用
  8. std::move 传参: 右值引用
  9. std::forward 传参: 左值引用
  10. static_cast<T&&> 传参: 左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发, 所以 std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forwardstd::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值, std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward<T>(v)static_cast<T&&>(v) 是完全一样的。

读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值, 我们再简单看一看 std::forward 的具体实现机制,std::forward 包含两个重载:

  1. template<typename _Tp>
  2. constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
  3. { return static_cast<_Tp&&>(__t); }
  4. template<typename _Tp>
  5. constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
  6. {
  7. static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
  8. " substituting _Tp is an lvalue reference type");
  9. return static_cast<_Tp&&>(__t);
  10. }

在这份实现中,std::remove_reference 的功能是消除类型中的引用, 而 std::is_lvalue_reference 用于检查类型推导是否正确,在 std::forward 的第二个实现中 检查了接收到的值确实是一个左值,进而体现了坍缩规则。

std::forward 接受左值时,_Tp 被推导为左值,而所以返回值为左值;而当其接受右值时, _Tp 被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + && 的右值。 可见 std::forward 的原理在于巧妙的利用了模板类型推导中产生的差异。

这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&& 是最安全的方式? 因为当 auto 被推到为不同的左右引用时,与 && 的坍缩组合是完美转发。

总结

本章介绍了现代 C++ 中最为重要的几个语言运行时的增强,其中笔者认为本节中提到的所有特性都是值得掌握的:

  1. Lambda 表达式
  2. 函数对象容器 std::function
  3. 右值引用

返回目录 | 上一章 | 下一章 容器

进一步阅读的参考文献

许可

知识共享许可协议

本教程由欧长坤撰写,采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议许可。项目中代码使用 MIT 协议开源,参见许可