C++ 类的拷贝控制:特殊成员与共享资源风险
04 拷贝控制决定两个对象是否共享同一份资源
下面这段代码看起来无害,但在大多数平台上运行几轮就会崩溃。问题不在于算法,也不在于输入,真正的根源在于一个只写对了半截的类。
class Buffer {
char* data_;
size_t size_;
public:
Buffer(size_t n) : data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; }
// 没有拷贝构造,没有拷贝赋值
};
void use(Buffer b) { /* ... */ }
Buffer a(1024);
Buffer b = a; // 拷贝构造——编译器生成的版本做了什么?use(a); // 值传递——又触发一次拷贝// 程序结束时 a、b 和形参的析构函数各自执行了一次 delete[] data_
编译这段代码不会有任何警告。编译器欣然生成了拷贝构造和拷贝赋值的默认版本,它们忠实地逐个复制每个非静态数据成员。data_ 是一个指针,复制指针就是复制地址值,不会复制它指向的那块堆内存。于是,三个对象的 data_ 指向了同一块 new char[1024] 分配出来的区域。每个对象的析构函数都执行一次 delete[] data_,导致同一块内存被释放了三次(第二次和第三次释放的是已经被回收的地址,引发未定义行为)。
这个例子暴露出一个比"写拷贝构造"更深层的问题:当一个对象拥有资源(堆内存、文件句柄、套接字、锁),编译器默认生成的逐成员复制不能正确表达"这个对象被复制后,新对象和原对象对资源的关系应该是什么"。编译器不知道资源的存在,它只知道复制字节。要回答这个关系,需要类型设计者显式写清楚拷贝时发生了什么。这就是拷贝控制(copy control)的全部意义。
编译器生成的默认拷贝操作执行的是逐成员复制(memberwise copy):对于类类型的成员,调用该成员的拷贝构造函数;对于内置类型(如指针、整数、浮点数),直接复制其值。这个行为在类没有管理外部资源时是正确的(如果所有成员都是 int、double、std::string 或 std::vector,逐成员复制自动产生正确的深拷贝,因为 std::string 和 std::vector 自己的拷贝构造函数已经处理了它们所管理的堆内存)。问题仅出现在这样一个交叉点上:类中有裸资源句柄(如原始指针指向动态分配的内存),而类的设计者没有告诉编译器"复制这个指针时应该同时复制它指向的东西"。编译器不会替你猜资源语义,它只做它知道的事,而逐成员复制是编译器唯一知道的默认行为。
拷贝构造是创建,拷贝赋值是替换
拷贝构造和拷贝赋值虽然都叫"拷贝",它们操作的对象所处的生命周期阶段却完全不同,这个差异决定了它们的实现逻辑也不一样。拷贝构造函数被调用时,目标对象尚未存在(它正在出生),没有旧资源需要释放,也没有旧状态需要清理。拷贝赋值运算符被调用时,目标对象已经是一个完整的、活着的对象,它可能持有资源、维护着不变量、关联着外部状态。所有这些都需要在接收新值之前妥善处理。
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* tmp = new char[other.size_]; // 先分配——如果抛异常,原对象不受影响
std::copy(other.data_, other.data_ + other.size_, tmp);
delete[] data_; // 释放旧资源
data_ = tmp;
size_ = other.size_;
return *this;
}

拷贝构造函数通过初始化列表直接分配新内存并填充数据,它不需要检查自赋值,因为一个新对象不可能等于源对象。拷贝赋值运算符则必须先处理自赋值(a = a 这种写法在通用代码中并不罕见)。如果函数上来就释放 data_,那 other.data_(也就是自己的 data_)已经变成野指针,后续复制无从谈起。自赋值检查之后它做的事实际上可以分为三步:申请新资源、复制数据、释放旧资源。这个顺序是有意设计的,如果新资源申请失败抛出了异常,原对象的状态完好无损,这是基本的异常安全保证。
两种拷贝操作还有一个共同的约束:它们产生的新对象在语义上应当等价但独立于源对象。换句话说,修改拷贝不应该影响原件,销毁拷贝也不应该连带原件。当类型拥有独占资源(如 unique_ptr)时,拷贝在物理上不可能满足这个条件——你不能同时"独占"又"和别人共享同一份"。这就引出了移动语义和禁止拷贝两条路线。
移动:把资源的所有权从源对象转移到目标对象
C++11 引入的移动语义处理的场景是:源对象即将被销毁或不再需要它的资源,把资源直接转交给目标对象,可以省去分配新资源、复制数据、再释放旧资源这一整条路径的成本。移动构造函数接收一个右值引用(T&&),它从源对象"窃取"资源后,必须把源对象留在一个可安全析构的状态(通常是置空指针、清零计数、断开句柄)。
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;
}

