万能引用与完美转发
万能引用与引用折叠
万能引用(universal reference),在现代 C++
中使用十分频繁,它几乎可以绑定任何类型的对象。万能引用和右值引用语法一样,都用
T&&
表示,但是只有在特定条件下,才会表现出万能引用的性质。
构成万能引用 / 转发引用的必要条件
1. 必须存在类型推导
万能引用会出现在两种上下文中,最普遍的是作为函数模板参数类型:
1 | |
或者是出现在 auto 声明中:
1 | |
这两种情况都会发生类型推导,分别是对 param 和
var2 的类型进行推导。
如果 T&&
没有伴随着类型推导,那么此时它就是右值引用:
1 | |
有时候,虽然 T&&
出现在模板中,但并不意味着一定会发生类型推导。比如:
1 | |
push_back 无法在 vector
实例化之前调用,但是实例化后的 vector
已经不会进行类型推导,push_back 的参数类型
T&& 已经成为了具体类型的右值引用。
2. 类型必须是严格的
T&& 形式
T&&
如果被其他关键字修饰,此时便成为了右值引用。比如:
1 | |
以上两种情况,T&& 配合 std::vector
使用或添加 const 修饰都导致 T&&
成为了右值引用,只能接收右值。
当然,只要是 T&& 形式,类型名没有关系:
1 | |
万能引用的原理
万能引用的底层原理是一套编译期类型系统规则————引用折叠(reference collapsing)。
在 C++ 中,你无法显式声明”引用的引用”,比如:
1 | |
但在编译期,模板类型推导、auto
推导、decltype 或 typedef/using
别名展开时系统可能会隐式地拼接出”引用的引用”。
比如 template 类型推导:
1 | |
当 ParamType
为万能引用时,根据类型推导规则,当参数为左值时,得到左值引用。此时就会出现:
1 | |
我们知道最终的推导结果是:
1 | |
这种转变的关键就是引用折叠。
引用折叠的规则很简单:只要存在一个左值引用时折叠结果就为左值引用,只有两个引用都为右值引用时折叠结果才为右值引用。
| 原类型组合 | 折叠后的最终类型 | 记忆口诀 |
|---|---|---|
& + & |
&(左值引用) |
左 + 左 = 左 |
& + && |
&(左值引用) |
左 + 右 = 左 |
&& + & |
&(左值引用) |
右 + 左 = 左 |
&& + && |
&&(右值引用) |
右 + 右 = 右 |
可见,万能引用并不是一种新的引用类型,它本质上是右值引用语法
T&& 在特定的类型推导环境下的具体体现。
完美转发
完美转发(perfect forwarding)是 C++11
引入的核心特性之一,用于解决在模板包装函数中向另一个函数传递参数时,保留参数的原始类型属性(左值/右值、const/volatile
限定符)的问题,且不产生任何不必要的拷贝开销。
在 C++ 中,只要一个变量有名字,在函数内部它就是一个左值。比如:
1 | |
无论传入的参数是什么类型,在 wrapper 函数内部它具有名字
arg,因此 process
传入的参数永远是一个左值,只会匹配 process
参数类型为左值的重载。
如果不使用完美转发,最常见的方法是重载模板:
1 | |
这样的方法在参数较多的时候需要大量重载,同时
const T&
完全破坏了右值的移动语义,只能触发拷贝构造。
但是如果使用了完美转发,代码就会变得异常简单且高效。例如:
1 | |
在这种写法下,传入左值与右值后,分别会正确调用 process
的左值和右值重载。
我们知道当 logAndProcess 分别传入左值和右值时,根据
template 类型推导与引用折叠规则,param 的类型
T&&
分别会成为左值引用和右值引用。但是右值引用本身却是一个左值(有名字),只有通过
std::forward 才能将其”还原”为右值。
std::forward 的实现
std::forward 与 std::move
一样,它既不转发任何东西,也不会生成任何运行时代码,它本质上也是类型转换,只不过相比
std::move,std::forward 根据模板参数
T 决定是否将参数转换为右值。
std::forward 的具体实现如下:
1 | |
其中 constexpr
告诉编译器这个类型转换可以在编译期执行,零运行时开销;typename
用以消除歧义,让编译器清楚后面是一个类型而不是类成员;noexcept
承诺这个强制转换永远不会抛出异常,让编译器放心优化。
std::forward 在使用时,需要显式传入 T
的类型,std::forward 模板内的
std::remove_reference<T> 会进行类型萃取并拼接
& 或
&&,分别形成与左值和右值匹配的重载类型。
下面我们分析完美转发过程中 std::forward 发挥的作用:
1. 外层模板传入左值
1 | |
当传入类型为左值时,外层 T
的推导结果为左值引用,显式传入 std::forward
后发生引用折叠,返回值类型还是左值引用,结果相当于原样返回。
2. 外层模板传入右值
1 | |
当传入类型为右值时,外层 T
的推导结果为非引用的原始类型,显式传入 std::forward 后与
&&
拼接,返回值类型成为了右值引用,发生了左值到右值引用的类型转换。
std::forward 的第二个重载是为了处理特殊情况:
1 | |
当 std::forward
传入右值时,会触发第二个重载,此时如果传入的 T
类型为左值引用,会触发断言导致编译失败。
std::move 与
std::forward 的使用
一般来说,我们会对右值引用使用 std::move:
1 | |
对万能引用使用 std::forward:
1 | |
因为右值引用总是绑定右值,我们可以直接对其使用
std::move;由于万能引用既可以绑定左值也可以绑定右值,如果我们对其使用
std::move 可能会引发一些问题:
1 | |
局部变量 n 被传递给了
w.setName,调用者很容易误以为这是对 n
的只读操作。但由于 setName 内部使用了
std::move
无条件地将其引用参数强制转换为右值,n 的值将被移入
w.name,导致 n 从 setName
调用返回时带有一个未指定的值。
还需要注意的是,只有对于不会再被使用的变量,我们才对其使用
std::move 或 std::forward。
返回值优化
对于函数返回值,使用 std::move 或
std::forward 可以使程序更高效:
1 | |
对于右值引用类型的左值,使用 std::move 或
std::forward
可以返回右值临时变量,触发移动构造或移动赋值运算符;即使该类型不支持移动相关操作,编译器也会匹配拷贝操作。
但是需要说明的是,由于编译器会对程序进行优化,有些时候人为的过度优化反而破坏了编译器优化。比如:
1 | |
你可能会认为使用 std::move
返回局部变量更加高效,但是编译器会进行 RVO/NRVO(Return Value
Optimization / Named Return Value Optimization,返回值优化 /
具名返回值优化),让返回对象直接在最终位置构造,避免创建中间对象。
需要满足的条件有:
w是局部自动变量- 类型和函数返回类型相同
- 直接返回这个变量
即使没有触发 RVO,对于局部变量这个将亡值(xvalue),编译器也会将其看作右值类型,自动应用移动语义。
这次的插图来自画师 TheProtagonists
图片地址:https://www.pixiv.net/artworks/147570230
文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item23、Item25、Item28