C++ 杂记之六从基础到进阶

大纲

C++ 面向对象进阶

右值引用、移动语义、完美转发的详细介绍

左值、右值、左值引用、右值引用、移动语义

左值引用、右值引用的概念

  • 左值与右值的概念

    • 左值:具有持久身份(可取地址)的表达式,通常表示一个在内存中有确定位置、可以被反复使用的对象。
    • 右值:通常表示临时对象或纯值的表达式,一般不可取地址,生命周期短,主要用于赋值或初始化。
  • 左值引用与右值引用的概念

    • 左值引用:使用 T& 声明,只能绑定到左值,本质上是某个已有对象的别名。
    • 右值引用:使用 T&& 声明,只能绑定到右值,用于支持移动语义(move)和完美转发(forward)。
  • 左值引用与右值引用的区别

    • 左值引用和右值引用的主要区别在于它们可以绑定的值类别,左值引用只能绑定到左值,而右值引用只能绑定到右值。
    • 右值引用引入了 move 移动语义,使得 C++ 可以更高效地处理临时对象。
    • 在泛型编程中,可以通过函数模板的类型推导来同时处理左值引用和右值引用,从而实现参数的 forward 完美转发。

左值和右值的总结

  • 左值:可以出现在赋值符号 = 的左边,本质是 "有地址的对象"。
  • 右值:可以出现在赋值符号 = 的右边,通常是临时值或纯值。
  • 判断规则:能取地址的一般是左值,不能取地址的一般是右值。
  • 特殊情况:在特定场景下,有些左值也可以被当作右值使用,比如 int i = 20; i = i + 10;

左值引用、右值引用的使用

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
#include <iostream>

using namespace std;

// 三种形式的引用
void test01() {
// 左值引用(绑定到左值)
int value = 10;
int &refval = value;
refval = 20;

// 常量引用,也是左值引用(绑定到左值)
const int &refval2 = value;

// 常量引用,也可以是右值引用(绑定到右值)
const int &refval3 = 3;

// 右值引用(绑定到右值)
int &&refval4 = 30;
refval4 = 35;
}

// 前置运算符和后置运算符
void test02() {
int i = 1;

// 左值引用(绑定到左值),前置 ++、前置 -- 返回的是左值表达式
int &refval = ++i;

// 右值引用(绑定到右边值),后置 ++、后置 -- 返回的是右值表达式
int &&refval2 = i++;
}

移动语义 std::move () 的使用

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
#include <iostream>

void test01() {
int i = 10;

// 错误写法,右值引用不能绑定左值
// int &&refval = i;

// 正确写法,std::move() 的作用:将一个左值强制转换为一个右值,这样就可以使用右值引用了
int &&refval = std::move(i);

i = 20;
std::cout << "i = " << i << ", refval = " << refval << std::endl;

refval = 25;
std::cout << "i = " << i << ", refval = " << refval << std::endl;
}

void test02() {
int &&refval = 10;

// 错误写法,右值引用不能绑定左值
// int &&refval2 = refval;

// 正确写法,std::move() 的作用:将一个左值强制转换为一个右值,这样就可以使用右值引用了
int &&refval2 = std::move(refval);

refval = 30;
std::cout << "refval = " << refval << ", refval2 = " << refval2 << std::endl;

refval2 = 35;
std::cout << "refval = " << refval << ", refval2 = " << refval2 << std::endl;
}

void test03() {
std::string str = "I Love China";

// 不会触发 std::string 的移动构造函数
std::string &&str2 = std::move(str);

std::cout << "str = " << str << ", &str = " << &str << std::endl;
std::cout << "str2 = " << str2 << ", &str2 = " << &str2 << std::endl;

// 会触发 std::string 的移动构造函数,即先申请一块新的内存空间,然后将 str 的内容拷贝到 str2,最后清空 str 的内容
std::string str3 = std::move(str);

std::cout << "str = " << str << ", &str = " << &str << std::endl;
std::cout << "str3 = " << str3 << ", &str3 = " << &str3 << std::endl;
}

