C++右值和右值引用是最容易用错的特性之一,相关教程普遍写得不清晰。实际上,只有在所有权转移时才需要使用右值引用,移动语义移动的是所有权,完美转发转发的也是所有权。

总结

先放总结

  1. 左值引用的语义是绑定、const指针和不转移所有权(rust称为借用),右值引用的语义是绑定、const指针和转移所有权
  2. 是否使用右值引用,取决于是否要转移变量所有权。右值引用和左值引用在传参时都不会调用构造函数,性能一样高效。
  3. 如果是字面量, 如果只是读字面量,直接用const& 参数接收即可 (最常见)。如果要转移所有权,例如使用容器时,字面值在当前函数创建随即交给容器,字面量的生命周期后续由容器负责。这时候容器函数应该使用&&,例如emplace()的接口就是右值引用。const&和&&的开销都是一次字面量的构造函数
  4. 如果是左值,如果需要转移所有权,推荐使用&&和std::move()。如果不要, 用左值引用或指针即可。是不是要转移所有权,在于你想要这个左值变量在当前函数析构,还是在函数外析构
  5. 新建对象,通过传std::move()左值,可以通过移动构造加快构造速度。如果传字面量,通过可以通过移动构造加快速度。但如果传左值,则需要调用拷贝构造函数。
  6. 函数返回值不要考虑右值引用,右值引用只在函数参数上使用

右值和右值引用

也许只有C++语言才有左值和右值的概念,程序语言中,左值和右值普遍的概念分别是变量和字面量。

变量可以认为是到内存地址的映射,(变量->内存地址),通过变量我们可以拿到想访问内存的地址,修改变量也就是修改指定地址中的内存。显然变量,或者说左值是可以取地址的。

字面量是程序中写的值,这完全是编译期的概念。编译器编译成运行的指令,将字面量放入内存中。运行期时,只能通过变量来获取值(指针也是一种变量)。因此,变量/左值是编译期和运行期都存在的概念,而字面量/右值只是编译期的概念!

这也导致了左值引用和右值引用的区别,左值引用同时是编译期语义和运行期语义

  1. 编译期语义,左值引用是一种静态类型,静态类型也是编译期的语义,运行期不存在静态类型的语义。引用类型的语义是绑定,也就是左值引用可以绑定左值,右值引用可以绑定右值。
  2. 运行期语义,左值引用在运行期的语义基本等于const 指针, 即可以修改引用的对象,但不能修改指针的指向。

右值引用同样1. 编译期语义,右值引用也是静态类型, 可以绑定右值 2. 运行期语言,右值引用记录字面量对象的地址

注意字面量虽然不能被取地址,但它是有地址的,右值引用就记录字面量对象的地址。因此右值引用可以取地址,修改字面量对象,和左值引用一样。

引用绑定和构造函数

引用类型的编译期语义是绑定,类型的构造函数,即拷贝构造、移动构造、拷贝赋值和移动赋值就是依赖引用类型的绑定来接收左值或右值

下列代码可以得到

  1. 拷贝构造和拷贝赋值传入左值引用,移动构造和移动赋值传入右值引用。这代表编译期拷贝函数的参数需要绑定左值,移动函数的参数需要绑定右值。如果传入左值,则不可调用移动函数,反之不可调用拷贝函数。
  2. 在使用变量创建IntVector时,由于拷贝函数绑定左值,因此会调用拷贝函数; 使用字面量(如IntVector{})创建IntVector,会调用移动函数。
  3. 移动构造和移动赋值的语义是所有权转移,例如使用字面量创建IntVector{},如auto x = IntVector(IntVector{});, 代表字面量对象的所有权交给了新建的对象x。

移动构造和移动赋值的所有权语言更典型的体现是在容器中,例如vec.emplace_back(1), 表示字面量的所有权交给了vec容器。清理容器时会清理这个字面量,字面量的生命周期由容器控制,不再由函数控制。

std::move可以把左值转为右值,这也是编译期的概念。例如vec.emplace_back(std::move(a)), 表示a的所有权交给了vec容器。清理vec时会清理a,a的生命周期由vec控制,不再由函数控制。 变量通过转换成右值,借助移动构造函数将所有权转移,当前函数后续就不能直接操作这个变量,当前函数不具有变量的所有权。

简单讲一下所有权,所有权代表生命周期控制,函数/对象都可以持有所有权。持有所有权代表函数/类负责对象的生命周期,例如类持有成员变量的所有权吗,当对象的生命周期脱离控制,也就是失去了该对象的所有权。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <algorithm> // std::copy
#include <utility> // std::swap

