本笔记为【彻底搞懂C++移动语义/左值/右值/引用!!!】 https://www.bilibili.com/video/BV17ce7zLEzu/?p=2&share_source=copy_web&vd_source=92a7dafd94e8cc1bfc97784a3732ea8d的总结整理

左值和右值

左值指的是一个指向特定内存的具有名称的值即具名对象。

如果一个值有名字,那么它就是左值。

右值为临时短暂的表达式或值,生命周期较短,没有一个稳定的、可识别的内存地址。

右值通常代表一个计算的中间结果,一个字面量或者一个即将被销毁的临时对象。

临时的,即用即弃的值,就是右值。

举例:

1
2
3
int a = 1;
int b = 2;
int c = a+b;

其中,a,b,c都是左值,1和2都是没有名字的字面值,为右值。a+b的结果也是右值,为临时的中间变量值。

1
2
x++;
++x;

其中,x++是右值,编译器先生成一份x值的临时复制,然后才对x递增,返回的是没有自加之前的临时版本。

++x是对x递增后,马上返回其自身,x和++x具有相同的地址,++x是一个左值。

++x可以进行赋值操作。x++作为一个右值,赋值会报错。

1
2
3
4
int get_val(int val)
{
return x;
}

其中,x也是一个右值,因为返回的不是x本身,而是x的一个临时复制。

1
2
3
4
5
6
7
8
9
10
int set_val(int val)
{
int *p = &val;
x=val;
}

int main()
{
set_val(2);//此处的实参2是右值
}

进入函数后,形参val变成了左值,可以取地址。函数形参一定是左值,因为有名字。要注意:字符串字面量是一个左值,因为字符串字面量在C++中是一个常量字符数组,编译器会将其存储到程序的只读数据段中。程序运行开始到结束会一直存在。

左值引用

我们使用指针可以对左值取地址,但是,指针指向的位置可被任意修改。

左值引用最常见的使用场景就是:希望函数能够修改传入的参数,虽然指针可以做到,但是引用会更清晰安全。

举例:值交换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void swap_ptr(int *x, int *y)
{
int temp = *x;
*x= *y;
*y= temp;
}

void swap_ref(int& x, int &y)
{
int temp=x;
x=y;
y=temp;
}

int main()
{
int a=1, b=2, c=3, d=4;
swap_ptr(&a,&b);
swap_ref(c,d);
}

可以发现引用更加简洁。

非常量左值的引用对象必须是左值。

但是常量左值引用,不仅可以引用左值,还能引用右值:

1
const int &x = 11;

但引用右值的作用没多大。

常量左值引用在于复制构造函数、复制赋值运算符的形参。

举例:类

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
#include "Person.h"

Person::Person(const std::string& nameVal, int ageVal): name(nameVal), age(ageVal)
{
std::cout << "普通构造函数被调用 for: " << nameVal << std::endl;

}

Person::~Person()
{
//std::cout << "析构函数被调用 for: " << name << std::endl;
std::cout << "析构函数被调用 for: " << name
<< " at " << this << std::endl;

}

Person::Person(const Person& other) : name(other.name), age(other.age) { // 初始化 age
std::cout << "拷贝构造函数被调用 from: " << other.name << std::endl;
}

Person& Person::operator=(const Person& other) {
std::cout << "拷贝赋值运算符被调用 from: " << other.name << " to: " << name << std::endl;
// 1. 检查自我赋值 (非常重要!)
if (this == &other)
{
return *this; // 如果是自己给自己赋值,直接返回
}
name = other.name;
age = other.age;
return *this;
}

void Person::printInfo() const {
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}
#include "Person.h"
#include <iostream>
#include <stdio.h>
#include <windows.h>

//hh
// 一个接受常量引用的函数,演示其用法
void printPersonInfo(const Person& person) {
std::cout << "在 printPersonInfo 函数中: ";
person.printInfo(); // 因为 printInfo() 是 const 成员函数,所以可以在 const 对象上调用
}

