【C++11】右值引用和移动语义 {左值引用和右值引用;移动语义;解决函数传值返回的深拷贝问题;完美转发;默认成员函数总结,default和delete关键字,final和override关键字}

一、左值引用和右值引用

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

什么是左值?什么是左值引用?

  • 左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取左值的地址,可以对左值赋值,左值可以出现赋值符号的左边,也可以出现在赋值符号的右边。定义const修饰的左值,不能赋值,但是可以取地址。能取地址的就是左值

  • 左值引用就是给左值的引用,给左值取别名。

  • 左值引用只能引用左值,不能引用右值。

  • 但是const左值引用既可引用左值,也可引用右值。

int main()
{
	// 以下的p、b、c、*p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;
    
	// 以下几个是对上面左值的左值引用
	int*& rp = p;
	int& rb = b;
	const int& rc = c;
	int& pvalue = *p;
    
    // 左值引用只能引用左值,不能引用右值。
	int a = 10;
	int& ra1 = a; // ra为a的别名
	//int& ra2 = 10; // 编译失败,因为10是右值
    
	// const左值引用既可引用左值,也可引用右值。
	const int& ra3 = 10;
	const int& ra4 = a;
	return 0;
}

什么是右值?什么是右值引用?

  • 右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(传值返回) 等等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址,不能赋值

  • 右值引用就是对右值的引用,给右值取别名。

  • 右值引用只能引用右值,不能引用左值。

  • 但是右值引用可以引用move以后的左值。

int main()
{
	double x = 1.1, y = 2.2;
	// 以下几个都是常见的右值
	10;
	x + y;
	fmin(x, y);
	
    // 以下几个都是对右值的右值引用
	int&& rr1 = 10;
	double&& rr2 = x + y;
	double&& rr3 = fmin(x, y);
 
    //右值不能取地址,不能赋值
	// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
    &10;
	10 = 1;
	x + y = 1;
	fmin(x, y) = 1;
    
    // 右值引用只能右值,不能引用左值。
	int&& r1 = 10;
	//int a = 10; 
	//int&& r2 = a;	// error:无法从“int”转换为“int &&”;无法将左值绑定到右值引用
    
	// 右值引用可以引用move以后的左值
	int&& r3 = std::move(a);
	return 0;
}

二、右值引用的使用场景

左值引用既可以引用左值和又可以引用右值(const左值引用),那为什么C++11还要提出右值引用呢?

  • 左值引用做函数参数可以减少拷贝次数,提高效率。但有时我们需要区分函数参数到底是左值引用还是右值引用。这是const左值引用无法做到的。

  • C++11提出右值引用之后,我们就可以重载一个参数是右值引用的函数,与左值引用进行区分处理。

  • const左值引用可以引用左值和也可以引用右值,但如果有专门的右值引用函数,编译器会优先选择后者。

区分出是右值引用后要干什么呢?

  • 之前我们只有左值引用,由于无法区分引用对象是左值还是右值,所以如果函数内涉及到对复杂类型的拷贝工作时,我们只能统统进行深拷贝,效率底下。
  • 而实际上右值对象通常都是一些匿名对象、函数返回值、表达式返回值等临时对象。其内部资源会在完成拷贝工作后立即被销毁。所以对右值复杂对象的深拷贝其实是一种浪费。
  • 右值引用的应用场景主要是移动语义。简单来说,移动语义就是将原本对右值复杂类型(涉及资源申请)的深拷贝工作,转变为直接移动其内部资源(主要指动态内存)。 移动语义的实现,减少了拷贝次数,提高了程序效率。
  • 移动语义包括:移动构造,移动赋值,移动插入。同时移动构造和移动赋值的实现解决了函数传值返回的深拷贝问题。

2.1 移动构造

以之前模拟实现的string类为例:【STL】模拟实现string类-CSDN博客

在拷贝构造的过程中:

  • 如果拷贝对象是左值,则必须进行深拷贝。

  • 但如果拷贝对象是右值,可以进行移动构造,提高效率。因为右值对象(又叫将亡值)会在完成构造后自动销毁,所以我们可以将右值对象的资源直接拿来占用,免去了开空间和拷贝数据的工作。

class string{
private:
	char *_str = nullptr; //注意!一定要将指针初始化为nullptr,防止野指针错误。
	size_t _size = 0;
	size_t _capacity = 0;
	
public:
  //拷贝构造
  Mystring(const Mystring &str){ //左值引用
    _size = str._size;
    _capacity = str._capacity;
    _str = new char[_capacity+1];
    memcpy(_str, str._str, str._size+1);
  }  
    
