前言
一个管理资源的 C++ 类,通常需要五个特殊成员函数:析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。它们合称「规则五」(rule of five)。写全这五个函数,类的拷贝、移动、销毁才有明确的语义;写错其中任何一个,轻则多一次拷贝,重则重复释放内存。
常见误解集中在三处。第一,认为只要写了析构函数,编译器仍会自动生成移动操作。实际上,一旦用户声明了析构函数、拷贝构造或拷贝赋值中的任意一个,编译器就不会再隐式声明移动构造和移动赋值,此时对右值的「移动」会退化成拷贝。第二,认为移动之后源对象一定变成空对象。标准只保证源对象处于「有效但未指定」的状态,可以安全析构和重新赋值,但不能假设它的具体内容。第三,认为移动赋值不需要处理自赋值。x = std::move(x)是合法表达式,虽然结果未指定,但至少不能因此崩溃或泄漏。
本文讲清五个函数的生成规则、拷贝赋值与移动赋值的实现要点,并给出一个管理动态数组的完整示例。示例以 C++17 为准。
一、五个特殊成员函数与生成规则
编译器会在需要时隐式生成这些函数,但生成规则有联动关系:
| 用户声明了什么 | 隐式生成的拷贝操作 | 隐式生成的移动操作 |
|---|
| 什么都没声明 | 逐个成员拷贝 | 逐个成员移动 |
| 析构、拷贝构造、拷贝赋值之一 | 仍生成(除非被删除) | 不生成 |
| 移动构造或移动赋值之一 | 定义为删除 | 只生成被声明的那类 |
这张表解释了两个常见现象。其一,给类加了一个空的析构函数,移动就悄悄消失了,之后std::vector扩容时可能改为拷贝元素。其二,只写了移动构造函数,拷贝构造会被定义为删除,类变成只能移动不能拷贝。
如果确实需要默认行为,可以用= default显式要求编译器生成,用= delete显式禁止。显式声明比依赖隐式规则更容易读懂,也更不容易在后续修改中意外改变。
二、拷贝赋值:先保证正确,再谈效率
拷贝赋值运算符的签名是T& operator=(const T& other)。返回引用是为了支持连续赋值a = b = c,参数用 const 引用是为了接受 const 对象和临时对象。
实现拷贝赋值有两个关键点。
第一是自赋值。a = a虽然罕见,但在别名场景下可能出现,例如通过两个引用操作同一个对象。如果实现是「先释放自己的资源,再从参数拷贝」,自赋值时参数引用的正是已被释放的资源,读到的是悬垂内存。处理方式有两种:在开头判断if (this != &other),或者采用「拷贝并交换」——先用参数构造一个临时副本,再用swap交换,临时对象在析构时释放旧资源。拷贝并交换天然处理自赋值,而且如果拷贝构造抛出异常,自身对象还没有被修改,满足强异常安全保证。
第二是资源释放的时机。直接delete[] data_;再new分配,如果分配抛出std::bad_alloc,对象已经丢了原有数据,处于不一致状态。拷贝并交换把「分配新资源」放在修改自身之前,异常发生时原对象保持不变。
// 适用于 C++17
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other); // 可能抛异常,此时 *this 未变
swap(tmp); // 不会抛异常
}
return *this;
}swap应当提供noexcept版本,只交换指针和长度,不分配内存。
三、移动构造与移动赋值:接管资源
移动构造的签名是T(T&& other) noexcept,移动赋值是T& operator=(T&& other) noexcept。参数是非 const 右值引用,因为函数内部要修改源对象,把它的指针「偷」过来并置空。
移动构造做的事:把自己成员的指针直接赋成源对象的指针,然后把源对象的指针置空、长度置零。置空不是可选的礼貌行为,而是正确性要求:如果源对象仍持有同一块内存,它析构时会释放一次,目标对象析构时又释放一次,造成重复释放。
移动赋值比移动构造多一步:目标对象原本可能持有资源,必须先释放,再接管源对象的资源。顺序不能反,否则原资源泄漏。同时要判断自赋值,x = std::move(x)时如果不加检查,先delete[] data_会把即将接管的指针释放掉,随后把悬垂指针赋回自身。
noexcept在移动操作上不是装饰。std::vector扩容时需要把旧元素搬到新缓冲区,如果元素的移动构造声明为noexcept,标准库可以放心使用移动;否则为了满足强异常安全保证,它可能退回使用拷贝构造。一个没有标noexcept的移动构造,可能让整个容器的性能特征发生变化。
移动之后,源对象处于「有效但未指定」状态。可以析构它、给它赋新值,但不能读取它的业务数据并假定那是原来的值。本文的实现把指针置空、长度置零,这只是一种常见选择,不是标准强制要求。
四、完整示例
// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n = 0)
: size_(n), data_(n ? new int[n] : nullptr) {}
~Buffer() { delete[] data_; }
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: size_(other.size_),
data_(other.size_ ? new int[other.size_] : nullptr) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
// 拷贝赋值:拷贝并交换,天然处理自赋值与异常
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other);
swap(tmp);
}
return *this;
}
// 移动构造:接管指针,源对象置空
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
// 移动赋值:先释放自身,再接管
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
std::size_t size() const noexcept { return size_; }
int* data() noexcept { return data_; }
const int* data() const noexcept { return data_; }
private:
std::size_t size_;
int* data_;
};
int main() {
Buffer a(3);
for (std::size_t i = 0; i < a.size(); ++i) {
a.data()[i] = static_cast<int>(i + 1);
}
Buffer b(a); // 拷贝构造
Buffer c(std::move(a)); // 移动构造,a 被置空
std::cout << b.size() << ' ' << c.size() << ' '
<< a.size() << '\n'; // 3 3 0
b = c; // 拷贝赋值
b = std::move(c); // 移动赋值,c 被置空
std::cout << b.size() << ' ' << c.size() << '\n'; // 3 0
return 0;
}推演对象状态。a构造后长度为 3,数据为 1、2、3。b(a)深拷贝,得到独立的 3 个元素。c(std::move(a))接管a的指针,c长度为 3,a被置为长度 0、指针空;输出为3 3 0。
b = c走拷贝赋值,tmp复制c的数据,交换后b持有新副本,tmp析构释放b原来的内存。b = std::move(c)走移动赋值,先释放b自己的内存,再接管c的指针,c被置空;输出为3 0。程序结束时三个对象各自析构,每块内存只释放一次。
需要说明的是,示例中的Buffer只用于演示五个函数的写法。实际工程中,管理动态数组更推荐直接使用std::vector,它已经正确实现了这些语义,用户类可以采用「规则零」:不写任何特殊成员函数,让编译器生成,成员各自管理自己的资源。
五、选择:规则零、规则五与拷贝并交换
规则零(rule of zero)是首选:类的成员都是能正确管理自身的类型(std::string、std::vector、智能指针),不手写析构、拷贝、移动,编译器生成的版本就是正确的。这样代码最少,也最不容易出错。
当类必须直接持有资源(裸指针、文件描述符、系统句柄)时,才需要规则五。此时应当把资源封装到一个小的 RAII 类型里,让外层类回到规则零。例如把int*换成std::unique_ptr<int[]>,析构、移动自动正确,只剩拷贝需要显式处理(或者直接禁用拷贝)。
拷贝并交换适合拷贝赋值,代码短、异常安全、自赋值安全。它的代价是每次赋值都多构造一个临时对象,对性能敏感的赋值路径可以改为手写「分配新资源、成功后再释放旧资源」。移动赋值不适合拷贝并交换,因为它本来就要接管资源,多一次交换没有意义。
| 场景 | 推荐做法 |
|---|
| 成员都能自我管理 | 规则零,不写特殊成员 |
| 直接持有裸资源 | 规则五,或封装成 RAII 成员 |
| 拷贝赋值实现 | 拷贝并交换,或先分配后释放 |
| 移动操作 | 接管指针并置空源对象,标noexcept |
| 禁止拷贝 | = delete拷贝构造与拷贝赋值 |
常见坑点
坑点一:拷贝赋值不处理自赋值。
❌delete[] data_; data_ = new int[other.size_]; std::copy(other.data_, ...);,a = a时读到已释放内存。
✅ 开头判断this != &other,或使用拷贝并交换。
坑点二:移动构造后不置空源对象。
❌data_(other.data_)但不修改other.data_,两个对象析构时重复释放。
✅ 接管后把源对象的指针置空、长度置零。
坑点三:移动操作缺少noexcept。
❌Buffer(Buffer&& other),std::vector扩容时可能放弃移动、改用拷贝。
✅ 移动构造与移动赋值都标noexcept,且实现中不抛异常。
坑点四:移动赋值不释放自身原有资源。
❌ 直接data_ = other.data_;,目标对象原来的内存泄漏。
✅ 先delete[] data_;再接管,并检查自赋值。
坑点五:以为声明析构函数后仍有隐式移动。
❌ 写了~Buffer(),却依赖编译器生成移动构造,实际走的是拷贝。
✅ 需要移动时显式= default或手写五个函数。
坑点六:拷贝赋值返回void。
❌void operator=(const Buffer&),不支持连续赋值,也偏离惯例。
✅ 返回Buffer&,函数末尾return *this;。
坑点七:移动赋值参数写成const T&&。
❌Buffer& operator=(const Buffer&& other),无法修改源对象,也就无法置空。
✅ 参数为T&&,非 const 右值引用。
坑点八:移动后继续读取源对象的业务数据。
❌Buffer c(std::move(a)); use(a.data()[0]);,源对象处于有效但未指定状态。
✅ 移动后只对源对象析构或重新赋值,不读取旧内容。
总结
| 函数 | 签名 | 核心动作 | 关键约束 |
|---|
| 析构函数 | ~T() | 释放资源 | 声明后不再隐式生成移动 |
| 拷贝构造 | T(const T&) | 深拷贝 | 参数 const 引用 |
| 拷贝赋值 | T& operator=(const T&) | 释放旧资源并拷贝 | 自赋值安全、异常安全 |
| 移动构造 | T(T&&) noexcept | 接管资源并置空源 | 必须noexcept |
| 移动赋值 | T& operator=(T&&) noexcept | 释放自身、接管、置空源 | 自赋值检查、noexcept |
五个函数的核心是「谁拥有资源、资源何时释放」。拷贝意味着两份独立资源,移动意味着所有权转移且源对象不再持有。把这两句话落实到每一个函数里,再配合规则零减少手写代码,资源管理的绝大多数问题都可以在编译期和代码审查阶段消除。