c++ 中的左右值
前言:最近又需要再次复习一下关于
c++
中lvaue
和rvalue
相关的知识。
左值和右值
简单看 int a = 1;
这条语句,对于左边的 a
,我们是可以对其取地址的,而对于右边的 1
来说,我们无法对其去地址。
对于能够取地址的 a
,位于=左边,我们就将其称之为左值,对于像 1
这样的无法取地址的值,就称之为右值。对于常量表达式、函数返回值之类的无法直接取地址的临时值,都属于右值。
不过,在 c++
中还存在有引用语义,由此而来的是左值引用和右值引用这两个令人头痛的东西。
注意:引用不是指针
对于左值来说,它是一个能够取到地址的值,那么对于其引用,其实就是一个能够指向这个左值的贴片而已。
int a = 1; // 左值
int &b = a; // 左值引用,指向 a
int &c = 1; // 错误!左值引用无法引用一个右值
const int &d = a; // 不可变的左值引用,指向 a
const int &e = 1; // 可以...
在上述代码中,第二行很简单,就是创建了一个左值引用,对于第三行来说,左值引用的对象需要是一个左值,因此无法绑定到右值。对于 const T&
来说,它可以绑定到一个左值,这表示引用一个左值,但是不能够通过该引用修改这个左值,因为该不可变的特性, const int &e = 1
绑定一个右值是可以被允许的。
对于左值引用来说,如果一个资源不需要拷贝时,使用左值引用可以减少不必要的资源复制。
// 这将传递一个左值引用
// 同时,它也可以绑定到一个右值
auto Foo(const std::vector<int> &a) -> void {
// do something...
}
// 这将拷贝一个 std::vector<int>
auto Bar(std::vector<int> b) -> void {
// do something...
}
对于右值引用来说,它能够做左值引用做不到的事情:指向一个右值。
int a = 1; // 一个左值
int &&b = 1; // 一个右值引用
int &&c = a; // 错误!右值引用无法绑定到左值
b = a; // 修改右值引用是被允许的
不过,如果细分的话,那么右值还可以分类为两种:纯右值和将亡值。对于纯右值,它无法被修改,例如 int a = 1;
中的 1
。
对于将亡值,理论上是不可以被修改,但是可以通过某些操作修改它。如果你选择遵守语义,不修改它,那么它就可以归类于右值,如果你修改它,那么它将和左值一起被归类在“泛左值”的类别中,也就是可以被修改的值类型。
左值引用和右值引用又是什么值?
既然左值引用和右值引用都是一个具名的对象,那么它们的值类型又是什么呢?以下代码可以测试一下值的类型。
auto Foo(int &t) -> void {
std::cout << "lvalue" << '\n';
}
auto Foo(int &&t) -> void {
std::cout << "rvalue" << '\n';
}
auto main() -> int {
int a = 1;
int &b = a;
int &&c = 1;
Foo(a); // lvalue
Foo(b); // lvalue
Foo(c); // lvalue
Foo(1); // rvalue
}
可以看到,对于 Foo(a)
和 Foo(1)
,我们自然是不会惊讶其是什么值,一个是左值,绑定到左值引用,一个是右值,绑定到右值引用上。但对于 Foo(b)
和 Foo(c)
来说,他们的结果表明他们都是属于左值而不是右值。按照对左值和右值的判定,等号左边的是左值,那么他们确实都是左值。但是如果返回值是一个引用,那么他们又是位于等号右边,此时他们又变成了一个右值了。
移动语义
一个左值可以被转化为右值吗?可以的,只需要使用 std::move
即可做到。
int a = 1; // 左值
int &&b = std::move(a); // move
这是怎么被做到的?一个左值被“移动”后就变成了一个右值?观察一下 std::move
的实现方式,我们将会发发现一个更加奇怪的事情。
template <typename T>
constexpr auto move(T &&t) noexcept -> std::remove_reference_t<T>&& {
// 只是一个强制类型转换
return static_cast<std::remove_reference_t<T>&&>(t);
}
std::move
的实现只是一个强制类型转换,甚至到编译后都将不会看到有任何关于这个强制类型转换的语句出现,它仅仅是忠实地将传入参数无条件转换为一个右值,同时标记了这个对象是可以被移动的。
移动有什么用呢?思考一下,假如你在某个函数中实现为申请了一大块内存,然后做了点计算,然后返回,如果你需要这块内存中的数据,最好的做法就是得到指向这块内存的指针,而不是重新申请一块内存,将原来的内存拷贝后删除。通过转移指针的方式,就实现了浅拷贝的方式,也就是所谓的移动语义了。通俗点来说,移动语义就是将一个对象对某个资源的所有权转移给了新的对象,但不涉及资源的拷贝,这样就实现了优化。
class Foo {
public:
Foo(int x = 0) : ptr_(new int(x)) {}
~Foo() {
if (this->ptr_)
delete this->ptr_;
}
// 拷贝构造函数
Foo(const Foo &other) : Foo(*(other.ptr_)) {}
auto operator=(const Foo &other) -> Foo& {
if (this->ptr_) {
delete this->ptr_;
}
this->ptr_ = new int(*(other.ptr_));
return *this;
}
// 移动构造函数
// 移动了资源!
Foo(Foo &&other) noexcept : ptr_(other.ptr_) {}
auto operator=(Foo &&other) noexcept -> Foo& {
this->ptr_ = other.ptr_;
return *this;
}
private:
int *ptr_:
};
int main() {
auto x = Foo(1);
auto b = Foo(x); // 拷贝了一份
auto c = Foo(std::move(x)); // 将 x 对资源的所有权给了 c
}
还记得上文中的 int &&b = 1;
是一个左值的事实吗?auto c = Foo(std::move(x));
这里就是 std::move
存在的原因了。同时,有一些地方需要使用移动语义,保证资源的独一性( std::unique_ptr
是没有拷贝构造函数的)。
万能引用
虽然上文讲了左值引用和右值引用,但是实际上还有一种情况,会导致一个引用搞得非常头大,他就是万能引用(或者叫通用引用)。
template <typename T>
auto Foo(T &&t) -> void;
// 或者参数中的 auto ,等价于模板形式
auto Foo(auto &&t) -> void;
// 这里是右值引用
auto Foo(int &&t) -> void;
上面的代码中出现了一个特殊的情况:类型参数需要被推导,同时其附带了一个看上去像是右值引用的符号。此时,这里其实出现的是万能引用,这里既可以匹配一个左值,又可以匹配一个右值。
struct Foo() {
Foo() = default;
};
Foo &&a = Foo(); // 右值引用
auto &&b = a; // 不是右值引用
由于 auto&&
也存在类型推导 ,因此这里也是一个万能引用。像以下情况就不是一个万能引用。
template <typename T>
struct Foo {
// 不是万能引用,类型在Foo<T>中被确定
auto Dump(T &&t) -> void;
// 是万能引用,这里需要类型推导
template <typename T2>
auto Print(T2 &&t) -> void;
// 特殊的,const auto && 不被认为是一个万能引用
// 万能引用必须恰好为 T&& 或 auto&&
template <typname T2>
auto Bar(const T2 &&t) -> void;
};
因为存在了类型推导,那么就可能出现一种常见的情况:T
是一个 int&
,此时万能引用被推导为 int & &&
。我们无法声明一个引用,绑定到其他的引用上,此时就会发生 引用折叠 ,类型最终被推导为 int &
。
T& & => T&
T& && => T&
T&& & => T&
T&& && => T&&
其实规则比较简单,就是只要涉及到左值,那么最终引用折叠的结果都是左值引用,很符合左值和右值系统,右值没有被绑定到左值之前不应具有一个实际的地址,而左值本来就有一个实际的地址,不可能突然一下子丢失了地址。
万能引用有什么用?想象一下,如果只是需要一个转发的函数,这个函数处理不同类型的值,并将其转发到对应的处理函数中,那么此时就需要万能转发,或者说是万能转发和 std::forward
的结合,即完美转发。
完美转发
如果说 std::move
是将传入的值忠实地转化为右值,那么 std::forward
则是根据传入值的类型,将其转发为原本的类型。为什么是原本类型?思考一下以下代码。
template <typename T>
auto Foo(T &&t) -> void {
Bar(t);
}
这里的 t
是什么值?不管它被传入的是什么值,它都是一个左值。如果我们传入的是一个右值,如 Foo(1)
,那么 t
的类型为 int&&
,是一个右值引用,但它却是一个具名的右值引用,它已经可以被取地址了,即 t
是一个左值。我们希望 Bar(t)
能够调用正确的重载函数,但是现在 t
是一个左值,我们不管传递什么值,Bar(t)
都只能匹配到左值版本,除非使用 Bar(std::forward<T>(t))
。
我们看看 std::forward
是如何做到这件事情的。
template <typename T>
constexpr auto forward(std::remove_reference_t<T>& t) noexcept
-> T&& {
return static_cast<T&&>(t);
}
template <typename T>
constexpr auto forward(std::remove_reference_t<T>&& t) noexcept
-> T&& {
static_assert(!std::is_lvalue_reference<T>::value,
"std::forward must not be used to convert an rvalue to an lvalue");
return static_cast<T&&>(t);
}
对于转发左值版本的 std::forward
,其中只是将类型强制转换为 T&&
。假设传入的基本类型为 int
,看看传入后会被推导为什么。
// 传入 int
template <>
constexpr auto forward(int& t) noexcept -> int&& {
return static_cast<int&&>(t);
}
// 传入 int&
template <>
constexpr auto forward(int& t) noexcept -> int& && {
return static_cast<int& &&>(t);
}
// 传入 int&&
template <>
constexpr auto forward(int&& t) noexcept -> int&& && {
return static_cast<int&& &&>(t);
}
因为引用折叠,第二个最终返回 int&
,第三个最终返回 int&&
,左值引用依然被返回为左值引用,右值引用依然返回右值引用。我们现在就可以实现完美转发了。
template <typename T>
void Foo(T &&t) {
Bar(std::forward<T>(t));
}
void Foo(auto &&t) {
Bar(std::forward<std::remove_reference_t<decltype(t)>>(t));
}
什么使用 std::move 和 std::forward
对于 std::move
,它只是将只无条件转发为右值,因此当需要转移所有权时,应当使用 std::move
,这适用于需要被转移的左值和所有的右值。
对于 std::forward
,当出现万能引用时,请使用它,以确保能够转发到正确的函数调用上。