20. 如何编写一个既支持拷贝又支持移动的类?

这个问题,说白了就是问:怎么让你的类既能当“老黄牛”老老实实拷贝,又能当“快递员”高效移动?

我在项目中见过不少同学,要么只写了拷贝构造,要么只写了移动构造,结果编译器报错或者性能惨不忍睹。其实,C++11 之后,标准库和编译器已经给了我们一套很成熟的“五法则”(Rule of Five)。

嗯,咱们先理清一个概念:拷贝和移动不是互斥的,它们是互补的。拷贝是“复制一份”,移动是“偷走资源”。一个设计良好的类,应该两者都支持。

核心原则:五法则(Rule of Five)

如果你手动定义了以下五个函数中的任何一个,那么通常你需要定义全部五个:

  1. 析构函数(~Class)
  2. 拷贝构造函数(Class(const Class&))
  3. 拷贝赋值运算符(Class& operator=(const Class&))
  4. 移动构造函数(Class(Class&&) noexcept)
  5. 移动赋值运算符(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_ptrstd::shared_ptr),那么编译器自动生成的拷贝和移动就够用了。

举个例子:

class Widget {
    std::unique_ptr<int> ptr_;
    int id_;
    // 不需要手动写任何拷贝/移动/析构
    // 编译器会生成正确的移动语义
};

但如果你手动管理资源(new/delete、文件句柄、socket 等),那就老老实实写五法则吧。

SVG 流程图:拷贝与移动的决策路径

拷贝 vs 移动 决策流程 需要传递对象 是临时对象? (右值) 调用移动构造/赋值 源对象被置空,资源转移 调用拷贝构造/赋值 深拷贝,源对象不变 关键:移动是“偷”,拷贝是“复制”。移动后源对象必须可析构。

避坑指南

我曾经踩过的一个坑: 在移动赋值运算符里忘了检查自赋值。虽然移动操作通常不会自赋值(因为右值引用不会绑定到左值),但如果你手动调用了 std::move 或者用了某些模板代码,自赋值还是可能发生的。所以,if (this != &other) 这个检查,我建议保留。

小技巧: 如果你觉得写五个函数太啰嗦,可以用 = default 让编译器生成默认版本。但前提是你的成员都支持正确的移动语义。比如 std::vectorstd::string 都自带高效的移动操作,你直接 = default 就行。

总结

写一个既支持拷贝又支持移动的类,其实就三步:

  1. 确定你的类是否管理独占资源(堆内存、文件句柄等)。
  2. 如果是,手动实现五法则:析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
  3. 移动操作记得加 noexcept,并且移动后源对象要安全。

如果你用的是智能指针或者标准容器,那就省心了——编译器生成的移动语义已经足够高效。但如果你在写底层库或者性能敏感代码,手写五法则依然是基本功。

嗯,记住一句话:拷贝是礼貌,移动是效率。两者都支持,你的类才算“现代 C++”。


专注资料整理