注意两个移动操作都标记了 noexcept。这不是可选的风格偏好。标准库容器在重新分配存储时,如果元素的移动构造函数是 noexcept 的,容器会选择移动元素而不是拷贝元素;如果移动构造没有 noexcept 保证,标准库为了在异常发生时能回滚到一致状态,会退回到拷贝路径(因为拷贝失败了原始元素还在,而移动失败可能留下一个已经被部分掏空的源对象无从恢复)。最典型的场景是 std::vector 的 push_back 触发重新分配:如果元素的移动构造是 noexcept 的,整个旧存储区中的元素可以被高效地移动到新存储区;否则它们会被拷贝,性能差异在元素数量大时可以非常显著。所以,凡是逻辑上不会抛异常的移动操作,都应该主动加上 noexcept。如果你不确定移动操作是否会抛异常,那就不要标记 noexcept。错误的 noexcept 承诺如果被打破,程序会直接调用 std::terminate,这比一次拷贝更糟糕。
移动之后的源对象处于一种"有效但状态未指定"的状态。所谓"有效",是指它可以被安全析构、可以被赋值新值,不会因为被移动过就进入某种非法状态导致调用析构函数时崩溃。所谓"状态未指定",是指源对象的具体内容(比如 data_ 现在指向哪里、size_ 的值是多少)没有标准保证,依赖它们的行为属于未定义行为。实践中,标准库组件(如 std::vector、std::string)的移动后状态通常是一个合法但内容为空的对象。C++ 标准并不强制所有用户定义类型的移动操作都遵守这个惯例,但这确实是一个好的工程实践,因为调用方可以合理地假设移动后对象至少是可用的,哪怕内容不确定。
移动操作并不是"比拷贝更快"的万能药。对于 std::array<int, 4> 或 std::string 的短字符串优化路径这种数据全部内嵌在对象布局中的类型,移动本质上就是拷贝。它们没有外部资源可以"窃取",也没有指针可以免于深度复制。把这类类型的参数写成 T&& 并在函数体内执行 std::move 会增加复杂度却不带来任何性能收益,因为数据本身就嵌在对象里,移动和拷贝做的是同一件事。只有在资源位于堆上或其他外部存储中时,移动操作转换所有权而非复制数据的优势才会显现。
五个特殊成员函数的一致性
对于直接管理资源的类,五个特殊成员函数形成一个整体:析构函数释放资源,拷贝构造创建独立副本,拷贝赋值在释放旧资源后创建独立副本,移动构造转移所有权,移动赋值在释放旧资源后转移所有权。如果只写了其中一部分(比如写了析构函数和拷贝构造函数,没写拷贝赋值运算符和移动操作),编译器会根据当前类声明的内容决定哪些特殊成员可以隐式生成、哪些应该标记为弃置(deleted),而这个决策逻辑的完整规则相当复杂。
这里有一条实用的经验规律:当你显式声明了析构函数、拷贝构造或拷贝赋值中的任何一个时,编译器不会为你隐式生成移动操作。这是一个保守的设计,因为如果你需要自定义析构行为,大概率也在管理需要特殊处理的资源,编译器不应该自作主张生成一个可能错误的逐成员移动。不过,在 C++11 之后的规则中,仅仅声明析构函数并不会阻止拷贝操作的隐式生成(编译器认为这是为了向后兼容),但移动操作确实被抑制了。如果你同时声明了移动操作,拷贝操作会被自动弃置,除非你显式要求它们。
这些生成规则的历史演化本身就是 C++ 向后兼容压力的一个缩影。C++98 时代只有三个特殊成员函数(析构、拷贝构造、拷贝赋值),编译器在所有情况下都会隐式生成它们,除非基类或成员的对应函数不可访问。C++11 引入了移动构造和移动赋值,但为了不破坏现有代码,设计了一条折中规则:如果你声明了任何拷贝控制函数或析构函数,编译器就不自动生成移动操作。这是因为那些老代码中的析构函数或拷贝构造极有可能在管理资源,自动生成的逐成员移动很可能错误。这个折中意味着,一个在 C++98 时代完全合法的类,搬到 C++11 编译器下如果只加了移动操作,拷贝操作会被自动弃置。这就是为什么实践中常有"想加移动却发现拷贝也没了"的困惑。解决方式很简单:如果确实需要移动和拷贝同时存在,五个都显式声明或用 = default 明确启用。
与其逐条背诵这些小规则,不如换一个更高的视角来看这个问题。C++ 核心指南提出了 Rule of Five 的另一种表述,它本质上是一个一致性检查:如果你需要自定义析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个,那么你很可能需要显式处理全部五个,因为它们共同决定了这个类型的资源和生命周期的完整语义。这个"规则"要表达的真正意思是:五个函数共享对资源的访问和管理逻辑,private 资源句柄的语义由它们共同维护。把它们视为一个不可分割的集合,有助于避免漏写某个函数从而导致编译器生成错误的默认版本。
Rule of Zero:让编译器去生成
上面的分析建立在一个前提上:类自己管理裸资源。如果类不使用裸资源句柄,而是用标准库组件(如 std::vector、std::string、std::unique_ptr)作为成员,那五个特殊成员函数都可以交给编译器生成。因为每个标准成员已经把自己那份资源管理好了,编译器生成的全类拷贝构造会依次调用各个成员的拷贝构造,而后者自己知道怎么拷贝、怎么移动、怎么释放。这就是 Rule of Zero:如果你的类不直接管理资源,就不要声明析构函数、拷贝/移动构造和拷贝/移动赋值。编译器生成的版本不仅正确、异常安全,而且在你增加新成员时会自动更新拷贝逻辑。人工维护的拷贝函数在增加新成员时如果忘了更新,会导致新成员被默认初始化或被遗漏,而编译器生成的版本永远覆盖所有非静态成员。
// 以前需要写五个函数的类class LabeledBuffer {
char* data_;
size_t size_;
std::string label_;
// 拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数——全都要手写
};
// Rule of Zero 版本class LabeledBuffer {
std::vector<char> data_; // 管理内存
std::string label_; // 管理字符串// 编译器生成所有五个特殊成员函数——全部正确
};

