🛸个人主页: dragoooon34
🚁所属专栏: C++
🚀操作环境: Visual Studio 2022

目录

🌞前言

🌜正文

一、😀右值引用

1-1 🍕什么是右值引用?

1-2 🍔move 转移资源

1-3 🍟左值引用 vs 右值引用

1-4 🌭右值引用的使用场景

1-5 🥖右值引⽤和移动语义解决传值返回问题

1-5-1 🐆右值对象构造,只有拷⻉构造,没有移动构造的场景

1-5-2 🫏右值对象构造,有拷⻉构造,也有移动构造的场景

1-5-3 🐎右值对象赋值,只有拷⻉构造和拷⻉赋值,没有移动构造和移动赋值的场景

2. 拷贝构造 vs 拷贝赋值的核心区别

3. 移动构造 vs 移动赋值的核心区别

1-5-4 🦌右值对象赋值,既有拷⻉构造和拷⻉赋值,也有移动构造和移动赋值的场景

1-6 🍿右值引用的意义

二、😁完美转发

2-1 🧂模板中的万能引用(引用折叠)

2-2 🥚传参过程中保持右值属性

2-2 🥓完美转发实际应用

三、😂新增类功能

3-1 🍳移动构造和移动赋值

3-2 🧇插入系列的重载版本

3-3 🧈新增关键字

3-3-1 🐕default 关键字

3-3-2 🐈‍⬛delete 关键字

3-4 🥞其他新功能

四、😃可变参数

4-1 🍞可变参数列表

4-2 🥐可变参数包

4-3 🥨可变参数包的解析

4-4 🥯emplace 系列函数

🔥总结🔥


🌞前言

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

🌜正文

一、😀右值引用

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

1-1 🍕什么是右值引用?

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

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

