⚡C++ 对象模型与多态

拷贝控制与特殊成员函数

面试回答

常见问法

  • 什么是拷贝控制?
  • 为什么会有三法则、五法则、零法则?
  • 特殊成员函数有哪些?编译器会自动生成哪些?
  • 什么时候该自己写拷贝构造/移动构造?
  • =default=delete 有什么作用?
  • 为什么移动构造和移动赋值通常建议加 noexcept

回答

拷贝控制,本质上是在定义对象“被复制、被移动、被赋值、被销毁”时的行为。 它主要由 5 个特殊成员函数组成:

  1. 析构函数 ~T()
  2. 拷贝构造 T(const T&)
  3. 拷贝赋值 T& operator=(const T&)
  4. 移动构造 T(T&&)
  5. 移动赋值 T& operator=(T&&)

面试里我通常会这样回答:

如果一个类直接管理资源,比如裸指针、文件句柄、socket、锁等,那么编译器默认生成的拷贝/赋值往往只是“成员逐个拷贝”,这很容易导致重复释放、悬空指针、资源语义错误。 所以在 C++98 时代总结出了三法则:只要你自定义了析构、拷贝构造、拷贝赋值中的一个,通常另外两个也要认真定义。 到了 C++11,引入移动语义后,又要把移动构造和移动赋值一起考虑,所以扩展成了五法则。 但现代 C++ 更推荐零法则:尽量不要自己管理底层资源,而是交给 std::stringstd::vectorstd::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::stringstd::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. 怎么选:值语义、独占语义、共享语义

面试高分回答通常不会停留在“法则名词”,而是会上升到语义设计

值语义

对象像 intstd::string 一样,拷贝后互不影响。 适合:

  • 配置对象
  • 数据对象
  • 普通业务实体

做法:

  • 支持拷贝
  • 必要时也支持移动

独占语义

资源只能有一个拥有者。 适合:

  • 文件句柄
  • socket
  • 互斥锁
  • GPU 资源句柄

做法:

  • 删除拷贝
  • 保留移动

共享语义

多个对象共享同一底层资源。 适合:

  • 生命周期跨模块共享的资源
  • 缓存、图结构节点等

做法:

  • 通常借助 std::shared_ptr
  • 但要注意循环引用和额外开销

8. 工程实践中怎么写更稳

原则一:能零法则就不要五法则

优先级通常是:

  1. 零法则
  2. 必须自己管资源时,再考虑五法则
  3. 只有在老代码 / 特殊约束下才落到裸资源手写

原则二:资源所有权要一眼看出来

  • 裸指针尽量不表达 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,失去容器追问加分点。


记忆技巧

  • 三法则:你管销毁,就要管复制。 “有析构,想拷贝

  • 五法则:你管复制,也要管移动。 “资源能拷也得能搬

  • 零法则:最好的拷贝控制,是自己别碰资源管理。 “不手写,最安全

  • 判断顺序可以背成一句话: 先问资源谁管理,再问对象能不能拷,最后问移不移动。

  • 面试回答顺序建议固定成:

    1. 是什么
    2. 为什么会有
    3. 怎么选(三 / 五 / 零)
    4. 典型场景
    5. 易错点

面试速答版

拷贝控制就是定义对象在拷贝、赋值、移动、销毁时的行为,核心是 5 个特殊成员函数:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。 三法则说的是:如果类直接管理资源,只要你自定义了析构、拷贝构造、拷贝赋值中的一个,通常另外两个也要一起定义,否则容易出现浅拷贝、重复释放。 C++11 加入移动语义后,扩展成五法则,要把移动构造和移动赋值也一起考虑。 但现代 C++ 更推荐零法则:尽量把资源交给 stringvectorunique_ptr 这类 RAII 成员管理,这样类本身通常不需要手写特殊成员函数。 工程上优先零法则;只有类自己直接持有底层资源时,才考虑五法则。独占资源一般删掉拷贝、保留移动,移动操作通常建议 noexcept,这样容器扩容时才能优先走移动路径。


面试加分版

我理解拷贝控制,本质上是在设计一个类型的对象语义和资源语义。 对象会经历初始化、赋值、销毁这些生命周期事件,所以 C++ 用 5 个特殊成员函数来控制这些行为:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。

为什么会有三法则?因为如果一个类自己管理资源,比如裸指针、文件句柄、socket,编译器默认生成的拷贝通常只是成员逐个复制,属于浅拷贝。这样多个对象会指向同一份资源,最后析构时就可能重复释放。所以在 C++98 时代总结出三法则:你只要接管了析构、拷贝构造、拷贝赋值中的一个,另外两个通常也得一起考虑。

到了 C++11,有了移动语义。很多资源其实不适合昂贵地深拷贝,但很适合“转移所有权”,所以又扩展成五法则:如果类自己管理资源,除了拷贝和析构,还要明确移动构造、移动赋值的行为,尤其要保证移动后源对象仍然是有效的,并且移动操作通常最好标成 noexcept,这样像 vector 这类容器在扩容时会优先使用移动而不是退回拷贝。

但现代 C++ 最推荐的是零法则。也就是尽量不要自己写资源管理代码,而是让成员对象,比如 std::vectorstd::stringstd::unique_ptr,通过 RAII 帮你管理资源。这样你的类往往不用自定义特殊成员函数,代码更短、更安全,也更符合工程实践。

所以我在工程里通常这样选:

  • 如果类型是普通值对象,就保持值语义,尽量走零法则;
  • 如果类型表达独占资源,就删除拷贝、支持移动;
  • 如果必须直接管理底层资源,再完整实现五法则,并重点关注异常安全、自赋值和移动后状态。

一句话总结: 三法则和五法则是对“自己管理资源”时的约束,零法则才是现代 C++ 的首选。