Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

拷贝控制

一个类如果持有资源(堆内存、文件句柄、socket),就必须自己决定:拷贝它时会发生什么?销毁它时该做什么?

编译器会为每个类自动生成一组特殊成员函数,但默认行为是「逐成员拷贝」——对指针来说就是拷贝地址,而不是拷贝指向的内容。这往往不是我们想要的。

拷贝控制就是关于这组函数的规则:什么时候该自己写,怎么写才不出错。

一、五个特殊成员函数

函数签名何时调用
拷贝构造T(const T&)用同类型对象初始化新对象
移动构造T(T&&)用右值初始化新对象
拷贝赋值T& operator=(const T&)已有对象被赋值
移动赋值T& operator=(T&&)已有对象被右值赋值
析构~T()对象生命周期结束
class Widget
{
public:
    Widget(const Widget& other);              // 拷贝构造
    Widget(Widget&& other) noexcept;          // 移动构造
    Widget& operator=(const Widget& other);   // 拷贝赋值
    Widget& operator=(Widget&& other) noexcept;  // 移动赋值
    ~Widget();                                // 析构
};

编译器生成的默认版本是这样的:

  • 拷贝构造/赋值:逐个成员拷贝
  • 移动构造/赋值:逐个成员移动
  • 析构:逐个成员析构

对只含 int、std::string 这类成员的类,默认版本完全正确。问题出在持有裸资源的类上。

二、默认行为为什么出错

看一个自己管理内存的类:

class Buffer
{
public:
    explicit Buffer(std::size_t n) : data_(new int[n]), size_(n) {}
    ~Buffer() { delete[] data_; }

private:
    int* data_;
    std::size_t size_;
};

这个类用了默认的拷贝构造和赋值,会出大问题:

Buffer a(10);
Buffer b = a;      // 默认拷贝构造:b.data_ 和 a.data_ 指向同一块内存

// 函数结束,b 先析构,delete[] b.data_
// 然后 a 析构,delete[] a.data_ —— 重复释放!

即使侥幸没崩溃,a 和 b 共享同一块内存也是灾难:修改 b 会影响 a,而这显然不是「拷贝」该有的语义。

这叫浅拷贝问题——拷贝了指针本身,而不是指针指向的内容。

三、拷贝构造与拷贝赋值

1. 拷贝构造

Buffer(const Buffer& other)
    : data_(new int[other.size_])    // 分配新内存
    , size_(other.size_)
{
    std::copy(other.data_, other.data_ + size_, data_);   // 拷贝内容
}

要点:

  • 参数必须是引用。如果按值传参,会无限递归(拷贝实参时又要调用拷贝构造)
  • 通常加 const,因为不应该修改源对象
  • 用初始化列表初始化成员,而不是在函数体里赋值

2. 拷贝赋值

Buffer& operator=(const Buffer& other)
{
    if (this != &other)              // 1. 自赋值检查
    {
        int* newData = new int[other.size_];   // 2. 先分配新内存
        std::copy(other.data_, other.data_ + other.size_, newData);

        delete[] data_;              // 3. 再释放旧内存
        data_ = newData;
        size_ = other.size_;
    }
    return *this;                    // 4. 返回 *this 以支持链式赋值
}

四个关键点:

自赋值检查。a = a 是合法的,如果不检查,会先 delete[] data_ 再拷贝 other.data_——但 other 就是自己,数据已经被删了。

先分配再释放。如果先 delete[] 再 new,而 new 抛异常,对象就处于「data_ 悬垂」的无效状态。反过来则异常安全——分配失败时对象原封不动。

返回 *this 的引用。这样才能支持 a = b = c 这样的链式赋值。返回值而非引用会导致额外的拷贝(对某些类型还可能编译失败)。

不能返回 const 引用。否则 (a = b) = c 无法编译,与内置类型行为不一致。

3. 更简洁的写法:copy-and-swap

Buffer& operator=(Buffer other)   // 注意:按值传参
{
    swap(*this, other);
    return *this;
}

friend void swap(Buffer& a, Buffer& b) noexcept
{
    using std::swap;
    swap(a.data_, b.data_);
    swap(a.size_, b.size_);
}

这个写法自动处理了自赋值(因为 other 是副本),而且天然异常安全。缺点是可能多一次拷贝,但现代编译器通常能优化掉。

四、移动构造与移动赋值

拷贝的代价是「分配 + 复制全部内容」。但如果源对象马上就要销毁,我们完全可以直接接管它的资源:

Buffer(Buffer&& other) noexcept
    : data_(other.data_)          // 接管指针
    , size_(other.size_)
{
    other.data_ = nullptr;        // 关键:置空,避免重复释放
    other.size_ = 0;
}

Buffer& operator=(Buffer&& other) noexcept
{
    if (this != &other)
    {
        delete[] data_;           // 释放自己的资源
        data_ = other.data_;      // 接管对方的
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
    }
    return *this;
}

