线上阅读链接:https://changkun.de/modern-cpp/zh-cn/00-preface/
最近正在阅读链接上这本《现代C++教程》,发帖作为学习笔记,如有侵权,告知即删。
第3章 语言运行期的强化
这一章介绍了Lambda表达式、函数对象包装器和右值引用三个内容,其中Lambda表达式、右值引用都是是C++11引入的重要特性。
Lambda表达式,适用于“需要一个函数,但是又不想费力命名一个函数”的情况,① 可以近似理解为在函数内定义的类函数,包括捕获列表、参数列表、返回类型定义以及内部函数体等等部分,在调用时直接传入需要的参数,捕获列表允许值传递、引用传递和通过表达式实现的右值传递等多种方式;而 ② 泛型 Lambda 则是可以进一步省略对参数列表参数类型、返回类型的定义,让编译器自主实现类型推导。
函数对象包装器,① 引入std::function
统一了空捕获列表的lambda表达式、函数类型等能够被调用的类型,为这些函数、函数指针的存储、复制和调用提供了类型安全的容器;② 使用std::bind
和std::placeholder
处理“不能够一次性获取某个函数全部参数”的情况,实现部分参数的绑定和占位,在参数齐全后完成调用。
右值引用,① 使用T&&
声明延长临时变量的声明周期,提供std::move
方法将左值无条件转换为右值,用右值转换和移动操作替代了传统C++中的创建、拷贝、销毁原对象的操作,大大减小额外开销;② 针对引入右值引用后的引用坍缩规则,使用std::forward
方法保证在传递函数时保持原来的参数类型,与static_cast<T&&>
等效。
3.1 Lambda表达式
基础Lambda
Lambda函数实际上就是提供一种类似于匿名函数的特性,用于“需要一个函数,但又不想费力命名一个函数的情况”,其基本语法是如下
[捕获列表](参数列表) mutable(可选/直接省略) 异常属性 -> 返回类型 {
// 函数体
}
其中的“捕获”改变较难理解,Lambda表达式内部函数体默认不能使用函数体外部的变量,因此捕获列表起到传递外部参数的作用,但是在后续调用这一表达式时只需传入参数,而无需传入捕获内容。
(1)值捕获,与参数传值类似,前提是该变量可以拷贝,被捕获的变量在Lambda表达式创建时即拷贝,而非调用时
int value = 1;
// 定义lambda表达式(值捕获)
auto copy_value = [value] {
return value;
};
value = 100;
// 使用lambda表达式
auto stored_value = copy_value();
(2)引用捕获,与引用传参类似,保存引用,值会发生变化
int value = 1;
// 定义lambda表达式(引用捕获)
auto copy_value = [&value] {
return value;
};
value = 100;
// 使用lambda表达式
auto stored_value = copy_value();
(3)隐式捕获,相对于前两种捕获方式,lambda表达式也可以不在[]
中写出变量名称,而是在**[]中说明捕获类型**、在函数体内直接使用对应变量名,让编译器自行推导捕获列表,具体的:[&]
为引用捕获,[=]
为值捕获。
(4)【表达式捕获】,然而,上面这些捕获方式捕获的都是左值,而不能捕获右值。C++14开始,允许捕获的成员用任意的表达式进行初始化,如下面的代码中important
是一个独占指针,不能被“=”值捕获到,此时将其转移为右值,在表达式中初始化。
#include <iostream>
#include <memory> // std::make_unique
#include <utility> // std::move
void lambda_expression_capture() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
// lambda表达式的使用,这里的3,4对应了x,y
std::cout << add(3,4) << std::endl;
}
应用:使用lambda表达式进行排序操作,为一些复杂功能创造可能
std::vector<int> v = {3, 1, 5, 4, 2};
sort(v.begin(), v.end(), [](int a, int b){
return b < a;
});
泛型Lambda
从C++14开始,Lambda函数的形参可以使用auto
关键字产生泛型:
auto add = [](auto x, auto y) {
return x+y;
};
add(1, 2);
add(1.1, 2.2);
3.2 函数对象包装器
std::function 可调用类型
Lambda表达式的本质是一个与函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象),当lambda表达式的捕获列表为空时,还能够传唤为函数指针进行传递,如下:
#include <iostream>
using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
f(1); // 通过函数指针调用函数
}
int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
f(1); // lambda 表达式调用
return 0;
}
可以看到,有两种不同的调用方式:① 作为函数类型传递进行调用;② 直接调用lambda表达式。C++引入了std::function
类型统一了这些能够被调用的对象的类型,统一称之为可调用类型。
std::function
的实例可以对任何可以调用的目标实体进行存储、复制和调用,相对于函数指针的调用来说,是一种类型安全的包裹。实际上就是为函数提供了容器,使得将函数、函数指针作为对象进行处理更加方便。
#include <functional>
#include <iostream>
int foo(int para) {
return para;
}
int main() {
// std::function 包装了一个返回值为 int, 参数为 int 的函数
std::function<int(int)> func = foo;
int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}
std::bind和std::placeholder
std::bind
用来绑定函数调用的参数,适用于我们“不能够一次性获得调用某个函数的全部参数”的情况,通过该函数,将部分参数提前绑定到函数身上,而使用std::placeholder
对未获得的参数进行占位,待参数齐全后完成调用。如:
#include <functional>
int foo(int a, int b, int c) {
;
}
int main() {
// 将参数1,2绑定到函数 foo 上,
// 但使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1);
}
3.3 右值引用
右值引用的引入解决了大量历史遗留问题,消除了诸如std::vector
、std::string
等类的额外开销,才使函数对象类std::function
成为了可能。
C++11引入的右值引用用&&
表示,并且可以进行修改。
int && a = 10;
a = 100;
概念理解:左值、右值、右值的纯右值、将亡值
- 左值:
lvalue
,left value
,赋值符号左边的值,更准确来说是在表达式之后可以持久存在的对象。 - 右值:
rvalue
,right value
,右边的值,表达式结束后就不再存在的临时对象。(传统C++中与纯右值是同一概念) - 纯右值:
prvalue
,pure rvalue
,纯粹的字面值(如10
,true
)或求值结果相当于字面量/匿名临时对象(如1+2
),非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、lambda表达式都属于纯右值。(注意,字符串字面量是一个坐值,类型为const char
数组) - 将亡值:
xvalue
,expiring value
,C++11为引入右值引用而提出的概念,也就是即将被销毁、却能够被移动的值。它定义了这样一种行为:临时的值能够被识别、同时又能够被移动。可以看下面一段代码的分析:
std::vector<int> foo() {
std::vector<int> temp = {1, 2, 3, 4};
return temp;
}
std::vector<int> v = foo();
在上面的代码中
- 传统C++中,函数
foo
的返回值temp
在内部创建之后赋值给v
,v
获得这个对象后将整个temp
拷贝一份,然后将temp
销毁,这个过程中经历了分别进行了一次创建和拷贝,如果temp
非常大,就会造成大量的额外开销。 - C++11之后,编译器做了一些工作,将对此处的左值
temp
进行隐式右值转换,进而v
会将foo
局部返回的值进行移动,也即后面将提到的移动语义。
这里还要区分常量和非常量的概念,常量定义时用 const
修饰,由此形成了(非)常量左/右值四种类型,它们的可以引用情况和使用场景如下:
值类型→ 引用类型↓ | 非常量左值 | 常量左值 | 非常量右值 | 常量右值 | 使用场景 |
---|---|---|---|---|---|
非常量左值引用 | Y | N | N | N | 无 |
常量左值引用 | Y | Y | Y | Y | 常用于类中构造拷贝构造函数 |
非常量右值引用 | N | N | Y | N | 移动语义、完美转发 |
常量右值引用 | N | N | Y | Y | 无实际用途 |
参考:http://c.biancheng.net/view/7829.html
【右值引用和左值引用】
右值引用 T &&
的声明可以让临时值的生命周期得以延长,C++11提供了 std::move
这个方法将左值参数无条件转换为右值。
不允许将非常量绑定到非左值上;常量允许绑定到到非左值上。
…
移动语义
传统C++中如果要实现对资源的移动操作,需要调用者先复制、再析构,或者自己实现移动对象的接口,这类似于搬家的时候,先把所有东西复制一份放入新家,然后把旧家所有东西全部扔掉或销毁,是不合理的操作。这里传统C++的问题在于没有区分移动和拷贝的概念,造成了大量不必要的数据拷贝,浪费了时间和空间,右值引用的出现恰好解决了这个问题,通过① 显式地使用 std::move
实现(需要声明 #include <utility>
头文件)或者② 函数返回值实现。
std::move
所做的工作实际就是将左值转换为右值。
看下面两段代码是使用移动语义的案例(分别通过 ① ② 方式):
#include <iostream> // std::cout
#include <utility> // std::move
#include <vector> // std::vector
#include <string> // std::string
int main() {
std::string str = "Hello world.";
std::vector<std::string> v;
// 将使用 push_back(const T&), 即产生拷贝行为
v.push_back(str);
// 将输出 "str: Hello world."
std::cout << "str: " << str << std::endl;
// 将使用 push_back(const T&&), 不会出现拷贝行为
// 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
// 这步操作后, str 中的值会变为空
v.push_back(std::move(str));
// 将输出 "str: "
std::cout << "str: " << str << std::endl;
return 0;
}
#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) {
std::cout << "构造" << pointer << std::endl;
}
A(A& a):pointer(new int(*a.pointer)) {
std::cout << "拷贝" << pointer << std::endl;
} // 无意义的对象拷贝
A(A&& a):pointer(a.pointer) { // 如果没有这一函数,下面的obj构造将自动调用上面的“拷贝”方式的构造函数
a.pointer = nullptr;
std::cout << "移动" << pointer << std::endl;
}
~A(){
std::cout << "析构" << pointer << std::endl;
delete pointer;
}
};
// 防止编译器优化
A return_rvalue(bool test) {
A a,b;
if(test) return a; // 等价于 static_cast<A&&>(a);
else return b; // 等价于 static_cast<A&&>(b);
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}
运行结果及解释
构造0x2425c20 // a被创建出来
构造0x2425c40 // b被创建出来
移动0x2425c40 // return b时,b作为右值引用传递到obj的构造函数中,指针被移动给obj
析构0 // return_rvalue函数运行结束,对涉及的临时变量进行析构,移动完成后b本身变为了nullptr
析构0x2425c20 // a的指针地址
obj:
0x2425c40
1
析构0x2425c40 // 程序结束,析构obj
完美转发
在传统C++中,我们不能对一个引用类型继续进行引用,但是C++由于右值引用的出现放宽了这一要求,产生了所谓“引用坍缩规则”,允许对引用(左/右)进行引用,遵循如下规则:
函数形参类型(参数定义类型) | 实参参数类型(调用传入类型) | 推导后形参类型(函数内使用类型) |
---|---|---|
T& | 左引用 | T& |
T& | 右引用 | T& |
T&& | 左引用 | T& |
T&& | 右引用 | T&& |
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) { // 函数形参类型
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&) // 推导后形参类型
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出是左值 // 实参参数类型是右引用,但是进入pass后实际调用了 void reference(int& v)
std::cout << "传递左值:" << std::endl;
int l = 1;
pass(l); // l 是左值, 输出左值 // 实参参数类型
return 0;
}
因为有了这一引用坍缩规则的存在,可能造成右引用在传递过程中变成了左引用,因此就需要有某种规则,帮助我们在传递参数时保持原来的参数类型(左引用保持左引用,右引用保持右引用),这就有了 std::forward
,与static_cast<T&&>
等效。使用方式如下,如此一来,不造成任何多余的拷贝,同时也实现了函数实参的完美转发。
void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}
reference(std::forward<T>(v)); // 保持v原本的参数类型(左/右引用)
// 等效于
reference(static_cast<T&&>(v));
【循环语句中 auto&&
的完美转发】