Rule of Zero 看似激进("一个都不写?"),但它的根基很扎实:标准库的 RAII 类型(std::vector、std::string、std::unique_ptr、std::shared_ptr、std::fstream)各自独立管理自己的资源,它们的拷贝/移动/析构行为已经经过了充分的测试和优化。把多个这样的组件合成为一个复合类型时,编译器为该复合类型生成的逐成员操作将依次调用各组件的对应操作。如果某组件不可拷贝(如 std::unique_ptr),则复合类型也不可拷贝,编译器自动逐级传播了这个约束。换句话说,Rule of Zero 并不意味着"放弃对拷贝含义的控制";每个成员类型的选择本身就是对拷贝语义的声明(选 unique_ptr 就声明了独占所有权不可拷贝,选 shared_ptr 就声明了共享所有权使拷贝会增加引用计数,选 vector 就声明了值语义的深拷贝)。
这并不是说 Rule of Zero 和 Rule of Five 相互对立,它们处理的是不同的场景。如果你的类型需要直接管理资源(比如实现一个新的智能指针、一个新的容器、一个自定义的文件包装),那 Rule of Five 是必需的,五个函数写齐是一种自我保护的纪律,避免编译器在你不经意间生成一个部分正确的版本导致运行时错误。如果你的类型只是一个业务逻辑的组合体,用标准库组件表达所有资源和所有权关系,Rule of Zero 就是最安全也最少出错的选择。不写代码的代码 bug 最少,在大型项目中,Rule of Zero 类型的比例通常远高于 Rule of Five 类型,这正是因为大多数业务类型不需要也不应该亲自管理原始资源。
= default 和 = delete:把意图写明确
有些时候,你的设计意图是"这个操作使用编译器生成的默认行为",编译器也恰好能生成正确的版本。但如果不说清楚,接手代码的人就需要推断你是忘了写还是故意不写。= default 在声明处明确表达:这个函数存在,行为由编译器生成。它常见于基类的虚析构函数。你需要声明它为虚函数以支持多态删除,但析构逻辑本身不需要特别处理,于是写 virtual ~Base() = default; 既确保了虚析构的存在,又不引入多余的自定义代码。
= delete 的作用方向相反:它禁止某个操作。被 = delete 标记为弃置的函数仍然参与重载决议,但如果被选中,编译器就会报错。常见用法包括:禁止类的拷贝但保留移动(给拷贝构造和拷贝赋值标记 = delete,同时显式定义或 = default 移动操作);禁止某类隐式转换(标记特定参数类型的构造函数为 = delete);禁止在堆上创建对象(标记 operator new 为 = delete,不过这个场景较为少见)。
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
NonCopyable(NonCopyable&&) = default;
NonCopyable& operator=(NonCopyable&&) = default;
};

