20. 如何编写一个既支持拷贝又支持移动的类?
这个问题,说白了就是问:怎么让你的类既能当“老黄牛”老老实实拷贝,又能当“快递员”高效移动?
我在项目中见过不少同学,要么只写了拷贝构造,要么只写了移动构造,结果编译器报错或者性能惨不忍睹。其实,C++11 之后,标准库和编译器已经给了我们一套很成熟的“五法则”(Rule of Five)。
嗯,咱们先理清一个概念:拷贝和移动不是互斥的,它们是互补的。拷贝是“复制一份”,移动是“偷走资源”。一个设计良好的类,应该两者都支持。
核心原则:五法则(Rule of Five)
如果你手动定义了以下五个函数中的任何一个,那么通常你需要定义全部五个:
- 析构函数(~Class)
- 拷贝构造函数(Class(const Class&))
- 拷贝赋值运算符(Class& operator=(const Class&))
- 移动构造函数(Class(Class&&) noexcept)
- 移动赋值运算符(Class& operator=(Class&&) noexcept)
重要提醒: 如果你只写了移动构造,没写拷贝构造,那么当你尝试拷贝时,编译器会报错或者退化为拷贝。反过来,只写拷贝不写移动,移动操作会退化为拷贝,性能就没了。
一个完整的例子:StringBuffer 类
咱们写一个管理动态字符数组的类,既支持拷贝(深拷贝),又支持移动(转移所有权)。
#include <cstring>
#include <algorithm>
class StringBuffer {
public:
// 默认构造函数
StringBuffer() : data_(nullptr), size_(0) {}
// 带参构造函数
explicit StringBuffer(const char* str) {
if (str) {
size_ = std::strlen(str);
data_ = new char[size_ + 1];
std::memcpy(data_, str, size_ + 1);
} else {
data_ = nullptr;
size_ = 0;
}
}
// 析构函数
~StringBuffer() {
delete[] data_;
}
// 拷贝构造函数 —— 深拷贝
StringBuffer(const StringBuffer& other)
: size_(other.size_) {
if (other.data_) {
data_ = new char[size_ + 1];
std::memcpy(data_, other.data_, size_ + 1);
} else {
data_ = nullptr;
}
}
// 拷贝赋值运算符 —— 拷贝并交换(copy-and-swap)
StringBuffer& operator=(const StringBuffer& other) {
if (this != &other) {
StringBuffer tmp(other); // 调用拷贝构造
swap(tmp); // 交换资源
}
return *this;
}
// 移动构造函数 —— 偷走资源,置空源对象
StringBuffer(StringBuffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值运算符
StringBuffer& operator=(StringBuffer&& other) noexcept {
if (this != &other) {
delete[] data_; // 释放当前资源
data_ = other.data_; // 偷走资源
size_ = other.size_;
other.data_ = nullptr; // 置空源对象
other.size_ = 0;
}
return *this;
}
// 交换函数
void swap(StringBuffer& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
// 获取数据(只读)
const char* c_str() const { return data_ ? data_ : ""; }
private:
char* data_;
size_t size_;
};
关键点解析
我挑几个容易踩坑的地方说说:
- 移动操作必须标记 noexcept —— 这是为了兼容标准库容器。比如
std::vector在扩容时,如果移动构造是 noexcept,它会优先用移动;否则会退化为拷贝。我曾经因为忘了加 noexcept,导致一个高性能模块在 realloc 时性能暴跌 30%。 - 移动后源对象必须处于“可析构”状态 —— 也就是移动后,源对象的资源指针要置空,这样析构时不会 double free。但注意,源对象不一定非要置零,只要保证析构安全即可。
- 拷贝赋值用 copy-and-swap 更安全 —— 这样写既处理了自赋值,又保证了强异常安全。不过代价是多了一次拷贝构造和一次交换,对于大对象可能有点开销。我个人习惯在小型类中用这个模式,大型类则手写优化。
什么时候可以偷懒?
如果你的类所有成员都是可平凡拷贝的(比如 int、double、指针),或者你用了智能指针(std::unique_ptr、std::shared_ptr),那么编译器自动生成的拷贝和移动就够用了。
举个例子:
class Widget {
std::unique_ptr<int> ptr_;
int id_;
// 不需要手动写任何拷贝/移动/析构
// 编译器会生成正确的移动语义
};
但如果你手动管理资源(new/delete、文件句柄、socket 等),那就老老实实写五法则吧。
SVG 流程图:拷贝与移动的决策路径
避坑指南
我曾经踩过的一个坑: 在移动赋值运算符里忘了检查自赋值。虽然移动操作通常不会自赋值(因为右值引用不会绑定到左值),但如果你手动调用了 std::move 或者用了某些模板代码,自赋值还是可能发生的。所以,if (this != &other) 这个检查,我建议保留。
小技巧: 如果你觉得写五个函数太啰嗦,可以用 = default 让编译器生成默认版本。但前提是你的成员都支持正确的移动语义。比如 std::vector 和 std::string 都自带高效的移动操作,你直接 = default 就行。
总结
写一个既支持拷贝又支持移动的类,其实就三步:
- 确定你的类是否管理独占资源(堆内存、文件句柄等)。
- 如果是,手动实现五法则:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
- 移动操作记得加
noexcept,并且移动后源对象要安全。
如果你用的是智能指针或者标准容器,那就省心了——编译器生成的移动语义已经足够高效。但如果你在写底层库或者性能敏感代码,手写五法则依然是基本功。
嗯,记住一句话:拷贝是礼貌,移动是效率。两者都支持,你的类才算“现代 C++”。