// 左值引用
int main()
{
            
      
	int a = 10;

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

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

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

// 右值引用
int main()
{
            
      
	int a = 10;

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

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

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

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

// 判断左值 / 右值
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 转移资源

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

int a = 10;

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

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

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

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

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

int a = 10;

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

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

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

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 后构造一个新对象,会导致原本左值中的 资源 丢失

// 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

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

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 类

// 移动构造
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
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() 获得一个临时对象,然后通过该临时对象去构造一个对象

int main()
{
            
      
	Yohifo::string str(Yohifo::to_string(100) + 'a');

	return 0;
}

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

结果为 3 次深拷贝


分析:第一次为 to_string() 函数执行完后返回的临时对象的拷贝,第二次为 operator+() 函数中生成的临时对象(不可避免),第三次为 str 对 to_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()
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 🥖右值引⽤和移动语义解决传值返回问题
using namespace std;

namespace bit
{
    string addStrings(string num1, string num2)
    {
        string str;
        int end1 = num1.size() - 1, end2 = num2.size() - 1;
        int next = 0; // 进位标志

        while (end1 >= 0 || end2 >= 0)
        {
            // 获取当前位的数值,超出字符串长度则取0
            int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
            int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
            int ret = val1 + val2 + next; // 当前位总和(包含进位)
            
            next = ret / 10; // 更新进位
            ret = ret % 10;  // 获取当前位最终数值
            str += ('0' + ret); // 存入结果(此时是逆序)
        }

        // 处理最后剩余的进位
        if (next == 1)
        {
            str += '1';
        }

        reverse(str.begin(), str.end()); // 反转得到正序结果
        cout << "******************************" << endl;
        return str;
    }
}

// 场景1
// int main()
// {
//     bit::string ret = bit::addStrings("11111", "2222");
//     cout << ret.c_str() << endl;
//     return 0;
// }

// 场景2
int main()
{
    bit::string ret;
    ret = bit::addStrings("11111", "2222");
    cout << ret.c_str() << endl;
    return 0;
}
1-5-1 🐆右值对象构造,只有拷⻉构造,没有移动构造的场景

1-5-2 🫏右值对象构造,有拷⻉构造,也有移动构造的场景

1-5-3 🐎右值对象赋值,只有拷⻉构造和拷⻉赋值,没有移动构造和移动赋值的场景

2. 拷贝构造 vs 拷贝赋值的核心区别
类型 触发时机 操作本质 对象状态
拷贝构造 对象初始化阶段(未创建完成) 从已有对象创建新对象(深拷贝) 新对象内存未分配 / 未初始化
拷贝赋值 对象已初始化完成后赋值 给已有对象更新资源(先释放自身资源,再深拷贝) 对象已存在,有

3. 移动构造 vs 移动赋值的核心区别
类型 触发时机 操作本质 对象状态
移动构造 对象初始化阶段(未创建完成) 从右值对象转移资源创建新对象 新对象内存未分配 / 未初始化
移动赋值 对象已初始化完成后赋值 给已有对象转移资源(先释放自身资源,再接管右值资源) 对象已存在,有可用内存

1-5-4 🦌右值对象赋值,既有拷⻉构造和拷⻉赋值,也有移动构造和移动赋值的场景

1-6 🍿右值引用的意义

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

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

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

二、😁完美转发

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

2-1 🧂模板中的万能引用(引用折叠)

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

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

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 函数可以恢复它的右值属性

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

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

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

2-2 🥓完美转发实际应用

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

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

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

#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 类

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

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

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

测试移动构造是否生效

int main()
{
            
      
	Yohifo::list<Yohifo::string> l;
	l.push_back("Hello World!");

	return 0;
}

执行结果为 两次深拷贝

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

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

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

  • 右值引用版的 push_back() — 位于 list 类
// 右值引用版
void push_back(T&& x)
{
            
      
	// 完美转发
	insert(end(), std::forward<T>(x));
}

结果仍然是两次 深拷贝

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

// 右值引用版
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 的 移动构造 函数中

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

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

list_node(T&& x)
	:_next(nullptr)
	, _prev(nullptr)
	, _data(std::forward<T>(x))
{
            
      }

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

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

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


三、😂新增类功能

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

3-1 🍳移动构造和移动赋值

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

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

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

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

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

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

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

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

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

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

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

// 移动构造
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

// 右值引用版
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 🧈新增关键字
3-3-1 🐕default 关键字

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

  • 测试类 Test
class Test
{
            
      
public:
	// 构造
	Test() = default;
	
	// 拷贝构造
	Test(const Test&) = default;

private:
	Yohifo::string _str;
};

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

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

int main()
{
            
      
	Test t1;

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

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

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

Test(Test&&) = default; // 指定生成移动构造

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

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

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

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

3-3-2 🐈‍⬛delete 关键字

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

// 删除构造函数
Test() = delete;

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

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

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

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

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

3-4 🥞其他新功能

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

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

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

class A
{
            
      
public:
	void Print()
	{
            
      
		cout << _a << endl;
	}

private:
	int _a;
};

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

	return 0;
}

打印结果为 随机值

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

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

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

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

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

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

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

class A
{
            
      
public:
	A(int a = 0)
		:_a(a)
	{
            
      }

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

private:
	int _a;
};

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


四、😃可变参数

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

4-1 🍞可变参数列表

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

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

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语言 那样指定数量和类型,这个改动非常激进,导致整个 可变参数 语法变得十分抽象

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

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

template<class ...Args>
void showList(Args... args)
{
            
      
	// ...
}

为了提高参数传递时的效率,可变参数包 的类型一般都会写成 Args&&...
这在模板中称为 万能引用(引用折叠),既可以引用 左值,也可以引用 右值

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

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

int main()
{
            
      
	showList();
	showList(1, 2.2, 'c');
	showList("111111111111111");
	showList(vector<int>(), list<double>());
	return 0;
}
4-3 🥨可变参数包的解析

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

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

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

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

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

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

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

// 递归推导时结束时调用的函数
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 的重载函数,推导逻辑如下

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

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 参数个数变成了两个

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

具体实现如下

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() 的返回值来简化代码

template<class T>
int Print(T val)
{
            
      
	// 获取参数
	cout << val << " ";

	return 0;
}

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

编译后代码如下

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 系列

比如 list 的 emplace_back()

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

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

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

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

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『右值引用 ‖ 完美转发 ‖ 新增类功能 ‖ 可变参数模板』」的理解

感谢你的三连支持!!!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