C++11『右值引用 ‖ 完美转发 ‖ 新增类功能 ‖ 可变参数模板』

✨个人主页: 北 海
🎉所属专栏: C++修行之路
🎃操作环境: Visual Studio 2022 版本 17.6.5

成就一亿技术人

文章目录


🌇前言

自从C++98以来,C++11无疑是一个相当成功的版本更新。它引入了许多重要的语言特性和标准库增强,为C++编程带来了重大的改进和便利。C++11的发布标志着C++语言的现代化和进步,为程序员提供了更多工具和选项来编写高效、可维护和现代的代码


🏙️正文

1.右值引用

右值引用C++11 的重大更新之一,它的出现很好的解决了 临时资源浪费 的问题,同时也给 类和对象 做了一个全面升级,使其能轻松规避很多低效拷贝问题

1.1.什么是右值引用?

在学习 右值引用 之前,需要先来看看 左值引用引用C++ 相对于 C语言 的升级点之一,引用 既能像指针那样获取资源的地址,直接对资源进行操纵,也不必担心多重 引用 问题,对于绝大多数场景来说,引用指针 好用得多

而我们之前使用的所有引用都称为 左值引用 ,主要用于引用各种 变量 ,如果想引用 常量 ,需要使用 const 修饰

cpp 复制代码
// 左值引用
int main()
{
	int a = 10;

	// 引用变量
	int& ra = a;

	// 引用 常量/临时对象
	const int& rb = 10;
	const int& rc = int();
	return 0;
}

C++11 中,新增了 右值引用 的概念,就是将 左值引用 中的 & 变为 &&右值引用 可以直接引用 左值引用 中需要加 const 引用的值;也可以通过函数 move 引用 左值引用 直接引用的值

cpp 复制代码
// 右值引用
int main()
{
	int a = 10;

	// 引用 常量/临时对象
	int&& rrb = 10;
	int&& rrc = int();

	// 引用变量
	int&& ra = move(a);
	return 0;
}

其中,诸如 「变量 / 数组元素 / 解引用后的指针」 等,在表达式结束后仍然存在、并且可以被取地址的值称为 左值 ;而 「常量 / 临时对象 / 表达式结果」 等,在表达式结束后即将被销毁的临时对象,或者无法被直接取地址的值称为 右值

快速判断 左值 / 右值 的方法之一就是 看看能不能取地址

cpp 复制代码
// 判断左值 / 右值
int main()
{
	int a = 10;

	// 左值
	cout << &a << endl;

	// 右值
	cout << &10 << endl; // 【报错】
	cout << &int() << endl; // 【报错】
	return 0;
}

直接可以引用 左值 的称为 左值引用 ,直接可以引用 右值 的就是 右值引用

注意:

  1. 左值引用 可以通过其他手段引用 右值 ,比如加 const右值引用 也可以通过其他手段引用 左值 ,比如 move 函数
  2. 赋值语句左边的一定是 左值 ,但右边的不一定是 右值 ,比如 int a = b

1.2.move 转移资源

无论是 左值引用 还是 右值引用 ,本质上都是在给 资源 起别名,当 左值引用 引用 左值 时,是直接指向 资源 ,从而对 左值 进行操作;当 右值引用 引用 右值 时,则是先将 常量 等即将被销毁的临时资源 "转移" 到特定位置,然后指向该位置中的 资源 ,对 右值 进行操作

cpp 复制代码
int a = 10;

// 左值引用 引用 左值
int& ra = a;

// 右值引用 引用 右值
int&& rr = 10;

正因为将资源 "转移" 了,右值引用 才可以对资源进行利用

所以虽然 右值引用 引用的是 右值 ,但 右值引用 本身是可以取地址的,比如 &rr 是可以的,毕竟 rr 也指向了一块空间,这块空间中存储的是临时资源,这也就意味着 右值引用 是可以对临时资源进行修改操作的,也就是将临时资源再利用

对于 「常量 / 临时对象 / 表达式结果」右值 ,编译器会直接转移资源,但对于用户自定义的 左值 ,编译器不敢轻举妄动,只敢给用户提供一个 转移变量资源 的函数 move,有了 move 之后,右值引用 就能引用 左值

cpp 复制代码
int a = 10;

// 左值引用 引用 右值
const int& r = 10;

// 右值引用 引用 左值
int&& rr = move(a);

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

cpp 复制代码
int main() 
{
	int a = 10;

	const int&& crr = 10;
	const int&& crra = move(a);

	++crr; // 【报错】
	++crra; // 【报错】

	return 0;
}

一般情况下是不会这样干的,右值引用 是为了移走资源,加了 const 还不如直接改用 const 左值引用


不要轻易使用 move 函数,左值 中的资源可能会被转走,在 C++11 之后,几乎所有的 STL 容器都增加了一个 移动构造 函数,其中就用到了 右值引用

如果此时我们直接将 左值 move 后构造一个新对象,会导致原本左值中的 资源 丢失

cpp 复制代码
// move 转移资源
int main()
{
	string str = "Hello World!";
	cout << "str: " << str << endl;

	// 使用 move 函数后
	string tmp = move(str);
	cout << "str: " << str << endl;

	return 0;
}

所以一般情况下不要轻易使用 move 移动函数,除非你确定该资源后续不再使用

1.3.左值引用 vs 右值引用

