左值和右值

左值引用和右值引用

左值和右值

在C++中,左值(lvalue)和右值(rvalue)是用于标识表达式的两种不同类型的值。

  1. 左值(lvalue)
    • 左值表示一个具有持久性的对象,它们可以在表达式中出现在等号的左边或右边。
    • 左值具有内存地址,因此可以取地址,并且可以被修改。
    • 典型的左值包括变量、对象、数组元素、返回左值引用的函数调用等。
  2. 右值(rvalue)
    • 右值是临时性的,通常表示即将被销毁的对象,或者是无法取地址的临时对象。
    • 右值不能在表达式中出现在等号的左边,因为它们不具有持久性。
    • 典型的右值包括字面量(如数字、字符串)、临时对象(例如返回右值引用的函数调用)、移动语义产生的临时对象等。

左值引用

C++传统的左值引用,定义一个左值引用在汇编指令上和定义一个指针是没有任何区别的

int main()
{
	int a = 10;
	int &b = a; // 定义一个左值引用变量
	b = 20; // 通过左值引用修改引用内存的值
	return 0;
}

右值引用

右值引用就是对一个右值进行引用的类型。因为右值是匿名的,所以我们只能通过引用的方式找到它。无论声明左值引用还是右值引用都必须立即进行初始化,因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名。通过右值引用的声明,该右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。

#include <iostream>
using namespace std;

int&& value = 520;
class Test
{
public:
    Test()
    {
        cout << "construct: my name is jerry" << endl;
    }
    Test(const Test& a)
    {
        cout << "copy construct: my name is tom" << endl;
    }
};

Test getObj()
{
    return Test();
}

int main()
{
    int a1;
    int &&a2 = a1;        // error
    Test& t = getObj();   // error
    Test && t = getObj();
    const Test& t = getObj();
    return 0;
}

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,
调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。
试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、
再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。
右值引用的出现恰好就解决了这两个概念的混淆问题,例如:

#include <iostream>
class A {
public:
    int *pointer;
    A():pointer(new int(1)) {
        std::cout << "构造" << pointer << std::endl;
    }
    A(A& a):pointer(new int(*a.pointer)) {
        std::cout << "拷贝" << pointer << std::endl;
    } // 无意义的对象拷贝
    A(A&& a):pointer(a.pointer) {
        a.pointer = nullptr;
        std::cout << "移动" << pointer << std::endl;
    }
    ~A(){
        std::cout << "析构" << pointer << std::endl;
        delete pointer;
    }
};
// 防止编译器优化
A return_rvalue(bool test) {
    A a,b;
    if(test) return a; // 等价于 static_cast<A&&>(a);
    else return b;     // 等价于 static_cast<A&&>(b);
}
int main() {
    A obj = return_rvalue(false);
    std::cout << "obj:" << std::endl;
    std::cout << obj.pointer << std::endl;
    std::cout << *obj.pointer << std::endl;
    return 0;
}

在上面的代码中:

  1. 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
  2. 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

完美转发

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

void reference(int& v) {
    std::cout << "左值" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通传参:";
    reference(v); // 始终调用 reference(int&)
}
int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1); // 1是右值, 但输出是左值

    std::cout << "传递左值:" << std::endl;
    int l = 1;
    pass(l); // l 是左值, 输出左值

    return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。
因此 reference(v) 会调用 reference(int&),输出『左值』。
而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,
但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,
既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型实参参数类型推导后函数形参类型
T&左引用T&
T&右引用T&
T&&左引用T&
T&&右引用T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。
更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型
这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,
保持原来的参数类型(左引用保持左引用,右引用保持右引用)。
为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

#include <iostream>
#include <utility>
void reference(int& v) {
    std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "              普通传参: ";
    reference(v);
    std::cout << "       std::move 传参: ";
    reference(std::move(v));
    std::cout << "    std::forward 传参: ";
    reference(std::forward<T>(v));
    std::cout << "static_cast<T&&> 传参: ";
    reference(static_cast<T&&>(v));
}
int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);

    std::cout << "传递左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
}

输出结果为:

传递右值:
              普通传参: 左值引用
       std::move 传参: 右值引用
    std::forward 传参: 右值引用
static_cast<T&&> 传参: 右值引用
传递左值:
              普通传参: 左值引用
       std::move 传参: 右值引用
    std::forward 传参: 左值引用
static_cast<T&&> 传参: 左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发;
由于类似的原因,std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forwardstd::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值,
std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看,
std::forward<T>(v)static_cast<T&&>(v) 是完全一样的。

读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值,
我们再简单看一看 std::forward 的具体实现机制,std::forward 包含两个重载:

template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
        " substituting _Tp is an lvalue reference type");
    return static_cast<_Tp&&>(__t);
}

在这份实现中,std::remove_reference 的功能是消除类型中的引用,
std::is_lvalue_reference 则用于检查类型推导是否正确,在 std::forward 的第二个实现中
检查了接收到的值确实是一个左值,进而体现了坍缩规则。

std::forward 接受左值时,_Tp 被推导为左值,所以返回值为左值;而当其接受右值时,
_Tp 被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + && 的右值。
可见 std::forward 的原理在于巧妙的利用了模板类型推导中产生的差异。

这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&& 是最安全的方式?
因为当 auto 被推导为不同的左右引用时,与 && 的坍缩组合是完美转发。

  • 13
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值