引用
A reference can be thought of as a name of an object.
-
左值引用: 引用左值.
-
右值引用: 引用右值, 用于参数传递, 函数返回值, 表达式中间结果. 类似于常量引用, 但是可以被修改.
(左值)引用类型的变量只能绑定到左值表达式, 只有const引用可以绑定到右值.
右值引用类型的变量只能绑定到右值表达式.
左值引用很好理解也经常用到, 右值引用还有点陌生, 接下来详细聊一聊.
引入右值引用是为了解决什么问题? 引入后又带来的什么新的问题? 新的问题又是怎么解决的?
引入右值引用是为了解决什么问题?
为了弥补C++在移动语义上的缺失. 在右值引用出现之前, 我们在函数调用传参时, 只有两种语义: 给它一份拷贝(按值传递), 或者给它一份引用(按引用传递).
那移动语义又是什么玩意儿? 为什么需要移动语义?
其实就是移动构造和移动赋值运算符, 把一个对象持有的资源转移给另一个新的对象, 避免先拷贝再释放资源的问题. 区别于已有的拷贝构造和赋值.
至于为什么需要, 无外乎是实践中有相应的使用场景.
比如std::unique_ptr, 它不能支持拷贝构造, 因为std::unique_ptr的语义就是要独占对象所有权.
这样一来函数要想返回一个std::unique_ptr就做不到了, 因为函数返回的时候要要构造一个新的std::unique_ptr, 自然就会涉及到拷贝构造.
有了移动语义就方便多了, 把原来的std::unique_ptr上的对象所有权转移到一个新的std::unique_ptr上并返回. 原来的std::unique_ptr随着函数结束自然销毁.
class unique_ptr {
public:
unique_ptr(unique_ptr&& _Right) noexcept // 移动构造
{
}
template <class _Dx2 = _Dx, enable_if_t<is_move_assignable_v<_Dx2>, int> = 0>
unique_ptr& operator=(unique_ptr&& _Right) noexcept // 移动赋值
{
if (this != _STD addressof(_Right)) {
reset(_Right.release());
_Mypair._Get_first() = _STD forward<_Dx>(_Right._Mypair._Get_first());
}
return *this;
}
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
};
引入后又带来的什么新的问题?
先看个例子, 下面代码中的foo(rref)
会调到哪个实现foo会调到哪个重载?
void foo(int &) { std::cout << "lvalue" << std::endl; }
void foo(int &&) { std::cout << "rvalue" << std::endl; }
int main() {
int &&rref = 1;
foo(rref); // output: lvalue
}
答案是foo(int &)
, 是不是有点无法理解? 接着往下看.
foo(int &)
接受一个左值类型的形参, 根据左值引用的规则, 左值引用类型的变量只能绑定左值. 所以, 要想匹配这个重载, 调用者必须传一个左值. 比如foo(a)
而foo(int &&)
接受的是一个右值类型的形参, 根据右值引用的规则, 右值引用类型的变量只能绑定右值表达式. 所以, 要想匹配这个重载, 调用者必须传一个右值, 比如foo(99)
.
这很好理解, 神奇的地方在rref
这个变量. rref
的类型是int&&
(右值引用), 绑定的值的类别
是右值, 但有意思的是, 这个变量本身却是一个左值.
所以, 按照上面的重载匹配规则, 自然就匹配到了foo(int &)
.
这也就是引入右值引用后带来的问题:
变量类型
和值类别
, 变成了两个概念. 和左值引用表达式只能是左值不同, 右值引用类型的表达式既可以是左值也可以是右值.
重载匹配的时候右值引用类型的实参被当成了左值来用. 导致语义上出现错误.
新的问题又是怎么解决的?
标准库中提供了个std::move
函数:
template <class _Ty>
constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg)
{
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
其实就是一个类型转换, 把传入的实参_Arg
转换成一个右值.
所以, 上面的foo
应该这样调:
int main() {
int &&rref = 1;
foo(std::move(rref));
}
完美转发
上面讲的是最基础的场景, 接下来再看看和模板结合的场景:
void foo(int &)
{
std::cout << "lvalue" << std::endl;
}
void foo(int &&)
{
std::cout << "rvalue" << std::endl;
}
template<typename T>
int bar(T&& x)
{
foo(x);
}
从语义上看, 我们希望给bar
传一个左值时能够转调到foo
的左值引用版本(foo(int&)
),
当bar
传一个右值时能够转调到foo
的右值引用版本(foo(int&&)
).
但是bar的形参x
本身就是一个左值, 所以无论怎么传只能匹配到foo的左值引用版本, 显然达不到我们想要的目的.
这时候就需要完美转发了, 将函数实参以其原本的值类别
转发出去. 标准库中提供了个std::forward
函数.
所以上面的bar
可以这么写:
template<typename T>
int bar(T&& x)
{
foo(std::forward<T>(x));
}
扒一扒std::forward源码:
template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{
return static_cast<_Ty&&>(_Arg);
}
template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept
{
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call"); // _Ty不能是左值引用类型
return static_cast<_Ty&&>(_Arg);
}
其实就是一个静态类型转换, 传入左值的时候, 会匹配第一个重载, 这个重载根据_Ty
的类型将实参转发为左值或右值. 传入右值时, 会匹配第二个重载, 这个重载根据_Ty
的类型将实参转发为右值.
需要注意的是模板参数_Ty
无法由编译器推断, 必须显式指定.
第一个重载不是很好理解, 补充一下展开过程:
- 模板参数传入
int
展开:
int && forward(int& _Arg)
{
return static_cast<int &&>(_Arg); // 按右值转发
}
- 模板参数传入
int&
展开:
int& && forward(int& _Arg)
{
return static_cast<int& &&>(_Arg); // 引用折叠, 折叠为int&, 按左值转发
}
- 模板参数传入
int&&
展开:
int&& && forward(int& _Arg)
{
return static_cast<int&& &&>(_Arg); // 引用折叠, 折叠为int&&, 按右值转发
}
这里其实涉及到引用折叠
和转发引用
两个概念, 再补充说明一下:
o 引用折叠:
模板编程中参数类型推导出现双重引用时, 双重引用将被折叠成一个引用, 要么是左值引用, 要么是右值引用.
折叠规则是: 如果任一引用为左值引用, 则结果为左值引用, 否则(即两个都是右值引用), 结果为右值引用.
rvalue reference to rvalue reference collapses to rvalue reference, all other combinations form lvalue reference.
o 转发引用:
转发引用就是对一个待推导的类型 T 应用上右值引用的形式(T&&)。编译器在做类型推导时,会将绑定左值的T推导为左值引用类型,将绑定右值的T推导为原基本类型,再叠加引用坍缩规则后,绑定左值的变量的实际类型为左值引用,绑定右值的变量的实际类型为右值引用。
参考文献:
Everything about Rvalue Reference: https://www.zhihu.com/question/363686723/answer/1976488046
c++11的移动语义和完美转发: https://zhuanlan.zhihu.com/p/398817111