【C++11】右值引用 + 移动语义 + 完美转发(重点)

本文详细介绍了C++STL中左值、右值、右值引用的概念,以及移动构造、移动赋值和完美转发的作用。重点讨论了如何利用右值引用提高效率,以及在不同场景下的应用,如移动语义在容器插入和完美转发中的体现。
摘要由CSDN通过智能技术生成

在这里插入图片描述

👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨


前言:STL中一些变化

• 新容器

C++11中新增了以下几个容器(用橘色圈起来):

在这里插入图片描述

实际上最有用的是哈希系列unordered_mapunordered_set

剩下的容器arrayforward_list非常鸡肋,实际上很少使用。

  • array容器

文档介绍:点击跳转

在这里插入图片描述

C++11标准中,引入了一个容器array,它的底层使用了非类型模板参数,是一个真正意义上的泛型数组(定长数组),这个是用来对标C语言传统数组的。

以下是array容器的基本用法:

在这里插入图片描述

看完以上接口,array支持的,数组也都是支持的。那么它们有什么区别呢?

  • 相同点:array也并没有进行初始化。

在这里插入图片描述

  • 要说有区别的话:array对于越界读、写检查更为严格;传统数组越界读写,不会发生报错

在这里插入图片描述

【吐槽】虽然对越界行为检查严格 ,但在实际开发中,很少使用array容器,因为它对标传统数组,连初始化都没有,并且大小也是固定的,因此不够灵活。

相比之下,vector也是类似于数组的容器,它允许动态改变大小、对于越界读和写检查也一样严格。因此,在功能和实用性上可以全面碾压array,因此可以说array是一个鸡肋的容器。

  • forward_list容器

文档介绍:点击跳转

在这里插入图片描述

以下是forward_list的常见接口:

在这里插入图片描述

forward_list翻译过来就是单链表,因此一个结点只存值和指向下一个结点的指针。算了,直接开始(吐槽)吧。首先先说结论:forward_list还是一个非常鸡肋的容器。

  • 从以上接口可以看出,它只支持头删pop_front和头插push_front。为什么不支持尾删和尾插呢?因为它效率低啊!尾插需要找到尾结点、尾删需要找到尾节点的前一个结点,这些操作都要从头部开始向后遍历,时间复杂度铁铁的O(N)
  • 另外,forward_list还不提供size()接口

如果要说forward_list有优势,那就是内存占用更小(每个结点节省了一个前驱指针),但是它还是比较鸡肋,因此在实际中使用list会更多一点。

• 容器中的一些新方法

如果我们再细细去看会发现基本每个容器中都增加了一些C++11的方法,但是其实很多都是用得比较少的。

比如cbegincend

在这里插入图片描述

这玩意其实也很鸡肋,因为普通版的beginend都已经重载了const版本。对于C++开发人员还是区分得开的。

有坏当然也有好的方面,比如:

  1. 所有容器均重载了initializer_list<T>,使容器初始化更加方便。
  2. 所有容器均支持移动构造和移动赋值,可以极大提高效率(本篇重点)

在这里插入图片描述

  1. 所有容器均支持右值引用相关插入接口,同样可以提高效率(本篇重点)

在这里插入图片描述


一、如何判断左值和右值

要想搞懂左值引用和右值引用,首先要得明白什么是左值和右值。很多人认为在赋值符号左边的就是左值,在赋值符号右边的就是右值。 这其实并不完全正确,比如:

int main()
{
	int a = 1; // a是左值
	int b = a; // a又变成右值?
}

所以,以上变量a到底是左值还是右值?(答案是:左值)

  • 交给大家简单粗暴的判断左值的方法:可以取地址就是左值

举个例子,以下是常见的左值:

在这里插入图片描述


  • 如何判断右值?

右值通常被认为是临时的、一次性的值或者是将亡值右值可以出现在赋值符号的右边,但是绝不能出现出现在赋值符号的左边。就这么说吧,只要 不能取地址的就是右值。常见的右值有:字面常量、表达式返回值,函数返回值(临时对象返回)等。

在这里插入图片描述

二、左值引用和右值引用

大家只要记住一句话,不管是什么引用,都是取别名左值引用就是对左值取别名,右值引用就是对右值取别名

  • 首先先来看看左值引用的例子
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;

	return 0;
}

由上可以看出,左值引用就是C++刚入门学的那个引用,唯一有区别的还是右值引用。

  • 用两个&&表示右值引用。
double Min(double x, double y)
{
	return x > y ? y : x;
}

int main()
{
	// 以下几个都是常见的右值
	10;
	1.1 + 2.2;
	Min(1.1, 2.2);

	// 以下几个都是对右值的右值引用
	int&& rr1 = 10;
	double&& rr2 = 1 + 2;
	double&& rr3 = Min(1, 2);

	return 0;
}