  //移动构造
  Mystring(Mystring &&str){ //右值引用
    swap(str);
  }  
    
  void swap(Mystring &str){
    ::swap(_str, str._str); 
    ::swap(_size, str._size);
    ::swap(_capacity, str._capacity);
  }
};

int main(){
	string str1 = "abc"; //构造(隐式类型转换)
    string str2 = str1; //左值构造——拷贝构造
    string str3 = str1 + str2; //右值构造——移动构造
  	return0;  
}

提示:

  • 注意!一定要将指针初始化为nullptr,防止野指针错误。

  • 在拷贝构造中,左值引用加const,只是为了保证对象在拷贝过程中不被修改。

  • 在移动构造中,右值引用不能加const,因为要在构造时移动右值对象的内部资源。


2.2 移动赋值

移动赋值也是同样的道理:

  • 如果拷贝对象是左值,则必须进行深拷贝。

  • 但如果拷贝对象是右值,可以进行移动赋值。因为右值对象(又叫将亡值)会在完成赋值操作后自动销毁,所以我们可以将右值对象的资源直接拿来占用,同时将赋值对象的原数据交换给右值对象让其帮助销毁。

class string{
  //拷贝赋值
  Mystring& operator=(const Mystring &str){ //左值引用
    if(this != &str) 
    {
      char *tmp = new char[str._capacity+1]; 
      memcpy(tmp, str._str, str._size+1);
      
      delete[] _str; 
      _str = tmp;
      _size = str._size;
      _capacity = str._capacity;
    }
    return *this; 
  }
	
  //移动赋值
  Mystring& operator=(Mystring &&str){ //右值引用 
    swap(str);
    return *this; 
  }
};

int main(){
    string str1 = "abc";
    string str2 = "def";
    str1 = str2; //左值赋值——拷贝赋值
    str1 = "ghi"; //右值赋值——移动赋值    
}

如果想让左值进行移动构造或者移动赋值怎么办?用move()!

在这里插入图片描述

move是一个函数模版,返回指定对象的右值引用,用于将左值临时转换为右值

int main(){
	string str1 = "abc"; 
    string str2 = str1; //左值构造——拷贝构造
    string str3 = move(str1); //move将str1临时转为右值——移动构造。
    //完成移动构造之后,str1中的资源就被转移走了,此时str1为空。
  	return 0}

2.3 移动插入

C++11以后,STL中的所有容器都增加了移动插入接口。

  • 原来C++98中的插入接口其实都是拷贝插入,即不管要插入的元素是左值还是右值都统统需要重新开空间并进行数据拷贝。

  • 而C++11中的移动插入接口则不同,如果插入的元素是右值,则直接移动其资源,无需进行拷贝,提高效率。

在这里插入图片描述

以list为例:

int main(){
    list<string> ls;
    string str = "hello world!";
    ls.push_back(str); //插入左值——拷贝插入
    ls.push_back(move(str)); //move将左值临时转为右值——移动插入
    ls.push_back("china"); //插入右值——移动插入
}

提示:list移动插入的模拟实现在【完美转发的使用场景】部分介绍。


2.4 解决传值返回的深拷贝问题

首先在讲解这个问题的解决方法之前,我们需要先回顾一下编译器是如何优化连续的构造和拷贝构造的:

【Object-Oriented C++】类和对象(下) {初始化列表,explicit关键字,匿名对象,static成员,友元,内部类,优化连续的构造和拷贝构造}_芥末虾的博客-CSDN博客

  • 在一条语句中,连续的构造和拷贝构造一般都会被编译器优化,将两个过程合二为一
  • 但是编译器不会对连续的拷贝构造和赋值重载进行优化,不能将两个过程合二为一。

因此当函数传值返回时,构造接收返回值和赋值接收返回值的优化结果是不同的,因该一分为二的看待。

2.4.1 构造接收

如果函数的返回值是一个局部对象,出了函数作用域就会被销毁,就不能使用引用返回,只能传值返回。

例如:在bit::string to_string(int value)函数中可以看到,这里只能使用传值返回。传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造),效率较低。

在这里插入图片描述

但C++11引入了右值引用之后,传值返回的深拷贝问题得到了彻底的解决。

在bit::string类中增加移动构造函数,再去调用bit::to_string(1234)

