本文通过一些简单的代码范例描述C++11引入的右值引用、转移语义、完美转发等新特征所解决的问题和初阶的用法。
左值与右值
在等号左边的值就称为左值,在等号右边的称为右值;
左值一般是可寻址的变量,右值一般是不可寻址的字面常量或者是在表达式求值过程中创建的可寻址的无名临时对象;
左值具有持久性,右值具有短暂性。
例如,语句a = b + c,其中,a在等号左边,有名,可寻址,且生命周期持久,称为左值;b+c在等号右边,无名,且在该语句结束后即被销毁从而结束生命周期,被称为右值。
右值引用和转移语义
#include <iostream>
#include <vector>
class Test
{
public:
Test(int size) : sz(size)
{
std::cout << "new" << std::endl;
pIntData = new int[sz];
pIntData[0] = 10;
}
Test(const Test & h) : sz(h.sz) // 拷贝构造函数
{
std::cout << "copy" << std::endl;
pIntData = new int[sz];
for (int i = 0; i < sz; i++)
pIntData[i] = h.pIntData[i];
}
~Test()
{
std::cout << "delete" << std::endl;
delete [] pIntData;
}
int *pIntData;
int sz;
};
Test GetTemp()
{
std::cout << "test" << std::endl;
return Test(1024);
}
int main()
{
std::cout << "start" << std::endl;
std::vector<Test> v;
v.push_back(GetTemp());
std::cout << v[0].pIntData[0] << std::endl;
std::cout << "end" << std::endl;
return 0;
}
如上代码的输出结果如下。GetTemp()函数生成一个临时对象,该对象可寻址但匿名,而且在对应语句结束后也就随着结束生命周期,是一个右值。vector变量的push_back()函数触发了这个临时对象的拷贝构造函数。
start
test
new
copy
delete
10
end
delete
但是以上代码有一个效率问题:Test类的成员变量pIntData所指向的数据首先从临时对象创建时产生,接着在临时对象拷贝时拷贝一个副本到目标对象的内存空间,然后在临时对象销毁时随着销毁,最后在代码执行结束时,所拷贝的副本也随着vector变量销毁。
如果把临时对象的这个数据直接由vector变量管理,就没有副本的拷贝和销毁这两个步骤了,效率将会大大提升,基于此,C++11引入了2个新特征:右值引用和转移语义。
Test(Test && h) : sz(h.sz), pIntData(h.pIntData) // 转移构造函数
{
std::cout << "move" << std::endl;
h.pIntData = nullptr;
}
为Test类增加如上成员函数后,再次编译(编译时使用了-std=c++11)并执行,则输出结果如下。
start
test
new
move
delete
10
end
delete
满足C++11规范的编译器在发现GetTemp()函数返回后生成的临时对象是右值时,则调用如上的转移构造函数(而不再是拷贝构造函数),该函数将源对象的成员变量pIntData赋值给目标对象,也就是将pIntData所指向的数据的“管理权”转移给目标对象(而不再是重新拷贝一个副本),这就是转移语义。
拷贝构造函数与转移构造函数的区别在于形参类型,前者是左值引用Test&,后者是C++11引入的右值引用Test&&,编译器根据实参的左右值属性调用对应的函数。
拷贝构造函数与转移构造函数还有另外一个区别,是形参的const属性,前者是const,后者是non-const,这导致了后者可以将源对象的成员变量pIntData重新赋值为nullptr。因为数据的管理权已经转移到目标对象了,所以源对象的指针应该设置为nullptr,从而源对象和目标对象都调用析构函数时,才不会对同一数据执行两次delete,导致异常。
完成转移构造函数后,push_back()函数也随着执行完毕,临时对象的生命周期也就结束,此时自动调用其析构函数,由于该临时对象成员变量pIntData值为nullptr,所以没有对已经转移的数据进行销毁。
std::move
转移语义和拷贝语义对比,转移语义类似于计算机中对文件的剪切,而拷贝语义类似于文件的复制。
通过定义拷贝构造函数和拷贝赋值操作符,可以实现拷贝语义,如果没有定义,编译器会生成默认的实现。同理,要实现转移语义,则需要定义转移构造函数和转移赋值操作符。实现转移语义后,对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符,如果转移构造函数和转移赋值操作符没有定义,则拷贝构造函数和拷贝赋值操作符会被调用。
满足C++11规范的编译器只对右值才能调用转移构造函数和转移赋值函数。当一个左值不想调用拷贝构造函数或者拷贝赋值操作符,而是想调用转移构造函数和转移赋值操作符,则需要把自己从左值转化为右值。std::move()函数提供了这个能力。
template <typename T>
decltype(auto) move(T&& param) {
using return_type = std::remove_reference<T>::type&&;
return static_cast<return_type>(param);
}
如上代码是std::move()函数的实现,本质就是将一个对象强制转型为右值引用类型的对象而已,并不做任何移动工作。
std::move()函数在提高swap()函数的的性能上非常有帮助,一般来说,swap函数的通用定义如下:
template <classT> swap(T& a, T& b) {
T tmp(a); // copy a to tmp
a = b; // copy b to a
b = tmp; // copy tmp to b
}
使用std::move()函数后,swap()函数的定义则如下 :
template <classT> swap(T& a, T& b) {
T tmp(std::move(a)); // move a to tmp
a = std::move(b); // move b to a
b = std::move(tmp); // move tmp to b
}
通过 std::move()函数,swap()函数避免了3次不必要的拷贝操作。
完美转发std::forward
C++11引入如下2条规则:
1,模板函数对右值引用参数的推导:
向一个模板函数传递一个左值实参,同时该模板函数的对应形参是右值引用,编译器会把该实参推导为左值引用。
2,引用折叠(Reference Collapsing):
C++中会出现“引用的引用”(reference to reference),“引用的引用”包括如下4种情况:Lvalue reference to Rvalue reference,Lvalue reference to Lvalue reference,Rvalue reference to Lvalue reference,Rvalue reference to Rvalue reference。由于C++不允许“引用的引用”,编译器会根据引用折叠规则将这4种情况转变为single reference。其中,前3种情况会转化为左值引用,后1种情况会转化为右值引用。也就是说,T& &&,T&& &,T& &会转化为T&,T&& &&会转化为T&&。
结合如上2个规则分析下面的例子,因为变量i是类型为int的左值,所以根据规则1,传入模板函数f()的实参是int&类型,因此推导出T是int&,所以此时模板函数f(T&&)则为f(int&& &),根据规则2,转化为f(int&),也就是说,模板函数f(T&&)被实例化为f<int&>(int&)。
template<typename T>
void f(T&&);
int i = 10;
f(i);
不管传入的实参是左值还是右值,因为参数在函数内部有了名字,所以在函数内部就都变成了左值了。
如下代码,是一个模板函数调用另外一个模板函数的例子。根据如上2个规则可以得出,传入func_p()函数的参数是右值(Test()生成的一个临时变量),但在func_p()函数内部调用func_c()函数时,传入的参数则是左值了(因为变量名叫t,有名且可寻址)。
Test类既有拷贝构造函数,又有转移构造函数。因为传入函数func_c()的Test对象是左值,根据推导可以得出,此时模板函数func_c()中的T是Test类型,也就是说,函数func_c()的传参是值传递方式,从而触发编译器调用其拷贝构造函数,如果此时能传入右值,编译器才有可能调用其转移构造函数。
template <typename T>
void func_c(T t) {
std::cout << "func_c" << std::endl;
}
template <typename T>
void func_p(T&& t) {
std::cout << "func_p" << std::endl;
func_c(t);
}
int main() {
func_p(Test(1024));
return 0;
}
std::forward()函数提供了保留变量左右值属性的能力。对上面的代码进行如下改造后,变量的右值属性将得到保留,并传递给func_c()函数,从而让编译器可以调用func_c()函数的转移构造函数。
这就是std::forward()函数的完美转发:变量的左右值属性完美地保留下来并得到转发。
template <typename T>
void func_p(T&& t) {
std::cout << "func_p" << std::endl;
func_c(std::forward<T>(t));
}