如何理解C++中的左值引用与右值引用

过去C++中只有左值引用,没有右值引用,引用只允许绑定在左值上,就是只可以绑定能够持久存在的对象,不能绑定表达式、字面常量或将要被销毁的临时对象(例如非引用类型的函数返回值),因为该对象销毁后我们就无法通过引用来使用或改变这个对象,C++就将这种绑定行为归为非法。

string& s="temporary";   //错误。字面常量“temporary”在这一行结束后就不存在了,string&不能绑定到一个快要不存在的对象上。
int fun() {
    return 0;
}
int& x=fun();   //错误。函数返回一个临时量,这一行结束该临时量就被销毁,int&不能绑定。

但是我们知道,常量引用可以绑定表达式、字面常量或将要被销毁的临时对象。

const string& s="temporary";   //正确
const int& x=fun();   //正确

这是因为在使用常量引用绑定表达式、字面常量或将要被销毁的临时对象时,编译器隐式地创建了一个新的临时量,常量引用绑定到这个临时量上。虽然我们不能使用这个临时量,但它不会在该行代码结束后立刻被销毁,而是与引用一起存在到生命周期结束。

/******编译器隐式地将代码变成以下形式******/
const string s_temp="temporary";   //编译器创建的临时量
const string& s=s_temp;

const int x_temp=fun();   //编译器创建的临时量
const int& x=x_temp;

我理解引用的本质就是“别名”,为某个存在于内存中的对象取个新名字,引用必须依附于某个显式或隐式存在的对象。当这样的对象表面上不存在时,编译器就隐式地创建一个(例如上述代码中创建的临时量)。占用内存的实际上就是这个对象,而引用本身可以理解为几乎不占用内存。我们对引用的一切操作都是对这个对象的操作。
以普遍理性而论,表达式、字面常量或临时对象在它被销毁的那一刻,值就不应该变了,我们也不会想改变一个不由我们创建的隐式的临时量,因此非常量的左值引用不能绑定表达式、字面常量或临时对象。
常量引用虽然能够保留生命周期短暂的临时对象,弥补了普通左值引用的不足,但在某些情况下,我们又希望改变对象的值。为了能够利用这些“边角料”的资源,于是C++11引入了右值引用。我理解右值引用就是可以绑定临时对象、又能改变对象的值的引用,巧妙利用右值引用可以提高性能。例如下面这样一个类:

class A {
public:
    A() : p(new int(0)) {  }
    A(const A& a) : p(new int(*a.p)) {  }   //拷贝构造函数
    ~A() { delete p; }   //析构函数
private:
    int* p;
};

A getA();   //声明函数getA(),该函数返回值是A类型
A a(getA());   //调用拷贝构造函数

在以上程序中,函数getA()返回一个A类型的临时对象,再用这个对象调用拷贝构造函数创建对象a。这一过程中,临时对象构造又析构,要创建的对象只是拷贝了临时对象的资源(new了新的动态内存),感觉有点浪费。既然我们已经有了一个A类型的对象,为什么不能利用它占有的资源直接构造对象呢?于是我们又定义了移动构造函数,它的参数是A的右值引用。

class A {
    //前后内容不变
    ...
    A(A&& a) : p(a.p) { a.p=nullptr }   //移动构造函数
    ...
}

A getA();
A a(getA());   //调用移动构造函数

这时创建对象a调用拷贝构造函数和移动构造函数都是可以的,但调用拷贝构造函数需要进行一次const转换,所以这里优先使用了移动构造函数。移动构造函数中,a的指针p直接拷贝了临时对象的指针p,没有申请新的动态内存空间。临时对象把对资源的“控制权”移交(拷贝指针p)后,自身必须置空,因为临时对象与对象a的指针都指向同一个动态内存,如果不置空,临时对象析构时会释放这个内存,导致后面使用对象a出错。置空后临时对象不需要在析构时释放内存。相比于拷贝构造函数,移动构造函数减少了系统调用,提升了性能。
我们如何理解拷贝指针后临时对象的指针必须置空?C++中,多个用户控制同一个资源是一件带有危险性的事。比如多个指针指向同一个动态内存,一旦其中一个指针可能释放内存,我们就要特别注意其他指针不能重复释放。右值引用绑定的又是临时对象,临时对象随时可能被销毁。为了避免这个危险,我们使用右值引用时有一个标准:该引用必须是它所绑定对象的唯一用户。左值引用绑定的对象可以有很多“别名”,即我们可以通过不同用户使用这个对象。但我们进行右值引用时,我们就向程序承诺了这个引用是这个对象今后的唯一名字,我们不会通过这个引用创建前的其他名字使用这个对象。

int b=1;   //创建一个对象
int& c=b;   //左值引用绑定一个左值,编译通过
int&& d=1;   //绑定一个隐式创建的临时量,编译通过
int&& e=b;   //错误:绑定一个会持久存在的左值,不保证e是所绑定对象的唯一名字,编译不通过
int&& f=std::move(b);   //使用std::move()相当于承诺之后f会是所绑定对象的唯一名字,不再使用包括b在内的其他名字,编译通过

事实上这只是我们的承诺,程序并不会强制移动什么,只会在编译器力所能及的范围内不让我们犯可能出现的错误(比如不允许右值引用绑定一个左值)。

int b=1;
int& c=b;
int&& f=std::move(b);
b=2;
cout << b << " " << c << " " << f << endl;
c=3;
cout << b << " " << c << " " << f << endl;
f=4;
cout << b << " " << c << " " << f << endl;

以上代码的输出结果如下:

2 2 2
3 3 3
4 4 4

虽然我们对b使用std::move(),但它其实并没有移动什么,后面b依然可以被改变和使用。不过既然我们已经承诺将b对象的控制权移交出去,我们就应该只通过f来使用这个对象,以上用任何f之外的名字使用对象的行为都应该禁止。程序员有责任按照规范使程序尽可能的安全。
回到类型A的移动构造函数,当我们使用右值引用时,我们应该信守承诺,临时对象移交了资源的控制权之后,应该保证自己不能再通过指针使用动态内存中的资源,于是置空。我们也受益于此,对象a在析构自身时不会出错。看,信守承诺会有回报的!

总结一下,右值引用和左值引用一样,是内存中某个对象的名字,通过名字可以使用这个对象,所以右值引用被创建后可以像左值一样被使用(虽然我们应尽可能避免像左值一样使用)。右值引用与非常量左值引用的区别在于:

  • 右值引用承诺自己之后会是对象的唯一用户,我们不会通过其他用户使用这个对象。
  • 表达式、字面常量和将要被销毁的临时对象天然满足以上这种唯一性,所以C++允许我们用右值引用绑定临时对象(事实上绑定的是编译器隐式创建的临时量,该临时量不会被立刻销毁,也不能被引用之外的其他用户使用)。

关于如何理解std::move()。右值引用不能绑定左值,std::move()相当于开了个口子,让右值引用能合法地绑定一个左值对象,其实并不会对这个对象本身做什么。当然我们也要保证以后不会再像一个左值一样使用这个对象。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值