在这里插入图片描述

  1. 编译器会在返回对象进行销毁之前(调用析构函数),先将其临时转换为右值(类似于move());
  2. 然后调用移动构造,将返回对象中的资源直接移动到接收对象中,完成接收对象的构造。
  3. 最后才销毁返回对象,释放空间 。

注意:

  • 编译器在优化传值返回时,对析构函数的调用顺序做了特殊调整。

  • 不能显示的返回局部对象的右值引用。如果是显示返回,会先析构,再返回。在函数外访问时,空间已经被销毁。


2.4.2 赋值接收

再在bit::string类中增加移动赋值函数,再去调用bit::to_string(1234),不过这次是将bit::to_string(1234)返回的右值对象赋值给ret1对象,这时调用的是移动赋值。

注意:编译器不会对连续的拷贝构造和赋值重载进行优化,不能将两个过程合二为一。

在这里插入图片描述

  1. 这里运行后,我们会发现调用了一次移动构造和一次移动赋值。
  2. 因为如果是用一个已经存在的对象赋值接收,编译器就没办法优化了。bit::to_string函数中会先用str生成构造生成一个临时对象。
  3. 但是我们可以看到,编译器很聪明的在这里把str转换成了右值,调用移动构造来构造临时对象。
  4. 然后再把这个临时对象做为bit::to_string函数调用的返回值赋值给ret1,这里调用的是移动赋值。

总的来说,不管是构造接收还是赋值接收,不管会不会进行合并优化。由于移动构造和移动赋值的实现,使得复杂函数的传值返回不再需要进行深拷贝,大大提高了传值返回的效率。因此,STL中几乎所有的容器都增加了移动构造和移动赋值。


2.5 总结

左值引用和右值引用都是通过减少拷贝来提高效率的。

  • 左值引用:
    • 左值引用传参,左值引用返回,可以直接减少拷贝。
    • 漏洞一:没有解决用右值(将亡值)进行构造、赋值、插入时的对象拷贝问题。
    • 漏洞二:没有解决局部对象传值返回的深拷贝问题。
  • 右值引用:
    • 对于内置类型和没有动态内存申请的复杂类型,移动语义没有什么意义。
    • 但是对于有动态内存申请的复杂类型,移动语义可以间接减少拷贝构造(针对左值引用的漏洞进行了补充)。
    • 补丁一:如果是右值引用传参,则构造、赋值、插入不再进行深拷贝,而是直接移动资源,提高效率。
    • 补丁二:如果有资源申请的复杂类型实现了移动构造和移动赋值,在函数中返回该类型的局部对象时,会将其资源直接移动到外部接收对象中(赋值接收需要移动两次),无需进行拷贝,提高效率。

三、完美转发

3.1 完美转发的概念

模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值:

void Fun(int &x){ cout << "左值引用" << endl; }
void Fun(const int &x){ cout << "const 左值引用" << endl; }
void Fun(int &&x){ cout << "右值引用" << endl; }
void Fun(const int &&x){ cout << "const 右值引用" << endl; }

//模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
template<typename T>
void PerfectForward(T&& t) //模板中的&&——万能引用
{
	Fun(t);
}

int main()
{
	PerfectForward(10); // 右值
	int a;
	PerfectForward(a); // 左值
	PerfectForward(std::move(a)); // 右值
	const int b = 8;
	PerfectForward(b); // const 左值
	PerfectForward(std::move(b)); // const 右值
	return 0;
}

运行结果:

在这里插入图片描述

为什么全都调用的是左值引用版本的Fun函数呢?

给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,并对值进行修改

  • 例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。

  • 如果不想rr1被修改,可以用const int&& rr1 去引用,const右值引用可以取地址,不可修改。

  • 可以这么理解:右值取右值引用后变为了左值,这么设计是因为要使用右值引用移动右值对象的资源,而移动资源就意味着要修改右值(矛盾),所以要将右值转为左值。

int main()
{
	double x = 1.1, y = 2.2;
	int&& rr1 = 10;
	const double&& rr2 = x + y;
	rr1 = 20;
	rr2 = 5.5; // 报错,const右值引用不能修改
	return 0;
}

那么如何在内外层函数传递参数的过程中保持参数的原生类型属性呢?这时就需要用到新语法:完美转发

//同样还是上面的代码,加入完美转发
template<typename T>
void PerfectForward(T&& t) //模板中的&&——万能引用
{
    // std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
	Fun(std::forward<T>(t));
}

再次运行:

在这里插入图片描述