class IntVector {
public:
IntVector() : data_(nullptr), size_(0) {}

explicit IntVector(size_t size)
: data_(new int[size]), size_(size) {}

~IntVector() {
delete[] data_;
}

// 拷贝构造函数(深拷贝)
IntVector(const IntVector& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}

// 移动构造函数(转移资源)
IntVector(IntVector&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 置空原对象指针
other.size_ = 0;
}

// 拷贝赋值运算符
IntVector& operator=(const IntVector& other) {
if (this != &other) { // 避免自我赋值
delete[] data_; // 释放现有资源
data_ = new int[other.size_];
size_ = other.size_;
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}

// 移动赋值运算符
IntVector& operator=(IntVector&& other) noexcept {
if (this != &other) {
delete[] data_; // 释放现有资源
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr; // 置空原对象
other.size_ = 0;
}
return *this;
}

// 获取数组大小
size_t size() const { return size_; }

// 访问元素
int& operator[](size_t index) { return data_[index]; }
const int& operator[](size_t index) const { return data_[index]; }

// 交换函数(辅助移动赋值)
friend void swap(IntVector& a, IntVector& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}

private:
int* data_;
size_t size_;
};

这部分总结就是

  1. 左值引用和右值引用的编译期语义分别是绑定左值和右值
  2. 拷贝函数参数是左值引用,它绑定一个左值,根据这个左值拷贝得到新对象;
  3. 移动函数参数是右值引用,它绑定一个右值,根据这个右值移动得到新对象。移动构造函数实现时可以移动右值的成员到新对象,可以减少拷贝,这本身是所有权转移,于是右值引用又增加了移动所有权的语义。

前面讲到左值引用和右值引用都有绑定和指针的语义,这里又增加了右值引用的移动所有权的语义,相反左值引用的语义则是不移动所有权

  1. 需要注意C++的灵活性,你通过左值引用传左值到新函数,同时在新函数将该左值析构。这代码上没错,但违反了左值引用不移动所有权的语义。正确的使用是传通过右值引用传右值到新函数,新函数可以将该右值移动或析构。在rust中,前一种操作会直接编译报错。
  2. 总之编码时只有右值引用才转移所有权(例如将unique_ptr传给新函数,推荐使用右值引用,因为发生所有权转移)。传指针/引用都不应该转移所有权,这种编码风格才是推荐的。通过函数参数就知道对象被谁管理。

为什么不拷贝/移动函数不使用值传递,因为值传递过程中就需要拷贝,拷贝有需要拷贝函数。。。拷贝函数/移动函数通过左值引用和右值引用,在传参时都不会有拷贝。

移动函数和拷贝函数的调用时机

观察下面程序,可以得到

  1. MyClass bb = a 调用构造函数,MyClass bb; bb = std::move(a) 调用赋值函数。
  2. 执行c = MyClass{},虽然是调用移动函数, 但会MyClass{}字面量的构造需要调用一次构造函数。MyClass bb = a 只需要一次拷贝构造函数,不用构造函数
  3. c = std::move(a); 是真正的只要调用一次移动赋值函数
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <algorithm> // std::copy
#include <utility> // std::swap
#include <cstdio>

class MyClass {
public:
MyClass() {
printf ("构造函数\n");
}
~MyClass() {
printf ("析构函数\n");
}
MyClass(const MyClass& other) {
printf ("拷贝构造函数\n");
}
MyClass(MyClass&& other) noexcept {
printf ("移动构造函数\n");
}
MyClass& operator=(const MyClass& other) {
printf ("拷贝赋值函数\n");
return *this;
}
MyClass& operator=(MyClass&& other) noexcept {
printf ("移动赋值函数\n");
return *this;
}
};

int main() {
MyClass a;
printf ("MyClass b(a);\n");
MyClass b(a);
printf ("MyClass bb = a;\n");
MyClass bb = a;
printf ("MyClass c(std::move(a));\n");
MyClass c(std::move(a));
printf ("b = a\n");
b = a;
printf ("c = std::move(a)\n");
c = std::move(a);
printf ("c = MyClass{};\n");
c = MyClass{};
}

// 输出
构造函数
MyClass b(a);
拷贝构造函数
MyClass bb = a;
拷贝构造函数
MyClass c(std::move(a));
移动构造函数
b = a
拷贝赋值函数
c = std::move(a)
移动赋值函数
c = MyClass{};
构造函数
移动赋值函数
析构函数
析构函数
析构函数
析构函数
析构函数

字面量使用const&和&&的区别,
观察下面程序,可以看到