// 一个返回新对象的函数,用于演示
Person createPerson(const char* name, int age) {
Person p(name, age); // 调用普通构造函数
return p; // 返回值可能会触发拷贝构造(或受RVO/NRVO优化影响)
}

int main() {
std::cout << "当前控制台代码页: " << GetConsoleOutputCP() << std::endl;
std::cout << "----- 创建对象 p1 -----" << std::endl;
Person p1("Alice", 25); // 调用普通构造函数

std::cout << "\n----- 通过拷贝构造创建 p2 -----" << std::endl;
Person p2 = p1; // 调用拷贝构造函数
// p1 是一个左值,完美匹配 const Person& 形参
p2.printInfo();

std::cout << "\n----- 通过拷贝赋值给 p2 -----" << std::endl;
Person p3("Bob", 30); // 调用普通构造函数
p2 = p3; // 调用拷贝赋值运算符
p2.printInfo();

std::cout << "\n----- 传递常量对象 -----" << std::endl;
const Person constP("Charlie", 40); // 一个常量对象
Person p4 = constP; // 成功调用拷贝构造函数,因为形参是 const Person&
p4.printInfo();

std::cout << "\n----- 使用临时对象(右值) -----" << std::endl;
p4 = Person("David", 50); // 先调用普通构造函数创建临时对象,再调用拷贝赋值运算符
p4.printInfo();

std::cout << "\n----- 函数按常量引用传参 -----" << std::endl;
printPersonInfo(p4); // p4 被以引用的方式传入,避免了一次拷贝

std::cout << "\n----- 函数返回值(可能涉及拷贝) -----" << std::endl;
Person p5 = createPerson("Eve", 60);
p5.printInfo();

std::cout << "\n----- Main 函数结束,开始析构 -----" << std::endl;




return 0;
// 所有对象离开作用域,析构函数按顺序被调用
}

输出:

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
----- 创建对象 p1 -----
普通构造函数被调用 for: Alice

----- 通过拷贝构造创建 p2 -----
拷贝构造函数被调用 from: Alice
Name: Alice, Age: 25

----- 通过拷贝赋值给 p2 -----
普通构造函数被调用 for: Bob
拷贝赋值运算符被调用 from: Bob to: Alice
Name: Bob, Age: 30

----- 传递常量对象 -----
普通构造函数被调用 for: Charlie
拷贝构造函数被调用 from: Charlie
----- 使用临时对象(右值) -----
普通构造函数被调用 for: David
拷贝赋值运算符被调用 from: David to: Charlie
析构函数被调用 for: David at 000000BDF61BF888
----- 函数按常量引用传参 -----
在 printPersonInfo 函数中: Name: David, Age: 50

----- 函数返回值(可能涉及拷贝) -----
普通构造函数被调用 for: Eve
Name: Eve, Age: 60

----- Main 函数结束,开始析构 -----
析构函数被调用 for: Eve at 000000BDF61BF5F8
析构函数被调用 for: David at 000000BDF61BF5A8
析构函数被调用 for: Charlie at 000000BDF61BF558
析构函数被调用 for: Bob at 000000BDF61BF508
析构函数被调用 for: Bob at 000000BDF61BF4B8
析构函数被调用 for: Alice at 000000BDF61BF468

Process finished with exit code 0.

复制构造函数的参数为什么必须是引用类型呢?

1
2
Person a;
Person b = a; // 希望调用拷贝构造函数

如果没有引用,other是按值传参,为了把a的参数传到other,编译器需要先把a拷贝到other,

那拷贝 a 的时候,需要调用 拷贝构造函数 —— 可问题是这个拷贝构造函数就是我们正在定义的函数! 于是就形成了一个无限递归调用,编译器根本无法完成参数传递。

因此,C++ 标准规定:拷贝构造函数必须以引用的形式接收参数,否则无法定义。

左值引用和右值引用都是新名字而已,所以都是左值。

右值引用和移动语义

复习一下拷贝构造:

1
2
3
4
5
6
+----------------+           +----------------+
| Person p1 | | Person p2 |
| this=0x1234 | | this=0x5678 |
| name -> 0xAAAA | =====> | name -> 0xBBBB |
| "Alice" | | "Alice" |
+----------------+ +----------------+

