一起聊聊C++中的特殊成员函数
前言
特殊成员函数(special member functions)是编译器"会替你写"的那几个函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符,以及 C++11 起的移动构造函数、移动赋值运算符。它们特殊在两点:不需要你声明就可能存在;一旦你声明了其中某一个,其他的生成规则会跟着改变。
这两点正是大量 bug 的来源。比如只写了析构函数去delete一个指针,却没写拷贝构造,编译器仍会生成逐成员拷贝的版本,于是一次拷贝就产生两个对象指向同一块内存,析构时重复释放。又比如为了支持移动而声明了移动构造,结果拷贝构造被隐式删除,某个需要拷贝的容器操作突然编译失败。
本文按"有哪几个 → 什么时候自动生成 → 默认做什么 → 什么时候该手写"的顺序讲,重点讲清抑制规则,因为它是所有困惑的源头。代码以 C++17 为基准。
一、六个特殊成员函数
| 函数 | 典型签名 | 作用 |
|---|
| 默认构造函数 | T() | 无参构造 |
| 析构函数 | ~T() | 释放资源 |
| 拷贝构造函数 | T(const T&) | 用同类型左值初始化新对象 |
| 拷贝赋值运算符 | T& operator=(const T&) | 把同类型左值赋给已存在对象 |
| 移动构造函数 | T(T&&) noexcept | 从右值"窃取"资源构造新对象 |
| 移动赋值运算符 | T& operator=(T&&) noexcept | 从右值"窃取"资源赋值 |
注意拷贝构造的参数必须是同类型的引用,不能按值——按值会递归调用拷贝构造,逻辑上无法成立,编译器会直接拒绝。拷贝赋值的返回类型应当是T&,以支持a = b = c这样的链式赋值。
二、隐式声明与抑制规则
编译器是否替你生成某个特殊成员函数,取决于你声明了什么。规则可以整理成一张表:
| 你声明了 | 默认构造 | 析构 | 拷贝构造 | 拷贝赋值 | 移动构造 | 移动赋值 |
|---|
| 什么都没声明 | 生成 | 生成 | 生成 | 生成 | 生成 | 生成 |
| 任一构造函数 | 不生成 | 生成 | 生成 | 生成 | 生成 | 生成 |
| 析构函数 | 生成 | — | 生成(弃用) | 生成(弃用) | 不生成 | 不生成 |
| 拷贝构造 | 不生成 | 生成 | — | 生成 | 不生成 | 不生成 |
| 拷贝赋值 | 不生成 | 生成 | 生成 | — | 不生成 | 不生成 |
| 移动构造 | 不生成 | 生成 | 删除 | 生成 | — | 不生成 |
| 移动赋值 | 不生成 | 生成 | 生成 | 删除 | 不生成 | — |
几条要点:
- 只要声明了任何一个构造函数(包括拷贝构造、移动构造),编译器就不再生成默认构造函数。需要的话写
= default。 - 声明析构、拷贝构造、拷贝赋值中的任意一个,都会抑制移动操作的生成。此时对右值的"移动"会退化成拷贝。
- 声明移动构造或移动赋值,会把对应的拷贝操作定义为删除(不是不生成,而是明确删除)。这就是"加了移动反而不能拷贝"的原因。
- 用
= default在类内声明,仍算"用户声明",同样会抑制移动操作的生成。
第三、四条合起来解释了一个常见现象:一个类只写了移动构造,std::vector里存它没问题,但按值传参就编译失败,因为拷贝构造已被删除。
三、默认实现做了什么
编译器生成的拷贝构造与拷贝赋值是逐成员的:对每个非静态数据成员,调用其拷贝构造或拷贝赋值。移动版本同理,对每个成员调用移动构造或移动赋值。
这意味着:
- 成员是
std::string、std::vector时,默认实现是正确的——它们自己管理资源,拷贝/移动语义已经写好; - 成员是裸指针时,默认实现只复制指针值,不复制指向的对象,两个对象共享同一块内存;
- 成员是引用时,拷贝构造仍可生成(绑定到同一对象),但拷贝赋值会被定义为删除,因为引用不能重新绑定;
- 成员是
const时,拷贝赋值同样被删除,因为不能给const成员赋值。
默认析构函数对每个成员调用析构,但不会delete裸指针成员。这就是"零规则"(rule of zero)的由来:让成员自己管理自己,类就不需要手写任何特殊成员函数。
四、三法则、五法则与零法则
- 三法则(rule of three):如果需要一个自定义的析构、拷贝构造或拷贝赋值,通常三个都需要。因为它们往往共同服务于"手动管理的资源"。
- 五法则(rule of five):C++11 之后再加上移动构造与移动赋值,共五个。
- 零法则(rule of zero):优先用能自我管理的成员(
std::string、std::vector、std::unique_ptr),从而一个都不写。
下面是一个"必须手写五个"的例子:一个持有new[]数组的缓冲区。
// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(new int[n]()), size_(n) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other); // 先复制成功,再交换
swap(tmp);
}
return *this; // 返回引用,支持链式赋值
}
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 源对象置空,避免重复释放
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_; // 先释放自己的资源
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const { return size_; }
private:
int* data_ = nullptr;
std::size_t size_ = 0;
};几个设计点值得说明:
- 拷贝赋值采用"复制—交换"(copy-and-swap):先在临时对象上完成复制,成功后再交换。如果复制过程中抛异常,原对象保持不变,提供强异常保证。
- 移动构造与移动赋值都标了
noexcept。这不只是装饰:std::vector扩容时,只有在移动构造被声明为noexcept的情况下才会优先使用移动,否则为了保证强异常安全会退回复制。 - 移动后把源的指针置空。被移动对象处于"有效但未指定"状态,可以析构、可以重新赋值,但不能读它的值;置空让它的析构成为安全的空操作。
- 每个赋值都检查了自赋值。移动赋值如果不检查,
a = std::move(a)会先delete[]自己的数据,再把自己已经释放的指针赋回自己。
五、什么时候该= default/= delete
= default表示"按编译器生成的语义来",同时明确表达意图;= delete表示"禁止这个操作",且参与重载决议,比把它声明为 private 而不定义更清晰。
// 适用于 C++17
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
class Base {
public:
virtual ~Base() = default; // 多态基类必须虚析构
Base(const Base&) = default;
Base& operator=(const Base&) = default;
};给多态基类写virtual ~Base() = default;时要注意:声明析构会抑制移动操作的生成,派生类的移动也就退化成拷贝。如果确实需要移动,应把五个都显式写全。
常见坑点
1. 手写析构却不写拷贝,导致重复释放
// ❌ 默认拷贝只复制指针,两个对象 delete 同一块内存
class Buf { int* p_; public: ~Buf() { delete[] p_; } };
// ✅ 三法则:同时实现拷贝构造与拷贝赋值,或改用 unique_ptr2. 拷贝赋值忘记释放旧资源
// ❌ 旧缓冲区泄漏
Buffer& operator=(const Buffer& o) { data_ = new int[o.size_]; return *this; }
// ✅ 复制—交换,或先 delete[] 再分配3. 移动后没有清空源对象
// ❌ 源对象仍持有同一指针,析构时重复释放
Buffer(Buffer&& o) noexcept : data_(o.data_), size_(o.size_) {}
// ✅ 把源指针置空
o.data_ = nullptr;4. 移动操作没有标noexcept
// ❌ vector 扩容时可能退回复制
Buffer(Buffer&& o);
// ✅ 声明不抛异常
Buffer(Buffer&& o) noexcept;5. 拷贝赋值返回void
// ❌ 不支持 a = b = c
void operator=(const Buffer&);
// ✅ 返回自身引用
Buffer& operator=(const Buffer&);6. 拷贝构造参数按值传递
// ❌ 递归调用拷贝构造,编译错误
Buffer(Buffer o);
// ✅ 传 const 引用
Buffer(const Buffer& o);7. 声明移动后忘了拷贝也被删除
// ❌ 只写移动构造,按值传参编译失败
Buffer(Buffer&&) noexcept;
// ✅ 需要拷贝就显式 = default
Buffer(const Buffer&) = default;8. 多态基类析构不是虚函数
// ❌ 通过基类指针 delete 派生对象是未定义行为
class Base { ~Base() = default; };
// ✅ 虚析构
class Base { virtual ~Base() = default; };总结
| 场景 | 建议 |
|---|
| 成员能自我管理 | 零法则,一个都不写 |
| 手动管理资源 | 五法则,五个都写全 |
| 需要禁止拷贝 | = delete,不要用 private 未定义 |
| 需要默认行为 | = default,明确表达意图 |
| 多态基类 | 虚析构,并考虑移动是否退化 |
| 移动操作 | 标noexcept,移动后置空源 |
特殊成员函数的难点不在语法,而在"我声明了一个,编译器会收走哪些"。把第二节的抑制表记住,再记住默认实现是逐成员的,绝大多数"为什么突然不能拷贝了""为什么会重复释放"都能定位。最后一条经验:能用零法则就不要手写五个——手写的每一个都是一次出错机会。