左值右值

什么是左值和右值

左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边

比如:

1
2
int a = 5;
A b = A(); //A是一个类

其中ab是左值,5和A()是右值。

左值引用和右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。

左值引用

能指向左值,不能指向右值的就是左值引用

1
2
3
int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败

引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。但可以通过const左值引用指向右值。编译器将创建一个临时对象来持有这个右值,这个临时变量是可以进行取地址操作,并且在 var 的生命周期内,该临时对象都是有效的。

1
const int &ref_a = 5;  // 编译通过

const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为函数参数的原因之一,如std::vectorpush_back

1
void push_back (const value_type& val);

如果没有constvec.push_back(5)这样的代码就无法编译通过了。

右值引用

右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值

1
2
3
4
5
6
7
int &&ref_a_right = 5; // ok

int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值

ref_a_right = 6; // 右值引用的用途:可以修改右值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向

可以通过std::move函数使右值引用指向左值。

在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。

std::move是一个非常有迷惑性的函数,不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量,但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换:static_cast<T&&>(lvalue)。 所以,单纯的std::move(xxx)不会有性能提升

总结

  1. 被声明出来的左、右值引用都是左值
  2. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  3. 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
  4. 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。

应用场景

为什么要有右值引用呢,在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数拷贝构造函数赋值运算符重载析构函数等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Array {
public:
Array(int size) : size_(size) {
data = new int[size_];
}

// 深拷贝构造
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

// 深拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;

size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

~Array() {
delete[] data_;
}

public:
int *data_;
int size_;
};

该类的拷贝构造函数、赋值运算符重载函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,无法避免。 这时,有人提出一个想法:是不是可以提供一个移动构造函数,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免深拷贝了,如:

1
2
3
4
5
6
7
// 移动构造函数,可以浅拷贝
Array(const Array& temp_array, bool move) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}

这么做有2个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。
  • 无法实现!temp_array是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...},这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以右值引用为参数移动构造函数移动赋值重载函数,或者其他函数,最常见的如std::vector的push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

1
2
3
4
5
6
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}

移动构造函数

上面我们说过,为了解决避免深拷贝的麻烦,我们引入了移动构造函数,也就是移动语义,将一个对象中的资源移动到另一个对象(资源控制权的转移)

拷贝构造函数和移动构造函数都是构造函数的重载函数,所不同的是:

  • 拷贝构造函数的参数是 const左值引用,接收左值或右值
  • 移动构造函数的参数是右值引用,接收右值或被 move 的左值

比如书vectorpush_back

1
2
3
// std::vector方法定义
void push_back (const value_type& val); //可接受左值引用,左值和右值
void push_back (value_type&& val);

当传来的参数是右值时,虽然拷贝构造函数可以接收,但是编译器会认为移动构造函数更加匹配,就会调用移动构造函数。

总的来说,如果这两个函数都有在类内定义的话,在构造对象时:

  • 若是左值做参数,那么就会调用拷贝构造函数,做一次拷贝(如果是像 string 这样的在堆空间上存在资源的类,那么每调用一次拷贝构造就会做一次深拷贝)。
  • 若是右值做参数,那么就会调用移动构造,而调用移动构造就会减少拷贝(如果是像 string 这样的在堆空间上存在资源的类,那么每调用一次移动构造就会少做一次深拷贝)。

比如下面的代码:

1
2
3
string s("Hello World11111111111111111");
string s1 = s; // s是左值,所以调用拷贝构造函数
string s2 = move(s); // s被move后变为右值,所以调用移动构造函数,s的资源会被转移用来构造s2

移动赋值

转移参数右值的资源来赋给自己。

1
2
3
4
5
//对于 std::string 来说,swap 函数会交换两个字符串对象的内部数据指针和其他相关的元数据,如字符串长度和容量。
string& operator=(string&& s){
  swap(s);
  return *this;
}

拷贝赋值函数和移动赋值函数都是赋值运算符重载函数的重载函数,所不同的是:

  • 拷贝赋值函数的参数是 const左值引用,接收左值或右值
  • 移动赋值函数的参数是右值引用,接收右值或被 move 的左值

总的来说,如果这两个函数都有在类内定义的话,在进行对象的赋值时:

  • 若是左值做参数,那么就会调用拷贝赋值,做一次拷贝(如果是像 string 这样的在堆空间上存在资源的类,那么每调用一次拷贝赋值就会做一次深拷贝)。
  • 若是右值做参数,那么就会调用移动赋值,而调用移动赋值就会减少拷贝(如果是像 string 这样的在堆空间上存在资源的类,那么每调用一次移动赋值就会少做一次深拷贝

比如下面的代码:

1
2
3
4
5
6
7
string s("11111111111111111");
string s1("22222222222222222");
s1 = s; // s是左值,所以调用拷贝赋值函数

string s2("333333333333333333");
s2 = std::move(s); // s被move后变为右值,所以调用移动赋值函数,s的资源会被转移用来赋给s2
// 要注意的是,move一般是不这样用的,因为s的资源被转走了

unique pointer

理解了左值和右值,我觉得可以顺带讲一下C++里面的unique_ptr

还有些STL类是move-only的,就比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

所以需要

1
2
UniquePtr(const UniquePtr &) = delete;
UniquePtr &operator=(const UniquePtr &) = delete;

需要申明移动构造函数和移动赋值函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 移动构造函数
UniquePtr(UniquePtr &&other) noexcept : ptr(other.ptr)
{
other.ptr = nullptr;
}

// 移动赋值运算符
UniquePtr &operator=(UniquePtr &&other) noexcept
{
if (this != &other)
{
delete ptr; // 释放当前资源
ptr = other.ptr; // 接管其他指针
other.ptr = nullptr;
}
return *this;
}

这样我们就不可以写出下面的代码

1
2
up1 = up2;       // 不可以,因为只有移动赋值函数,移动赋值函数需要传入一个右值,而up2显然是一个左值
up1 = UniquePtr(up2); // 不可以,因为只有移动构造函数,移动构造函数需要传入一个右值,而up2显然是一个左值

只能下面这样写(调用移动赋值函数或者移动构造函数):

1
2
up1 = std::move(up2);  //这样up2资源被移动到up1中,从而up2失效了
up1 = UniquePtr(std::move(pt2)); //同上

左值右值
http://example.com/2024/08/24/C语言编程拾遗/C++/左值右值/
作者
LiuZhaocheng
发布于
2024年8月24日
许可协议