NonCopyable 这种模式常见于那些逻辑上不应该被复制的东西(如文件流、数据库连接、线程句柄)。虽然 C++ 不强制要求你给这种类型单独抽一个基类(不像某些语言有显式的 NonCopyable 接口),但写清楚 = delete 也是一种文档。它让任何尝试复制这个类型的人看到编译错误,而不是在运行时遇到重复关闭句柄或双重释放。
有一类场景需要特别留意:禁止拷贝只影响类自身的拷贝行为,派生类的拷贝依然会调用基类的对应操作。如果基类的拷贝构造被 = delete 了,派生类的拷贝构造(无论是显式定义的还是编译器生成的)在尝试调用基类拷贝构造时会直接编译失败。这绝非运行时错误,而是编译器在构造阶段就拦截了。这一连锁反应恰好保障了禁止拷贝的意图贯穿整个继承链。
拷贝还是移动,值还是引用:几个工程决策
类型设计者在选择拷贝语义时,实际上在回答一个更根本的问题:这个类型的对象之间是什么关系?"复制"意味着什么?
如果一个类型表示一个值(比如复数、二维向量、时间点、金额),那么"复制"意味着创建另一个完全独立但等价的值。新对象和原对象不共享任何可变状态,修改各自独立,生命周期互不相关。这种类型应当提供拷贝构造和拷贝赋值,通常也应该允许移动以优化传参和返回值。标准库中的 std::string、std::vector、std::complex 都属于此类。
如果一个类型表示对资源的独占所有权(比如 std::unique_ptr、文件流),那么"复制"在语义上没有意义:你不能同时独自拥有又和别人分享同一个资源。这种类型应该禁止拷贝但保留移动(移动表达了所有权转移),或者干脆把拷贝构造和拷贝赋值标记为 = delete。
如果一个类型表示对共享所有权资源的引用(比如通过 std::shared_ptr 管理的一块内存),那么"复制"意味着增加一个引用计数。原始指针本身没有被复制,但共享的引用关系多了一个参与者。这种类型通常在内部使用 std::shared_ptr 作为成员,从而自然地获得引用计数的拷贝语义。要不要给整个类型提供拷贝语义,取决于外部调用方是否关心底层的共享所有权细节。
还有一些类型本质上就不该被拷贝或移动(比如 RAII 锁守卫 std::lock_guard、作用域守卫、不变量检查器)。它们的存在意义被绑定在创建它们的那个作用域上,离开作用域就销毁,不存在"复制一个作用域守卫"的合理场景。这类类型通常把所有特殊成员函数标记为 = delete,只有构造函数和析构函数有意义。
设计决策最终落在类的资源所有权模型上:谁拥有资源,资源的生命周期如何管理,复制和移动分别应表达什么语义。想清楚这些之后,五个特殊成员函数的声明方式自然确定。Rule of Zero 对应使用标准库 RAII 组件的组合类型,=delete 对应禁止拷贝的所有权独占类型,手写五个函数对应自定义资源管理。三类选择没有孰优孰劣,只有适不适合当前类型的资源关系和语义承诺。
拷贝控制不是 C++ 在语法上刁难人的规则集合,它是 C++ 的 RAII(Resource Acquisition Is Initialization)资源管理模型在对象复制场景下的自然延伸。构造和析构把资源的获取和释放绑定到对象生命周期上。拷贝控制进一步回答:当这个对象被复制时,它管理的资源是也被复制一份、被共享、被转移,还是根本不应该被复制。回答这个问题的能力,决定了你写的类在被放进容器、传给函数、作为返回值时,是否仍然安全且语义一致。
下一章要进入继承的话题:派生类对象内部并非只有自己定义的成员,它还包含一个完整的基类子对象。这个基类部分在派生对象的构造、析构和内存布局中如何存在,如何影响类型之间的转换关系,以及为什么继承并不等于"把基类的代码复制到派生类里"。
阅读导航