C++11 之前,使用 const 左值引用 也可以引用 右值 ,并且在我们之前的学习中只使用 左值引用 也没什么大问题啊,那为什么还要搞出一个 右值引用 呢?

答案是 右值引用可以提高资源的利用率,进而提高整体效率

有了右值引用之后,之前只能 【读取】、【拷贝】的临时资源变得更有价值了,可以在右值引用后进行操作,也可以将资源转移以减少拷贝

下面是 左值引用右值引用 的对比图

特征 左值引用 右值引用
语法 Type& lvalueRef = variable; Type&& rvalueRef = std::move(variable);
绑定对象 现有对象 临时对象或可移动对象
典型用途 函数参数、返回类型 移动语义、完美转发
示例 int x = 10; int& ref = x; int&& rref = 10;
可重新赋值
可为 nullptr 是(需谨慎使用)
引用折叠(C++11) Type&& && 折叠为 Type&&
生命周期延长(C++20) 否(延长临时对象的生命周期) 是(绑定到临时对象,如果绑定到右值则延长生命周期)

注意: 表格提供了一个高层次的概述,实际上有更多的细节和差异,尤其是在C++的后续版本中引入的一些特性

1.4.右值引用的使用场景

右值 分为

  • 纯右值(内置类型)
  • 将亡值(自定义类型)

纯右值 价值不大,但 将亡值 就不一样了,直接转移 将亡值 资源可以减少 拷贝次数,所以 右值引用 的使用场景主要体现了 拷贝

下面是简单模拟实现的 string,其中并未涉及 右值引用 相关知识

简单模拟实现的 string

cpp 复制代码
namespace Yohifo
{
	class string
	{
	public:
		typedef char* iterator;
		iterator begin()
		{
			return _str;
		}

		iterator end()
		{
			return _str + _size;
		}

		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			//cout << "string(char* str)" << endl;
			_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)
		{
			push_back(ch);
			return *this;
		}

		string operator+(char ch)
		{
			string tmp(*this);
			tmp += ch;
			return tmp;
		}

		const char* c_str() const
		{
			return _str;
		}

	private:
		char* _str;
		size_t _size;
		size_t _capacity; // 不包含最后做标识的\0
	};
}

为了更好的观察是否发生了 深拷贝行为 ,在 拷贝构造 函数中加入了对应的打印语句,这里的参数为 const 左值引用

主函数中测试 左值右值 两种拷贝构造

主函数 main.cpp

cpp 复制代码
int main()
{
	Yohifo::string str = "Hello World!";

	// str 为左值
	Yohifo::string s1 = str;

	// str+'\n' 为右值
	Yohifo::string s2 = str + '\n';

	return 0;
}

为什么要加 \n
防止编译器直接将 拷贝构造 优化为 直接构造

首先是测试 C++11 之前的结果(没有 右值引用

可以看到这里发生了 3深拷贝 行为,其中一次为 str 拷贝构造,一次为 str + '\n' 拷贝构造,还有一次是 operator+() 函数中的拷贝行为(无法避免)

现在足以证明,在没有使用 右值引用 的情况下,即便是传入 右值 ,触发的也是 深拷贝 ,浪费了 右值 这个临时资源

注意: 如果此时只显示了两次深拷贝,那是因为 VS 的平台工具集 v143 存在问题,会将 str+'\n' 这次拷贝构造优化掉,解决方法就是将平台工具集改为 v142

接下来在 string 中重载一个 拷贝构造 函数,参数为 右值引用 ,此时称为 移动构造

移动构造 string() --- 位于 string

cpp 复制代码
// 移动构造
string(string&& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);
}

编译后再次运行,可以看到此时少了一次 深拷贝 ,多了一次 移动构造

移动构造 是由 str+'\n' 拷贝构造时触发的,又因为参数是 右值(临时对象) ,所以这里的 string 对象只需与 "右值" 进行 swap 就行了

可以通过调试证明 s2 的资源是从其他地方 "转移" 过来的


如今的编译器都很智能,会自动进行优化以减少拷贝,比较典型的就是 构造 + 拷贝构造 优化为直接构造,那么对于 移动构造 编译器是否会做出优化?

为了模拟优化场景,这里简单实现一个 to_string,目的是为了在函数结束后返回一个 临时对象

整型转为字符串 to_string() --- 位于命名空间 Yohifo

cpp 复制代码
string to_string(int val)
{
	bool flag = false;
	if (val < 0)
	{
		flag = true;
		val *= -1;
	}

	string ret;
	while (val)
	{
		int n = val % 10;
		ret += n + '0';
		val /= 10;
	}

	if(flag)
		ret += '-';

	std::reverse(ret.begin(), ret.end());

	return ret;
}

主函数中就负责调用 to_string() 获得一个临时对象,然后通过该临时对象去构造一个对象

cpp 复制代码
int main()
{
	Yohifo::string str(Yohifo::to_string(100) + 'a');

	return 0;
}

