尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【C++】C++11新特性—右值引用,来看看怎么个事儿

【C++】C++11新特性—右值引用,来看看怎么个事儿 目录前言一、左值引用和右值引用右值一定不能取地址吗右值引用和普通变量存值有啥区别什么是 std::move 什么是 RVO 二、右值引用和移动语义2.1 移动构造2.2 移动赋值2.3 STL容器插入接口2.4 左值右值相互转换2.5 完美转发三、类的新功能3.1 新默认成员函数3.2 新关键字前言传统的C语法中就有引用的语法而C11中新增了的右值引用语法特性所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用都是给对象取别名。一、左值引用和右值引用引用简单来说就是给对象取别名我们刚开始接触C的时候就学过这里又区分出左值引用和右值引用它们有什么不同要想探讨这个问题首先应该了解清楚具体什么是左值什么是右值。左值一个表示数据的表达式一般情况可以赋值如果被const修饰就不能修改左值可以出现在“”的左边或右边最关键的特性是左值可以取地址右值一个表示数据的表达式一般不能修改通常是字面常量、临时对象、匿名对象等右值在“”的右边不能在左边右边不能取地址intmain(){//以下的a、p、b、*p、s[0]都是左值inta1;int*pa;constintba;*p10;strings(abcdef);s[0];//以下都是右值10;xy;fmin(x,y);//函数返回值string(1234);return0;}引用都是给对象取别名左值引用就是给左值取别名右值引用就是给右值取别名。那左值引用能不能给右值取别名右值引用能不能给左值取别名呢如果左值引用不能给右值取别名那C11出来之前右值是不是都不能取别名猜测一下也知道大概率不是的。左值引用一般是不能给右值取别名的但是可以用const修饰就行了。因为前面也说了右值一般都是字面常量、临时对象、匿名对象等而这些值都具有常性如果不用const修饰就存在权限放大的问题。所以早期右值引用没出来之前右值也可以通过左值引用给取别名。constintr110;constintr2xy;constintr3fmin(x,y);conststringr4string(abcdef);例如下面的场景intmain(){vectorstringv;strings(1111);v.push_back(s);v.push_back(string(2222));v.push_back(3333);return0;}前面我们模拟实现List的push_backvoid push(const T x)加上const修饰另一个目的也是为了既能接收左值又能接收右值这样我们既可以插入一个有名对象又能插入匿名对象了。同样的右值引用也一般不能给左值取别名但是可以通过move(左值)的方式来给左值取别名。move()可以看作像是强制类型转换所以也不会改变操作对象本身的属性。intmain(){inta1;int*pa;constintba;*p10;strings(abcdef);s[0];intr1move(a);int*r2move(p);constintr3move(b);stringr4move(s);stringr5(string)s;return0;}右值一定不能取地址吗右值不能取地址这句话没毛病但有一个微妙之处就是给右值取别名后右值会被存储到特定位置且可以取到该位置的地址可以修改如果不想被修改可以用const修饰。右值不能取地址这是 C 语法规则。右值如字面量42、表达式a b、临时对象std::string(hello)是没有持久内存地址的临时值对它们应用取地址符会导致编译错误。intx42;int*px;// 可以x 是左值int*q42;// 错误不能对右值取地址int*r(x1);// 错误表达式结果是右值给右值取别名后右值会被存储到特定位置当你用右值引用T绑定一个右值时编译器会延长该临时对象的生命周期把它分配到一个有确定地址的内存位置使其在引用的作用域内存活。intref42;// 右值引用绑定字面量临时值 42 被“存储”在栈上可以取到该位置的地址因为临时对象现在有了确定的存储位置它的地址自然可以被获取。intref42;int*pref;// 合法指向存储 42 的内存地址可以修改在没有const修饰的情况下右值引用允许修改绑定对象的内容。intref42;ref100;// ✅ 合法此时 42 被修改为 100如果不想被修改可以用const修饰用const int绑定右值可以防止修改。constintcref42;// cref 100; // 错误表达式必须是可修改的左值注意右值引用本身是左值即使它的类型是int一旦右值被引用绑定它就有了名字就成了左值需要通过std::move才能重新获得右值属性右值引用和普通变量存值有啥区别从内存布局的角度看这两行代码生成的汇编指令几乎是完全相同的inta10;// 将一个右值 10 存入栈内存intref10;// 同样将右值 10 存入栈内存在底层它们都是“分配一块栈空间写入数值 10”。从“把值存起来”这个结果看确实没区别但从“语法规则”和“设计意图”看它们有本质的不同。核心区别这两行代码的“类型”完全不同a和ref的类型不一样这决定了它们后续的使用方式变量类型是否可以绑定到右值是否可以重新赋值引用关系int aint(左值)可以不适用它是变量本身int refint(右值引用)只能绑定右值不可以引用一旦绑定就无法更改inta10;intref10;// 1. 能否重新赋值改变存储的值a20;// 可以ref20;// 也可以修改的是引用绑定的对象// 2. 能否重新绑定指向另一个对象intb30;ab;// 赋值,把 b 的值拷给 a,但 a 仍然是 arefb;// 赋值,把 b 的值拷给 ref 所绑定的对象,ref 仍然绑定原对象// 3. 类型是什么autot1a;// t1 是 int左值引用autot2ref;// t2 是 int左值引用! 因为 ref 自身是左值a是一个变量名而ref是一个别名。别名本身不可重新绑定但变量可以重新赋值。这个设计看似有点多余引入右值引用的目的不是为了“存右值”而是为了实现移动语义和完美转发这个后面说。什么是 std::move std::move是 C11 引入的一个极其关键的特性但它经常被误解它“移动”了什么吗实际上它什么都没移动。主要是这个名字太迷惑了std::move的本质是一个类型转换工具。std::move不做任何移动操作它只是一个编译期的类型转换 —— 无条件地把左值转换为右值引用。这是 GCC 中std::move的典型实现简化版templatetypenameTtypenamestd::remove_referenceT::typemove(Tt)noexcept{returnstatic_casttypenamestd::remove_referenceT::type(t);}它接受一个万能引用T它用std::remove_reference去掉T的引用属性它用static_cast把参数强制转换为右值引用std::move本身的执行成本为零编译期完成不产生任何运行时代码。std::move的作用激活移动语义它给编译器一个信号“这个对象我后面不再用了你可以‘偷’它的资源而不是复制它。”这个信号通过类型系统来传递表达式类型重载选择objT左值引用匹配const T参数拷贝构造/赋值std::move(obj)T右值引用匹配T参数移动构造/赋值std::vectorinta{1,2,3};std::vectorintba;// 拷贝构造a 保持不变std::vectorintcstd::move(a);// 移动构造a 的资源被转移到 ca 变为空// 之后 a 处于“有效但未指定”的状态// 可以继续使用但不能假设它原来的内容还在std::move(a)把a转换成std::vectorint编译器选择移动构造函数vector(vector other)移动构造函数把a的内部指针直接“偷”给ca被置空避免双重释放关键std::move后对象处于什么状态标准库规定被移动后的对象处于“有效但未指定”的状态。有效可以安全地调用不包含前置条件的成员函数如clear()、empty()、析构函数未指定具体内容不确定除了重新赋值不应该假设它原来的内容还在std::string shello;std::string tstd::move(s);// 安全可以调用无前置条件的函数std::couts.size()s.empty()\n;// 大概率输出 01s.clear();// 安全// 危险不应该读取 s 的内容// std::cout s \n; // 未定义行为可能崩溃对const对象使用std::move能走移动吗conststd::string shello;std::string tstd::move(s);// 不会移动会拷贝因为std::move返回的是const std::string而移动构造函数需要std::string无法匹配所以会退化为拷贝构造。什么是 RVO RVO返回值优化是 C 编译器做的一项强制性复制省略优化让函数直接在被调用方的栈帧上构造返回值从而避免一次甚至多次拷贝/移动操作。在没有优化的情况下函数返回一个对象会经历以下步骤在函数内部构造一个局部对象将局部对象拷贝或移动到调用方的接收变量中销毁局部对象这会导致额外的拷贝/移动构造开销尤其对于std::vector、std::string等重量级选手成本很高。RVO 通过直接在调用方的接收变量内存位置上构造返回值从而完全绕过了中间拷贝。#includeiostreamclassMyClass{public:MyClass(){std::coutConstructor\n;}MyClass(constMyClass){std::coutCopy Constructor\n;}MyClass(MyClass){std::coutMove Constructor\n;}~MyClass(){std::coutDestructor\n;}};MyClasscreate(){MyClass obj;// 在函数栈上构造returnobj;// 看起来要拷贝实际不会}intmain(){MyClass resultcreate();// 期望一次构造无拷贝/移动return0;}没有任何拷贝或移动构造的输出这就是 RVO 的效果。C17 规定当返回值是一个纯右值时拷贝/移动省略是强制的。MyClasscreate(){returnMyClass();// 纯右值强制 RVO不能没有拷贝/移动构造函数}MyClasscreate(){MyClass obj;returnobj;// 这是 NRVO命名返回值优化不是强制的编译器尽量做}如果编译器无法做 NRVO会退化为移动构造C11 后。RVO 和std::move的冲突不要画蛇添足地在返回值上手动加std::moveMyClasscreate(){MyClass obj;returnstd::move(obj);// 阻止 RVO强制移动构造}如果return obj编译器可以做 RVO零拷贝如果return std::move(obj)RVO 被破坏编译器必须调用移动构造函数有额外开销。C17 之后返回值优化RVO是强制性的不需要手动std::move。二、右值引用和移动语义引用的意义是减少拷贝。在右值引用出现之前左值引用还不太全面有些传返回值的场景只能传值返回不能传引用返回。比如传局部对象yjz::stringto_string(intvalue){boolflagtrue;if(value0){flagfalse;value0-value;}yjz::string str;while(value0){intxvalue%10;value/10;str(0x);}if(flagfalse){str-;}std::reverse(str.begin(),str.end());returnstr;}这里的str是一个局部对象出了作用域就销毁传引用会造成野引用所以只能传值返回值传值会先拷贝构造一个临时对象再用临时对象拷贝构造目标对象。2.1 移动构造右值可分为纯右值和消亡值纯右值比如字面常量消亡值比如临时对象。临时对象用完就要消亡再对它拷贝构造显得有点多余既然它的结局已经注定了还不如把它的东西直接拿过来这里就引出了移动构造所以移动构造直接将构造的对象和被构造的对象数据交换掠夺一下就行。移动构造本质是将参数右值的资源窃取过来占位已有那么就不用做深拷贝了所以它叫做移动构造就是窃取别人的资源来构造自己。//拷贝构造string(conststringstr){_strnewchar[str._capacity1];//多开一个存\0strcpy(_str,str._str);_sizestr._size;_capacitystr._capacity;}//移动构造string(stringstr){swap(str);}虽然即使没有移动构造只有上面的拷贝构造也能因为有const修饰而接收左值和右值但是有了移动构造编译器会走最匹配的。1、string类只有拷贝构造没有移动构造2、string类有拷贝构造也有移动构造虽然str是一个左值但是它出了作用域就消亡和临时对象的结局是一样的所以可以把str作为一个右值来走移动构造这里是隐式的将strmove为右值。返回局部的大对象调用移动构造的代价非常低很实用。也不是说所有的局部对象传值返回都要走移动构造只有需要深拷贝的对象移动构造才有意义像日期类这种对象拷贝构造和移动构造没有区别。上面我们提到了像VS2022这种比较激进的编译器优化比较夸张它一步到位优化为直接构造这里str就像ret1的左值引用一样。那既然编译器都优化的这么好了那移动构造还有意义吗并且它是直接构造而走移动构造的话是构造移动构造。既然右值引用现在被广泛使用了就说明移动构造还是有重要意义的。移动构造代价很小不是所有的编译器都像VS2022这样做极致的优化有其他场景下优化不了2.2 移动赋值除了移动构造还有移动赋值本质还是一样的。下面我们来看下有移动赋值和没有移动赋值有什么区别。//赋值重载stringoperator(conststringstr){//防止自己给自己赋值if(this!str){delete[]_str;_strnewchar[str._capacity1];strcpy(_str,str._str);_sizestr._size;_capacitystr._capacity;}return*this;}//移动赋值stringoperator(stringstr){swap(str);return*this;}有调用赋值重载的情况时编译器不能像之前一样优化为直接构造因为这里调用to_string前ret1是已经存在的对象编译器就没办法优化了。这里还是把左值str隐式作为右值调用了移动赋值因为虽然str是左值但它是局部对象终归是为ret1服务的出了作用域就消亡和临时对象的意义差不多。所以不管是移动拷贝还是移动赋值都是有意义的编辑器的极致优化也处理不了所有情况相比之下这里编译器的极致优化反倒显得意义不大因为即使多了移动拷贝和移动赋值这一步骤它们的消耗也是非常小的。不管是移动构造还是移动赋值处理的都是传值返回的问题。2.3 STL容器插入接口右值引用解决的不只是传值返回的问题还有一些容器插入接口的问题。intmain(){std::listyjz::stringlt;yjz::strings1(111111);lt.push_back(s1);lt.push_back(yjz::string(222222));lt.push_back(333333);lt.push_back(move(s1));return0;}有了右值引用我们就可以很方便的插入一些匿名对象这样写不仅简单还会少一次拷贝构造。所以以后我们可以插入匿名对象少了一次拷贝构造消耗更低一些。这里插入匿名对象时还有一个奇怪的现象其中红色箭头是实际执行路径。首先插入了一个string类型的一个匿名对象push_back调到了右值引用的函数没问题但下一步调用insert函数时为什么调到了左值引用的函数呢探讨这个问题前我们先来看这个yjz::stringr1yjz::string(11111111);这是一个右值引用没错但右值是yjz::string(11111111)而r1却是一个左值所以右值引用本身是一个左值。虽然右值是不能取地址的但是给右值取别名后会导致右值被存储到特定位置且可以取到该位置的地址。例如不能取字面量10的地址但是r1引用后可以对r1取地址也可以修改r1。如果不想r1被修改可以用const int r1去引用。其实右值引用本身是左值也不奇怪如果右值引用本身是右值右值一般不能修改那还怎么通过移动语义来掠夺资源呢。再回到上面的问题虽然匿名对象push_back时调到了右值引用的接口但是接口中的x却是一个左值所以接下来调用insert时就调到了左值引用的接口。x本身一个左值其引用的对象是一个右值在调用insert时我们期望调用右值引用的接口是参数匹配的问题所以可以考虑用move进行类似强转的操作。voidpush_back(Tx){insert(end(),move(x));}这样就完了吗还没完在insert函数内部也存在着相同的问题。这样就完了吗还没完这里move(x)传过去是一个右值所以Node的构造函数也需要有一个右值引用为接口的版本。这样就完了吗还没完这里的x也是一个左值所以下面初始化_data时也需要move强转一下。这样就完了吗是的这次真的完了。上面的层层转换过程少一步都不行这类似一个属性退化的问题。2.4 左值右值相互转换x是一个左值如果我们需要它是一个右值也只是一句代码的事同样右值如果我们需要它是一个左值也可以强转得到。通过上面的一些实例可以看出左值和右值可以相互转换其实说到底左值和右值在底层没什么区别其能不能取地址也只是语法层面上的约束当然现阶段的我们还不适合过多关注底层因为底层和语法层在某些地方是相悖的这不利于我们小萌新学习我们学习主要还是以语法层为主的。2.5 完美转发上面看到C11后STL容器插入接口基本都对左值和右值做了对应的函数那以后类似这样的场景我们都要写两个甚至更多的版本吗为了方便C11又引入了万能引用templateclassTvoidfunc(Tx){//...}在函数模版中这里的T x不再是前面我们见到的右值引用而是万能引用。它不是具体的左值引用或右值引用而是根据传过去的参数自动推导引用类型。传左值就是左值引用传右值就是右值引用。在各种场景下它帮助我们实例化出下面四种函数voidfunc(intx);//左值voidfunc(constintx);//const 左值voidfunc(intx);//右值voidfunc(constintx);//const 右值为什么那些容器接口没有使用万能引用呢那些函数是在类模版中的类模版实例化后函数中的参数就是一个确定的类型除非再套一层模版历史遗留的原因因为前面已经有左值引用的版本了模板的万能引用只是提供了既能接收左值又能接收右值的能力但是引用类型的唯一作用就是限制了接收的类型后续使用中都退化成了左值。voidfunc(intx){cout左值引用endl;};voidfunc(constintx){coutconst 左值引用endl;};voidfunc(intx){cout右值引用endl;};voidfunc(constintx){coutconst 右值引用endl;};//函数模版templateclassTvoidPerfectForward(Tt){func(t);}intmain(){PerfectForward(10);//右值inta1;PerfectForward(a);//左值PerfectForward(move(a));//右值constintb2;PerfectForward(b);//const 左值PerfectForward(move(b));//const 右值return0;}我们希望能够在传递过程中保持它的左值或右值的属性就需要完美转发。std::forwardT(t)完美转发在传参的过程中保持了t的原生类型属性。std::forwardT(t)和move的区别是我们提前知道它是一个退化的右值需要继续保持它的属性就用move如果我们提前不知道它是左值还是右值就用std::forwardT(t)不管对于左值还是右值完美转发都会保持它原本的属性。三、类的新功能3.1 新默认成员函数前面我们学了类的6个默认成员函数构造函数析构函数拷贝构造函数拷贝赋值重载取地址重载const 取地址重载默认成员函数是我们不写编译器会默认生成的函数其中最后两个不常用。C11后又增加了两个默认的成员函数移动构造和移动赋值重载。它们两个的特点是如果你提供了移动构造或者移动赋值编译器不会自动提供拷贝构造和拷贝赋值如果你没有自己实现移动构造函数且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数对于内置类型成员会执行逐成员按字节拷贝值拷贝自定义类型成员则需要看这个成员是否实现移动构造如果实现了就调用移动构造没有实现就调用拷贝构造移动赋值重载和移动构造基本类似但是只要破坏了其中的一个条件就不会生成默认的移动构造比如实现了析构函数可能有同学觉得移动构造和移动赋值重载这两个默认成员函数的自动生成条件有点苛刻其实不然。如果某个类需要显示写析构就说明有资源释放那就需要显示的写拷贝构造和复制重载那就需要显示的写移动构造和移动赋值它们是一体化的。对于日期类这样的不需要深拷贝的类这些默认成员函数使用编译器默认生成的就可以了。所以对于这些默认成员函数要么都自己写要么都使用编译器默认生成的。而默认生成的移动构造和移动赋值主要是作用于上面Person这样的类它本身的成员并不需要深拷贝但是其有自定义类型的成员一般这个自定义类型成员都有自己的移动构造和移动赋值那就会调用这个自定义类型自己的移动构造和移动赋值。从这里也可以得出如果一个类不需要写析构、拷贝构造等就不要去多此一举了因为这样可能会在其他地方出问题。3.2 新关键字default强制生成默认成员函数关键字如果因为某些原因我们真的需要默认成员函数则可以用default强制生成。delete禁止生成默认成员函数关键字如果正常情况下某些成员函数会默认生成但是我们不想让它生成可以使用delete声明该函数为删除函数。final禁止父类被继承禁止虚函数被重写override检查虚函数是否重写这两个关键字在《多态》中已经有详细介绍这里就不再赘述。本篇文章的分享就到这里了如果您觉得在本文有所收获还请留下您的三连支持哦~
返回列表