三、左值引用 vs 右值引用

  • 问题1:左值引用能否给右值取别名?

在这里插入图片描述

正常来说是不行的,但由于 右值都具有常属性,因此用const引用即可

在这里插入图片描述

  • 问题2:右值引用能否引用左值?

在这里插入图片描述

编译器已经给出很明确的报错信息了:无法将右值引用绑定到左值

但右值引用可以引用move以后的左值点击跳转

既然左值引用即可以引用左值,也能引用右值。那么以下情景是否构造函数重载是否存在调用歧义呢

在这里插入图片描述

答案是当然构成重载,编译器会选择最匹配的参数调用

在这里插入图片描述

四、move函数

  • 虽然右值引用不能直接引用左值,但是可以通过调用一个名为move函数来获得绑定到左值上的右值引用

  • move的主要作用是显式地标识对象为右值,以便编译器能够选择调用移动语义相关的操作,而不是进行拷贝操作

int a = 0;
int&& rr = move(a);

可以这么理解:当右值引用 引用右值时,则是先将引用的对象的临时资源 “转移” 到特定位置(不会发生拷贝),然后指向该位置中的资源,此时可以对右值进行修改操作。

在这里插入图片描述

另外,虽然右值引用引用的是右值,但右值引用本身是可以取地址的

在这里插入图片描述

除此之外,语法还支持给右值引用加const,这样做的含义是 不能修改右值引用后的值

在这里插入图片描述

但我们一般建议不要用const右值引用,因为使用右值引用就是为了“转移”资源,加了const还不如直接改用const左值引用。


注意:不要轻易使用move函数,左值中的资源可能会被转走。如果此时我们直接将左值move后构造一个新对象,会导致原本左值中的资源丢失

在这里插入图片描述

五、 右值引用使用场景

5.1 场景一:移动语义

前面我们可以看到左值引用既可以引用左值也可以通过const引用引用右值,那为什么C++11还要提出右值引用呢?

既然提出了就一定是为了解决左值引用存在的缺陷,那么我们可以通过分析左值引用的使用场景及核心价值来推断。

【左值引用】

  • 使用场景:1. 做输出型参数(形参的改变影响实参)。 2. 做返回值。
  • 核心价值:减少拷贝,提高效率。

我们知道,左值引用做返回值是有一定的缺陷的!如果是左值引用做返回值,出了作用域,对象不能被销毁;如果出了作用域,对象被销毁,那么就不能使用左值引用做返回值
(不知道为什么可以看看这篇博客:点击跳转

string func()
{
	string str("hello world");
	return str;
}

int main()
{
	string s = func();
	cout << s << endl;
	return 0;
}

str是局部对象,出了func函数作用域,对象销毁,那么就不能用左值引用返回,那么按照惯例只能使用传值返回。而 传值返回是有代价的,对于较大的对象(如大型结构体、类对象等),可能会导致较大的性能开销,因为它需要在内存中复制整个对象的内容

接下来可以简单的来验证一下,下面是简单模拟实现的string类,为了更好的观察是否发生了深拷贝行为,在 拷贝构造函数中加入了对应的打印语句。

  • string.h
#pragma once
#include <iostream>
#include <assert.h>
namespace wj
{
	class string
	{
	public:
		typedef char* iterator;
		iterator begin()
		{
			return _str;
		}
		iterator end()
		{
			return _str + _size;
		}
		
		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			_str = new char[_capacity + 1];
			strcpy(_str, str);
		}
		
		void swap(string& s)
		{
			swap(_str, s._str);
			swap(_size, s._size);
			swap(_capacity, s._capacity);
		}
		// 拷贝构造
		string(const string& s)
			:_str(nullptr)
		{
			cout << "string(const string& s) -- 深拷贝" << endl;
			string tmp(s._str);
			swap(tmp);
		}

		// 赋值重载
		string& operator=(const string& s)
		{
			cout << "string& operator=(string s) -- 深拷贝" << endl;
			string tmp(s);
			swap(tmp);
			return *this;
		}
		
		~string()
		{
			delete[] _str;
			_str = nullptr;
		}
		char& operator[](size_t pos)
		{
			assert(pos < _size);
			return _str[pos];
		}
		void reserve(size_t n)
		{
			if (n > _capacity)
			{
				char* tmp = new char[n + 1];
				strcpy(tmp, _str);
				delete[] _str;
				_str = tmp;
				_capacity = n;
			}
		}
		void push_back(char ch)
		{
			if (_size >= _capacity)
			{
				size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
				reserve(newcapacity);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}
		//string operator+=(char ch)
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}
		const char* c_str() const
		{
			return _str;
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity; // 不包含最后做标识的\0
	};
}
  • Test.cpp

在这里插入图片描述

以上代码本来应该是两次拷贝构造,但对于一行的两次拷贝构造,新一点的编译器会优化成一次拷贝构造。虽然优化成一次拷贝构造,但是还是需要拷贝整个对象的内容,但是有很大的消耗。

因此,C++11中允许使用移动语义之移动构造解决上述问题:在wj::string中增加移动构造,移动构造本质是窃取别人的资源来构造自己,占位已有,那么就不用做深拷贝了(提高性能),所以它叫做移动构造。

  • 移动构造
string(string&& s)
	:_str(nullptr)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);
}