首先来看看 C++11 之前(屏蔽 移动构造

结果为 3 次深拷贝

分析:第一次为 to_string() 函数执行完后返回的临时对象的拷贝,第二次为 operator+() 函数中生成的临时对象(不可避免),第三次为 strto_string() + 'a' 形成的临时对象的拷贝

实际拷贝次数不止 3 次,就拿 to_string() 函数来说,需要先将 ret 拷贝给 临时对象 ,再将 临时对象 拷贝给 调用者 ,编译器在这里会优化,优化成一次拷贝构造:ret 拷贝给调用者

这里的 深拷贝 是可以避免的,现在重新启用 移动构造 函数,再看看结果

可以看到 3深拷贝 变成了 2移动构造 + 1深拷贝(不可避免)

分析:第一次拷贝的对象是 临时对象(右值),资源即将销毁,触发 移动构造,将资源及时转移;第三次拷贝也是如此,同样可以通过 移动构造 将临时对象资源转移

对于 to_string() 函数来说,也不应该只发生一次 移动构造 ,实际应该先把 ret 拷贝给 临时对象 ,再将 临时对象 中的资源转移;但编译器判断 ret 是一个局部变量,出了函数就销毁了,于是就优化成了 return move(ret); 函数返回时将 ret 中的资源通过 move 函数转移

由此可以看出,编译器会在 临时对象 当作中间人连续赋值的场景中,直接将 临时对象 优化掉,尽量减少拷贝,这才有了 to_string() 函数中最终看到的 一次拷贝构造 / 一次移动构造

言归正传,得益于 移动构造临时对象 的资源得到了回收利用,传值返回时不再需要经过无意义且低效的 深拷贝

这里只是一个小小的 string,如果是 vectormapunordered_map 等基于模板的复杂容器,移动构造 带来的效率提升是非常显著的


关于 移动构造 相关问题

Q1:能否将函数返回值设为 右值引用?

答案是 不行 ,不是说单纯的 右值引用 解决了 无效深拷贝 问题,而是基于 右值引用 实现的 移动构造 解决了问题,所以无论是 右值引用 还是 左值引用 ,在面对 传值返回时,都不能作为函数返回值类型,返回局部对象引用会导致程序异常退出

并且在使用 右值引用 作为返回类型时,需要手动把 ret 这个左值 move,否则无法编译(右值引用不能直接引用左值),即使编译通过了,运行后也是有问题的

有问题的函数 to_string()

cpp 复制代码
string&& to_string(int val)
{
	bool flag = false;
	if (val < 0)
	{
		flag = true;
		val *= -1;
	}

	string ret;
	while (val)
	{
		int n = val % 10;
		ret += n + '0';
		val /= 10;
	}

	if(flag)
		ret += '-';

	std::reverse(ret.begin(), ret.end());

	return move(ret);
}

可以看到,不仅没有触发 移动构造 ,还迫使程序异常终止(退出码不为 0

Q2: 函数传值返回,但在返回时能否手动 move 返回值?

答案是 可以的 ,前面说过,编译器优化后,会自动给返回值加上 move 以取出其中的资源,所以这里手动加上也没问题,但没必要

结果也是正常的

Q3: 右值引用什么时候作为参数类型使用?

当传入的参数为 右值 时,推荐使用 右值引用 作为参数类型;如果既有传入 左值 也有传入 右值 的情况,可以重载一个 右值引用 参数版本,编译器会匹配最合适的版本,确保资源不被浪费

常见的 右值引用 作为参数类型的有:拷贝构造函数赋值重载函数 (这两个函数都是重载版本),传值拷贝是比较低效的行为,有了这两个函数后, 中其他函数可以放心传值返回

力扣题目 「杨辉三角」中的函数返回值为 vector<vector<int>>,只要 vector 中实现了 移动构造 函数,就可以避免深拷贝,轻松返回结果

1.5.右值引用的意义

右值引用 是个好东西,它的核心功能在于再次利用 临时资源,避免无意义且低效的拷贝行为

右值引用左值引用 各有各的适用场景:左值引用 是引用返回以提高效率(减少拷贝);右值引用 则是移动构造提高效率(减少拷贝),两者的角度不同

  • 左值引用:直接引用对象以减少拷贝
  • 右值引用:间接减少拷贝,将临时资源等将亡值的资源通过 移动构造 进行转移,减少拷贝

2.完美转发

泛型编程C++ 中的核心功能之一,典型的让程序员少走弯路,让编译器多干活,伴随着 右值引用 的新概念加入,泛型编程 也需要随之升级

2.1.模板中的万能引用

泛型编程 的核心在于 模板根据参数类型推导函数 ,当我们分别传入 左值引用右值引用 时,模板 是否能正确推导呢

下面这段代码的含义是 分别传入 左值const 左值右值const 右值,并设计对应参数的回调函数,将参数传给模板,看看模板是否能正确回调函数

cpp 复制代码
void func(int& a)
{
	cout << "func(int& a) 左值引用" << endl;
}

void func(const int& a)
{
	cout << "func(const int& a) const 左值引用" << endl;
}

void func(int&& a)
{
	cout << "func(int&& a) 右值引用" << endl;
}

void func(const int&& a)
{
	cout << "func(const int&& a) const 右值引用" << endl;
}

template<class T>
void perfectForward(T&& val)
{
	// 调用函数
	func(val);
}

int main()
{
	int a = 10;
	const int b = 10;

	// 左值
	perfectForward(a);
	perfectForward(b);

	// 右值
	perfectForward(move(a));
	perfectForward(move(b));

	return 0;
}

注:move(const 左值) 可以获取 const 右值

模板中涉及引用参数传递时,可以将函数参数类型写为 T&&,因为模板具有自动推导的特性,当传入的参数为 左值 时,触发 引用折叠 机制,实际参数类型会变为 T&;当传入的参数为 右值 时,正常使用 T&& 就行了

这一机制在模板中称为 万能引用(引用折叠) ,既能推导 左值引用 ,也能推导 右值引用

预期结果:先调用 左值引用const 左值引用 版本的 func,再调用 右值引用const 右值引用 版本的 func

实际运行结果如下

调用的全是 左值引用 相关的 func,难道一向靠谱的模板推导出现问题了吗?

当然不是模板 是根据我们传入的参数类型,来推导出相应的函数,如果说 模板 推导没有问题,那问题就出在 回调函数 的参数上了,只有推导后,无论传的 左值 还是 右值 ,编译器都会把 val 变为 左值 ,这样才能解释为什么最终结果全部为 左值引用const 左值引用

编译器这么做合理吗?

非常合理 ,首先要明白 右值 是无法被取地址的,而 右值引用 是将 右值 中的资源转存到一块特定的空间中,这也就意味着 右值引用 后的值,必定是一个 左值 (拥有空间,可取地址),只有为 左值 才可以对其进行修改等操作

简单来说就是 右值属性转早了

解决问题的核心在于 perfectForward 传递 val 参数时,如何保证它的 右值属性 不丢失

2.2.传参过程中保持右值属性

要想在参数传递过程中保持其 右值属性 ,就需要使用 forward 函数,也就是 完美转发

forward 是一个带有参数模板的函数,主要在传参时使用: 如果参数原本是右值,但在右值引用后失去了右值属性,使用 forward 函数可以恢复它的右值属性

cpp 复制代码
template<class T>
void perfectForward(T&& val)
{
	// 调用函数
	func(forward<T>(val));
}

再次运行程序,可以发现调用结果符合预期

注意: forward 是一个模板函数,需要指定模板参数类型 T,确保能正确推导并传递

2.2.完美转发实际应用

完美转发 在实际开发中会经常用到,前面说过,在 C++11 之后,所有的类都可以新增一个 移动构造 以规避无意义的低效拷贝行为,并且由于大部分类中会涉及 模板 的使用,保持右值属性 就是一个必备的技巧,如果没有 完美转发 ,那么 移动构造 顶多也就减少了一次 深拷贝

接下来看看 完美转发 如何应用

首先准备一个模拟实现的 list

cpp 复制代码
#pragma once
#include<assert.h>

namespace Yohifo
{
	template<class T>
	struct list_node
	{
		list_node<T>* _next;
		list_node<T>* _prev;
		T _data;

		list_node(const T& x = T())
			:_next(nullptr)
			, _prev(nullptr)
			, _data(x)
		{}
	};

	template<class T, class Ref, class Ptr>
	struct __list_iterator
	{
		typedef list_node<T> node;
		typedef __list_iterator<T, Ref, Ptr> self;
		node* _node;

		__list_iterator(node* n)
			:_node(n)
		{}

		Ref operator*()
		{
			return _node->_data;
		}

		Ptr operator->()
		{
			return &_node->_data;
		}

		self& operator++()
		{
			_node = _node->_next;

			return *this;
		}

		self operator++(int)
		{
			self tmp(*this);
			_node = _node->_next;

			return tmp;
		}

		self& operator--()
		{
			_node = _node->_prev;

			return *this;
		}

		self operator--(int)
		{
			self tmp(*this);
			_node = _node->_prev;

			return tmp;
		}

		bool operator!=(const self& s)
		{
			return _node != s._node;
		}

		bool operator==(const self& s)
		{
			return _node == s._node;
		}
	};

	template<class T>
	class list
	{
		typedef list_node<T> node;
	public:
		typedef __list_iterator<T, T&, T*> iterator;
		typedef __list_iterator<T, const T&, const T*> const_iterator;

		iterator begin()
		{
			return iterator(_head->_next);
		}

		const_iterator begin() const
		{
			return const_iterator(_head->_next);
		}

		iterator end()
		{
			return iterator(_head);
		}

		const_iterator end() const
		{
			return const_iterator(_head);
		}

		void empty_init()
		{
			_head = new node(T());
			_head->_next = _head;
			_head->_prev = _head;
		}

		list()
		{
			empty_init();
		}

		template <class Iterator>
		list(Iterator first, Iterator last)
		{
			empty_init();

			while (first != last)
			{
				push_back(*first);
				++first;
			}
		}
		
		void swap(list<T>& tmp)
		{
			std::swap(_head, tmp._head);
		}

		list(const list<T>& lt)
		{
			empty_init();

			list<T> tmp(lt.begin(), lt.end());
			swap(tmp);
		}

		list<T>& operator=(list<T> lt)
		{
			swap(lt);
			return *this;
		}

		~list()
		{
			clear();
			delete _head;
			_head = nullptr;
		}

		void clear()
		{
			iterator it = begin();
			while (it != end())
			{
				//it = erase(it);
				erase(it++);
			}
		}

		void push_back(const T& x)
		{
			insert(end(), x);
		}

		void push_front(const T& x)
		{
			insert(begin(), x);
		}

		void pop_back()
		{
			erase(--end());
		}

		void pop_front()
		{
			erase(begin());
		}

		void insert(iterator pos, const T& x)
		{
			node* cur = pos._node;
			node* prev = cur->_prev;

			node* new_node = new node(x);

			prev->_next = new_node;
			new_node->_prev = prev;
			new_node->_next = cur;
			cur->_prev = new_node;
		}

		iterator erase(iterator pos)
		{
			assert(pos != end());

			node* prev = pos._node->_prev;
			node* next = pos._node->_next;

			prev->_next = next;
			next->_prev = prev;
			delete pos._node;

			return iterator(next);
		}
	private:
		node* _head;
	};
}

因为在构建链表节点时,是不需要 深拷贝 的,可以给 节点类 增加 移动构造函数

新增链表节点的移动构造 list_node --- 位于 list_node

cpp 复制代码
list_node(T&& x)
	:_next(nullptr)
	, _prev(nullptr)
	, _data(x)
{}

主函数中只需创建一个 list<string> 对象,,查看 移动构造是否被正确调用

注意: 这里的 liststring 都是模拟实现的

测试移动构造是否生效

cpp 复制代码
int main()
{
	Yohifo::list<Yohifo::string> l;
	l.push_back("Hello World!");

	return 0;
}

执行结果为 两次深拷贝

第一次深拷贝为构造时触发(默认构造传的是 右值 ),第二次则是插入时触发(插入的也是 右值

这里在 构造 / 插入 时使用的可是 右值 啊,为什么 string 中的 移动构造 函数没有被正确调用呢?进入调试模式,发现第一个问题:没有给 list 提供右值引用版本的 push_back()

这里先提供一个 右值引用版本push_back(),并在参数传递时使用 完美转发,看看能不能解决问题

右值引用版的 push_back() --- 位于 list

cpp 复制代码
// 右值引用版
void push_back(T&& x)
{
	// 完美转发
	insert(end(), std::forward<T>(x));
}

结果仍然是两次 深拷贝

原因是因为 push_back() 并没有干实事,它自己也在调用 insert(),而 insert() 还没有提供 右值引用 版,这里先试着补上

cpp 复制代码
// 右值引用版
void insert(iterator pos, T&& x)
{
	node* cur = pos._node;
	node* prev = cur->_prev;

	// 完美转发
	node* new_node = new node(std::forward<T>(x));

	prev->_next = new_node;
	new_node->_prev = prev;
	new_node->_next = cur;
	cur->_prev = new_node;
}

已经增加了两次 完美转发 了,结果仍是两次 深拷贝

仔细观察 insert() 的代码可以发现,在插入节点之前,需要先构建一个 node 节点对象,构建对象时已经进行了 完美转发 ,意味着当前参数传递没有问题,顺着线索来到 node移动构造 函数中

cpp 复制代码
list_node(T&& x)
	:_next(nullptr)
	, _prev(nullptr)
	, _data(x)
{}

其中的 _data 也就是 string 对象,在构造时,是直接传递了 x,并没有对其进行 完美转发 ,从而导致最终传给 string 的是一个 左值 ,自然调用的就是 深拷贝 了,话不多说,再加上一次 完美转发

cpp 复制代码
list_node(T&& x)
	:_next(nullptr)
	, _prev(nullptr)
	, _data(std::forward<T>(x))
{}

再次运行程序,发现这次终于成功调用了 string移动构造 函数


要想让我们之前模拟实现的 list 成功进行 移动构造 ,需要增加:一个移动构造、两个右值引用版本的函数、三次完美转发 ,并且整个 完美转发 的过程是层层递进、环环相扣的,但凡其中有一层没有进行 完美转发 ,就会导致整个传递链路失效,无法触发 移动构造

所以对于这种涉及多次函数回调的类,需要确保 右值 传递的每一层都不会丢失 右值属性 ,否则 移动构造 就断了


3.新增类功能

C++11 中新增了 右值引用 + 移动语义 ,应用到类中就诞生了 移动构造移动赋值 函数,除此之外,还对类中参数可能为 右值 的函数重载了 右值引用 版本

3.1.移动构造和移动赋值

之前类中有六个天选之子:构造函数、析构函数、拷贝构造、赋值重载、取地址重载 和 const 取地址重载

有了 右值引用 + 移动语义 后,对 拷贝构造赋值重载 进行了 "升级" ,增加了 移动构造移动赋值 这两个新函数,至此,类中共有八个天选之子(编译器会默认生成)

天选之子 的意思就是 即使我们不写,编译器也会默认生成(有条件)

之前六个 天选之子 的生成规则这里就不再阐述了,主要来说说 移动语义 相关的两个函数

移动语义就是通过右值引用将资源转移再利用

这两个函数生成的条件比较苛刻:

  1. 如果自己没有写 移动构造 ,并且没有实现 析构拷贝构造赋值重载 中的任意一个,那么编译器才会自动生成一个 移动构造 函数,移动构造 函数对于内置类型,会按字节拷贝,对于自定义类型,会去调用它的 移动构造 函数,如果没有,就调用 拷贝构造(目的:涉及深拷贝的类编译器期望我们自己设计 移动构造 函数)
  2. 移动赋值 的生成逻辑与上面一致

编译器为什么会这么要求?

如果我们实现了 析构、拷贝构造、赋值重载 ,就证明当前的类中涉及到了 动态内存管理 ,是需要自己进行 深拷贝 的,编译器无能为力,移动语义 也应该根据自己的实际场景进行设计,所以编译器就没有自动生成

如何自己实现这两个 移动语义 相关函数?

得益于 右值引用 ,这个实现起来并不复杂,以 string 为例,移动构造移动赋值 的实现如下

cpp 复制代码
// 移动构造
string(string&& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	swap(s);
}

// 移动赋值
string& operator=(string&& s)
{
	swap(s);
	return *this;
}

核心在于 与临界资源(将亡值)交换资源

默认生成的 移动构造 或者 移动赋值 并非没有用,就像 拷贝构造 一样,默认生成的拷贝构造会去调用该函数中涉及类的 拷贝构造 ,也就是说,只要底层类没问题,自动生成的函数也可以实现 深拷贝 / 移动构造 / 移动赋值


如果非要使用编译器默认生成的呢?

在想让编译器生成的函数之后加上 default 关键字,如果类中涉及 动态内存管理(比如这里的 string ,是不推荐使用默认生成函数的,因为会涉及到 深拷贝

并且由于 移动构造 属于 构造 家族,移动赋值 属于 赋值 家族,移动构造 / 移动赋值 存在的前提是 拷贝构造 / 赋值重载 也存在,如果都使用默认的,自然就无法 深拷贝


STL 中的容器都增加了 移动构造移动赋值

3.2.插入系列的重载版本

除了 构造 / 赋值 时提高效率,插入 时也能提高效率,也就是通过 右值引用 重载实现 移动语义 版的 插入函数

比如之前实现的 list

cpp 复制代码
// 右值引用版
void insert(iterator pos, T&& x)
{
	node* cur = pos._node;
	node* prev = cur->_prev;

	// 完美转发
	node* new_node = new node(std::forward<T>(x));

	prev->_next = new_node;
	new_node->_prev = prev;
	new_node->_next = cur;
	cur->_prev = new_node;
}

注意: 如果移动语义版的插入函数中涉及函数回调、构造对象等,就需要使用 完美转发 保持右值的属性,确保能成功调用移动语义版本的函数


STL 中同样更新了一波 移动语义 版的 插入函数

说到底 移动语义 其实就是通过 右值引用 进行资源转移的行为

移动语义是否能延长临时对象(将亡值)的生命周期?
不能,只是将其中的资源转移了,但临时对象(将亡值)本身仍然会被销毁
const 引用延长生命周期问题
这是 C++11 之前对于右值的处理手段,在 push_back() 等插入函数值,常常会传入一个临时对象,此时就可以使用 const 引用作为参数类型来延长临时对象的生命周期,伴随 push_back() 栈帧销毁而被销毁


注意不要认为 const 引用做返回值时能延长局部对象的生命周期,局部对象出了作用域就被销毁了,而 const 引用此时指向被销毁的对象,这是不合理的,是一种类似 "野指针" 的 "野引用" 行为

3.3.新增关键字

default 关键字

可以指定编译器生成默认的函数,比如在下面这个类 Test 中,我们指定编译器生成 构造拷贝构造

测试类 Test

cpp 复制代码
class Test
{
public:
	// 构造
	Test() = default;
	
	// 拷贝构造
	Test(const Test&) = default;

private:
	Yohifo::string _str;
};

这里的是 string 是之前模拟实现的,方便查看调用的是 深拷贝 还是 移动构造

分别传入 左值右值 查看函数调用情况

cpp 复制代码
int main()
{
	Test t1;

	Test t2(t1); // 传入左值
	Test t3(move(t1)); // 传入右值
	return 0;
}

可以看到当前两次都是 深拷贝 ,可以推断出编译器并没有给 Test 自动生成 移动构造 ,原因在于我们已经指定生成了 拷贝构造 ,编译器认为 Test 类中不具备自动生成 移动构造 的条件

可以使用 defalut 指定编译器自动生成 移动构造

cpp 复制代码
Test(Test&&) = default; // 指定生成移动构造

再次运行程序,可以看到当传入 右值 进行构造时,调用的是 移动构造

这里想强调的是 default 可以指定编译器自动生成类中的默认成员函数

能否使用 default 生成除默认成员函数之外的其他成员函数?

答案是 不行,如果这都可以的话,编译器都能自动写代码了,能自动生成默认成员函数,是因为这些函数的实现方式都是有模板的,编译器可以直接套用


delete 关键字

除了 default 关键字,C++11 还提供了 delete 关键字,用法和 default 一样,不过 delete 是声明该函数已被手动删除,不可以使用,比如将 Test 中的 构造 函数删除,就无法构造对象了

cpp 复制代码
// 删除构造函数
Test() = delete;

什么情况下需要删除函数?

比如在 单例模式 中,只允许创建一个对象,为了避免外部再次创建对象,需要将 构造、拷贝构造、移动构造 等函数删除;再比如 C++ 中的 IO 流类中,是不允许 IO 对象之间进行拷贝的,因为每个 IO 对象中的缓冲区都不一样,随意拷贝会造成资源混乱,索性直接删除了


至于 finaloverride 已经在 继承和多态 相关章节介绍过了

  • final 修饰类,类不能被继承
  • final 修饰成员函数,子类继承时,成员函数不能被重写
  • override 修饰子类虚函数,确保完成重写

更多新增关键字详见 C++11 官网

3.4.其他新功能

C++11 还修复之前 中的一个大坑:内置类型不会初始化

这就导致如果你没有在编写 构造 函数时对 内置类型 进行处理,会导致后续使用时出现 随机值

比如下面这个类中就没有对 内置类型 进行处理

cpp 复制代码
class A
{
public:
	void Print()
	{
		cout << _a << endl;
	}

private:
	int _a;
};

int main()
{
	A a;
	a.Print();

	return 0;
}

打印结果为 随机值

使用随机值的危害?
如果将随机值作为循环起始值,会导致循环 "失控"

像这种大坑,估计是 C++ 独有的,为了修复这个问题,C++11 中新增了一个小补丁:类成员变量初始化

就是在类成员定义时,允许给一个 缺省值,比如这样

cpp 复制代码
class A
{
	// ...
	
private:
	int _a = 0; // 此时给的是缺省值
};

此时输出的结果就是可预期的

注意: 这里给的是 缺省值,成员变量最终都是在 初始化列表中 进行初始化的,定义时给缺失值,就可以在初始化列表中使用


C++11 中还新增了 委托构造 ,就是允许在 初始化列表 中调用构造函数,这个语法作用并不是很大,并且不推荐使用,因为进入初始化列表就已经表示正在初始化了,再去调用其他构造函数会显得调用逻辑混乱

cpp 复制代码
class A
{
public:
	A(int a = 0)
		:_a(a)
	{}

	// 拷贝构造时,进行委托构造
	A(const A& a)
		:A()
	{
		// ...
	}

private:
	int _a;
};

注意: 只有 构造 相关函数才有 初始化列表,其他函数没有这个东西,自然也就不能使用委托构造


4.可变参数

C++11 引入了 可变参数模板可变参数包 的特性,允许定义和使用可接受任意数量参数的模板函数,这对于编写泛型代码、容器等方面提供了更大的灵活性

4.1.可变参数列表

C 语言就已经出现了 可变参数 ,语法表示为 ...C语言中的输入输出函数就用到了 可变参数列表

可变参数 的意思是你可以随便传入多个 参数 ,函数都能进行接收,C语言在使用 可变参数模板 时需要依赖 参数数量 + 参数类型 来进行识别,简单使用如下

cpp 复制代码
int main()
{
	int a;
	double b;
	char c;
	scanf("%d %lf %c", &a, &b, &c);

	printf("输入了: %d %lf %c\n", a, b, c);
	return 0;
}

虽然这里也支持接收任意数量的参数,但还得提前确定这些参数的类型,使用起来比较麻烦

4.2.可变参数包

C++11 之前只能像 C语言 那样使用固定参数的 可变参数列表 ,在 C++11 中进行了重大改动,新增了 可变参数包 ,支持直接传入任意数量、任意类型的参数,不必像 C语言 那样指定数量和类型,这个改动非常激进,导致整个 可变参数 语法变得十分抽象

把所有传入的参数,不论数量、类型,统统进行打包,也就形成了 可变参数包

下面是使用 可变参数包 的实际例子(由于不知道会传入什么类型的参数,这里需要借助 可变参数模板

cpp 复制代码
template<class ...Args>
void showList(Args... args)
{
	// ...
}


为了提高参数传递时的效率,可变参数包 的类型一般都会写成 Args&&...

这在模板中称为 万能引用(引用折叠) ,既可以引用 左值 ,也可以引用 右值

可变参数模板 允许传入 任意数量、任意类型 的参数

比如下面这几种函数传参都是可以的,由此可见 可变参数模板 的强大

cpp 复制代码
int main()
{
	showList();
	showList(1, 2.2, 'c');
	showList("111111111111111");
	showList(vector<int>(), list<double>());
	return 0;
}

4.3.可变参数包的解析

可变参数模板 传参简单,可变参数包 解析就麻烦了,下面是一种不被编译器支持的错误解析方式

cpp 复制代码
template<class ...Args>
void showList(Args... args)
{
	// 错误的解析参数方式
	int n = sizeof...(args);

	for (int i = 0; i < n; i++)
	{
		// 获取具体的可变参数
		args[i];
	}
}

注:使用 sizeof 计算可变参数包的大小时,需要在 sizeof 之后紧跟 ...,表示要计算的对象是可变参数包

这种解析方式很符合直觉,但编译器并不支持,具体报错信息为 必须在此上下文中扩展参数包

"上下文" 是一个抽象的术语,用于描述代码执行时所处的特定环境,这个环境可能是与函数调用相关的,也可能是其他方面的,这里的 上下文 具体指 模板的实例化和展开时的环境和情境

模板 的实例化和展开可以借助 递归 来实现

cpp 复制代码
// 递归推导时结束时调用的函数
void showList()
{}

template<class T, class ...Args>
void showList(const T& val, Args... args)
{
	cout << val << " ";
	showList(args...); // 递归解析
}

int main()
{
	showList(1, 2.2, 'c');

	return 0;
}

可变参数包 的参数被成功解析了

因为是 递归 解析的,所以需要一个递归出口,也就是 参数为 void 的重载函数,推导逻辑如下

相关模板参数在编译阶段就已经全部推导出来了,也就是说当程序运行时,在当前代码中,会同时存在 4showList() 的重载函数,可以通过 __FUNCTION__ 这个和宏以及 sizeof 验证

cpp 复制代码
template<class T, class ...Args>
void showList(const T& val, Args... args)
{
	cout << __FUNCTION__ << "(" << sizeof...(args) << ")" << endl;
	showList(args...); // 递归解析
}

可以看到 可变参数模板 中的函数共被调用了 3 次,再加上 showList() 无参版的调用,总共就是 4 个重载函数

main 函数第一次调用时,1 被赋给了 valargs 参数个数变成了两个

除了这种 递归 解析参数包的方式外,还有一种奇特的解析方式 通过逗号表达式展开

具体实现如下

cpp 复制代码
template<class T>
void Print(T val)
{
	// 获取参数
	cout << val << " ";
}

template<class ...Args>
void showList(Args... args)
{
	int arr[] = { (Print(args), 0)... };
}

关键点在于 arr 数组创建时,会根据 { } 中的参数进行初始化,可以在此直接将 可变参数包展开 ,展开过程中就完成了 参数 的解析工作

为什么要写出成 (Print(args), 0) 的形式?
这是一个逗号表达式,目的是让整个式子最终返回 0,用于初始化 arr 数组

可以设置 Print() 的返回值来简化代码

cpp 复制代码
template<class T>
int Print(T val)
{
	// 获取参数
	cout << val << " ";

	return 0;
}

template<class ...Args>
void showList(Args... args)
{
	int arr[] = { Print(args)... };
}

编译后代码如下

cpp 复制代码
template<class ...Args>
void showList(Args... args)
{
	int arr[] = { Print(1), Print(2.2), Print('c') };
}

这种参数包展开方式比较少用,简单了解即可


可变参数包 的应用场景在哪?

主要用于 线程回调函数 的参数传递,pthread 提供的线程创建接口 pthread_create 中只能给 线程回调函数 传递一个 指针变量C++11 中的 线程库 借助 可变参数包 进行了封装设计,可以在创建 线程 时轻易传递多个参数

注:这里的 Fn 是可调用的函数对象

关于 C++11 线程库 的更多知识将会放到下一篇文章中详谈

除此之外,可变参数包 还可以用于优化插入相关的函数

4.4.emplace 系列函数

C++11 还升级了 STL 中的插入函数(非右值引用版),这些新增的函数依赖 可变参数包 ,称为 emplace 系列

比如 listemplace_back()

empalce_back() 具备 push_back() 的所有功能,并且还在它的基础上进行了升级

如果只是单纯插入 左值 或者 move(左值),这两个函数没有区别

cpp 复制代码
int main()
{
	std::list<Yohifo::string> l;

	Yohifo::string str1 = "Hello";
	Yohifo::string str2 = "Hello";

	// 插入左值
	l.push_back(str1);
	l.emplace_back(str2);
	cout << endl;

	// 插入 move 出来的右值
	l.push_back(move(str1));
	l.emplace_back(move(str2));
	cout << endl;

	return 0;
}

但如果插入的是 纯右值,两个函数就有区别了

cpp 复制代码
int main()
{
	//...
	
	// 插入纯右值
	l.push_back("World");
	l.emplace_back("World");

	return 0;
}

插入纯右值时,只发生了一次 移动构造

通过调试发现,emplace_back() 在插入 纯右值 "World" 时,甚至都没有调用 移动构造 ,而是直接走的 构造函数

得益于 可变参数包emplace 系列函数可以直接将 纯右值 作为参数传递,传递途中不展开参数包,直到 构造函数 才把参数包展开,充分发挥了 可变参数包 的优势(直接传递参数)

因此可以得出结论:在插入纯右值,并且构造函数能正常接收时,emplace 系列函数可以直接构造,省去了调用移动构造函数时的开销

为什么传递 "World" 可以直接构造?
因为当前模拟实现的 string 中,构造函数参数就是 const char*,可以直接将参数包中的参数进行传递

注意: 插入 左值 或者 move(左值) 时,emplace 系列函数和普通函数没区别


🌆总结

以上就是本次关于 C++11 中右值引用和移动语义的相关知识了,右值引用的引入解决了临时资源过度消耗的问题,为类添加了移动语义函数,同时也升级了插入函数以支持右值引用版本。可变参数包的引入简化了多参数传递,尤其在 C++11 线程库的使用中更为方便。新的 emplace 系列函数通过利用可变参数包,为类构造函数提供了更灵活的调用方式,进一步优化了代码的效率和可读性。这些更新使得 C++11 更加强大、灵活


相关文章推荐

C++ 进阶知识

C++11『基础新特性』

C++ 哈希的应用【布隆过滤器】

C++ 哈希的应用【位图】

C++【哈希表的完善及封装】

C++【哈希表的模拟实现】

C++【初识哈希】

C++【一棵红黑树封装 set 和 map】

相关推荐
一朵好运莲4 分钟前
React引入Echart水球图
开发语言·javascript·ecmascript
EleganceJiaBao10 分钟前
【C语言】结构体模块化编程
c语言·c++·模块化·static·结构体·struct·耦合
Eiceblue17 分钟前
使用Python获取PDF文本和图片的精确位置
开发语言·python·pdf
xianwu54325 分钟前
反向代理模块。开发
linux·开发语言·网络·c++·git
xiaocaibao77731 分钟前
Java语言的网络编程
开发语言·后端·golang
Bucai_不才1 小时前
【C++】初识C++之C语言加入光荣的进化(上)
c语言·c++·面向对象
木向1 小时前
leetcode22:括号问题
开发语言·c++·leetcode
comli_cn1 小时前
使用清华源安装python包
开发语言·python
筑基.1 小时前
basic_ios及其衍生库(附 GCC libstdc++源代码)
开发语言·c++
yuyanjingtao1 小时前
CCF-GESP 等级考试 2023年12月认证C++三级真题解析
c++·青少年编程·gesp·csp-j/s·编程等级考试