目录
右值引用
引入
- 在之前,我们就已经接触了引用的概念
- 但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正是为了解决这个问题
- 如果传入右值引用,我感觉可能是编译器做出了特殊处理,在这里函数里,不把右值引用强制认为是左值,而是当作右值,这样就可以匹配第二个函数
- 返回值类型就是T&& &&,折叠后依然是右值引用
- 根据编译器会将函数返回的右值引用认为是一个右值,这样他就返回了右值,也就将右值属性传递下去了
完美转发
介绍
- 完美转发(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
合成的移动操作
条件
与合成的拷贝构造和赋值一样,编译器也能合成移动构造/移动赋值
但是,合成的条件要比之前的苛刻
- 当你没有声明自己的拷贝构造和拷贝赋值时,编译器就会自动合成一样,只不过是浅拷贝
- 而合成移动操作必须这三个函数都不能有(拷贝构造,拷贝赋值,析构),并且非stati成员都要是可以移动的
操作
- 对于内置类型直接拷贝
- 自定义类型就去调用它的移动函数,如果没有,就调用拷贝函数
- 移动构造/赋值都是如此
default
但是,即使我们没有满足上述要求,也可以显式要求编译器生成
(在函数参数列表后 + "=default")
但是,就像图中展示的那样,虽然我们要求编译器去生成,但有些类是真的无法生成,那么编译器就会将该函数定义成删除的函数(实际上就相当于没有,但编译器得给你一个交代)
定义为删除的函数条件
- 类成员没有/无法被访问移动函数(或者编译器无法合成)
- 如果类的析构函数是删除的/无法访问(需要移动的资源都是动态分配的那种,没有析构会导致内存泄漏,所以编译器不准你移动)
- 类成员是const/引用,移动赋值是删除的(无法为这些类型赋值,只能初始化)
移动迭代器
介绍
是一种迭代器适配器,用于提供对容器中元素的移动语义访问
可以将容器元素的值通过移动操作传递,而不是通过拷贝操作传递,提高了性能和效率
通常与标准库算法一起使用,特别是在对容器进行大规模元素移动或重排时
使用
- 通过头文件<iterator>中的std::make_move_iterator函数,将普通迭代器转换为移动迭代器
- 解引用一个移动迭代器,可以得到对应类型的右值引用
- 如果我们在该函数中使用的是普通迭代器,就会将空间内的元素重新拷贝过去
- 而使用移动迭代器,可以大大提高效率,直接对元素进行移动构造
- 但是要注意,不是所有的算法都可以支持移动迭代器
- 而且,移动后,不能再访问源对象!!
引用限定符
引入
- 前面我们说右值是不能赋值的,但那只针对纯右值,而c++11才提出的概念"将亡值"在之前是可以被赋值的,甚至可以访问其成员函数
- 这个特性被后续的版本兼容了下来
- 而在某些情况下,我们可能不希望出现这种情况
- 所以提出了一个新语法,在参数列表后增加一个引用限定符
使用
- 可以看到,引用限定符有两种,&和&&,并且可以和const一起使用
- 引用限定符用于指定this所指对象的属性(左值/右值)
注意点
还记得我们的const吗,它可以用于重载 同名同参数 的函数
但是,引用限定符不能这样使用,一旦其中一个函数使用了引用限定符,其他的重载函数都要使用