拷贝控制与特殊成员函数
面试回答
常见问法
- 什么是拷贝控制?
- 为什么会有三法则、五法则、零法则?
- 特殊成员函数有哪些?编译器会自动生成哪些?
- 什么时候该自己写拷贝构造/移动构造?
=default、=delete有什么作用?- 为什么移动构造和移动赋值通常建议加
noexcept?
回答
拷贝控制,本质上是在定义对象“被复制、被移动、被赋值、被销毁”时的行为。 它主要由 5 个特殊成员函数组成:
- 析构函数
~T() - 拷贝构造
T(const T&) - 拷贝赋值
T& operator=(const T&) - 移动构造
T(T&&) - 移动赋值
T& operator=(T&&)
面试里我通常会这样回答:
如果一个类直接管理资源,比如裸指针、文件句柄、socket、锁等,那么编译器默认生成的拷贝/赋值往往只是“成员逐个拷贝”,这很容易导致重复释放、悬空指针、资源语义错误。 所以在 C++98 时代总结出了三法则:只要你自定义了析构、拷贝构造、拷贝赋值中的一个,通常另外两个也要认真定义。 到了 C++11,引入移动语义后,又要把移动构造和移动赋值一起考虑,所以扩展成了五法则。 但现代 C++ 更推荐零法则:尽量不要自己管理底层资源,而是交给
std::string、std::vector、std::unique_ptr这类 RAII 成员,让类本身不需要自定义这些特殊成员函数。
一句话总结:
- 三法则:你自己管资源,就至少把“拷贝 + 赋值 + 析构”想清楚。
- 五法则:在三法则基础上,再把“移动”也想清楚。
- 零法则:最好根本不要自己管资源,而是交给标准库对象去管。
追问
1. 为什么移动构造/移动赋值通常要 noexcept?
因为标准容器(如 std::vector)扩容搬迁元素时,会优先使用不会抛异常的移动操作。
如果移动可能抛异常,容器为了保证强异常安全,可能退回使用拷贝,性能就打折了。
class A {
public:
A(A&&) noexcept = default; // 推荐
A& operator=(A&&) noexcept = default; // 推荐
};
2. 什么时候应该删除拷贝操作?
当类型表达的是独占资源或者对象不允许被复制时,比如:
std::unique_ptr- 文件句柄包装类
- 互斥锁、线程句柄
- 单例/不可复制上下文对象
class FileHandle {
public:
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
};
3. =default 和 =delete 的作用?
=default:显式要求编译器生成默认实现,常用于表达“语义上我就是要默认行为”=delete:显式禁用某个函数,常用于限制对象语义,避免误用
4. 自定义了析构函数,会发生什么?
这是高频追问。核心要点:
一旦你开始自己接管对象生命周期,就要小心默认生成规则。 特别是在 C++11 之后,你写了析构函数,往往就意味着移动操作不会再自动得到理想行为,这也是为什么很多场景下写了一个特殊成员函数后,其余几个也要一起审视。
原理展开
1. 什么是“拷贝控制”?
拷贝控制不是单独某一个语法点,而是一整套对象值语义 / 资源语义设计。
当一个对象经历这些操作时,都属于拷贝控制范畴:
- 用另一个对象初始化它 → 拷贝构造 / 移动构造
- 用另一个对象给它赋值 → 拷贝赋值 / 移动赋值
- 生命周期结束 → 析构函数
例如:
T a;
T b = a; // 拷贝构造
T c = std::move(a); // 移动构造
b = c; // 拷贝赋值
c = std::move(b); // 移动赋值
这里的关键不是“函数名字”,而是: 对象背后的资源,到底应该复制、转移、共享,还是禁止操作?
2. 为什么默认生成的版本有时不够?
编译器自动生成的特殊成员函数,基本是按成员逐个处理。
如果成员本身管理得很好,比如 std::string、std::vector,通常没问题。
但如果成员是裸资源句柄,就很容易出问题。
典型错误:浅拷贝导致重复释放
class Buffer {
char* data;
public:
Buffer(size_t n) : data(new char[n]) {}
~Buffer() { delete[] data; }
};
看起来析构没问题,但问题是:编译器默认生成的拷贝构造只是把 data 指针值拷贝过去。
于是两个对象会指向同一块内存,最终析构两次,直接出错。
这就是三法则出现的根源: 只要类自己管理资源,就不能只写析构,不管拷贝。
3. 三法则:资源类至少要管好三件事
三法则对应 C++98 时代的经验:
- 析构函数
- 拷贝构造
- 拷贝赋值
因为这三者共同决定资源是否会被正确释放、正确复制、正确覆盖。
一个更稳妥的三法则实现
#include <algorithm>
#include <cstddef>
class Buffer {
char* data = nullptr;
std::size_t size = 0;
public:
explicit Buffer(std::size_t n)
: data(new char[n]), size(n) {}
~Buffer() {
delete[] data;
}
Buffer(const Buffer& other)
: data(new char[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
char* new_data = new char[other.size];
std::copy(other.data, other.data + other.size, new_data);
delete[] data;
data = new_data;
size = other.size;
return *this;
}
};
这个版本比“先删旧资源再分配新资源”更稳妥,因为它先完成新资源申请,再替换旧资源,异常安全更好。
4. 五法则:C++11 以后,移动也要纳入设计
C++11 引入移动语义后,很多类型不仅要能“复制”,还应该能“高效转移资源所有权”。
五法则适用场景
- 类直接拥有资源
- 类希望支持高性能返回值、容器搬迁、临时对象优化
- 类的拷贝代价高,但移动代价低
典型实现
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
char* data = nullptr;
std::size_t size = 0;
public:
explicit Buffer(std::size_t n)
: data(new char[n]), size(n) {}
~Buffer() {
delete[] data;
}
Buffer(const Buffer& other)
: data(new char[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
char* new_data = new char[other.size];
std::copy(other.data, other.data + other.size, new_data);
delete[] data;
data = new_data;
size = other.size;
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) return *this;
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
return *this;
}
};
这里真正的重点
移动不是“把对象搬走”,而是:
把资源所有权从源对象转移到目标对象,同时保证源对象仍然处于“可析构、可赋值”的有效状态。
移动后的对象通常是:
- 有效的
- 但值未指定
不要假设移动后对象还能保留原始业务含义,除非类型文档明确承诺。
5. 零法则:现代 C++ 更推荐“不写”
零法则的核心思想是:
如果一个类本身不直接管理资源,而是把资源交给 RAII 成员去管理,那么通常不需要自定义这些特殊成员函数。
这往往才是工程里最优解。
例子:让标准库管理资源
#include <memory>
#include <vector>
class SmartBuffer {
std::vector<char> data;
public:
explicit SmartBuffer(std::size_t n) : data(n) {}
};
或者:
#include <memory>
class UniqueBuffer {
std::unique_ptr<char[]> data;
std::size_t size = 0;
public:
explicit UniqueBuffer(std::size_t n)
: data(std::make_unique<char[]>(n)), size(n) {}
// 不需要手写析构
// 但注意:因为成员是 unique_ptr,所以该类默认不可拷贝,可移动
};
为什么零法则更好?
因为你少写一份资源管理代码,就少一份 bug 风险,尤其减少:
- 内存泄漏
- 双重释放
- 异常安全问题
- 自赋值问题
- 移动后状态不一致问题
6. =default、=delete 背后其实是“显式表达语义”
=default
当你想明确表达“这个函数就是默认行为”时使用。
class X {
public:
X() = default;
X(const X&) = default;
X& operator=(const X&) = default;
~X() = default;
};
工程上它的价值在于:
- 代码可读性更强
- 你是在“明确声明语义”,不是依赖隐式规则
- 某些情况下可以恢复默认生成
=delete
当某个操作不应该存在时,直接在类型层面禁止。
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
这比“声明但不定义”更现代,也更早暴露错误。
7. 怎么选:值语义、独占语义、共享语义
面试高分回答通常不会停留在“法则名词”,而是会上升到语义设计。
值语义
对象像 int、std::string 一样,拷贝后互不影响。
适合:
- 配置对象
- 数据对象
- 普通业务实体
做法:
- 支持拷贝
- 必要时也支持移动
独占语义
资源只能有一个拥有者。 适合:
- 文件句柄
- socket
- 互斥锁
- GPU 资源句柄
做法:
- 删除拷贝
- 保留移动
共享语义
多个对象共享同一底层资源。 适合:
- 生命周期跨模块共享的资源
- 缓存、图结构节点等
做法:
- 通常借助
std::shared_ptr - 但要注意循环引用和额外开销
8. 工程实践中怎么写更稳
原则一:能零法则就不要五法则
优先级通常是:
- 零法则
- 必须自己管资源时,再考虑五法则
- 只有在老代码 / 特殊约束下才落到裸资源手写
原则二:资源所有权要一眼看出来
- 裸指针尽量不表达 owning
- owning 用
unique_ptr/ 容器 / RAII 包装类 - 非 owning 指针要清晰标注语义
原则三:移动操作尽量 noexcept
这样标准容器才能放心地走移动路径。
原则四:拷贝赋值优先考虑异常安全
尤其是“先构造新资源,再替换旧资源”的写法。
原则五:不要为了“会写五法则”而滥写
现代 C++ 真正加分的不是“手写内存管理”,而是知道什么时候不该自己写。
9. 一个常见加分点:拷贝并交换(copy-and-swap)
这是经典的拷贝赋值实现技巧。
#include <algorithm>
#include <utility>
class Buffer {
char* data = nullptr;
std::size_t size = 0;
public:
explicit Buffer(std::size_t n) : data(new char[n]), size(n) {}
~Buffer() { delete[] data; }
Buffer(const Buffer& other)
: data(new char[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
void swap(Buffer& other) noexcept {
std::swap(data, other.data);
std::swap(size, other.size);
}
Buffer& operator=(Buffer other) { // 按值传参,统一拷贝/移动
swap(other);
return *this;
}
};
好处
- 代码复用高
- 天然处理自赋值
- 异常安全好
代价
- 不是所有场景都最优
- 有时会多一次中间对象构造
面试中可以说:
工程上如果对象资源管理逻辑复杂,
copy-and-swap是一种可读性和异常安全都不错的实现方式;但如果性能极致敏感,也要结合对象规模和调用路径评估。
对比总结
| 概念 | 核心含义 | 适用场景 | 优点 | 缺点 / 风险 | 工程建议 |
|---|---|---|---|---|---|
| 三法则 | 析构、拷贝构造、拷贝赋值要一起考虑 | C++98 风格资源类、直接管理裸资源 | 能避免浅拷贝和重复释放 | 容易遗漏异常安全、自赋值问题 | 现代代码中尽量少手写 |
| 五法则 | 三法则 + 移动构造、移动赋值 | 需要高效转移资源所有权的资源类 | 兼顾正确性和性能 | 规则更复杂,容易写错移动后状态 | 确实自己管理资源时才写 |
| 零法则 | 资源交给成员对象管理,自己不写特殊成员函数 | 现代 C++ 主流写法 | 最稳、最简洁、最少 bug | 需要合理设计成员类型 | 优先采用 |
| 拷贝构造 | 用已有对象初始化新对象 | T b = a; | 明确新对象如何获得资源 | 容易浅拷贝 | 资源类通常要深拷贝或禁用 |
| 拷贝赋值 | 已存在对象接收另一个对象的值 | b = a; | 支持重新赋值 | 要处理旧资源释放、自赋值、异常安全 | 先申请新资源再替换旧资源 |
| 移动构造 | 从右值接管资源 | 返回值优化后的接收、临时对象转移 | 快 | 移动后源对象状态需要保证可析构 | 通常 noexcept |
| 移动赋值 | 已存在对象接管右值资源 | b = std::move(a); | 避免深拷贝 | 要释放旧资源并维护源对象状态 | 通常 noexcept |
=default | 要求编译器生成默认实现 | 默认语义就是正确语义的类 | 语义明确,可读性好 | 不了解生成规则时容易误判 | 能默认就默认 |
=delete | 禁止某个操作 | 独占资源、不可复制对象 | 约束明确,错误早暴露 | 需要同步考虑可移动性 | 用类型系统表达限制 |
| 值语义 | 拷贝后互不影响 | 数据类、DTO、普通业务对象 | 易用、直观 | 大对象拷贝可能贵 | 小对象/逻辑值对象优先 |
| 独占语义 | 资源只有一个拥有者 | 文件、锁、socket | 所有权清晰 | 不能随便复制 | 用 unique_ptr 风格表达 |
| 共享语义 | 多个对象共享资源 | 跨模块共享对象 | 使用方便 | 引用计数开销、循环引用风险 | 谨慎使用 shared_ptr |
易错点
-
只写析构函数,不写拷贝构造和拷贝赋值,导致浅拷贝和重复释放。
-
以为“写了析构就够了”,忽略了对象还会被复制、赋值、移动。
-
移动后把源对象留在非法状态,而不是“有效但未指定状态”。
-
手写移动操作却忘了把源对象置空或重置。
-
拷贝赋值中先释放旧资源,再申请新资源,导致异常安全差。
-
忘记处理自赋值:
a = a; -
误以为“所有类都应该实现五法则”,其实现代 C++ 首选零法则。
-
在有
std::unique_ptr成员的类里,还误以为该类默认可拷贝。 -
没有区分“拷贝构造”和“拷贝赋值”:
- 构造:创建新对象
- 赋值:已有对象接收新值
-
只背“三五零法则”的名字,却答不出背后的资源管理问题。
-
以为移动一定比拷贝快;对小对象或可平凡复制对象,不一定有明显收益。
-
没解释为什么移动要
noexcept,失去容器追问加分点。
记忆技巧
-
三法则:你管销毁,就要管复制。 “有析构,想拷贝”
-
五法则:你管复制,也要管移动。 “资源能拷也得能搬”
-
零法则:最好的拷贝控制,是自己别碰资源管理。 “不手写,最安全”
-
判断顺序可以背成一句话: 先问资源谁管理,再问对象能不能拷,最后问移不移动。
-
面试回答顺序建议固定成:
- 是什么
- 为什么会有
- 怎么选(三 / 五 / 零)
- 典型场景
- 易错点
面试速答版
拷贝控制就是定义对象在拷贝、赋值、移动、销毁时的行为,核心是 5 个特殊成员函数:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
三法则说的是:如果类直接管理资源,只要你自定义了析构、拷贝构造、拷贝赋值中的一个,通常另外两个也要一起定义,否则容易出现浅拷贝、重复释放。
C++11 加入移动语义后,扩展成五法则,要把移动构造和移动赋值也一起考虑。
但现代 C++ 更推荐零法则:尽量把资源交给 string、vector、unique_ptr 这类 RAII 成员管理,这样类本身通常不需要手写特殊成员函数。
工程上优先零法则;只有类自己直接持有底层资源时,才考虑五法则。独占资源一般删掉拷贝、保留移动,移动操作通常建议 noexcept,这样容器扩容时才能优先走移动路径。
面试加分版
我理解拷贝控制,本质上是在设计一个类型的对象语义和资源语义。 对象会经历初始化、赋值、销毁这些生命周期事件,所以 C++ 用 5 个特殊成员函数来控制这些行为:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
为什么会有三法则?因为如果一个类自己管理资源,比如裸指针、文件句柄、socket,编译器默认生成的拷贝通常只是成员逐个复制,属于浅拷贝。这样多个对象会指向同一份资源,最后析构时就可能重复释放。所以在 C++98 时代总结出三法则:你只要接管了析构、拷贝构造、拷贝赋值中的一个,另外两个通常也得一起考虑。
到了 C++11,有了移动语义。很多资源其实不适合昂贵地深拷贝,但很适合“转移所有权”,所以又扩展成五法则:如果类自己管理资源,除了拷贝和析构,还要明确移动构造、移动赋值的行为,尤其要保证移动后源对象仍然是有效的,并且移动操作通常最好标成 noexcept,这样像 vector 这类容器在扩容时会优先使用移动而不是退回拷贝。
但现代 C++ 最推荐的是零法则。也就是尽量不要自己写资源管理代码,而是让成员对象,比如 std::vector、std::string、std::unique_ptr,通过 RAII 帮你管理资源。这样你的类往往不用自定义特殊成员函数,代码更短、更安全,也更符合工程实践。
所以我在工程里通常这样选:
- 如果类型是普通值对象,就保持值语义,尽量走零法则;
- 如果类型表达独占资源,就删除拷贝、支持移动;
- 如果必须直接管理底层资源,再完整实现五法则,并重点关注异常安全、自赋值和移动后状态。
一句话总结: 三法则和五法则是对“自己管理资源”时的约束,零法则才是现代 C++ 的首选。