说明:

  • p1name 指针指向堆内存 0xAAAA
  • 调用拷贝构造时,p2 重新分配了一块堆内存 0xBBBB,把 "Alice" 复制过去。
  • 两个对象互不影响,拥有各自的资源。

移动构造:

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
// 移动构造
Person::Person(Person&& other) noexcept : name(other.name), age(other.age) {
other.name = nullptr; // 转移后置空
std::cout << "移动构造函数 from (对象地址=" << &other
<< ", 旧name指针=" << static_cast<void*>(name)
<< ") → 新对象地址=" << this << "\n";
}

// 移动赋值
Person& Person::operator=(Person&& other) noexcept {
if (this == &other) return *this;
delete[] name; // 清理旧资源
name = other.name; // 接管资源
age = other.age;
other.name = nullptr;
std::cout << "移动赋值运算符 (接管指针 " << static_cast<void*>(name)
<< ") 到对象地址=" << this << "\n";
return *this;
}

int main()
{
......
p2 = Person("Charlie", 40); // 移动赋值
......
}
构造前:
+----------------+
| 临时Person tmp |
| this=0x9999 |
| name -> 0xCCCC |
| "Bob" |
+----------------+

移动构造后:
+----------------+ +----------------+
| 临时Person tmp | | Person p3 |
| this=0x9999 | | this=0x8888 |
| name -> null | =====> | name -> 0xCCCC |
| | | "Bob" |
+----------------+ +----------------+

说明:

  • tmp 是一个临时对象,原本持有堆内存 0xCCCC
  • 移动构造时,p3 直接接管 tmp 的指针 0xCCCC
  • tmp.name 被置为 nullptr,所以它析构时不会释放这块内存。
  • 避免了深拷贝的开销。

总结:

1
2
3
4
5
6
7
拷贝构造:              移动构造:
p1(name->0xAAAA) tmp(name->0xCCCC)
| |
|----> new 0xBBBB |----> p3 接管指针
| copy "Alice" | tmp.name=null
| |
p2(name->0xBBBB) p3(name->0xCCCC)

拷贝 = 重新分配一块新内存,复制内容

移动 = 直接偷走旧对象的指针,旧对象清空指针

*为什么移动构造函数后面要写noexpect?(GPT生成总结)*

  1. 背景:STL 容器的元素移动

比如 std::vector 扩容时,它会把旧元素转移到新分配的内存里:

1
2
3
std::vector<Person> v;
v.push_back(Person("Alice", 20));
v.push_back(Person("Bob", 30));

vector 空间不够,需要重新分配时,它要把旧的元素(Alice)搬到新的内存。 此时有两个选择:

  1. 调用 移动构造函数(更快)

  2. 调用 拷贝构造函数(更安全)

  3. 如果移动构造函数可能抛异常

  • vector 在扩容时,搬到一半,结果某个元素的移动构造抛出了异常。
  • 这时新内存里的部分对象构造完成,部分对象失败,旧内存里的对象可能已经被破坏(移动后被置空)。
  • 整个容器就可能进入 不一致状态(既不能保证强异常安全,也不能 rollback)。

为了避免这种情况,标准库****要求:只有在移动构造函数被声明为 noexcept 时,容器才会使用它。否则容器会退而求其次,调用 拷贝构造函数(因为拷贝一般不会抛异常)。

  1. 因此在自定义类里实现移动构造函数时,加上 noexcept
1
2
3
4
5
Person(Person&& other) noexcept
: name(std::move(other.name)), age(other.age) {
std::cout << "移动构造函数被调用 from: " << other.name << std::endl;
other.age = 0;
}
  1. 总结
  • 没有 noexcept STL 容器在需要移动元素时,可能会选择 拷贝构造,牺牲性能保证安全。
  • noexcept STL 容器可以放心大胆地使用移动构造,获得最大性能提升。

所以 noexcept 是一个 性能保证的承诺: 告诉编译器“移动构造不会抛异常”,容器才敢优化。