  1. const&和&&作为引用,传参时都不会调用构造函数
  2. 左值优先匹配const&,右值优先匹配&&
  3. std::move()后的左值优先匹配&&
  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
    35
    36
    37
    38
    39
    void func(const MyClass& object) {
    printf ("const& 接收\n");
    }

    void func(MyClass&& object) {
    printf ("&&接收\n");
    }

    void func1(const MyClass& object) {
    printf ("强制const& 接收\n");
    }

    int main() {
    MyClass object1;
    printf ("func(object1);\n");
    func(object1);
    printf ("func(std::move(object1));\n");
    func(std::move(object1));
    printf ("func(MyClass());\n");
    func(MyClass());
    printf ("func1(MyClass());\n");
    func1(MyClass());
    }

    // 输出
    构造函数
    func(object1);
    const& 接收
    func(std::move(object1));
    &&接收
    func(MyClass());
    构造函数
    &&接收
    析构函数
    func1(MyClass());
    构造函数
    强制const& 接收
    析构函数
    析构函数

右值引用可以设置值,取地址

1
2
3
4
5
void func(MyClass&& object) {
printf ("&&接收\n");
object.a = 1;
printf ("object address %p\n", &object);
}

左值引用的取地址是绑定变量的地址

函数返回值

前面讲了函数参数,我们得到当传一个左值或右值且所有权转移,使用右值引用绑定;否则使用左值引用或指针绑定。

引用传参,无论是左值引用还是右值引用,都不会触发构造语义。如果我们返回一个对象呢,例如

1
2
3
4
MyClass create() {
return MyClass(); // ✅ RVO 触发,直接在调用处构造
}
MyClass x = create();

事实上返回值一般会执行返回值优化,即返回的对象在调用处直接构造,无须构造临时对象。因此我们没有任何必要调用return std::move(xx)

实际编码中,我们推荐

  1. 使用引用或指针函数参数只读或修改对象
  2. 用专门的工厂函数创建对象,并返回对象的指针,或者返回shared_from_this()
  3. 函数返回错误码,尽量避免返回对象

因此右值引用一般只用于函数参数,用来绑定右值并执行移动语义和所有权转移,无须在返回值中考虑右值引用

什么时候用右值引用

由上我们总结, 右值引用和左值引用在传参时都不会调用构造函数,性能一样高效。是否使用右值引用,取决于是否要转移变量所有权
1.如果是字面量, 如果只是读字面量,直接用const& 参数接收即可 。如果要转移所有权,例如使用容器时,字面值在当前函数创建随即交给容器,字面量的生命周期后续由容器负责。这时候容器函数应该使用&&,例如emplace()的接口就是右值引用。使用左值引用接收右值,需要1构造+1拷贝函数,而右值引用需要1构造+1移动函数
2. 如果是左值,如果需要转移所有权,推荐使用&&和std::move()。如果不要, 用左值引用或指针即可。是不是要转移所有权,在于你想要这个左值变量在当前函数析构,还是在函数外析构
3. 移动构造函数使用右值引用作为参数,可以加快构造速度。但移动构造函数只有在新建对象时才使用。
4. 函数返回值不要考虑右值引用,右值引用只在函数参数上使用

完美转发

std::move(x) 将左值转为右值,从而利用右值引用函数参数(右值也是转成右值)

std::forward<T>, 若T是左值引用,返回左值。若 T 是右值引用,返回右值。完美转发主要处理《右值引用也是一种左值》这个问题,使用forward,可以把右值引用再次转成右值,从而继续使用下一个函数的右值引用函数参数。

所有权语义上分析,完美转发就是在A函数将对象所有权转移到B函数,B函数再将对象所有权转给C函数。移动语义移动的是所有权,完美转发转发的也是对象所有权。

如果参数是通用引用,对于需要转移所有权的右值引用,B函数会将参数传递给C函数,对于无须转移所有权的左值引用,B函数不会转移所有权给C函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <utility>
#include <iostream>

// 目标函数
void process(int& x) { std::cout << "左值: " << x << "\n"; }
void process(int&& x) { std::cout << "右值: " << x << "\n"; }

template <typename T>
void relay(T&& arg) {
process(std::forward<T>(arg)); // 完美转发, 二次转发对象。arg如果是左值引用,这里转型为左值,如果是右值引用,这里转型为右值
// 如果是process(arg), arg将一直认为是左值
}

int main() {
int a = 10;
relay(a); // 传递左值 → 调用 process(int&)
relay(20); // 传递右值 → 调用 process(int&&)
relay(std::move(a)); // 传递右值引用 → 调用 process(int&&)
}

转型、左右值、左右值函数引用,函数参数优先匹配等逻辑,都是编译期的行为。