目录
右值引用
引入
介绍
左值
左值引用
左值引用的缺陷
引入
缺陷
解决
右值
纯右值
将亡值
右值引用
move函数
介绍
底层实现
参数 -- 通用引用类型
引用折叠
折叠规则:
返回值
remove_reference
移动
引入
介绍
移动构造函数
介绍
是否抛出异常
noexcept
使用
应用场景
返回值 -- 一次深拷贝->移动构造(编译器优化后)
传参 -- 传入右值
list举例
vector举例
forward
介绍
底层
原理
完美转发
介绍
示例
改造我们的list
代码
测试
移动赋值函数
使用
右值引用
引入
- 在之前,我们就已经接触了引用的概念
- 但c++进一步增加了右值引用的概念,所以之前使用的引用就被叫做左值引用了(为了与右值引用区分开)
- 但无论左值引用还是右值引用,都是给对象取别名,只不过对象类型不同
介绍
左值
- 左值表示对象的身份,我们可以获取它的地址+可以对它赋值
- 左值有自己长久的生命周期,要么全局存在,要么随所在的函数存在
- 以下都是左值:
int* p = new int(0); int b = 1; const int c = 2;
- 赋值符号的左边 -- 只能是左值
- 定义时const修饰符后的左值,既可以引用左值,也可以引用右值
左值引用
- 左值引用就是给左值取别名,在之前的引用中已经介绍过了
int*& rp = p; int& rb = b; const int& rc = c; int& pvalue = *p;
左值引用的缺陷
引入
既然c++11引入了右值引用,说明之前只有左值引用存在时有缺陷
众所周知,用引用作为参数,可以减少拷贝次数,尤其是深拷贝,对需要深拷贝的提升的效率更大
缺陷
但是,如果涉及到临时变量,是绝对不能使用左值引用的:
- (在之前引用那里就有介绍过,不能传临时变量的引用作为返回值,也不能使用引用接收返回值)
- 因为可能会出现"悬挂引用"的问题,或者把不该绑定在一起的变量绑定了
- 所以,一般这里就不会使用引用,而是传值返回,以及用值接收返回值
- 但就又会出现拷贝的问题
- 因为传参会先创建一个临时变量,再将临时变量拷贝给ret2,这就会出现2次深拷贝,代价很大
- 当然,新一点的编译器会做出优化,会将返回值直接拷贝给接收变量,但依然会有1次深拷贝
解决
- 实际上,我们可以直接将str的资源交给ret2,而不是拷贝一份再给他
- str只有它自己拥有,不存在什么析构两次的问题,所以直接给没问题
- 交给ret2后,可以考虑将初始值给str,或者把ret2原先的值给他,然后str被成功析构
- 皆大欢喜噜~
- 所以,c++引入了移动语义,补上了左值引用的短板
- 移动语义的核心就是通过使用右值引用和移动构造函数来实现资源的有效转移
右值
- 右值表示对象的值,无法对它取地址,也不能赋值
- 右值的生命周期很短,可能只在当前行存在
- 它要么是字面常量,要么是表达式求值过程中/函数返回值时创建的临时对象
右值也分为纯右值和将亡值(将亡值实际上是针对自定义类型存在的)
纯右值
是字面常量/表达式返回值/匿名的临时对象
(都是内置类型)
double x = 1.1, y = 2.2;//下面都是右值 10; x + y; fmin(x, y);
将亡值
- 指的是 -- 当该右值完成初始化或赋值的任务时,它的资源已经移动给了被初始化者或被赋值者,同时该右值也将会马上被销毁(也就是前面我们举的返回值问题的例子)
- 而且,只有自定义类型有需要转移的资源(主要指的是需要开空间的资源,因为开空间上限很大,消耗大),而内置类型最大也就8字节,无所谓转不转移
- 所以,将亡值是针对部分自定义类型的概念
右值引用
右值引用就是对右值的引用,给右值取别名
int&& rr1 = 10; double&& rr2 = x + y; double&& rr3 = fmin(x, y);
- 右值引用本身应该属于右值,但是,如果它是右值,后面我们的移动操作就无法实现,因为需要改变右值引用绑定的右值
- 所以,编译器强制认为右值引用是左值,然后就可以通过右值引用为右值赋值
- 右值原本不能取地址,但给右值取别名后,会导致右值被存储到特定位置,就可以取到该位置的地址,也就可以实现对右值赋值
当我们学习了右值/右值引用的概念了,看待很多函数的参数/返回值就可以换一种方式了
move函数
介绍
- 是 C++ 标准库中的一个函数模板,用于将左值转换为右值引用
- 头文件<utility>
- 所以右值引用也可以引用move后的左值
- 但注意,move本身不对左值做修改
- 但一旦拿右值接收了move(左值),这个左值的资源就已经被转移了,不能再使用这个移后源对象
底层实现
这是g++下的move函数
参数 -- 通用引用类型
- 可以看出来,它是用模板参数&&作为参数
- 而T&&是通用引用的形式,它是同时具备左值引用和右值引用性质的引用类型
- T 不仅可以是左值引用,也可以是右值引用,具体取决于传递给函数的参数的类型
- 但是,为什么可以实现这样的功能呢?又为什么一定要写成T&&的形式呢?
- 这就需要介绍一下引用折叠了
引用折叠
- 用于确定在涉及到引用的类型推断和引用性质时的行为
- 引用折叠主要用于处理 通用引用 和 模板参数的类型推断
- 折叠也就是有多个引用进行组合,而我们的引用类型有两种,所以有4种组合
- 但我们实际见不到这样的引用类型,这些形式可以算是一种中间状态
- 我们得到的引用折叠后的结果 -- 要么是左值引用,要么是右值引用
折叠规则:
- 如果任一引用为左值引用,则结果为左值引用;否则为右值引用
- 比如:
- 根据规则,只要双方有一方是左值引用,就为左值
- 所以最后结果是:
- 同时,这也符合我们的预期,不同类型的实参传进来,形参就是他们对应类型的引用
- 而auto实际上也是模板T的一种形式,他们是等价的
auto && a = 1; //通用引用,可以接收右值 int b = 1; auto && c = b;//通用引用,也可以接收左值
返回值
可以看到,它使用了remove_reference这个模板类
remove_reference
- 从名字和代码就能看出来,它可以去除引用返回基本类型
- 所以,这个返回值实际上是先去除掉参数的引用,然后转换为右值引用后返回
- 所以,它的代码也可以被写成(以int为例):
以上几个函数的详细介绍来源于 -- https://avdancedu.com/a39d51f9/
讲的特别好,我哭死
移动
引入
将亡值那里有说过,内置类型的拷贝都是小事情,但如果一旦涉及到资源的分配问题,拷贝的消耗可能会很大
对应的也就是类里的构造/赋值/插入函数,这些都是拷贝的重灾区
如果可以为这些成员函数引入移动操作,将大大提高效率
介绍
在C++中,"移动" 是指将资源(如动态分配的内存、文件句柄、对象等)的所有权从一个对象转移到另一个对象,而不进行不必要的数据复制
移动构造函数
介绍
- 接受一个右值引用参数,用于实现资源的移动(因为只有右值的资源是可以被移动的,左值是不可以轻易拿资源的)
- 在移动构造函数内部,资源的所有权被转移到新对象,同时原对象进入有效但未定义的状态,以确保原对象不再持有资源,防止原对象被析构后出现问题
void swap(myvector<T> &v) //交换资源{std::swap(_start, v._start);std::swap(_finish, v._finish);std::swap(_endOfStorage, v._endOfStorage);} myvector(myvector<T> &&v) noexcept: _start(nullptr), _finish(nullptr), _endOfStorage(nullptr){cout << "移动构造" << endl;swap(v);}
是否抛出异常
- 因为移动构造只是交换资源,而没有分配资源,那么一般移动操作是不会抛出异常的
- 又因为标准库会为某个函数可能会抛出异常而做出处理
- 并且防止真的出现异常,导致移动构造执行一半跳走了(可能在分配空间的过程中,也使用了移动操作),让源对象和新对象都处于中间状态,这样可能会导致很多问题
- 为了避免这些问题,所以需要我们[显式告诉标准库,移动函数是没有问题的]
- 这样就不会中止掉移动操作,也减少了标准库额外的操作
- 而告诉标准库它不会产生异常,就需要一个关键字"noexcept"
noexcept
是C++11引入的关键字,用于指示函数是否会抛出异常
它用来标记和控制函数的异常行为,从而增强代码的可靠性和性能
使用
- 可以附加在函数声明的尾部(声明和定义都要添加),表示该函数不会抛出异常:
- 检查表达式是否会引发异常,返回一个
bool
值,指示表达式是否会引发异常:
应用场景
返回值 -- 一次深拷贝->移动构造(编译器优化后)
bit::myvector<int> func_move() {cout << "func_move" << endl;auto it = {1, 2, 3, 4};myvector<int> tmp(it);return tmp; } void test7() {bit::myvector<int> s1(func_move()); }
- 当该函数返回时,需要先深拷贝一个临时对象
- 然后这个临时对象作为函数返回值(它是将亡值,当这个返回值完成拷贝工作后,就没了),调用移动构造初始化ret1
- 但编译器做出了优化,跳过那个中间状态,直接对str进行移动构造,也就是直接将str识别成将亡值
- 编译器将tmp(也就是图中的str)的资源直接给[接收返回值的对象],使tmp成为有效但未定义的状态,之后随着func_move的结束而销毁
- (深拷贝是构造tmp时的),这样就是减少了一次深拷贝
传参 -- 传入右值
list举例
list<bit::mystring> s1;bit::mystring arr("1234");s1.push_back(arr);cout<<endl;s1.push_back("243");
这里使用库中的list作为例子(方便看),而string是我们自己模拟实现的
当我们直接传入右值时
- 如果没有移动构造,那么就会在构造list的结点的时候进行一次深拷贝
- 可以看到,无论传入左值还是右值,都是两次深拷贝:(第一次是字符串构造string,第二次是string构造list的结点)
- 如果有移动构造,那传入右值时,那个[被右值构造出来的string临时对象]就会直接将资源交给list结点
- 可以看到,传入右值时会减少一次深拷贝
vector举例
void test8(){vector<bit::mystring> s1;bit::mystring arr("1234");s1.push_back(arr);cout<<endl;s1.push_back("243"); }
但是,如果用vector的话,会在移动构造的下面,还会调用一次深拷贝:
为啥呢?按理说,移动资源后就应该没事了啊,怎么还会有一次深拷贝
让我们看看实际调用了哪些函数吧:
- 构建出string临时对象后,转到vector的pushback函数,这里识别到了string是个右值,匹配进了接收右值的pushback:
- (注意,这里把右值引用参数move了之后才传到下一个函数!!!因为前面有介绍,右值引用被编译器强制认为是左值,而这里为了延续它的右值属性,就得用move,否则根本调不到移动版本的构造)
- 然后进入emplace_back函数,这里将传入的右值引用放进forward函数里面:
- 这里我们可以猜测,他可能和move有类似的功能,否则为什么要对传进来的参数进行处理呢
- 然后进了一堆函数(不再贴图了,太多了太多了)
- 最后在这里进入移动构造:
- 然后后面又经过一系列操作,在和上面的很相似的函数中,却进入了拷贝构造:
- (是传入的第一个模板参数不同导致的吗,不太懂,先把这个问题搁这吧,我一时半会也不会知道为啥)
反正我们可以从上面的例子中知道,右值引用可以让传参时减少深拷贝次数
总之,我们先看看里面函数调用时,出现的forward函数吧(次数频率可高,基本函数参数里都有它)
forward
介绍
是C++标准库中的一个函数模板,用于实现完美转发的关键工具
允许在函数模板中将参数以原始的值类别(左值或右值)传递给其他函数,而不会改变它们的值类别
底层
这是g++下,forward的实现情况
- 它重载了两个函数,可以看到,都是用remove_reference去除掉引用属性后,分别让他们接收左值/左值引用 和 右值
- 返回的是同一句代码,但实际效果却不同(因为使用了通用引用,而通用引用可以引用折叠)
原理
- 第一个
- 它接收左值/左值引用,T的类型都是T&,这样返回值处类型就是 T& && ,折叠后就是T&,和传进来的左值是一样的属性
- 第二个
- 接收右值,T的类型是T,那这里的返回值类型就不需要折叠,直接是T&&,而这里直接返回一个右值引用,编译器会将函数返回的右值引用认为是一个右值
- 这样我们就得到了一个右值,那下一个函数得到的就是右值了,而不是左值(因为编译器强制将右值引用识别成左值)
- 而forward正是为了解决这个问题
完美转发
介绍
- 完美转发(Perfect Forwarding)是C++中的一个重要概念,它允许函数将其参数以原始的值类别(左值或右值)和常量性 传递给其他函数,同时保持参数的特性
- 完美转发通常用于泛型编程,特别是在模板和泛型函数中
- 核心就是标准库中的forward函数和通用引用,以确保在参数传递过程中保持参数的原始性质
示例
template <typename T>
void process(T&& arg) {// 使用std::forward确保完美转发参数some_function(std::forward<T>(arg));
}void some_function(int& x) {std::cout << "Lvalue reference: " << x << std::endl;
}void some_function(int&& x) {std::cout << "Rvalue reference: " << x << std::endl;
}int main() {int value = 42;process(value); // 调用some_function(int&)process(123); // 调用some_function(int&&)
}
改造我们的list
- 还记得前面,我们用库中的vector配合着我们自己实现的string,验证了移动构造的好处吗
- 我们可以从调用函数的参数发现,库中是将右值的属性一直保持着的,否则调不到移动版本的构造函数
- 现在我们也试着用完美转发,实现库中的功能
代码
// List的节点类template <class T>struct ListNode // struct默认公有(因为不会有人去访问结点成员的){typedef ListNode<T> *PNode;// ListNode(const T &val)// : _ppre(nullptr), _pnext(nullptr), _val(val){};// ListNode(T &&val) //注意,这里有两个重载函数时,不能都有缺省值,所以这里不设置缺省值了// : _ppre(nullptr), _pnext(nullptr), _val(forward<T>(val)){};// 我们也可以用通用引用,来实现不同类型的参数,调用不同的构造template <class Data>ListNode(Data &&val): _ppre(nullptr), _pnext(nullptr), _val(forward<Data>(val)){};PNode _ppre;PNode _pnext;T _val;};void push_back(const T &val){insert(end(), val);}void push_back(T &&val) // 移动构造,如果这里传入一个右值{insert(end(), std::forward<T>(val)); // 需要我们保持它的右值属性}void push_front(const T &val){insert(begin(), val);}void push_front(T &&val) // 移动构造,如果这里传入一个右值{insert(begin(), std::forward<T>(val)); // 需要我们保持它的右值属性}// 在pos位置前插入值为val的节点iterator insert(iterator pos, const T &val){PNode cur = pos._pNode;PNode pre = cur->_ppre;PNode newnode = new Node(val);newnode->_pnext = cur;pre->_pnext = newnode;cur->_ppre = newnode;newnode->_ppre = pre;_size++;return newnode;}iterator insert(iterator pos, T &&val) // 继承push函数给的右值{PNode cur = pos._pNode;PNode pre = cur->_ppre;PNode newnode = new Node(std::forward<T>(val)); // 要保持它的右值属性newnode->_pnext = cur;pre->_pnext = newnode;cur->_ppre = newnode;newnode->_ppre = pre;_size++;return newnode;}//不要忘了,这里把构造的缺省值去掉了,那头结点就得用默认构造初始化void CreateHead(){_pHead = new Node(T()); // 因为去掉了缺省值,所以这里给个默认构造_pHead->_pnext = _pHead;_pHead->_ppre = _pHead;_size = 0;}
测试
void test9() {bit::mylist<bit::mystring> l;l.push_back("123");cout << endl;bit::mystring arr("q34");l.push_back(arr); }
移动赋值函数
myvector<T> &operator=(myvector<T>&& v) noexcept{cout << "移动赋值" << endl;swap(v);return *this;}
和移动拷贝非常像,都是转移资源,也都需要加上noexcept关键字
使用
bit::myvector<int> func_move() {cout << "func_move" << endl;auto it = { 1, 2, 3, 4 };bit::myvector<int> tmp(it); //深拷贝return tmp; }void test8() {bit::myvector<int> s2;s2 = func_move(); }
- (这里因为s2的赋值和定义不在一行,所以没有优化)
- 因为要返回tmp赋值给s2,所以要先创建临时变量作为函数返回值
- 但和上面一样,编译器优化后,直接将tmp认为是将亡值,且有移动构造,所以使用移动构造构建临时对象
- 然后将这个临时对象使用[=重载]赋值给s2
- 而因为临时对象也是将亡值(匿名对象,且马上就要被销毁) ,且有移动赋值的存在,所以直接调用移动版本的,把临时对象的资源交给s2