注意:在多层嵌套调用时,要想在内外层函数传递参数的过程中保持参数的原生类型属性,需要在所有的传参位置进行完美转发。


3.2 完美转发的使用场景

以之前模拟实现的list和string为例:

下面我们实现Mylist的移动插入:

template <class T>    
struct list_node{
  T _data;                         
  list_node *_next;        
  list_node *_prev;    
  //节点的构造 
  list_node(const T &val = T()) //左值引用    
    :_data(val), //调用存储类型的拷贝构造             
    _next(nullptr),    
    _prev(nullptr)    
  {}  
  //重载了右值引用版本                         
  list_node(T &&val = T()) //右值应用    
    :_data(forward<T>(val)), //完美转发3-->调用存储类型的移动构造  
    _next(nullptr),    
    _prev(nullptr)                 
  {}                         
};                             

template <class T>
class List{
  //拷贝插入 
  iterator insert(iterator pos, const T &val){
    Node *cur = pos._pnode;
    Node *prev = cur->_prev;
    Node *newnode = new Node(val);
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev = newnode;
    return iterator(newnode);
  }
    
  //移动插入  
  iterator insert(iterator pos, T &&val){
    Node *cur = pos._pnode;
    Node *prev = cur->_prev;
    //需要在所有的传参位置进行完美转发
    Node *newnode = new Node(forward<T>(val)); //完美转发2
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev = newnode;
    return iterator(newnode);
  }
  //拷贝插入 
  void push_back(const T &val){  
    insert(end(), val); //val是左值引用,调用拷贝插入insert             
  }                                
  //移动插入                     
  void push_back(T &&val){               
    //insert(end(), val); //右值引用将val转换为左值,所以也调用拷贝插入insert  
    insert(end(), forward<T>(val)); //完美转发1
  }    
};

测试代码:

#include <iostream>    
#include "list.hpp"    
#include "string.hpp"    
using namespace std;    
    
int main(){        
  Mylist<Mystring> ls; //在创建头结点时会进行一次移动构造(用匿名对象初始化头结点)
  cout << "----------------------------------" << endl;    
  Mystring str1 = "abcd";    
  cout << "----------------------------------" << endl;    
  ls.push_back(str1); //插入左值——拷贝构造                                   
  cout << "----------------------------------" << endl;      
  ls.push_back(Mystring("qwer"));  //插入右值——移动构造        
  cout << "----------------------------------" << endl;      
  ls.push_back("1234"); //插入右值——移动构造                 
  cout << "----------------------------------" << endl;      
}             

完美转发前:

在这里插入图片描述

由于右值引用会将右值的属性转换为左值,所以也去调用了拷贝插入insert 。因此我们需要将移动插入过程中所有涉及的函数都实现一份右值引用版本,并在所有的传参位置进行完美转发,以保持参数的右值属性。

完美转发后:

在这里插入图片描述


四、新的类功能

4.1 默认成员函数

原来C++类中,有6个默认成员函数:

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值重载
  5. 取地址重载
  6. const 取地址重载

最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会自动生成一个默认的。

C++11 新增了两个默认成员函数:移动构造函数和移动赋值运算符重载。

针对默认移动构造和默认移动赋值有一些需要注意的点:

  1. 如果你没有实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。在进行右值构造时调用默认生成的移动构造函数,对于内置类型成员会执行逐字节拷贝;对于自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,否则就调用拷贝构造。

  2. 如果你没有实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。在进行右值赋值时调用默认生成的移动赋值函数,对于内置类型成员会执行逐字节拷贝;对于自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,否则就调用拷贝赋值。

  3. 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。

  4. 为什么生成默认移动构造/赋值的前提条件是:没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个?

    如果你实现了其中之一,编译器就会认为该类是直接涉及资源申请的类。 因为只有直接涉及资源申请的类才有实现析构 、拷贝构造、拷贝赋值的必要。而面对这种类,编译器不知道该如果进行资源的移动处理。所以就不会生成默认移动构造/赋值。

// 以下代码在vs2013中不能体现,在vs2019下才能演示体现上面的特性。
class Person
{
public:
	Person(const char* name = "", int age = 0)
	:_name(name)
	, _age(age)
	{}
    
    //生成默认移动构造/赋值的前提条件是:没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个
	/*Person(const Person& p)
	:_name(p._name)
	,_age(p._age)
	{}*/
    
	/*Person& operator=(const Person& p)
	{
		if(this != &p)
		{
			_name = p._name;
			_age = p._age;
		}
		return *this;
	}*/
    