两个要点:

必须置空源对象。否则源对象析构时会 delete[] 一块已经不属于它的内存。

应该标记 noexcept。这直接影响 std::vector 的行为——如果移动构造可能抛异常,vector 扩容时会退化为拷贝,性能差很多。

class Good { Good(Good&&) noexcept; };   // vector 会移动
class Bad  { Bad(Bad&&); };              // vector 会选择拷贝

移动后的对象状态

被移动的对象处于「有效但未指定」状态:

  • 可以安全析构 ✅
  • 可以重新赋值 ✅
  • 不应该假设它的内容 ❌
Buffer a(10);
Buffer b = std::move(a);
// a.data_ 是 nullptr,a.size_ 是 0
// 但这是实现细节,不要依赖
a = Buffer(5);   // 合法:重新赋值

五、Rule of Three / Five / Zero

Rule of Three(C++98)

如果需要自定义析构函数、拷贝构造、拷贝赋值中的任何一个,那么通常三个都需要。

原因:需要自定义析构,说明持有需要手动释放的资源;那么默认的浅拷贝会导致重复释放,必须一并处理。

Rule of Five(C++11)

加上移动构造和移动赋值,变成五个。

如果定义了拷贝操作但没定义移动操作,编译器不会生成移动版本——此时「移动」会静默退化为拷贝:

class OnlyCopy
{
public:
    OnlyCopy(const OnlyCopy&);              // 自定义拷贝构造
    OnlyCopy& operator=(const OnlyCopy&);
    // 没有移动构造 → std::move 会调用拷贝构造
};

OnlyCopy a;
OnlyCopy b = std::move(a);   // 实际调用的是拷贝构造,不是移动

Rule of Zero

最好的做法是一个都不写。

用现成的 RAII 类型作为成员,编译器生成的默认操作就已经正确:

class Widget
{
    std::string name_;                 // 自己管理内存
    std::vector<int> data_;            // 自己管理内存
    std::unique_ptr<Impl> impl_;       // 独占所有权
    std::shared_ptr<Config> config_;   // 共享所有权
};
// 不需要写任何特殊成员函数

这是现代 C++ 推荐的风格:让成员类型去管理资源,自己只管组合。

判断标准很简单:如果你的类里有裸指针、裸文件句柄、裸 socket,才需要写这五个函数。否则遵循 Rule of Zero。

六、什么时候编译器不生成

编译器生成默认版本是有条件的:

情况影响
定义了拷贝构造不生成移动构造(但拷贝构造仍默认生成)
定义了移动构造拷贝构造被删除
定义了析构不生成移动操作(C++11 起仍生成拷贝,但已弃用)
成员不可拷贝拷贝操作被删除
成员不可移动移动操作被删除

一个容易踩的坑:只写了析构函数,移动操作就没了。

class Widget
{
public:
    ~Widget() { /* 清理 */ }
    // 移动构造不会被生成!
};

此时 std::vector<Widget> 扩容会退化为拷贝。修复方法是显式声明:

class Widget
{
public:
    ~Widget() { /* 清理 */ }
    Widget(Widget&&) noexcept = default;
    Widget& operator=(Widget&&) noexcept = default;
};

七、= default 与 = delete

C++11 允许显式控制这些函数:

class Widget
{
public:
    Widget() = default;                              // 使用默认实现
    Widget(const Widget&) = default;
    Widget& operator=(const Widget&) = default;
    ~Widget() = default;
};

= delete 则用于禁止某个操作:

class NonCopyable
{
public:
    NonCopyable() = default;
    NonCopyable(const NonCopyable&) = delete;             // 禁止拷贝
    NonCopyable& operator=(const NonCopyable&) = delete;
};

NonCopyable a;
// NonCopyable b = a;   // 编译错误

这比 C++98 时代「声明为 private 且不实现」的做法清晰得多——错误在编译期就暴露,而不是链接期。

八、常见误区

「拷贝构造的参数可以按值传递」 —— 会导致无限递归。必须是引用。

「赋值运算符不需要自赋值检查」 —— a = a 合法且常见(比如通过引用间接赋值),不检查会先释放自己的资源再读取已释放的数据。

「移动构造不需要置空源对象」 —— 会导致源对象析构时重复释放。

「移动构造不标 noexcept 也没关系」 —— vector 会检测这个标记,没标就退化为拷贝,性能损失可能很大。

「Rule of Three 只是建议,可以不遵守」 —— 违反它会导致重复释放、内存泄漏等未定义行为,不是风格问题。

「写了析构函数,编译器还会生成移动操作」 —— 不会。这是 C++11 的一个常见陷阱。

「= default 和什么都不写是一样的」 —— 不完全一样。= default 是显式声明,会影响其他特殊成员函数的生成规则。

九、相关章节