《Effective Modern C++》学习记录(四):右值与移动语义
1 左值 lvalue 与右值 rvalue
1.1 区分
右值对应着从函数返回的临时对象,左值对应着可以被引用reference的(通过名字、指针pointer或左值引用)。
能取到地址的,是左值;不能取到地址的,是右值。
拷贝右值的构造通常是移动构造,拷贝左值的构造通常是拷贝构造。
1.2 函数传参时
一旦参数进入函数内部,它们就获得了具体的名称和持久的存储位置,因此所有的参数都是左值。
即使传的参数是右值引用类型rvalue reference types,它本身在函数中也是一个左值。
1.3 实参arguments和形参parameters
1 | int sum(int a, int b) |
其中,a和b是形参,x、y、10、20是实参。形参是左值,而实参可能是左值,也可能是右值。
2 通用引用和右值引用
2.1 概念
右值引用:主要存在原因是为了识别可移动操作的对象。
通用引用:保留绑定对象的左值/右值和const性质。
因为通用引用是引用,所以它们必须被初始化。一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。对那些是函数形参的通用引用来说,初始值在调用函数的时候被提供:
1 | template<typename T> |
2.2 区别
对于通用引用,类型推导是必要的。
2.2.1 两种情况会出现通用引用
1 | //函数模板形参 |
2.2.2 标准的右值引用
1 | void f(Widget&& param); //没有类型推导, |
2.2.3 易被理解成通用引用的右值引用
虽然类型T会被推导,但param的类型声明不是
T&&,而是std::vector<T>&&,因此param是一个右值引用。1
2
3
4
5
6template <typename T>
void f(std::vector<T>&& param); //param是一个右值引用
//此时如果传递左值实参会报错
std::vector<int> v;
f(v);push_back函数在有一个特定的vector实例之前不可能存在,而实例化vector时,类型已经确定了,进而也决定了push_back的声明。1
2
3
4
5
6
7template<class T, class Allocator = allocator<T>>
class vector
{
public:
void push_back(T&& x);
…
}
例如:实例化以下对象时,
1 | std::vector<Widget> v; |
std::vector模板会被实例化为以下代码,因此push_back这里的形参总会是右值引用而不是通用引用。
1 | class vector<Widget, allocator<Widget>> { |
const T&&和const auto&&都是右值引用,因为通用引用必须能同时绑定左值和右值,而const破坏了这一特性。
3 std::move
3.1 作用
仅仅是执行转换(cast)的函数模板,无条件的将它的实参转换为右值。
3.2 内部实现示例
并不满足标准细则,但接近
- C++11:
1 | //在std命名空间 |
解析:
1、返回类型是typename remove_reference<T>::type&&,代表一个去除引用修饰(因为引用折叠,所以要去除引用修饰),得到原始类型,再添加&&,使其成为右值引用的类型。
2、参数T&& param是通用引用,可以接受任何类型的参数(左值或右值)。
3、用using将typename remove_reference<T>::type&&声明为ReturnType
4、最后用static_cast将参数强制转换为右值引用。
- C++14:
1 | //在std命名空间 |
3.3 使用示例
用std::move实现以下移动构造函数
1 | class Widget { |
3.4 注意
const右值作为构造函数的实参,会调用拷贝构造函数而不是移动构造函数。因为不允许const对象被传递给可以修改他们的函数(例如移动构造函数)
- 示例:
1 | class Annotation { |
上面的std::string value构造时调用的是下面的拷贝构造函数而不是移动构造函数。
1 | class string { //std::string事实上是 |
4 std::forward
4.1 作用
只对绑定了右值的引用进行右值转换。
4.2 使用
假设函数模板logAndProcess用来将通用引用形参传递给函数process处理。这里重载了两个process函数版本分别处理左值和右值。
1 | void process(const Widget& lvalArg); //处理左值 |
传入左值w作为实参时,形参param会被类型推导为Widget&(左值引用),std::forward也会返回左值引用,所以会调用process(const Widget& lvalArg);。
传入std::move(w)作为实参时,传入的实参类型是Widget&&,T会被推导为Widget,param会被推导为Widget&&类型。此时,param本身是左值,但它的类型是Widget&&(右值引用)类型。std::forward也会返回右值引用类型,所以会调用void process(Widget&& rvalArg);。
1 | Widget w; |
5 std::move和std::forward的使用场景
5.1 右值引用-std::move,通用引用-std::forward
- 当把右值引用转发给其他函数时,右值引用应该无条件转换为右值(通过
std::move)
1 | class Widget { |
- 当转发通用引用时,通用应用应该有条件的转换为右值(通过
std::forward)
1 | class Widget { |
当转发右值引用时使用
std::forward可能会导致的问题:由于需要传递一个模板实参,就会有更多的犯错的可能,如果模板实参传递
std::string&会导致rhs.s转发为左值,进而导致成员变量std::string s被复制而不是被移动构造。
1 | class Widget{ |
当转发通用引用时使用
std::move可能会导致的问题——意外改变左值:一个局部变量左值
n作为参数传入setName后,被转发为一个右值,移动给了成员变量name,此时n变成了未定义的值。
1 | class Widget { |
5.2 在最后一次时使用
函数中多次使用绑定到右值引用或通用引用的对象,要确保在完成其他操作前,这个对象不会被移动。于是,在最后一次使用时使用std::move和std::forward。
1 | template<typename T> |
5.3 按值返回右值引用或通用引用形参时
这里返回一个右值引用的形参,优先通过std::move将其转换为右值。
1 | Matrix operator+(Matrix&& lhs, const Matrix& rhs) //按值返回 |
如果直接返回左值,会强制编译器拷贝它到返回值的内存空间。而将其转换为右值,如果Matrix支持移动操作,就会提高代码效率;如果不支持,右值也可以被Matrix的拷贝构造函数拷贝,不会出现问题。
1 | Matrix operator+(Matrix&& lhs, const Matrix& rhs) |
按值返回通用引用形参时,也是同样优先使用
std::forwad返回。
5.4 避免使用的情况
以下可能会抑制编译器优化,导致性能下降
1 | Widget makeWidget() //makeWidget的移动版本 |
5.4.1 RVO
返回值优化(return value optimization,RVO)
作用:C++标准中已经实现了,只要满足RVO的条件,针对以下例子,编译器会避免拷贝局部变量w,而直接在分配给函数返回值的内存中构造w来实现。
RVO满足条件:返回一个局部对象,局部对象与函数返回值的类型相同
1 | Widget makeWidget() |
5.4.2 隐式移动
对于有些很难让编译器实现RVO的情况,比如控制路径返回不同局部变量时:
1 | Widget makeWidget(bool flag) { |
或不满足RVO条件,按值返回函数形参:
1 | Widget makeWidget(Widget w) //传值形参,与函数返回的类型相同 |
C++标准规定,如果一个函数返回一个局部变量或传值形参,若未RVO优化,则隐式将返回对象当做右值处理,也就是以上两段代码会被编译器看作:
1 | Widget makeWidget(bool flag) { |
1 | Widget makeWidget(Widget w) |
6 通用引用与重载
6.1 避免在通用引用上重载
原因:通用引用几乎可以精准匹配任何类型的实参。
例1:(这里重载是为了传字符串走完美转发重载版本,传整型走int重载版本)传入short,通用引用推导的类型是short,因此匹配优先级高于int,所以调用通用引用重载版本,进而出错。
1 | //通用引用版本 |
例2:(这里第二个构造函数希望去调用拷贝构造函数)手动添加构造函数重载或编译器生成也会出现一样问题,这里传p不会调用拷贝构造,而会调用完美转发的构造函数,而完美转发构造函数内部需要的类型是string而不是Person,编译出错。
1 | class Person { |
原因:Person p匹配实例化后的完美转发构造函数更精准,所以会调用完美转发构造函数。如果是const Person p("Nancy"),则两者一样会优先调用拷贝构造函数。
1 | class Person { |
例3:甚至基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用基类的完美转发构造函数而不是基类的拷贝或者移动构造。因为实际派生类传递给基类构造函数的参数类型是SpecialPerson。
1 | class SpecialPerson: public Person { |
6.2 同时使用通用引用和重载的办法
6.2.1 标签分派tag dispatch
继续以前面的例子为例:封装一个函数logAndAddImpl实现具体功能以及重载,如果std::is_integral<typename std::remove_reference<T>::type>()接收到的参数是int,则会调用下面第二个形参为std::true_type的重载版本;反之则调用第二个形参为std::false_type的重载版本。
1 | template<typename T> |
6.2.2 std::enable_if
版本1:应对构造函数使用通用引用的问题
typename std::decay<T>::type表示移除引用和const或volatile标识符的修饰。std::is_same用来比较两种类型,此时配合std::decay即Person、Person&、Person&&、const Person、volatile Person、const volatile Person等都和Person一样。std::is_same<T1, T2>::value获取bool值作为条件。typename = typename std::enabe_if<conditon>::type指condition为true时才会启用这个模板。
1 | class Person { |
版本2:用std::is_base_of代替std::is_same可以额外应对派生类拷贝和移动构造函数错误调用基类完美转发构造函数的问题。
1 | class Person { |
版本3:除了区分拷贝和移动构造函数,还可以额外区分整型有参构造函数和完美转发构造函数
1 | //C++14风格 |
7 可能失败的完美转发
完美转发标准模板:
1 | template<typename... Ts> |
完美转发失败原因:编译器不能推导出fwd的一个或多个形参类型;编译器推导“错”了fwd的一个或者多个形参类型(将推导出的类型传入f和直接将原实参传入f表现出不一致的行为)。
7.1 花括号初始化
设定f声明为:
1 | void f(const std::vector<int>& v) ; |
直接用花括号初始化调用f会将std::initializer_list与f形参关联,然后隐式转换为std::vector<int>,因此可以。
用花括号初始化调用fwd,则不会形参关联,而是首先会执行类型推导。这时因为函数模板无法将花括号初始化推导为std::initializer_list,因此错误。
1 | f({1, 2, 3}); //可以 |
解决办法:先使用auto推导类型(auto可以推导出花括号初始化的类型为std::initializer_list),声明一个局部变量,再将局部变量传进转发函数。
1 | auto il = {1, 2, 3}; |
7.2 0或者NULL作为空指针
如果f声明的形参是一个指针,这时候fwd传入0或NULL则会推导为整型,与f声明的形参类型不同,因此错误。
解决办法:传入nullptr而不是0或NULL。
7.3 没在类外定义的static const整型成员变量
7.3.1 常量传播
只有整型static const成员变量可以不在类外定义,只需在类内声明,此时编译器不会为其留存空间,直接将声明的值放入需要这个变量的地方。
1 | class Widget { |
此时如果有地方有使用Widget::MinVals的地址,就会出现链接错误。在类外提供它的定义则可解决这个问题。
7.3.2 失败原因
尽管代码中没有使用MinVals的地址,但fwd的形参是通用引用就会被视为有取地址的操作,引发链接错误。同样可以在类外提供MinVals的定义解决。
1 | f(Widget::MinVals); //可以,视为“f(28)” |
7.4 将重载的函数作为参数传入转发函数中
设f被声明为:
1 | void f(int pf(int)); |
设有重载函数,processVal
1 | int processVal(int value); |
一般情况下将重载函数作为参数传入函数时,可以根据f声明的形参去匹配重载函数,但fwd没有声明具体的形参类型,所以会失败
1 | f(processVal); //可以 |
解决办法:定义一个显示函数指针指向重载函数,将此显示函数指针传入fwd
1 | int (*processValPtr)(int) = processVal; |
将函数模板作为参数传入转发函数中也有同样问题,解决办法同样是显示指定传入的函数模板的指针类型。
1 | template<typename T> |