	/*~Person()
	{}*/
    
private:
	string _name;
	int _age;
};

int main()
{
	Person s1;
	Person s2 = s1; //拷贝构造
	Person s3 = std::move(s1); //移动构造
	Person s4;
	s4 = std::move(s2); //移动赋值
	return 0;
}

运行结果:

在这里插入图片描述

只要实现了析构函数 、拷贝构造、拷贝赋值重载中的任意一个,就不会生成默认移动构造/赋值:

在这里插入图片描述

总结:

  • 直接涉及资源申请的类需要显示的写析构、拷贝/移动构造、拷贝/移动赋值,以实现类的深析构/深拷贝。例如:上面的Mystring, Mylist类。
  • 未涉及资源申请的类不需要写析构、拷贝/移动构造、拷贝/移动赋值,默认生成的就会完成类的值拷贝(浅拷贝)。移动语义对于未涉及资源申请的类无意义。
  • 未直接涉及资源申请的类也不需要写析构、拷贝/移动构造、拷贝/移动赋值,默认生成的就会调用其自定义类型成员的析构、拷贝/移动构造、拷贝/移动赋值。例如:上面的Person类。

4.2 强制生成默认函数的关键字default

C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成默认移动构造/赋值了,那么我们可以使用default关键字显示指定移动构造/赋值生成。

class Person
{
public:
	Person(const char* name = "", int age = 0)
	:_name(name)
	, _age(age)
	{}
    
	Person(const Person& p) //实现了拷贝构造
	:_name(p._name)
	,_age(p._age)
	{}
    
	Person(Person&& p) = default; //强制生成默认移动构造
    Person& operator=(Person&& p) = default; //强制生成默认移动赋值
    
private:
	bit::string _name;
	int _age;
};

int main()
{
	Person s1;
	Person s2 = s1; //拷贝构造
	Person s3 = std::move(s1); //移动构造
	Person s4;
	s4 = std::move(s2); //移动赋值
	return 0;
}

运行结果:

在这里插入图片描述


4.3 禁止生成默认函数的关键字delete

  • 如果能想要禁止某些默认函数的生成,在C++98中是将该函数设置成private,并且写明函数声明即可,这样只要其他人想要调用就会报错。

  • 在C++11中更简单,只需在该函数声明后加上=delete即可。该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。

  • 比如istream和ostream类就禁止生成默认的拷贝构造,即不允许拷贝构造对象:

    在这里插入图片描述

class Person
{
public:
	Person(const char* name = "", int age = 0)
	:_name(name)
	, _age(age)
	{}
    
	Person(const Person& p) = delete; //禁止生成默认拷贝构造:C++11
    Person(Person&& p) = default; //强制生成默认移动构造
    
private:
	bit::string _name;
	int _age;
    //Person(const Person& p) //禁止生成默认拷贝构造:C++98
};
int main()
{
	Person s1;
	Person s2 = s1; //不允许进行拷贝构造,报错。
    //由于声明了拷贝构造(虽然是禁止声明),所以没有生成移动构造。
    //要想进行移动构造需要使用default进行强制生成。
	Person s3 = std::move(s1);
	return 0;
}

4.4 类成员变量初始化

C++11允许在类成员变量声明时指定缺省值,默认生成的构造(包括拷贝构造、移动构造)函数会在初始化列表使用这些缺省值进行初始化,这个我们在类和对象章节就讲了,这里就不再细讲了。

详细内容请回顾:【Object-Oriented C++】类和对象(下) {初始化列表,explicit关键字,匿名对象,static成员,友元,内部类,优化连续的构造和拷贝构造}_芥末虾的博客-CSDN博客

4.5 继承和多态中的final与override关键字

这两个关键字我们在继承和多态章节已经进行了详细讲解这里就不再细讲。

  • final:1. 修饰类,表示该类不能被继承;2. 修饰虚函数,表示该虚函数不能被重写
  • override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

详细内容请回顾:

  1. 【Object-Oriented C++】继承 {继承的概念;继承的定义;继承中的作用域;基类和派生类对象赋值转换;派生类的默认成员函数;继承与友元;继承与静态成员;菱形继承和虚拟继承;继承和组合}
  2. 【Object-Oriented C++】多态 {多态的构成条件,virtual函数;抽象类;多态的实现原理,虚函数表,动态绑定与静态绑定;单继承和多继承的虚函数表;}_芥末虾的博客-CSDN博客
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

芥末虾

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值