int main() {
test01();
test02();
test03();
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
i = 20, refval = 20
i = 25, refval = 25
refval = 30, refval2 = 30
refval = 35, refval2 = 35
str = I Love China, &str = 0x7fffb7650ef0
str2 = I Love China, &str2 = 0x7fffb7650ef0
str = , &str = 0x7fffb7650ef0
str3 = I Love China, &str3 = 0x7fffb7650ed0

特别注意

在 C++ 中,std::move() 的作用是将一个左值强制转换为一个右值,这样就可以绑定到右值引用(比如 T &&t = std::move(obj);)。

临时对象深入探讨、解析、性能优化手段

特别注意

下面的案例代码运行后,可能会出现不同的结果,这是因为 C++ 编译器优化导致的,具体来说是返回值优化(Return Value Optimization,RVO)或命名返回值优化(NRVO)。从 C++ 17 开始,在某些返回值场景(如 return T(...)return local_object)中,标准规定必须进行强制拷贝消除(Guaranteed Copy Elision),对象会直接在调用者的存储位置构造,而不会产生临时对象,因此即使关闭编译器优化(如 g++ main.cpp -fno-elide-constructors),也不会调用拷贝构造函数或移动构造函数,从而使程序的运行效率更高。

产生临时对象的案例一

后置 ++ 运算符,会产生临时对象

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main() {
int i = 1;
int&& ref1 = i++; // i++ 会产生临时变量,ref1 绑定的是临时变量,ref1 和 i 之间没有任何关系
ref1 += 5;
std::cout << "i = " << i << ", ref1 = " << ref1 << std::endl;

return 0;
}

程序运行输出的结果如下:

1
i = 2, ref1 = 6

产生临时对象的案例二

以传值的方式给函数传递参数,会产生临时对象

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
#include <iostream>

class MyValue {
public:
// 有参构造函数
MyValue(int v1 = 0, int v2 = 0) : val1(v1), val2(v2) {
std::cout << "MyValue()" << std::endl;
}

// 拷贝构造函数
MyValue(const MyValue& mv) : val1(mv.val1), val2(mv.val2) {
std::cout << "MyValue(const MyValue&)" << std::endl;
}

// 析构函数
~MyValue() {
std::cout << "~MyValue()" << std::endl;
}

// 成员函数(以传值的方式给函数传递参数,会产生临时对象)
int sum1(MyValue mv) {
int tmp = mv.val1 + mv.val2;
mv.val1 = 1000;
return tmp;
}

// 成员函数(以传引用的方式给函数传递参数,不会产生临时对象)
int sum2(MyValue& mv) {
int tmp = mv.val1 + mv.val2;
mv.val1 = 1000;
return tmp;
}

public:
int val1;
int val2;
};

void test01() {
std::cout << "======== test01() =======" << std::endl;

MyValue mv(10, 20); // 会调用有参构造函数
int sum = mv.sum1(mv); // 会调用拷贝构造函数(性能差)

std::cout << "sum = " << sum << std::endl;
std::cout << "mv.val1 = " << mv.val1 << std::endl;
}

void test02() {
std::cout << "======== test02() =======" << std::endl;

MyValue mv(30, 40); // 会调用有参构造函数
int sum = mv.sum2(mv); // 不会调用拷贝构造函数(性能高)

std::cout << "sum = " << sum << std::endl;
std::cout << "mv.val1 = " << mv.val1 << std::endl;
}

int main() {
test01();
test02();
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
======== test01() =======
MyValue()
MyValue(const MyValue&)
~MyValue()
sum = 30
mv.val1 = 10
~MyValue()
======== test02() =======
MyValue()
sum = 70
mv.val1 = 1000
~MyValue()

产生临时对象的案例三

基础类型发生隐式类型转换,会产生临时对象

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
#include <iostream>

// 统计某个字符在字符串中出现的次数
int calc(const std::string& str, char ch) {
int count = 0;
for (const char& c : str) {
if (ch == c) {
count++;
}
}
return count;
}

int main() {
char str[50] = "I Love China, Yeah!";

// 隐式类型转换(char[] 转 string)以保证函数调用成功,这里会产生 string 临时对象
int count = calc(str, 'a');
std::cout << "count = " << count << std::endl;

// 优化后的写法,不使用隐式类型转换,不会产生 string 临时对象,性能更高
std::string str2 = "I Love China, Yeah!";
int count2 = calc(str, 'a');
std::cout << "count2 = " << count2 << std::endl;

return 0;
}

程序运行输出的结果如下:

1
2
count = 2
count2 = 2

产生临时对象的案例四

构造函数发生隐式类型转换,会产生临时对象

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
#include <iostream>

class MyValue {
public:
// 有参构造函数
MyValue(int v1 = 0, int v2 = 0) : val1(v1), val2(v2) {
std::cout << "MyValue()" << std::endl;
}

// 拷贝构造函数
MyValue(const MyValue& mv) : val1(mv.val1), val2(mv.val2) {
std::cout << "MyValue(const MyValue&)" << std::endl;
}

// 赋值运算符重载函数
MyValue& operator=(const MyValue& mv) {
std::cout << "MyValue& operator=(const MyValue&)" << std::endl;
val1 = mv.val1;
val2 = mv.val2;
return *this;
}

// 析构函数
~MyValue() {
std::cout << "~MyValue()" << std::endl;
}

public:
int val1;
int val2;
};

void test01() {
std::cout << "======== test01() =======" << std::endl;

MyValue mv;
std::cout << "--------------" << std::endl;

// 会发生构造函数隐式类型转换,先调用有参构造函数,然后再调用赋值运算符重载函数
// 这里的类型转换会产生临时对象、隐式类型转换以保证代码可以正常运行
mv = 2000;

std::cout << "--------------" << std::endl;
}

void test02() {
std::cout << "======== test02() =======" << std::endl;

// 将定义对象和给对象初始化值写在同一行,这种写法不会产生临时对象,性能更高
// 为 mv 对象预留内存空间,用 1000 构造 mv 对象,而且是直接构造在 mv 对象预留的内存空间中
MyValue mv = 1000;
}

int main() {
test01();
test02();
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
11
======== test01() =======
MyValue()
--------------
MyValue()
MyValue& operator=(const MyValue&)
~MyValue()
--------------
~MyValue()
======== test02() =======
MyValue()
~MyValue()

产生临时对象的案例五

函数将临时对象作为返回值,会产生临时对象

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
#include <iostream>

class MyValue {
public:
// 有参构造函数
MyValue(int v1 = 0, int v2 = 0) : val1(v1), val2(v2) {
std::cout << "MyValue()" << std::endl;
}

// 拷贝构造函数
MyValue(const MyValue& mv) : val1(mv.val1), val2(mv.val2) {
std::cout << "MyValue(const MyValue&)" << std::endl;
}

// 赋值运算符重载函数
MyValue& operator=(const MyValue& mv) {
std::cout << "MyValue& operator=(const MyValue&)" << std::endl;
val1 = mv.val1;
val2 = mv.val2;
return *this;
}

// 析构函数
~MyValue() {
std::cout << "~MyValue()" << std::endl;
}

// 成员函数(当函数的返回值是临时对象时,编译器可能会调用拷贝构造函数生成一个临时对象,并将该临时对象作为函数返回值)
MyValue Double1(MyValue& mv) {
MyValue tmp;
tmp.val1 = mv.val1 * 2;
tmp.val2 = mv.val2 * 2;
return tmp;
}

// 优化后的写法,为了避免编译器可能调用拷贝构造函数生成一个临时对象
MyValue Double2(MyValue& mv) {
return MyValue(mv.val1 * 2, mv.val2 * 2);
}

public:
int val1;
int val2;
};

int main() {
MyValue mv(10, 20);

std::cout << "------------" << std::endl;

// 不接管临时对象,临时对象会立即析构
mv.Double1(mv);

// 其他常见写法(接管临时对象,临时对象不会立即析构)
// MyValue mv2 = mv.Double1(mv);
// MyValue&& refVal = mv.Double1(mv); // 临时对象是右值,所以可以被右值引用绑定

std::cout << "------------" << std::endl;

return 0;
}

程序运行输出的结果如下(编译器没有进行返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
MyValue()
------------
MyValue()
MyValue(const MyValue&)
~MyValue()
~MyValue()
------------
~MyValue()

程序运行输出的结果如下(编译器进行了返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
MyValue()
------------
MyValue()
~MyValue()
------------
~MyValue()

产生临时对象的案例六

类外实现运算符重载时,函数将临时对象作为返回值,会产生临时对象

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
#include <iostream>

class MyValue {
public:
// 有参构造函数
MyValue(int v1 = 0, int v2 = 0) : val1(v1), val2(v2) {
std::cout << "MyValue()" << std::endl;
}

// 拷贝构造函数
MyValue(const MyValue& mv) : val1(mv.val1), val2(mv.val2) {
std::cout << "MyValue(const MyValue&)" << std::endl;
}

// 析构函数
~MyValue() {
std::cout << "~MyValue()" << std::endl;
}

public:
int val1;
int val2;
};

// 类外实现运算符重载(当函数的返回值是临时对象时,编译器可能会调用拷贝构造函数生成一个临时对象,并将该临时对象作为函数返回值)
MyValue operator+(MyValue& m1, MyValue& m2) {
std::cout << "operator+()" << std::endl;
MyValue result;
result.val1 = m1.val1 + m2.val1;
result.val2 = m1.val2 + m2.val2;
return result;
}

// 优化后的写法,为了避免编译器可能调用拷贝构造函数生成一个临时对象
MyValue operator-(MyValue& m1, MyValue& m2) {
std::cout << "operator-()" << std::endl;
return MyValue(m1.val1 - m2.val1, m1.val2 - m2.val2);
}

int main() {
MyValue mv1(10, 20);
MyValue mv2(30, 40);

std::cout << "------------" << std::endl;

// 不接管临时对象,临时对象会立即析构
mv1 + mv2;

// 其他常见写法(接管临时对象,临时对象不会立即析构)
// MyValue mv3 = mv1 + mv2;
// MyValue&& refVal = mv1 + mv2; // 临时对象是右值,所以可以被右值引用绑定

std::cout << "------------" << std::endl;

return 0;
}

程序运行输出的结果如下(编译器没有进行返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
MyValue()
MyValue()
------------
operator+()
MyValue()
MyValue(const MyValue&)
~MyValue()
~MyValue()
------------
~MyValue()
~MyValue()

程序运行输出的结果如下(编译器进行了返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
MyValue()
MyValue()
------------
operator+()
MyValue()
~MyValue()
------------
~MyValue()
~MyValue()

对象移动、移动构造函数、移动赋值运算符

为了解决临时对象带来的性能问题,C++ 11 引入了 “对象移动” 的概念,并增加了移动构造函数和移动赋值运算符的支持。在 C++ 中,建议统一使用以下四个标准术语:

  • 拷贝构造函数(Copy Constructor),比如:MyString(const MyString& str) { }
  • 移动构造函数(Move Constructor),比如:MyString(MyString&& str) { }
  • 拷贝赋值运算符(Copy Assignment Operator),比如:MyString& operator=(const MyString& str) { }
  • 移动赋值运算符(Move Assignment Operator),比如:MyString& operator=(MyString&& str) { }

提示

上面介绍的拷贝赋值运算符(Copy Assignment Operator),其实就是平时常说的赋值运算符(带左值引用参数),比如:MyString& operator=(const MyString& str) { }在 C++ 中,移动构造函数和移动赋值运算符并不是必须配合 std::move 一起使用,比如对于 MyString a = MyString("hello");,编译器会自动调用移动构造函数;函数返回值通常也不需要使用 std::move,因为编译器可能会直接消除拷贝(Copy Elision)或者调用移动构造函数;但当对象是左值时,则需要使用 std::move 将其转换为右值后,才能触发移动语义,这样编译器才会自动调用移动构造函数或者移动赋值运算符

特别注意

下面的案例代码运行后,可能会出现不同的结果,这是因为 C++ 编译器优化导致的,具体来说是返回值优化(Return Value Optimization,RVO)或命名返回值优化(NRVO)。从 C++ 17 开始,在某些返回值场景(如 return T(...)return local_object)中,标准规定必须进行强制拷贝消除(Guaranteed Copy Elision),对象会直接在调用者的存储位置构造,而不会产生临时对象,因此即使关闭编译器优化(如 g++ main.cpp -fno-elide-constructors),也不会调用拷贝构造函数或移动构造函数,从而使程序的运行效率更高。

移动构造函数使用案例

不使用移动构造函数的案例代码

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
69
70
71
72
73
74
75
#include <iostream>

class B {
public:
// 默认构造函数
B(int bm = 0) : m_bm(bm) {
std::cout << "B(int bm)" << std::endl;
}

// 拷贝构造函数
B(const B& b) : m_bm(b.m_bm) {
std::cout << "B(const B&)" << std::endl;
}

// 析构函数
~B() {
std::cout << "~B()" << std::endl;
}

private:
int m_bm;
};

class A {
public:
// 默认构造函数
A() : m_pb(new B()) {
std::cout << "A()" << std::endl;
}

// 拷贝构造函数,m_pb(new B(*(a.m_pb))) 表示深拷贝(会分配新的内存空间)
A(const A& a) : m_pb(new B(*(a.m_pb))) {
std::cout << "A(const A&)" << std::endl;
}

// 析构函数
~A() {
std::cout << "~A()" << std::endl;
delete m_pb;
}

private:
B* m_pb;
};

static A getA() {
A tmp; // 临时对象
return tmp; // 返回临时对象
}

void test01() {
std::cout << "======== test01() =======" << std::endl;
B* pb = new B(); // 调用构造函数
B* pb2 = new B(*pb); // 调用拷贝构造函数
delete pb;
delete pb2;
}

void test02() {
std::cout << "======== test02() =======" << std::endl;
A a = getA(); // 在编译器不进行 RVO 优化的情况下,会调用拷贝构造函数,增加了资源拷贝的开销
}

void test03() {
std::cout << "======== test03() =======" << std::endl;
A a = getA();
A a2 = a; // 调用拷贝构造函数
}

int main() {
test01();
test02();
test03();
return 0;
}

程序运行输出的结果如下(编译器没有进行返回值优化【RVO / NRVO】的情况下):

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
======== test01() =======
B(int bm)
B(const B&)
~B()
~B()
======== test02() =======
B(int bm)
A()
B(const B&)
A(const A&)
~A()
~B()
~A()
~B()
======== test03() =======
B(int bm)
A()
B(const B&)
A(const A&)
~A()
~B()
B(const B&)
A(const A&)
~A()
~B()
~A()
~B()

程序运行输出的结果如下(编译器进行了返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
======== test01() =======
B(int bm)
B(const B&)
~B()
~B()
======== test02() =======
B(int bm)
A()
~A()
~B()
======== test03() =======
B(int bm)
A()
B(const B&)
A(const A&)
~A()
~B()
~A()
~B()

使用移动构造函数的案例代码

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <iostream>

class B {
public:
// 默认构造函数
B(int bm = 0) : m_bm(bm) {
std::cout << "B(int bm)" << std::endl;
}

// 拷贝构造函数
B(const B& b) : m_bm(b.m_bm) {
std::cout << "B(const B&)" << std::endl;
}

// 析构函数
~B() {
std::cout << "~B()" << std::endl;
}

private:
int m_bm;
};

class A {
public:
// 默认构造函数
A() : m_pb(new B()) {
std::cout << "A()" << std::endl;
}

// 移动构造函数(形参a通常是临时对象),m_pb(a.m_pb) 表示转移临时对象的资源所有权
// 建议移动构造函数都加上 noexcept 关键字,表示函数不会抛出任何异常,提高编译器的执行效率
A(A&& a) noexcept : m_pb(a.m_pb) {
// 重置临时对象持有的m_pb指针
a.m_pb = nullptr;
std::cout << "A(A &&)" << std::endl;
}

// 拷贝构造函数,m_pb(new B(*(a.m_pb))) 表示深拷贝(会分配新的内存空间)
A(const A& a) : m_pb(new B(*(a.m_pb))) {
std::cout << "A(const A&)" << std::endl;
}

// 析构函数
~A() {
std::cout << "~A()" << std::endl;
delete m_pb;
}

private:
B* m_pb;
};

static A getA() {
A tmp; // 临时对象
return tmp; // 返回临时对象
}

void test01() {
std::cout << "======== test01() =======" << std::endl;
B* pb = new B(); // 调用构造函数
B* pb2 = new B(*pb); // 调用拷贝构造函数
delete pb;
delete pb2;
}

void test02() {
std::cout << "======== test02() =======" << std::endl;

// 如果类 A 没有移动构造函数,那么编译器就会自动调用拷贝构造函数,会带来额外的资源拷贝开销
// 如果类 A 有移动构造函数,那么编译器就会自动调用移动构造函数,将临时对象的资源所有权转移给 a 对象,不会调用拷贝构造函数,从而减少资源拷贝带来的开销
A a = getA();
}

void test03() {
std::cout << "======== test03() =======" << std::endl;

// 如果类 A 没有移动构造函数,那么编译器就会自动调用拷贝构造函数,会带来额外的资源拷贝开销
// 如果类 A 有移动构造函数,那么编译器就会自动调用移动构造函数,将临时对象的资源所有权转移给 a 对象,不会调用拷贝构造函数,从而减少资源拷贝带来的开销
A a = getA();

// 这写法等效于上面的写法,如果类 A 有移动构造函数,那么编译器就会自动调用移动构造函数,不会调用拷贝构造函数,从而减少资源拷贝带来的开销
// A &&a = getA();

// 这样写法,编译器不会调用移动构造函数,也就是不会创建新的对象,不会发生资源所有权转移,即仅仅是将左值强制转换为右值,并绑定到右值引用,效果等同于 a 对象有了一个新的别名叫 ref
// A &&ref = std::move(a);

// 使用移动语义 std::move(),将左值强制转换为右值,这样编译器就会自动调用移动构造函数,而不是调用拷贝构造函数,也就是将 a 对象的资源所有权转移给 a2 对象,从而减少资源拷贝带来的开销
// 特别注意,通过移动语义 std::move() 将 a 对象的资源所有权转移给 a2 对象后,后续都不能再使用 a 对象,因为 a 对象已经失去了资源所有权(即不再是一个完整的对象),建议后续都使用 a2 对象
A a2 = std::move(a);
}

int main() {
test01();
test02();
test03();
return 0;
}

程序运行输出的结果如下(编译器没有进行返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
======== test01() =======
B(int bm)
B(const B&)
~B()
~B()
======== test02() =======
B(int bm)
A()
A(A &&)
~A()
~A()
~B()
======== test03() =======
B(int bm)
A()
A(A &&)
~A()
A(A &&)
~A()
~B()
~A()

程序运行输出的结果如下(编译器进行了返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
======== test01() =======
B(int bm)
B(const B&)
~B()
~B()
======== test02() =======
B(int bm)
A()
~A()
~B()
======== test03() =======
B(int bm)
A()
A(A &&)
~A()
~B()
~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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
#include <iostream>

class B {
public:
// 默认构造函数
B(int bm = 0) : m_bm(bm) {
std::cout << "B(int bm)" << std::endl;
}

// 拷贝构造函数
B(const B& b) : m_bm(b.m_bm) {
std::cout << "B(const B&)" << std::endl;
}

// 析构函数
~B() {
std::cout << "~B()" << std::endl;
}

private:
int m_bm;
};

class A {
public:
// 默认构造函数
A() : m_pb(new B()) {
std::cout << "A()" << std::endl;
}

// 拷贝构造函数,m_pb(new B(*(a.m_pb))) 表示深拷贝(会分配新的内存空间)
A(const A& a) : m_pb(new B(*(a.m_pb))) {
std::cout << "A(const A&)" << std::endl;
}

// 拷贝赋值运算符
A& operator=(const A& a) {
std::cout << "A& operator=(const A&)" << std::endl;

// 防止自赋值
if (this == &a) {
return *this;
}

// 释放原有的内存空间
if (m_pb != nullptr) {
delete m_pb;
}

// 深拷贝(分配新的内存空间)
m_pb = new B(*(a.m_pb));

return *this;
}

// 移动赋值运算符(形参a通常是临时对象)
// 建议移动赋值运算符重载函数都加上 noexcept 关键字,表示函数不会抛出任何异常,提高编译器的执行效率
A& operator=(A&& a) noexcept {
std::cout << "A& operator=(A&&)" << std::endl;

// 防止自赋值
if (this == &a) {
return *this;
}

// 释放原有的内存空间
if (m_pb != nullptr) {
delete m_pb;
}

// 浅拷贝(不分配新的内存空间,直接转移临时对象的资源所有权)
m_pb = a.m_pb;

// 重置临时对象持有的m_pb指针
a.m_pb = nullptr;

return *this;
}

// 析构函数
~A() {
std::cout << "~A()" << std::endl;
delete m_pb;
}

private:
B* m_pb;
};

void test01() {
std::cout << "======== test01() =======" << std::endl;

A a;
A a2;

// 这种写法,编译器只会调用拷贝赋值运算符重载函数,而不会调用移动赋值运算符重载函数,会带来额外的资源拷贝开销
// 因为 a 对象是左值,需要使用 std::move 将其转换为右值后,才能触发移动语义,这样编译器才会自动调用移动构造函数或者移动赋值运算符重载函数
a2 = a;
}

void test02() {
std::cout << "======== test02() =======" << std::endl;

A a;
A a2;

// 如果类 A 没有移动赋值运算符,那么编译器就会自动拷贝赋值运算符重载函数,会带来额外的资源拷贝开销
// 如果类 A 有移动赋值运算符,那么编译器就会自动调用移动赋值运算符重载函数,将 a 对象的资源所有权转移给 a2 对象,不会调用拷贝赋值运算符重载函数,从而减少资源拷贝带来的开销
// 特别注意,通过移动语义 std::move() 将 a 对象的资源所有权转移给 a2 对象后,后续都不能再使用 a 对象,因为 a 对象已经失去了资源所有权(即不再是一个完整的对象),建议后续都使用 a2 对象
a2 = std::move(a);
}

int main() {
test01();
test02();
return 0;
}

程序运行输出的结果如下(编译器没有进行返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
======== test01() =======
B(int bm)
A()
B(int bm)
A()
A& operator=(const A&)
~B()
B(const B&)
~A()
~B()
~A()
~B()
======== test02() =======
B(int bm)
A()
B(int bm)
A()
A& operator=(A&&)
~B()
~A()
~B()
~A()

程序运行输出的结果如下(编译器进行了返回值优化【RVO / NRVO】的情况下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
======== test01() =======
B(int bm)
A()
B(int bm)
A()
A& operator=(const A&)
~B()
B(const B&)
~A()
~B()
~A()
~B()
======== test02() =======
B(int bm)
A()
B(int bm)
A()
A& operator=(A&&)
~B()
~A()
~B()
~A()

合成的移动操作的使用案例

  • 在某些情况下,C++ 编译器会为类自动合成(生成)以下两个移动操作:

    • 移动构造函数
    • 移动赋值运算符
  • 编译器不会自动合成移动操作的情况

    • 如果一个类显式定义了以下任意一种函数:
      • 拷贝构造函数
      • 拷贝赋值运算符
      • 析构函数
    • 那么编译器通常不会再为该类自动合成:
      • 移动构造函数
      • 移动赋值运算符
    • 因此,有些类实际上是没有移动构造函数和移动赋值运算符的
  • 编译器会自动合成移动操作的条件

    • 只有在满足以下全部条件的情况下,编译器才会为类合成移动构造函数或者移动赋值运算符:
      • (1) 一个类没有定义任何拷贝控制成员,包括拷贝构造函数或者拷贝赋值运算符
      • (2) 并且类的每个非静态成员都是可以移动的
        • 什么叫非静态成员都是可以移动?
          • 基础类型(内置类型)的非静态成员,都是可以移动的
          • 类类型的非静态成员,只有这个类有对应的移动构造函数和移动赋值运算符,才可以移动
    • 此时编译器才会自动生成移动操作,包括移动构造函数和移动赋值运算符
  • 类有拷贝操作但没有移动操作时

    • 如果类没有定义移动构造函数和移动赋值运算符,那么系统会在需要移动语义时,自动调用该类的拷贝构造函数或者拷贝赋值运算符进行替代

总结

  • 声明和定义移动构造函数和移动赋值运算符时,建议统一使用 noexcept 关键字修饰,以便 STL 容器在优化时能够优先选择移动操作。
  • 如果类没有定义移动构造函数和移动赋值运算符,那么系统会在需要移动语义时,自动调用该类的拷贝构造函数或者拷贝赋值运算符进行替代。
  • 对于涉及动态资源管理(如 malloc()new、文件句柄等)的复杂类,建议增加移动构造函数和移动赋值运算符,以减少不必要的资源拷贝开销。
  • 有些类是没有移动构造函数和移动赋值运算符的,因为它们可能定义了拷贝构造函数、拷贝赋值运算符或者析构函数,导致 C++ 编译器不会再自动生成移动操作。