原视频链接:https://www.youtube.com/watch?v=pIzaZbKUw2s
图片来源PPT链接: https://github.com/CppCon/CppCon2019/blob/master/Presentations/back_to_basics_move_semantics_part_2/back_to_basics_move_semantics_part_2__klaus_iglberger__cppcon_2019.pdf
“&&”出现在类型声明时,可能有下面两种意思:
- 能确定下来,就是右值引用
- 不能确定下来,可以是右值引用或左值引用,要看实际上下文来定。这种情况,这种引用,有个专门的词,叫forwarding reference(转发引用)
什么时候“&&”为转发引用呢:
- 出现在类型推导式的时候,例如
T&&
或auto&&
例如下图,如果不了解的人望文生义,很可能会得出无法调用、编译错误的结论,因为函数foo似乎是要一个右值,但我们只传递了一个左值。
可实际上下方的代码是可以被正常调用的。
因为这里的T
被推导成了Widget&
,关于这部分类型推导的详述,讲者推荐大家去看CppCon 2014: Scott Meyers “Type Deduction and Why You Care”。
总之就是在Universal References(万能引用)语义下,你传递进去的左值会被编译器特别的推导为左值引用类型。Scott也提到,Universal References一开始并不是官方标准中的术语,是在自己的书中对这一现象(酷似右值引用但既可以接受左值也可以接受右值)做的总结术语。当然,现在这个词还是有一定影响力的。
(比其“转发引用”我也更喜欢叫“万能引用”,因为我个人觉得更直观一点。)
但现在还有问题,即使T被推导成了Widget&
,可我们自己的代码也还写了&&
,那所以现在应该是Widget& &&
?在类型推导时出现了这种引用的引用情况,编译器会通过引用折叠(Reference Collapsing)规则推导出最终的类型。引用折叠规则简单的概述就是有一个是左值引用那么结果就是左值引用,如果两个都是右值引用才推导出右值引用。
引用折叠规则是给编译器自己推导时用的,我们并不能写出形如Widget& &&
这样表示“引用的引用”的代码,编译器会报错。
所以最后,函数foo就被推导为了foo(Widget&)
形式,因此是可以被正常调用的。
如果传的是一个临时对象,也就是右值进去,那么自然匹配这个模板,可以被正常调用。
设计出比较复杂的规则,背后肯定是有原因的,下面就来看一下万能引用的应用场景,同时介绍完美转发(Perfect Forwarding)。
例如下图中,我们要去实现make_unique
的话,需要怎么样设计才能把参数完美转发到T的构造函数里去呢?
第一种方法是按值传递,这样做最大的缺点就是性能开销比较大,所以显然这不是完美转发。
第二种方法,设计为引用类型,但是右值是没有办法绑定的非const引用类型上,所以是错误的。
第三种方法,设计为const引用,但这样参数就会被绑定上const,例如构造函数中需要的是普通的引用类型的话就会出错。
所以这个时候就需要万能引用出场了,既可以接受左值引用,也可以接受右值引用。
但是现在还称不上是完美转发,因为上一期也见到过,虽然参数进来的时候是右值,但是被变量保存之后,这个变量是一个左值,这样子转发给构造函数的话语义可能会发生改变(本来直接调用构造函数是传递右值的,通过make_unqie
传递后却改变为了左值),
由此引入std::forward
,可以通过下图看到std::forward
传入进去一个模板参数Arg
,转发为左值或右值依赖于Arg
的类型,现在才称得上是完美转发。
这只是转发变“完美”了,如果想要函数也更加“完美”,就需要加入模板参数包(Parameter pack)来匹配零个或者多个参数。
来看一下std::forward
的实现,可以看到其实std::forward
也并没有转发任何东西(C++标准库命名传统艺能了),只是根据模板参数的类型来决定把参数转发成什么参数类型。
先来看加入给定一个左值该函数是如何运作的,跟上问说的一样,给定一个左值会在万能语义下特别的T
被推导为左值引用。
引用折叠+std::remove_reference_t
删除引用。
结果如下图所示,返回左值引用类型的原函数参数。
如果传递进去的是右值,因为“&&
”匹配上了,所以T是被推导为Widget
。
再计算完std::remove_reference_t
后结果如下图所示,返回右值引用类型的原函数参数。
这里稍微再提一下std::move
,上一节其实对std::move
的实现也是一笔带过,而到这一章我们可以看到其实std::move
的实现也是使用了万能引用,同时要注意区分std::move
和std::forward
的区别和应用场景,一个是直接转换为右值,一个是根据模板参数决定转换为什么类型的值。
万能引用是很酷,不过如果滥用的话可能也会存在一些风险。
下面举个小例子,假如我们有个Person的结构体,有两个构造函数,一个接受const std::string&
,另一个接受万能引用。
在下面三种情况中,都会调用万能引用的构造函数版本,具体原因见下图注释。
继续深入一下关于函数重载相关的,假如有如下图左边的6个版本函数重载,则右边代码的调用优先级如下图所示。
- 不需要做任何改变、完全匹配的优先级最高
- 当模板推导具有和非普通推导函数同样的签名时,非模板函数的优先级更高
const T&&
并不是万能引用,只是const右值
如果觉得这些规则有点绕,那么可以遵守Effective Modern C++ Item 26:避免对万能引用进行重载。
最后再来看关于移动语义的几个小问题
1、下图中程序哪里可能存在隐患?
因为上面说过了这里的T&&
是万能引用,因此可能是左值进来,如果左值进来还调用了std::move
转换为右值,可能会调用到移动构造将t的内容“搬空”,所以要用std::forward
2、下图里可能存在什么问题?
其实这里当你使用错时编译器就会给你报错了。
这里的T&&
并不是万能引用,只是普通的右值,为什么会这样呢?因为(构造)函数A()
并不能脱离class A
存在,而当你把class A
的类型写出来,例如A<int>
的时候,这里的(构造)函数就不需要做类型推导了,因此这个地方只是一个普通的右值,并不是万能引用。
解决办法是在(构造)函数中额外多用一个模板(如果确实想用万能引用的话),或者把std::forward
改成std::move
让别人更清楚这是一个右值。
3、下图可能存在什么问题?
假如T是右值引用的话,那么_b
可能会调用移动构造函数将t
的东西"搬空",这样c_
就拿不到想要的东西了。
所以要保证移动语义只会被调用一次,哪怕前面的损失点性能,但是包括前面的问题来说,如果写出了这样的代码就要考虑一下是不是设计上出了问题了。
(4没什么问题,主要举个例子)
5、下图可能存在什么问题?
可能阻止RVO,RVO是Return Value Optimization的缩写,指的是函数返回临时对象时可以返回对象本身,而不是重新创建一个的优化技术。
当然编译器在判定上还是有一定局限的,例如此处调用了std::move
就会影响编译器的判断,从而调用移动构造重新创建一个出来。
这个地方我们是创建了一个新变量uptr
,如果不调用std::move
的话也能被RVO,实际上叫Named Return Value Optimization,NRVO,属于RVO的一个变种,如果你担心因为这个地方是创建变量出来不能被正常优化的话,那可以直接return对象,这样在C++17的标准下保证进行RVO。
也不要返回一个右值,下图是一个错误示范,这样是一个dangling reference。
6、下图可能存在什么问题?
这里的T是一个万能引用,换也就是T&&
推导出来可能是int&
,很坑的一点是std::is_integral_v<int&>
返回的是false(0)。
因此,如果是作为一个“正常”的用法,即想让int&
也判定为整数类型的话,那么在使用std::is_integral_v
之前应该先把引用去掉,如下图所示。