目录
1.左值、右值
1.1 什么是左值和右值?
简单一句话:可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。
相对于左值,右值表示字面常量、表达式、函数的非引用返回值等。
1.2 什么是右值引用?
左值引用是对一个左值进行引用的类型,右值引用则是对一个右值进行引用的类型。左值引用是对一个左值(有名字)起别名,同理,右值引用就是对一个右值起别名,因为右值本身是不具名的(就是右值没有名字),所以给右值起一个别名,有了名字才好操作它。
论是声明一个左值引用还是右值引用,都必须立即进行初始化。因为引用只是该对象的一个别名。
右值引用,使用&&表示:
int && r1 = 22;
int x = 5;
int y = 8;
int && r2 = x + y;
1.3.右值引用有什么作用?
C++11引入右值引用的目的是为了实现移动语义。
2. 移动语义
2.1 移动语义是相对于复制语义而言的一个概念
2.2 什么是复制语义?
在C++中有四个特殊的成员函数:默认构造函数、拷贝构造函数,析构函数和拷贝赋值运算符。之所以称之为特殊的成员函数,这是因为如何开发人员没有定义这四个成员函数,那么编译器则在满足某些特定条件(仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中明确声明)下,自动生成。这些由编译器生成的特殊成员函数是public且inline。
传统的默认构造函数、拷贝构造函数和拷贝赋值运算符实现的就是复制语义,当用一个对象初始化另外一个对象时,调用的拷贝构造函数内部会把参数对象的各个成员变量复制给新对象,如果有指针类型的变量还要使用深拷贝重新开辟一块内存,然后把内存中的内容复制到新内存中,之所以使用深拷贝而不是简单将旧指针赋值新指针是为了避免浅拷贝带来的内存泄漏隐患;但这种深拷贝在需要多次开辟大量内存的对象赋值时,多次的内存处理会严重影响程序性能,为了解决由此带来的性能问题,便引出了移动语义;
2.3 什么是移动语义?
自C++11起,引入了另外两个特殊的成员函数:移动构造函数和移动赋值操作符,分别对标拷贝构造函数和拷贝赋值操作符。
对于左值的拷贝和赋值会调用拷贝构造函数和拷贝赋值操作符,而右值的拷贝和赋值会调用移动构造函数和移动赋值操作符。如果移动构造函数和移动拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。普通的函数和操作符也可以利用右值引用操作符实现移动语义。看代码
// ConsoleApplication1.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string.h>
using namespace std;
class MyString
{
public:
MyString(const char *tmp = "abc")
{//普通构造函数
len = strlen(tmp); //长度
str = new char[len + 1]; //堆区申请空间
strcpy(str, tmp); //拷贝内容
cout << "普通构造函数 str = " << str << endl;
}
MyString(const MyString &tmp)
{//拷贝构造函数
len = tmp.len;
str = new char[len + 1];
strcpy(str, tmp.str);
cout << "拷贝构造函数 tmp.str = " << tmp.str << endl;
}
MyString &operator= (const MyString &tmp)
{//赋值运算符重载函数
if (&tmp == this)
{
cout << "赋值运算符重载函数" << endl;
return *this;
}
//先释放原来的内存
len = 0;
delete[]str;
//重新申请内容
len = tmp.len;
str = new char[len + 1];
strcpy(str, tmp.str);
cout << "赋值运算符重载函数" << endl;
return *this;
}
~MyString()
{//析构函数
cout << "析构函数: ";
if (str != NULL)
{
cout << "已操作delete, str = " << str;
delete[]str;
str = NULL;
len = 0;
}
cout << endl;
}
private:
char *str = NULL;
int len = 0;
};
MyString func() //返回普通对象,不是引用
{
MyString obj("mike");
cout << "&obj=" << &obj << endl;
return obj;
}
int main()
{
MyString obj("abc");
MyString tmp1(obj);//obj是左值,所以调用拷贝构造函数
MyString tmp2 = func(); //func返回值的obj为右值,但是因为没有声明移动构造函数和重载移动赋值运算符,所以仍然调用拷贝构造函数
cout << "&tmp2=" << &tmp2 << endl;
return 0;
}
//输出
普通构造函数 str = abc
拷贝构造函数 tmp.str = abc
普通构造函数 str = mike
&obj=00EFF76C
拷贝构造函数 tmp.str = mike //临时对象A被obj初始化,调用拷贝构造
析构函数: 已操作delete, str = mike // 临时对象A被析构,调用析构
&tmp2=00EFF874
析构函数: 已操作delete, str = mike
析构函数: 已操作delete, str = abc
析构函数: 已操作delete, str = abc
可以看到,func函数中的obj对象的地址和 tmp2对象的地址不一样,因为返回的obj对象实际上是一个右值,当func函数运行完毕就被释放了,中间是通过一个临时对象作为中介,将obj的值赋值给tmp2,这当中临时对象的拷贝构造函数和析构函数被编译器优化了,实际上的传递过程为:obj->临时对象A,临时对象A->tmp2;这一过程中,临时对象A的创建和析构影响了性能,实际上它只是作为一个中介,并没有做什么事儿,而移动语义的出现可以解决这一问题,通过在类中声明移动构造函数和重载移动赋值运算符,将obj对象这一右值中的资源直接转移给tmp1,避免了临时对象A的创建和析构;
下面是引入移动构造函数和移动赋值运算符的代码
// ConsoleApplication1.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string.h>
using namespace std;
class MyString
{
public:
MyString(const char *tmp = "abc")
{//普通构造函数
len = strlen(tmp); //长度
str = new char[len + 1]; //堆区申请空间
strcpy(str, tmp); //拷贝内容
cout << "普通构造函数 str = " << str << endl;
}
//移动构造函数
//参数是非const的右值引用
MyString(MyString && t)
{
str = t.str; //拷贝地址,没有重新申请内存
len = t.len;
//原来指针置空
t.str = NULL;
cout << "移动构造函数" << endl;
}
/* MyString &operator= (const MyString &tmp)
{//赋值运算符重载函数
if (&tmp == this)
{
return *this;
}
//先释放原来的内存
len = 0;
delete[]str;
//重新申请内容
len = tmp.len;
str = new char[len + 1];
strcpy(str, tmp.str);
cout << "赋值运算符重载函数 "<< endl;
return *this;
} */
//移动赋值函数
//参数为非const的右值引用
MyString &operator=(MyString &&tmp)
{
if (&tmp == this)
{
return *this;
}
//先释放原来的内存
len = 0;
delete[]str;
//无需重新申请堆区空间
len = tmp.len;
str = tmp.str; //地址赋值
tmp.str = NULL;
cout << "移动赋值函数\n";
return *this;
}
~MyString()
{//析构函数
cout << "析构函数: ";
if (str != NULL)
{
cout << "已操作delete, str = " << str;
delete[]str;
str = NULL;
len = 0;
}
cout << endl;
}
private:
char *str = NULL;
int len = 0;
};
MyString func() //返回普通对象,不是引用
{
MyString obj("mike");
return obj;
}
int main()
{
MyString tmp("abc"); //实例化一个对象
tmp = func();//func返回的是一个右值,调用移动构造函数
return 0;
}
//输出:
普通构造函数 str = abc
普通构造函数 str = mike
移动构造函数
析构函数:
移动赋值函数
析构函数:
析构函数: 已操作delete, str = mike
可以看到,有移动构造和移动赋值运算符的情况下,func函数的返回值作为一个右值,其中的资源直接被移动给tmp对象,并没有涉及临时变量的中转操作,这样就提升了代码的性能;
2.4 移动构造函数的注意点
1.移动构造函数的参数为参数为非Const的右值引用类型,因为在函数内部需要修改实参中的指针变量。内部代码中,直接将对象obj的指针地址赋值给了新对象,实际上属于对指针变量浅拷贝,没有开辟新的内存,转移资源后将obj的指针置空,这样操作避免了浅拷贝带来的内存泄漏问题。
和拷贝构造函数类似,移动构造函数有几点需要注意:
(1).参数的符号必须是右值引用符号,即“&&”。
(2).参数不可以是常量,因为我们需要修改右值。
(3).参数的资源链接和标记必须修改,否则,右值的析构函数就会释放资源,移动到新对象的资源也就无效了。
2.移动赋值运算符重载函数的参数为非Const的右值引用类型,因为在函数内部需要修改实参中的指针变量,此外,在运算符函数内部,并没有给指针变量新开辟内存,而是直接赋值;
2.5 总结移动语义
移动构造函数:使用右值来初始化一个对象时,使用移动构造函数来实现移动语义
重载移动赋值运算符:使用右值来对另一个对象赋值时,使用重载的移动赋值运算符实现移动语义
(对应拷贝构造函数和拷贝赋值运算符),二者在函数内部的实现逻辑中都无需为指针变量开辟新的空间,而是简单的直接赋值;
移动构造函数和移动赋值运算符的引入,完成了所谓的移动语义;
有了移动语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计移动构造函数和移动赋值函数,以提高应用程序的效率。
3. 移动构造和移动赋值运算符的调用时机
问:什么时候调用移动构造函数?
答:只有当用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数
众所周知,在C++中有四个特殊的成员函数:默认构造函数、析构函数,拷贝构造函数,拷贝赋值运算符。之所以称之为特殊的成员函数,这是因为如何开发人员没有定义这四个成员函数,那么编译器则在满足某些特定条件(仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中明确声明)下,自动生成。这些由编译器生成的特殊成员函数是public且inline。
自C++11起,引入了另外两只特殊的成员函数:移动构造函数和移动赋值运算符。如果开发人员没有显示定义移动构造函数和移动赋值运算符,那么编译器也会生成默认。与其他四个特殊成员函数不同,编译器生成默认的移动构造函数和移动赋值运算符需要,满足以下条件:
1.如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或者析构函数(这三者之一,表示程序员要自己处理对象的复制或释放问题),编译器就不会为它生成默认的移动构造函数或者移动赋值运算符,这样做的目的是防止编译器生成的默认移动构造函数或者移动赋值运算符不是开发人员想要的
2.如果类中没有提供移动构造函数和移动赋值运算符,且编译器不会生成默认的(定义了1中的三个函数之一),那么我们在代码中通过std::move()调用的移动构造或者移动赋值的行为将被转换为调用拷贝构造或者赋值运算符
3.如果显式声明了移动构造函数或移动赋值运算符,则拷贝构造函数和拷贝赋值运算符将被 隐式删除(因此程开发人员必须在需要时实现拷贝构造函数和拷贝赋值运算符)
两个拷贝操作是独立的:声明一个不会限制编译器自动生成另一个。所以如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。上述规则在C++98和C++11中都成立。
两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止编译器生成移动赋值运算符,声明移动赋值运算符同样阻止编译器生成移动构造函数。
4. 既然移动构造和移动赋值这么牛逼,为什么只有当用一个右值(将亡值)初始化另一个对象的时候才调用移动构造函数,左值初始化另一个对象时也调用移动构造函数不是更好吗?
答:之所以只把右值(将亡值)初始化另一个对象时调用移动构造函数,是因为将亡值生命周期即将结束(如函数返回一个对象),它的内存资源即将被回收,这时候使用移动构造函数将它的资源移动赋值给新对象,然后再切断它的指针与其所指内存间的联系,这样在析构该对象时,它的内存资源不会被释放,不会被浪费。而普通的左值对象去初始化另一个新对象时,该左值对象本身的资源还要接着使用,如果按移动构造函数来处理它的资源,那它自己的资源就会被夺走,它本身就GG了,所以不能这样玩。