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

大纲

C++ 面向对象进阶

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

  • 左值:具有持久身份(可取地址)的表达式,通常表示一个在内存中有确定位置、可以被反复使用的对象。
  • 右值:通常表示临时对象或纯值的表达式,一般不可取地址,生命周期短,主要用于赋值或初始化。
  • 左值引用:使用 T& 声明,只能绑定到左值,本质上是某个已有对象的别名。
  • 右值引用:使用 T&& 声明,只能绑定到右值,用于支持移动语义(move)和完美转发(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

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

产生临时对象的案例一

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()

特别注意

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

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