[C++——lesson38.——「C++11『右值引用 ‖ 完美转发 ‖ 新增类功能 ‖ 可变参数模板』」]


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

目录
1-5-1 🐆右值对象构造,只有拷⻉构造,没有移动构造的场景
1-5-3 🐎右值对象赋值,只有拷⻉构造和拷⻉赋值,没有移动构造和移动赋值的场景
1-5-4 🦌右值对象赋值,既有拷⻉构造和拷⻉赋值,也有移动构造和移动赋值的场景
🌞前言
自从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; }直接可以引用 左值 的称为 左值引用,直接可以引用 右值 的就是 右值引用
注意:
- 左值引用 可以通过其他手段引用 右值,比如加
const;右值引用 也可以通过其他手段引用 左值,比如move函数- 赋值语句左边的一定是 左值,但右边的不一定是 右值,比如
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,其中并未涉及 右值引用 相关知识简单模拟实现的
stringnamespace 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.cppint 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()— 位于命名空间Yohifostring 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,如果是vector、map、unordered_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>对象,,查看 移动构造是否被正确调用注意: 这里的
list、string都是模拟实现的测试移动构造是否生效
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取地址重载
有了 右值引用 + 移动语义 后,对 拷贝构造 和 赋值重载 进行了 “升级”,增加了 移动构造 和 移动赋值 这两个新函数,至此,类中共有八个天选之子(编译器会默认生成)
天选之子 的意思就是 即使我们不写,编译器也会默认生成(有条件)
之前六个 天选之子 的生成规则这里就不再阐述了,主要来说说 移动语义 相关的两个函数
移动语义就是通过右值引用将资源转移再利用
这两个函数生成的条件比较苛刻:
- 如果自己没有写
移动构造,并且没有实现析构、拷贝构造、赋值重载中的任意一个,那么编译器才会自动生成一个移动构造函数,移动构造函数对于内置类型,会按字节拷贝,对于自定义类型,会去调用它的移动构造函数,如果没有,就调用拷贝构造(目的:涉及深拷贝的类编译器期望我们自己设计移动构造函数)移动赋值的生成逻辑与上面一致编译器为什么会这么要求❓
如果我们实现了 析构、拷贝构造、赋值重载,就证明当前的类中涉及到了 动态内存管理,是需要自己进行 深拷贝 的,编译器无能为力,移动语义 也应该根据自己的实际场景进行设计,所以编译器就没有自动生成
如何自己实现这两个 移动语义 相关函数❓
得益于 右值引用,这个实现起来并不复杂,以
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中,我们指定编译器生成 构造 和 拷贝构造
- 测试类
Testclass 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被赋给了val,args参数个数变成了两个除了这种 递归 解析参数包的方式外,还有一种奇特的解析方式 通过逗号表达式展开
具体实现如下
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『右值引用 ‖ 完美转发 ‖ 新增类功能 ‖ 可变参数模板』」的理解
感谢你的三连支持!!!


更多推荐



































































所有评论(0)