当一个对象即将销毁或后续不再使用时,如果我们想省去重新分配内存和复制数据的开销,直接把它的内存资源转交给目标对象,就需要依靠类里面的两个特殊成员函数:移动构造函数和移动赋值运算符。
上一节学过的 std::move 只负责把左值转换为右值引用,相当于贴上一张 “允许搬走” 的标签。而真正动手把底层内存接管过来,并将原对象内部指针置空的,正是移动构造函数与移动赋值运算符。
C++ 移动构造函数
在 C++ 中,移动构造函数(Move Constructor)和拷贝构造函数一样,都是在 “创建新对象” 时自动调用的。区别在于,拷贝构造函数的形参是 const 类名&,会通过深拷贝复制出一份独立的内存。
而移动构造函数的形参是右值引用 类名&&,它不会重新申请内存,而是直接接管来源对象现有的内存资源。
语法:
class 类名 {
public:
类名(类名&& other) noexcept {
// 接管 other 的资源,并将源指针置为 nullptr
}
};说明:
移动构造函数的形参是右值引用 类名&& other,声明时需要注意以下 2 个细节:
- 形参前面不能加 const:接管资源之后,我们需要把来源对象
other内部的指针修改为空指针nullptr,因此other必须是可修改的。 - 函数头末尾推荐加上 noexcept:用来告诉编译器该移动操作只是转移指针地址、不会抛出异常,方便标准库容器安全调用。
假设类中有一个管理动态内存的指针成员 data,移动构造函数内部只需完成两步:先在初始化列表中直接拿 other.data 初始化新对象的 data,不再使用 new 开辟新内存。随后在函数体内立即执行 other.data = nullptr;,切断来源对象与原内存的联系。
手写移动构造函数时,小伙伴们千万不要漏掉把源对象指针置为 nullptr 的这一步:
// 错误方式
Buffer(Buffer&& other) noexcept : data(other.data) {}
// 正确方式
Buffer(Buffer&& other) noexcept : data(other.data) {
other.data = nullptr;
}如果不把 other.data 设为 nullptr,新对象和源对象的指针就会指向同一块动态内存。等到两个对象离开作用域先后销毁时,就会对同一块内存重复执行两次 delete 而引发崩溃。
而在 C++ 中,对空指针执行 delete nullptr; 是完全安全的,什么也不会发生。因此把 other.data 置空后,源对象就能安全走完析构流程。下面我们在 Buffer 类中同时定义深拷贝构造与移动构造,对比两者的地址变化。
示例 1:移动构造与深拷贝对比
#include <iostream>
#include <utility>
class Buffer {
public:
int* data;
Buffer(int val) : data(new int(val)) {}
~Buffer() {
delete data;
}
// 1. 深拷贝构造函数
Buffer(const Buffer& other)
: data(new int(*other.data)) {
std::cout << "调用拷贝构造函数\n";
}
// 2. 移动构造函数
Buffer(Buffer&& other) noexcept : data(other.data) {
other.data = nullptr;
std::cout << "调用移动构造函数\n";
}
};
int main() {
Buffer buf1(100);
std::cout << "初始 buf1.data 地址:" << buf1.data << "\n";
// 传入左值,调用拷贝构造
Buffer buf2 = buf1;
std::cout << "拷贝后 buf2.data 地址:" << buf2.data << "\n";
// 传入右值,调用移动构造
Buffer buf3 = std::move(buf1);
std::cout << "移动后 buf3.data 地址:" << buf3.data << "\n";
std::cout << "移动后 buf1.data 地址:" << buf1.data << "\n";
return 0;
}运行结果如下。
初始 buf1.data 地址:0x55b4e1c02eb0
调用拷贝构造函数
拷贝后 buf2.data 地址:0x55b4e1c032e0
调用移动构造函数
移动后 buf3.data 地址:0x55b4e1c02eb0
移动后 buf1.data 地址:0分析:
在这个例子中,执行 Buffer buf2 = buf1; 时,实参 buf1 是有名字的左值,因此匹配拷贝构造函数。程序使用 new 新开辟了一块地址不同的独立内存,buf1 原来的内存保持不变。
而执行 Buffer buf3 = std::move(buf1); 时,std::move(buf1) 把实参转换成了右值引用,优先匹配形参为 Buffer&& 的移动构造函数,让 buf3 直接接管了 buf1 原来的内存地址 0x55b4e1c02eb0。
与此同时,buf1.data 被置为了 nullptr,因此打印出来的空指针地址变成了 0,在部分编译器下也会显示为 0x0。
移动函数与 noexcept
可能有些小伙伴会好奇:编写移动构造函数和移动赋值运算符时,就算不写 noexcept 代码也能正常编译运行,为什么实际开发中总是推荐加上它呢?
这主要与 std::vector 等容器的自动扩容有关。当容器装满需要把旧元素搬移到新内存时,为防止中途发生异常破坏原有数据,只有在确认移动构造函数标记了 noexcept 时才会放心调用它。如果漏写,扩容时就会退回较慢的深拷贝。
C++ 移动赋值运算符
在 C++ 中,移动赋值运算符(Move Assignment Operator)与移动构造函数的区别,依然在于 “等号左边的目标对象在这一行代码之前是否已经存在”。
当两个对象都已经创建完成,比如执行 buf2 = std::move(buf1); 把一个对象的资源转移给另一个已存在对象时,调用的就是移动赋值运算符,这与前面学过的拷贝赋值运算符是一一对应的。
语法:
class 类名 {
public:
类名& operator=(类名&& other) noexcept {
if (this != &other) {
// 释放自身旧资源,接管 other 的资源并将源指针置空
}
return *this;
}
};说明:
为什么移动赋值运算符比移动构造函数多出了 “释放自身旧资源” 这一步呢?因为移动构造面对的是刚创建的新对象,手里还没有任何旧内存。而移动赋值面对的是已经存在的对象,内部原本就已经持有一块动态内存了。
因此,以指针成员 data 为例,移动赋值运算符需要按先后顺序完成以下 4 步操作:
- 检查自移动:先通过
if (this != &other)判断左右两边是不是同一个对象,防止出现buf1 = std::move(buf1);自己移动给自己的情况。如果不加检查直接执行下一步的delete,就会把自己唯一的数据释放掉。 - 释放目标对象原有的旧内存:执行
delete data;,在接管新地址之前先把当前对象原本持有的旧内存释放掉,否则直接覆盖指针会导致旧内存无法释放而造成内存泄漏。 - 接管来源对象的资源:执行
data = other.data;,直接把来源对象other的指针地址赋给当前对象的指针成员。 - 将源指针置空并返回当前对象:执行
other.data = nullptr;切断来源对象与该块内存的联系,最后通过return *this;返回当前对象的引用。
下面我们结合代码来演示移动赋值运算符的完整执行过程。
示例 2:使用移动赋值运算符
#include <iostream>
#include <utility>
class Buffer {
public:
int* data;
Buffer(int val) : data(new int(val)) {}
~Buffer() {
delete data;
}
// 定义移动赋值运算符
Buffer& operator=(Buffer&& other) noexcept {
std::cout << "调用移动赋值运算符\n";
if (this != &other) {
delete data; // 释放原有旧内存
data = other.data; // 接管来源地址
other.data = nullptr; // 将源指针置空
}
return *this;
}
};
int main() {
Buffer buf1(200);
Buffer buf2(50);
std::cout << "移动前 *buf2.data = " << *buf2.data << "\n";
buf2 = std::move(buf1); // 移动赋值给 buf2
std::cout << "移动后 *buf2.data = " << *buf2.data << "\n";
std::cout << "移动后 buf1.data 地址:" << buf1.data << "\n";
return 0;
}运行结果如下。
移动前 *buf2.data = 50
调用移动赋值运算符
移动后 *buf2.data = 200
移动后 buf1.data 地址:0分析:
在执行 buf2 = std::move(buf1); 之前,buf2 持有值为 50 的动态内存,buf1 持有值为 200 的动态内存。进入移动赋值运算符后,程序先通过 delete data; 释放了 buf2 存放 50 的旧内存。
接着 buf2 直接接管了 buf1 存放 200 的内存地址,并将 buf1.data 置为 nullptr,这样 main() 结束时两个对象都能安全析构。
另外,如果直接把临时右值赋给已存在的对象,比如写出 buf2 = Buffer(300);,同样会自动调用移动赋值运算符。
五法则与零法则
学到这里,我们已经把 C++ 类中与资源管理相关的 5 个特殊成员函数全部认识完了。在实际开发中,什么时候编译器会自动生成移动操作,什么时候需要手写,什么时候又完全不用操心呢?现代 C++ 将这些经验总结成了 “五法则” 与 “零法则”。
1. 五法则
和默认拷贝类似,如果一个类没有显式定义析构函数、拷贝构造函数、拷贝赋值运算符以及移动操作,编译器会自动为它生成默认的移动构造函数和移动赋值运算符,按成员依次移动数据。
不过需要注意,只要我们手动定义了析构或拷贝函数中的任意一个,编译器就不再自动生成默认移动操作了。反过来,一旦手动定义了移动构造或移动赋值,编译器也会自动禁用默认的拷贝操作。
前面学习深拷贝时我们认识过 “三法则”,在 C++11 引入移动构造和移动赋值之后,它就自然扩展成了 “五法则(Rule of Five)”。这 5 个特殊成员函数的职责如下表所示。
| 特殊成员函数 | 声明格式 | 触发时机 | 资源操作 |
|---|---|---|---|
| 析构函数 | ~类名() | 对象销毁时 | 释放当前对象持有的资源 |
| 拷贝构造函数 | 类名(const 类名& other) | 使用左值创建新对象 | 分配新内存并复制数据 |
| 拷贝赋值运算符 | 类名& operator=(const 类名& other) | 将左值赋给已存在对象 | 释放旧资源,分配新内存并复制 |
| 移动构造函数 | 类名(类名&& other) noexcept | 使用右值创建新对象 | 直接接管源资源并将源指针置空 |
| 移动赋值运算符 | 类名& operator=(类名&& other) noexcept | 将右值赋给已存在对象 | 释放旧资源,接管源资源并置空 |
五法则的意思是:当一个底层类因为直接管理裸指针或系统句柄,需要手动编写上面 5 个特殊成员函数中的某一个时,通常需要把其余 4 个函数的行为也一并考虑清楚,避免漏掉移动操作而退回深拷贝,或者因浅拷贝引发重复释放。
2. 零法则(推荐)
看到五法则,可能有些小伙伴会问:难道以后每写一个类,都要把这 5 个函数手写一遍吗?
恰恰相反,在现代 C++ 日常开发中,我们最常用的是 “零法则(Rule of Zero)”:普通业务类不要自己去写裸指针和 new / delete,而是直接使用 std::string、std::vector 或智能指针作为成员变量。
借助前面学过的 RAII 机制自动管理内存后,上面那 5 个特殊成员函数我们 “一个都不需要手写”。下面通过一个例子来验证一下。
示例 3:遵循零法则的业务类
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Student {
private:
std::string name;
std::vector<int> scores;
public:
Student(const std::string& n, const std::vector<int>& s)
: name(n), scores(s) {}
// 无需手写拷贝与移动函数
void setFirstScore(int val) {
if (!scores.empty()) {
scores[0] = val;
}
}
void show() const {
if (scores.empty()) {
std::cout << "成绩数组已为空\n";
return;
}
std::cout << name << ",成绩:" << scores[0] << "\n";
}
};
int main() {
Student s1("小明", {90, 95});
// 1. 自动调用默认拷贝构造
Student s2 = s1;
s2.setFirstScore(100);
std::cout << "拷贝后 s1:";
s1.show();
std::cout << "修改后 s2:";
s2.show();
// 2. 自动调用默认移动构造
Student s3 = std::move(s1);
std::cout << "移动后 s3:";
s3.show();
std::cout << "移动后 s1:";
s1.show();
return 0;
}运行结果如下。
拷贝后 s1:小明,成绩:90
修改后 s2:小明,成绩:100
移动后 s3:小明,成绩:90
移动后 s1:成绩数组已为空分析:
在这个例子中,Student 类只使用了 std::string 和 std::vector<int> 保存数据,那 5 个资源管理成员函数一个都没有手写,编译器会自动根据成员变量的能力配齐默认拷贝和移动操作。
执行 Student s2 = s1; 时会自动调用各成员的深拷贝,修改 s2 不会影响 s1。执行 Student s3 = std::move(s1); 时则自动调用各成员的移动构造,直接转走字符串和数组内存,因此 s1 的成绩数组变为空了。
同理,如果在类中定义了独占智能指针 std::unique_ptr 成员,外层类不用写任何额外代码,也会自动变成 “禁止拷贝、但支持移动” 的类型,用起来非常省心。
