23. 如何禁用拷贝但允许移动?如何禁用移动但允许拷贝?

这个问题,说白了就是问:你怎么控制一个对象的“复制”和“搬家”权限?

我在项目里见过不少新手,上来就把拷贝构造函数和移动构造函数全删了,结果发现标准库容器根本用不了。嗯,这里要讲清楚:拷贝和移动是两回事,控制权在你手里。

一、禁用拷贝,但允许移动

这种场景很常见。比如你有一个管理动态内存的类,拷贝起来太贵了,你希望用户只能用移动来转移所有权。典型的例子就是 std::unique_ptr

怎么做?把拷贝构造函数和拷贝赋值运算符标记为 = delete,然后自己实现移动构造函数和移动赋值运算符。

class Buffer {
public:
    // 构造函数
    Buffer(size_t size) : size_(size), data_(new char[size]) {}

    // 禁用拷贝
    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;

    // 允许移动
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
    }

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = other.data_;
            other.size_ = 0;
            other.data_ = nullptr;
        }
        return *this;
    }

    ~Buffer() { delete[] data_; }

private:
    size_t size_;
    char* data_;
};

这样写之后,下面的代码就能正常编译:

Buffer a(1024);
Buffer b = std::move(a);  // OK,移动构造
Buffer c(std::move(b));   // OK,移动构造

但如果你试图拷贝:

Buffer d = a;  // 编译错误!拷贝构造被删除

注意: 我曾经在代码审查时发现有人忘了给移动操作加 noexcept。结果 std::vector 在扩容时,宁愿选择拷贝也不调用移动构造。为什么?因为标准库需要保证强异常安全。如果你的移动操作可能抛异常,它就不敢用。所以,移动操作一定要加 noexcept

二、禁用移动,但允许拷贝

这个场景相对少见,但也不是没有。比如你有一个对象,它的身份标识很重要,你不想让用户“偷走”它的内部状态。或者,你的对象内部有指向自身的指针,移动后指针就失效了。

做法正好相反:把移动构造函数和移动赋值运算符标记为 = delete,保留拷贝操作。

class ImmutableBuffer {
public:
    ImmutableBuffer(size_t size) : size_(size), data_(new char[size]) {}

    // 允许拷贝(深拷贝)
    ImmutableBuffer(const ImmutableBuffer& other)
        : size_(other.size_), data_(new char[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    ImmutableBuffer& operator=(const ImmutableBuffer& other) {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = new char[size_];
            std::copy(other.data_, other.data_ + size_, data_);
        }
        return *this;
    }

    // 禁用移动
    ImmutableBuffer(ImmutableBuffer&&) = delete;
    ImmutableBuffer& operator=(ImmutableBuffer&&) = delete;

    ~ImmutableBuffer() { delete[] data_; }

private:
    size_t size_;
    char* data_;
};

这样,用户只能拷贝,不能移动:

ImmutableBuffer a(1024);
ImmutableBuffer b = a;           // OK,拷贝构造
ImmutableBuffer c = std::move(a); // 编译错误!移动构造被删除

一个小技巧: 如果你想让一个类既不能拷贝也不能移动,那就把拷贝和移动都 = delete。但说实话,这种类很少见,除非你是在写一个单例或者某个全局资源管理器。

三、核心逻辑图

下面这张图帮你理清思路:

拷贝与移动的控制策略 类设计决策 禁用拷贝,允许移动 禁用移动,允许拷贝 拷贝构造/赋值 = delete 移动构造/赋值 = 自定义实现 拷贝构造/赋值 = 自定义实现 移动构造/赋值 = delete 记得移动操作加 noexcept

四、一些实战经验

我个人习惯是:如果一个类管理了资源(内存、文件句柄、网络连接等),我默认会禁用拷贝,只允许移动。为什么?因为深拷贝太容易出性能问题了。我在项目中遇到过好几次,因为不小心拷贝了一个大对象,导致内存暴涨,程序直接 OOM。

反过来,如果一个类不管理资源,只是简单的数据聚合(比如一个 Point 结构体),那我连移动都不会去写,直接用默认的拷贝就行。移动对于小对象来说,其实没什么性能优势。

避坑指南: 我曾经在写一个网络库时,把 Socket 类的移动构造函数写成了深拷贝。结果客户端连接一多,内存就炸了。后来才发现,移动构造里忘了把源对象的文件描述符置为 -1,导致析构时把资源给释放了。所以,移动操作一定要记得“清空源对象”,否则就是假移动。

五、总结一下

策略 拷贝构造/赋值 移动构造/赋值 典型场景
禁用拷贝,允许移动 = delete 自定义实现 + noexcept 管理动态内存、文件句柄、unique_ptr
禁用移动,允许拷贝 自定义实现 = delete 对象有内部自引用、身份标识重要
两者都禁用 = delete = delete 单例、全局资源管理器

说白了,拷贝和移动的控制,就是你对对象生命周期的管理策略。想清楚你的类到底需不需要“复制”,需不需要“搬家”,然后果断地删掉或实现对应的函数。别犹豫,犹豫就会败北。

专注资料整理