简介
本文详细阐述了 C++ 中关于移动语义、左值右值等技术的基本概念和常用技巧。
问题的产生
每一项技术的诞生都是为了解决某一个问题,移动语义、左值右值也是一样,因此我们先来看看问题产生的背景。
先来看一段代码:
#include <iostream>
#include <string>
#include <string.h>#pragma warning(disable:4996)using namespace std;class String {
public:String() :_pstr(nullptr) {cout << "无参构造String()" << endl;}String(const char* pstr) : _pstr(new char[strlen(pstr) + 1]()) {cout << "C风格字符串构造 String(const char* )" << endl;strcpy(_pstr, pstr);}String(const String& rhs):_pstr(new char[strlen(rhs._pstr)+1]()) {cout << "拷贝构造 String(const String &)" << endl;}String& operator=(const String& rhs) {cout << "拷贝赋值运算符函数String& operator=(const String&)" << endl;if (this != &rhs) { //1、防止自复制delete[] _pstr; //2、释放左操作数_pstr = nullptr;//深拷贝_pstr = new char[strlen(rhs._pstr) + 1]();strcpy(_pstr, rhs._pstr);}return *this;}~String() {cout << "~String()" << endl;if (_pstr) {delete[] _pstr;_pstr = nullptr;}}friend ostream& operator<<(ostream& os, const String& rhs);
private:char* _pstr;
};ostream& operator<<(ostream& os, const String& rhs) {if (rhs._pstr) {os << rhs._pstr;}return os;
}void test() {// 会调用含 const char* 参数的构造函数String s1("hello");cout << "s1 = " << s1 << endl;cout << endl;// 会调用拷贝构造函数String s2 = s1;cout << "s1 = " << s1 << endl;cout << "s2 = " << s2 << endl;cout << endl;// 从结果上看,好像是调用 const char* 参数的构造函数?String s3 = "world";cout << "s3 = " << s3 << endl;
}int main() {test();return 0;
}
运行结果:
从运行结果上看,貌似 s1 和 s3 这两种初始化方式都调用的是 C 风格字符串参数的构造函数:
String s1("hello");
String s3 = "world";
但是实际上,String s3 = "world";
这行代码本应该是和 s1 调用不一样的构造函数,为什么?
因为 "world"
属于一个 C 风格的字符串,而 s3 可以看作是一个 C++ 风格的字符串。
从 C 风格字符串向 C++ 风格字符串进行赋值这个行为如果能执行的话,其背后一定会有个隐式类型转换的操作,因为转换的时候只能是同种类型之间的转换。
因此下面这行代码的行为:
String s3 = "world";
实际上背后还执行了一步:
String("world");
而 s1 则是直接调用了对应的构造函数完成初始化:
String s1("hello");// s1 直接调用 String(const char*) 构造函数完成初始化了
因此对于 s3 来说,它先将 “world” 转换成了 String(“world”) 这个临时对象,然后再从 String(“world”) 这个临时对象又转换成了实际的对象 s3 。
正因为转换过程中产生了 String(“world”) 这个临时对象,因此其会调用一次含 C 风格字符串参数的构造函数一次,所以最后从运行结果上来看,似乎 s1 和 s3 的初始化过程相同,看起来好像都是通过含 C 风格字符串参数的构造函数完成对象初始化的。
实际上,在临时对象转换为 s3 对象这个过程,会调用一次拷贝构造函数,又因为这是个临时对象,被临时创建完之后就会立马被销毁,那么自然而然就会调用一次析构函数,但是因为现在的编译器给我们做了编译优化导致最后的运行结果中没有出现这个现象。
不过通过一些编译指令我们还是可以人为的看到这个现象:
对比两次的编译结果,不难发现上面多了一次拷贝构造的调用以及一次析构函数的调用。
最后可以得出一个结论,String("world")
属于一个右值或者说一个临时对象,它的生命周期就只在这一行代码中:
String s3 = "world";
由此不难发现,这多出来的一次拷贝构造和析构函数的调用并没有多大意义(因为只是临时的,创建完之后立马就会销毁),相反还会浪费资源和时间,会让程序执行的效率降低。
因此对于这种情况,实际上我们是可以做相应的处理的,怎么做?
如果说我们能去将相应的右值能够识别出来,那么这个右值里所涉及到的内存空间我们就不让它销毁而直接赋值给我们的 s3,那么这个时候问题就解决了。
具体的说,临时对象 String("world")
的创建是没有问题的也是肯定得创建的,它会去正常的申请堆内存空间,但这个时候如果说我们能够有一种语法规则能把该临时对象给识别出来然后把该临时对象所申请的堆内存空间直接转给我们的 s3,那么这个时候 s3 去进行创建的时候就不会需要再 new 一次了,问题也就圆满解决了。
那么如何区分出右值呢?这就要进入到我们的下一节内容了。
左值与右值的区分
直接上代码:
#include <iostream>
#include <string>
using namespace std;void test() {int a = 10;int b = 20;//不难发现,对于 a 和 b ,我们都是可以正常取地址的//因此此时的 a 和 b 就都是左值&a;&b;//我们使用指针变量,发现对于指针变量 pflag 也可以取地址//因此 pflag 也是左值int* pflag = &a;&pflag;&*pflag;//对于string类型,也可以用类似的方法测试//不难发现一样是可以取地址的,因此 s1 和 s2 也是左值string s1 = "hello";string s2 = "world";&s1;&s2;//对于自增的情况&(++a); //正确//&(a++); 报错,因为后置++这个过程返回的是一个临时对象,无法取地址,因此是右值//对于表达式的情况//&(a + b); 报错,是一个右值
}int main() {test();return 0;
}
由上述代码不难推知,在 C++ 中,左值和右值的区别就是左值是可以取地址的,而右值不可以。
另外引用相关的概念也与左值右值有关联,我们也来看一下,依然是看代码:
#include <iostream>
#include <string>
using namespace std;void test() {int a = 10;int b = 20;//对 ref 取地址是 ok 的const int& ref = a; //const左值引用可以绑定到左值&ref;// int& ref1 = 10; 报错,左值引用不可以绑定到右值上const int& ref1 = 10; //但const左值引用可以绑定到右值上//这也是为什么我们的拷贝构造函数的参数必须要写成const左值引用的原因//因为这样不管传的是左值还是右值进入拷贝构造函数就都是ok的
}int main() {test();return 0;
}
因为 10 不能被取地址,因此上面的代码注释中将其也归入了右值一列,但它其实还有个名字,叫做字面值常量。
因此简单的对左值和右值进行分类的话大致如下:
左值:可以取地址。
右值:不能进行取地址。包括临时变量、临时对象、字面值常量。
但依据上面的内容,我们现在也只能做到把左值给区分出来,而没办法有效区分出右值。
区分左值的话我们只需要使用左值引用即可:
而 const 左值引用既可以绑定到左值又可以绑定到右值,因此其也是区分不出来右值的。
因此现在我们需要一种新的语法手段,来将右值给区分出来。
右值引用的概念
这个语法就是右值引用,看代码:
#include <iostream>
#include <string>
using namespace std;void test() {//&&表示右值引用,这是C++11标准提出来的//使用右值引用,发现可以绑定到右值上int&& rref = 10;//那么右值引用可以绑定到左值上面吗?int a = 10;//int&& rref = a; 报错,说明右值引用无法绑定到左值上
}int main() {test();return 0;
}
通过上面的代码,说明了通过右值引用此时我们可以有效地区分出右值了。
右值引用可以识别右值,但不能识别左值。
这就意味着如果我们将原来的拷贝构造函数的左值引用给换成右值引用的话,那么在传进一个右值的时候应该就会调用这个有右值引用的 “拷贝构造函数” 了吧?
移动构造函数
当然!
依然是之前的 String 例子的代码,如下所示:
String(String&& rhs){cout << "右值引用的拷贝构造 String(const String &&)" << endl;
}
那么此时当我们再执行程序到下面这一行时:
String s3 = "world";
之前我们说过这行代码会产生一个临时对象,为了避免效率降低我们提出的方法是让这个临时对象申请的堆空间资源不要销毁,而是直接转给 s3,这样就能有效提升效率,那么这个右值引用的拷贝构造函数要做的就应该是这么一件事情,我们来实现一下:
//在执行 String s3 = String("world");的时候调用下面的右值拷贝构造
//直接将传进来的 _pstr 转给 s3
//_pstr是s3的成员变量,rhs._pstr是临时对象的成员变量
//此时进行浅拷贝,将s3的_pstr指向了临时对象所申请的内存空间
//此时s3就不需要再次申请空间,就解决了我们之前提出的问题
String(String&& rhs):_pstr(rhs._pstr) {cout << "右值引用的拷贝构造 String(const String &&)" << endl;//这里还要做一步,为了防止临时对象被销毁时调用delete造成二次析构//因此这里需要将临时对象的指针置为空rhs._pstr = nullptr;
}
而这就是我们一直在说的移动语义,而我们一直说的右值形式的构造函数其正名为:移动构造函数。
“ 移动 ” 二字的意思以上面的例子就是说将临时对象 String(“world”) 申请的堆空间直接转移给 s3 对象的数据成员 _pstr 了。
完整的代码如下:
#include <iostream>
#include <string>
#include <string.h>#pragma warning(disable:4996)using namespace std;class String {
public:String() :_pstr(nullptr) {cout << "无参构造String()" << endl;}String(const char* pstr) : _pstr(new char[strlen(pstr) + 1]()) {cout << "C风格字符串构造 String(const char* )" << endl;strcpy(_pstr, pstr);}String(const String& rhs):_pstr(new char[strlen(rhs._pstr)+1]()) {cout << "拷贝构造 String(const String &)" << endl;strcpy(_pstr, rhs._pstr);}//移动构造函数//在执行 String s3 = String("world");的时候调用下面的右值拷贝构造//直接将传进来的 _pstr 转给 s3//_pstr是s3的成员变量,rhs._pstr是临时对象的成员变量//此时进行浅拷贝,将s3的_pstr指向了临时对象所申请的内存空间//此时s3就不需要再次申请空间,就解决了我们之前提出的问题String(String&& rhs):_pstr(rhs._pstr) {cout << "右值引用的拷贝构造 String(const String &&)" << endl;//这里还要做一步,为了防止临时对象被销毁时调用delete造成二次析构//因此这里需要将临时对象的指针置为空rhs._pstr = nullptr;}String& operator=(const String& rhs) {cout << "拷贝赋值运算符函数String& operator=(const String&)" << endl;if (this != &rhs) { //1、防止自复制delete[] _pstr; //2、释放左操作数_pstr = nullptr;//深拷贝_pstr = new char[strlen(rhs._pstr) + 1]();strcpy(_pstr, rhs._pstr);}return *this;}~String() {cout << "~String()" << endl;if (_pstr) {delete[] _pstr;_pstr = nullptr;}}friend ostream& operator<<(ostream& os, const String& rhs);
private:char* _pstr;
};ostream& operator<<(ostream& os, const String& rhs) {if (rhs._pstr) {os << rhs._pstr;}return os;
}void test() {// 会调用含 const char* 参数的构造函数String s1("hello");cout << "s1 = " << s1 << endl;cout << endl;// 会调用拷贝构造函数String s2 = s1;cout << "s1 = " << s1 << endl;cout << "s2 = " << s2 << endl;cout << endl;// 从结果上看,好像是调用 const char* 参数的构造函数?String s3 = "world";cout << "s3 = " << s3 << endl;
}int main() {test();return 0;
}
此时再编译运行,可以发现已经达到了我们要的效果(注意去掉编译器优化嗷):
最后注意:针对于右值而言,移动构造函数优先于拷贝构造函数的执行。
移动赋值运算符函数
同样的道理,我们的赋值运算符函数也存在这样的移动语义的问题。
来看代码:
//省略其它代码void test() {// 会调用含 const char* 参数的构造函数String s1("hello");cout << "s1 = " << s1 << endl;cout << endl;// 会调用拷贝构造函数String s2 = s1;cout << "s1 = " << s1 << endl;cout << "s2 = " << s2 << endl;//调用赋值运算符函数cout << endl;s2 = String("world");cout << "s2 = " << s2 << endl;
}int main() {test();return 0;
}
运行结果如下:
可以发现在进行赋值运算的时候也存在这样关于移动语义的问题,解决方法也是一样的。
根据之前的经验,我们可以写出移动赋值运算符函数如下:
//在执行 s2 = String("world"); 时就会调用下面这个移动赋值运算符函数了
String& operator=(String&& rhs) {cout << "移动赋值运算符函数String& operator=(const String&&)" << endl;//虽然右值没办法取地址//但是右值引用在作为函数形参的情况下是可以取地址的(函数的形参都是左值)//因为右值引用在作为函数形参时本身是可以绑定到某个右值上的//这意味着这里的右值引用是个左值(后面会举例子什么时候右值引用其实是个右值)//因此下面的 &rhs 成立if (this != &rhs) { //1、防止自复制delete[] _pstr; //2、释放左操作数_pstr = nullptr;//浅拷贝_pstr = rhs._pstr;rhs._pstr = nullptr;}return *this;
}
此时运行结果:
可以看到移动赋值运算符函数被调用了,这就印证了我们的想法。
std::move() 的使用
在上面的移动赋值运算符函数中:
//在执行 s2 = String("world"); 时就会调用下面这个移动赋值运算符函数了
String& operator=(String&& rhs) {cout << "移动赋值运算符函数String& operator=(const String&&)" << endl;//虽然右值没办法取地址//但是右值引用在作为函数形参的情况下是可以取地址的(函数的形参都是左值)//因为右值引用在作为函数形参时本身是可以绑定到某个右值上的//这意味着这里的右值引用是个左值(后面会举例子什么时候右值引用其实是个右值)//因此下面的 &rhs 成立if (this != &rhs) { //1、防止自复制delete[] _pstr; //2、释放左操作数_pstr = nullptr;//浅拷贝_pstr = rhs._pstr;rhs._pstr = nullptr;}return *this;
}
乍一看貌似好像这个防止自复制的操作是多余的,其实不然,因为在 C++ 中有一个函数可以将左值变成右值,它就是 std::move() 。
因此如果不考虑自复制的情况的话,当有下面代码的时候:
s2 = std::move(s2);
此时就一定会出问题了,因此防止自复制这一步一定不能省嗷。
std::move() 的作用就是将左值转换为右值,表明不想再使用该左值了。
这个转换的过程实际上在底层中就是发生了一次强制转换 static_cast<T &&> (lvalue) 而已。
一点点细节补充
为什么右值引用不考虑 const
因为没有意义啊,右值引用引用的都是右值,而我们拿到右值都是要修改的,比如上面代码我们要将临时对象的指针置空,加上 const 反而无法执行置空操作了。另外对于比如字面值常量等右值而言,它本来就是没办法改变的,加上 const 虽然不报错但是也没有什么额外的收益:
const int&& ref = 10;
因此对于右值引用我们一般不会考虑 const 的情况。
拷贝控制语义与移动语义的概念
拷贝构造函数与赋值运算符函数,编译器会自动提供;但是移动构造函数与移动赋值运算符函数,编译器不会自动提供,必须要手写。
将拷贝构造函数与赋值运算符函数称为具有拷贝控制语义的函数;将移动构造函数与移动赋值运算符函数称为具有移动语义的函数。
最后,移动语义函数的调用优先级要高于拷贝语义的函数。
总结
综上所述,我们所谓的移动语义呢其实指的就是类里面所具有的两个函数:一个叫做移动构造函数,一个叫做移动赋值运算符函数。这样当我们传递进来的是右值的时候我们就不必要再去执行深拷贝而只要去执行浅拷贝即可。
最后再简要总结一下本文的内容: