C++ 11降本增效,左值引用、右值引用、拷贝构造、移动构造、拷贝赋值、移动赋值、完美转发全拿捏
C++作为编程语言大哥大,为满足高性能、安全性等要求,在C++ 11中添加了很多新概念。该篇文章主要阐述C++11如何通过引用、拷贝/移动构造、拷贝/移动赋值,来实现程序降本增效。本文观点均为个人理解,如有错误敬请指教。
在C++中,struct/class的实例内存一般都分配在栈(stack)中;函数在传参/结果返回时,会在实参到形参、返回值到接受变量间进行N次值拷贝。如果这些实例在栈中占有较大的内存空间,高效的传递这些实例做参数的需求变得急迫起来。
本文所有内容均围绕解决该需求而展开,思考以下程序代码:
//该程序演示函数调用时,实参被复制到形参的事实 typedef struct { int a; int b; } Array_2; void func(Array_2 arr){ arr.a = arr.a+arr.b; cout << "a = "<< arr.a << endl; }; int main() { Array_2 a ={1,10}; func(a); cout << "-----------------------"<<endl; cout << "a = "<< a.a << endl; cout << endl; return 0; }
输出:

从输出结果可以看出,调用函数时实参a被完整复制了一份到形参arr,实参a与arr在程序中拥有不同的内存空间。如下图:
、
函数调用过程的数据传递可简化成:main中创建a => 复制main中实参a到函数形参arr => 函数功能执行(使用形参arr)
在C/C++中,默认的函数非指针参数传递都是上述方式(数组、指针除外)。这种传参方式在基本数据类型如:int、bool、double,类、结构体数据空间很少的情况下工作良好。
如将Array_2修改如下:
typedef struct { int a[1000] ={1,2,3,4,5}; } Array_2;
现在Arry_2的字段(属性)a变为长度1000的数组,按照上面的方式进行函数传参,理论上工作l量将增加了1000/2=500倍。
在旧C/C++开发中,可通过将函数参数指针化来优化上面的情况:
void func(Array_2* arr){ //函数参数为Array_2的指针 for(int i = 0; i< 5; i++){ arr->a[i] += 1; } for(int i = 0; i< 5; i++){ cout << "arr.a["<< i<<"]="<< arr->a[i] << " "; } cout << endl; }; int main() { Array_2 a ; func(&a); //将a的地址做参数传递给函数 cout << "-----------------------"<<endl; for(int i = 0; i< 5; i++){ cout << "a.a["<< i<<"]="<< a.a[i] << " "; } cout << endl; return 0; }
输出:

这里取a的地址值复制传递到函数形参中,并未对a的内存进行复制传递。因此需要栈(stack)上大内存对象参与函数运算时,传递对象的指针更加高效。过程大体如下:

然而指针在使用上存在较多问题,如:使用指针要取地址(&)、解引用(*)不够便捷,必须判断否为空,指针同时还存在类型强转的安全隐患,......为了解决这些问题,C++11中引入了这里要说的第一个新概念。
一、引用(&,也被称为左值引用)
引用:引用是对象的别名(C#、Java中处处是引用,引用早已成为常识),让Coder可以方便的达到使用指针一样的效果(C/C++初学者可以把引用当作安全指针来理解)。
使用引用需要满以下要求:
- 使用 类型& 来声明一个引用变量
- 变量声明时必须被赋值
- 引用确定后无法再修改其引用指向
int a = 10; int b = 20; int& c = a; //定义引用c为a的别名
//int& d = 30; 这是错误的,引用必须被赋值一个变量,不能时常量或函数返回值
cout << "c=" << c << endl; //输出c,即输出a c = 15; //修改c的值为15,即修改a的值 cout << "a=" << a << endl; c = b; //c引用a的引用关系在定义后不能再次修改,此处仅是将c的值改成b的值 cout << "a=" << a << endl;
输出:

从上面的程序与运行结果可以看出, 引用也是一种类型(与指针是一样的),也是用来定义变量并引用(指向)具体对象。
引用(&)为什么又被称为左值引用?
还是看上面的程序:int a = 10是一个赋值语句,=左边a被称为左值,=右边的10被称为右值。int& c声明的变量c只能引用 赋值语句中 位于=左边的 左值,所以被称为左值引用。
下面用引用优化指针:
void func(Array_2& arr){ //函数参数为Array_2的引用 for(int i = 0; i< 5; i++){ arr->a[i] += 1; } for(int i = 0; i< 5; i++){ cout << "arr.a["<< i<<"]="<< arr->a[i] << " "; } cout << endl; }; int main() { Array_2 a ; Array_2& b = a; //定义引用 func(b); //将引用b做参数传递给函数 cout << "-----------------------"<<endl; for(int i = 0; i< 5; i++){ cout << "a.a["<< i<<"]="<< a.a[i] << " "; } cout << endl; return 0; }
输出:

从给函数传参角度来看,引用实现了指针的全部功能,并且语法更加简便。这在编码层面上实现了降本(编码更简单、理解更直观),相比原始实/形参完全拷贝传递实现了增效。
上述调用可优化如下:
Array_2 a ={1,10}; func(a); //将a传递给arr,相当于func(Array_2& arr = a)
C++ 11 搞出引用(&)这个骚操作,本质还是为了函数调用传参时直接将实参传入函数内部。

二、右值引用(&&)
下面是函数返回值接收代码:
class MyData{ public: MyData(int i = 0){ fill(d,d+5,i);//设置数组元素默认值 cout << "create instrance "<< this <<endl; } MyData(const MyData& other){ cout << "copy other "<< &other <<" -> this "<< this <<endl; } ~MyData(){ cout << "release instrance " << this << endl; } void haHa(){ cout << "haha" << endl; } void show(){ cout << "show: "; for(int i = 0; i < 5; i++){ cout << d[i] <<" "; } cout << endl; } private: int d[5]; }; MyData createData(){ MyData d; return d; } int main() { MyData a = createData(); a.haHa(); return 0; }
输出:

观察函数返回值接收代码输出:函数调用createDate时执行了构造函数(输出:create instrance 0x61fe5c),然后在return d中临时对象tmp(0x61feac)通过拷贝构造复制了d的值,之后释放d(0x61fe5c)。接下来将临时对象tmp(0x61feac)复制到 main中的a(0x61fe98),之后释放临时对象tmp(0x61feac),然后再调用a.haHa()输出,最后释放掉a(0x61fe98)。
函数返回值接收过程中的数据传递大体可简化如下:
函数功能执行(创建d) => 复制函数执行结果tmp到main中a => main中使用a
细心的朋友应该已发现上面的过程,与“一章节”中的函数调用参数传递过程是逆向关系。一章节函数调用参数如下:
main中创建a => 复制main中实参a到函数形参arr => 函数功能执行(使用形参arr)
在“一章节”中,函数调用中使用“引用”实现了实参直接传入函数,从而完成了程序的降本增效;如果这里也能使用“引用”将函数执行结果tmp直接传回到main中a,那不也能省取一个复制过程。看下图

确实有这么一个“引用”可以实现我们的需求,它叫做:右值引用(&&),先看程序及执行输出:
//仅需调整main中的代码 int main() { MyData&& a = createData();//注意这里 cout << &a<<":";//输出右值引用的地址 a.haHa(); return 0; }
输出:

那什么是右值引用?其实它是“一章节”左值引用(&)反向。

左值引用变量 引用的是 赋值操作时=号 左边的东西
右值引用变量 引用的是 赋值操作时=号 右边的东西
如下代码示例:
MyData getData(){ MyData d; return d; } int main(){ int a = 10; int&& b = a;//错误,a时左值 int && c = 10;//正确,10在赋值时只能位于=右边 MyData e = getData(); MyData&& f = getData();//ok }
总结:
左/右值引用:数据类型+左值引用(&)/右值引用(&&),也是一种类型(如:int& x = a声明int&类型变量x并赋值,int&& y = 10声明int&&类型变量y并赋值),只是左/右值引用的变量分别是赋值号(=)左/右两边的对象。
到这里可以得到 C++ 11中变量由:值类型(int、MyData)+ 值类别(左/右)+ 值 组成
三、引用(左值、右值)的使用
1右值引用接收函数返回值,如“二章节”代码所示:
int main() { MyData&& a = createData();//注意这里 cout << &a<<":";//输出右值引用的地址 a.haHa(); return 0; }
右值引用(&&)接收函数返回值,为了将函数执行结果直接传回给调用者。这样处理临时对象(右值),避免了不必要的临时对象拷贝,程序执行更加高效了。
2、做普通函数参数
2.1、左值引用:将外部实参变量直接传入函数内部,避免复制传参提升效率
void test1(MyData& d){
cout << "left ref:" d.show(); }
int main(){ MyData data; test1(data); //调用左值引用t,将data直接传入函数内部 retun 0; }
2.2、右值引用:将外部临时对象(表达式结果也是临时对象)直接传入函数内部,避免复制传参提升效率
void test2(MyData&& d){
if() cout<< "rigth ref:" d.show(); }
int main(){ test2(MyData()); //调用右值引用,传入MyData()默认构造函数的结果 test2(MyData()+MyData());//调用右值引用test2函数 接受表达式(expression),这里假设MyData中operator+返回MyData对象 retun 0; }
3、做类 构造函数/赋值操作重载 参数
修改MyData类如下:
class MyData{ public: //普通构造函数 MyData(int size = 5){ this->size = size; d = new int[size]; std::fill(d,d+size,size); cout << "Create this:"<< this <<endl; } //拷贝构造函数 MyData(const MyData& other):size(other.size){ d = new int[size]; std::copy(other.d,other.d+size,d); cout << "Copy constructor. copy other:"<< &other << "->this:" << this << endl; } //拷贝赋值运算 MyData& operator=(const MyData& other){ if(this != &other){ size = other.size; d = new int[size]; std::copy(other.d,other.d+size,d); cout << "Copy assignment. copy other:"<< &other << "->this:" << this << endl; } return *this; } //移动构造函数 MyData(MyData&& other) noexcept:size(other.size),d(other.d){ other.size = 0; other.d = nullptr; cout << "Move constructor. move other:"<< &other << "->this:" << this << endl; } //移动赋值运算 MyData& operator=(MyData&& other) noexcept{ if(this != &other){ size = other.size; d = other.d; other.size = 0; other.d = nullptr; cout << "Move assignment. move other:"<< &other << "->this:" << this << endl; } return *this; } //析构函数 ~MyData(){ cout << "release instrance " << this; if(nullptr != d){ cout << " delete size:"<<size; delete[] d; size = 0; } cout << endl; } void show(){ cout << "show:"; if(nullptr == d){ cout << "empty......"<<endl; return; } for(int i = 0; i < size; i++){ cout << d[i] <<" "; } cout << endl; } private: int size; int* d; };
其他:
MyData createData(){ MyData d(5); d.show(); return d; }
3.1、在类中 拷贝构造/赋值操作,都以 左值引用 做参数。首先传递效率肯定高,第二意图明显:在构造函数/赋值操作中克隆一份与参数完全相同的对象(内存空间一致、数据一样)。
注意:const来修饰引用对象,确保了函数不能修改传入的左值对象。
3.2、在类中 移动构造/赋值操作,都以 右值引用 做参数。首先传递效率肯定高,第二意图明显:右值作为临时对象,在构造函数/赋值操作中抢占右值的资源,即能省去开辟空间操作、又能节内存省空间、临时对象(右值)还没意见(因为它马上就要被释放掉)。如下:
int main() { MyData c(createData()); c.show(); return 0; }
输出:

上面程序中的出现了多个临时对象(MyData d,函数返回值tmp),因为MyData类中加入了移动构造,从而使临时对象的资源转移(被抢占)表现的更加自然高效。
这种应用在STL的容器操作中很常见,如:
vector<MyData> datas(3); MyData a(5); datas.push_back(a); MyData b(6); datas.push_back(b); MyData c(5); datas.push_back(c); datas.push_back(MyData(5)); datas.push_back(MyData(6)); datas.push_back(MyData(7));
上面的2类向vector中添加元素的操作,无论时代码量还是性能,第2类都完胜。
4、使用左右值引用面临的问题:
预测下面代码输出结果:
int main() { MyData&& a = createData();//可简化为MyData a(createData())
MyData b(a);//
return 0;
}
预测输出:MyData&& a = createData()使用右值引用a接收函数返回值(之前已经实验过,只看后面程序),a作为右值引用在MyData b(a)中应该调用MyData的移动构造函数。
。
。
。
。
。
。
。
。
。
输出:

输出上面结果的原因,MyData&& a =createMyData()虽被定义为MyData类型的右值引用变量,但程序调用a变量时,只会把它当作普通引用变量来用(MyData&即左值引用,因为是无论左/右值引用,都可调用a.show,所以根本无需在实际使用过程中区分)。这个就是:引用变量无法携带值类别信息。
在MyData类中,重载的拷贝构造与移动构造函数与引用变量的值类别(左还是右)密切相关;又因引用变量无法携带值类别信息,那如何才能在程序中按我们的心意来调用不同的构造函数呢?如下:
int main() { MyData a(10); MyData b(a);//我想使用拷贝构造来创建b MyData&& c = createData(); MyData d(c);//我想使用移动构造函数 return 0; }
这个问题可以使用std::move(变量)来解决,如下:
输出:

std::move(变量):将变量转换为右值,更多关于move的内容请自行查找。
注意:
右值与右值引用的区别。右值如:9,ture,右值引用:则是变量,并且只能被赋右值。
左值与左值引用都是变量
有关拷贝赋值、移动赋值其原理与对应构造相同,不在引申,大体参考以下代码:
int main() { MyData a(3); MyData b(5); b = a; cout << "---------------------------------------" << endl; MyData c; c = std::move(a); cout << "---------------------------------------" << endl; MyData&& d = std::move(c); d.show(); return 0; }
输出:

四、完美转发
如上MyData类有2个与引用值类别相关的构造函数,作为代码使用者,在哪个时机调用哪个构造函数必须要记清楚。
如果能建立一个工厂函数,不论用户传入的是哪个值类别的引用参数,工厂方法都能根据传入的引用参数,来构建并返回MyData对象,岂不美哉!
现有以下代码做个测试:
template<typename T> MyData createData(T&& arg) { return MyData(std::forward<T>(arg)); }
int main() { MyData a; //...... //...... //......
MyData&& b = createData(a);
//......
MyData&& c = createData(std::move(a));
return 0;
}
输出:

上述程序实现了我们工厂方法设想,上面的程序相比之前MyData对象创建方式,有了以下升级:
- 通过工厂函数将MyData对象创建的接口进行了统一
- 自动选择合适的构造函数进行MyData对象的创建
代码分析:
template<typename T> //首先这是个模板函数 MyData createData(T&& arg) { //T&&是通用引用 return MyData(std::forward<T>(arg)); //forward<T>(arg)参数转发 }
T&&被称为通用引用(要与模板结合使用),它解决了普通引用(&,&&)变量无法携带 值类别 信息的问题。通用引用由 T+&& 组成,在T中同时保存着 值类型+值类别 信息,然后再与 && 进行组合。
简单记忆:T&&会将完整形参信息通过forword<T>(arg),传递给被调函数,这就是转发。
具体过程如下:
MyData a;// a变量是个左值
MyData&& b = createData(a);// 调用工厂函数
进行推导:
T &&,如果a被推导为MyData, 则结果为MyData&& ,将MyData&& arg = a,无法将左值绑定到右值引用arg。
T &&,如果a被推导为MyData&,则结果为MyData&&&,折叠引用得MyData&, MyData& arg = a,将a作为左值绑定到左值引用arg,OK。
T &&,a不可能推导为MyData&&。
执行:return MyData(std::forward<T>(arg));//推导结果为:return MyData(std::forward<MyData&>(arg)),执行MyData拷贝构造函数。
MyData&& c = createData(std::move(a));
进行推导:(注意:std::move(a)是右值,如:9)
T &&,如果std::move(a)被推导为MyData, 则结果为MyData&& ,将MyData&& arg = std::move(a),将std::move(a)绑定到右值引用arg,OK。
T &&,如果std::move(a)被推导为MyData&,则结果为MyData&&& ,折叠引用得MyData& arg = std::move(a), 无法将右值绑定到左值引用arg。
T &&,std::move(a)是右值,不可能被推导为MyData&&右值引用。
执行:return MyData(std::forward<T>(arg));//推导结果为:return MyData(std::forward<MyData>(arg)),执行MyData移动构造函数。
现在看来,这个工厂函数很完美的完成了转发工作,因此被称为完美转发。以上仅是完美转发的一个demo。
作者:DW039
出处:http://www.cnblogs.com/dw039
本文由DW039原创并发布于博客园,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

浙公网安备 33010602011771号