接下来再来看看结果:

在这里插入图片描述

那么问题来了,对象str是一个左值啊,它是怎么调用移动构造函数的?

这其实是编译器的优化!当Myfunc函数返回一个wj::string对象时,编译器会在其内部将str视为右值,并使用移动构造函数来将其内容转移到 s 中。这样做可以避免不必要的拷贝操作,提高了程序的性能


接下来再来看,如果两次的拷贝构造不再同一行,编译器就不能实现优化,那么就是实打实的两次拷贝构造,那么这个消耗是巨大的。(在此之前我屏蔽了移动构造)

在这里插入图片描述

第三次打印的结果是无法避免的,因为在调用operator=会重新开辟空间来深拷贝对象的资源。

在这里插入图片描述

因此,C++11同时也引入了移动语义之移动赋值,用于在对象之间实现资源的高效转移。移动赋值运算符允许将一个对象的资源从另一个对象转移到自身,而不是通过拷贝构造或拷贝赋值运算符来进行资源的复制。

  • 移动赋值
// 赋值重载
string& operator=(string&& s)
{
	cout << "string& operator=(string& s) -- 移动赋值" << endl;
	swap(s);
	return *this;
}

在这里插入图片描述

接下来再将移动拷贝的代码取消注释,然后再来看看打印结果

在这里插入图片描述

综上,移动语义(移动构造 + 移动赋值)弥补了自定义类型中深拷贝的类,必须传值返回的场景。避免不必要的资源复制,提高了程序的性能和效率

5.2 场景二:STL容器插入接口函数

C++11标准出来之后,STL中的容器除了增加移动构造和移动赋值之外,STL容器插入接口函数也增加了右值引用版本。

在这里插入图片描述

那么右值引用版本插入函数的意义是什么呢?

如果list容器当中存储的是string对象,那么在调用push_backlist容器中插入元素时,可能会有如下几种插入方式:

在这里插入图片描述

  • 对于第一个一定会完成深拷贝,因为s对象是左值,那么lt对象在调用push_back一定会选择最合适的,也就是void push_back (const value_type& val);

  • 剩下的一定会调用void push_back (value_type&& val);。字符串字面量(如 "11111111111111")时,它会调用右值引用版本的 push_back。这是因为字符串字面量是临时对象,是右值,而 push_back 函数的右值引用版本接受右值参数(提高效率)。

5.3 场景三:完美转发

5.3.1 万能引用

在模板中的&&不代表右值引用,而是万能引用,其既能接收任意类型的左值和右值。

  • 如果传入的实参是左值,那么编译器就会将模板实例化为左值引用,也称做引用折叠。
  • 如果传入的实参是右值,那么编译器就会将模板实例化为右值引用。

【基本语法】

template<class T>
void PerfectForward(T&& t)
{
	//...
}

下面重载了四个Func函数,参数类型分别是左值引用、const左值引用、右值引用和const右值引用。在主函数中调用PerfectForward函数时分别传入左值、右值、const左值和const右值,在PerfectForward函数中再调用Func函数。

我们来判断是否真的很“万能”

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;
}

【程序结果】

在这里插入图片描述

输出的结果好像和我们一开始说的不太一样,最终都匹配到了左值引用版本的Func函数。接下来可以分析为什么?

首先先看第一次调用PerfectForward(10),由于PerfectForward函数的参数类型是万能引用,因此在编译器眼中其实是如下这样的:

// 实参10是int类型,那么对应的T应该实例化为int
// 并且实参10是右值,编译器就会将模板实例化为右值引用
template<typename int>
void PerfectForward(int&& t)
{
    Fun(t);
}

int main()
{
	PerfectForward(10); // 右值
	return 0;
}

这下好像有点眉目了,实参(右值)10传递给形参t,然后再通过t去调用Func函数,而t虽然引用右值,但是它本身是可以被取到地址,并且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。

也就是说,右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发。

5.3.2 完美转发保持值的属性

要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。基本语法如下:

template<class T>
void PerfectForward(T&& t)
{
	Func(forward<T>(t));
}

经过完美转发后,调用PerfectForward函数时传入的是右值就会匹配到右值引用版本的Func函数,传入的是const右值就会匹配到const右值引用版本的Func函数,这就是完美转发的价值。

在这里插入图片描述

六、相关代码

Gitee仓库链接:点击跳转

  • 11
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值