学习35 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 3 课(3-1):拷贝控制与移动语义

从浅拷贝导致的重复释放出发,系统理解拷贝构造、拷贝赋值、移动构造、移动赋值、左值与右值、std::move、noexcept,以及 Rule of Three、Rule of Five 和 Rule of Zero,并通过树莓派 + AddressSanitizer 实验验证资源复制与所有权转移的安全性。

03|用拷贝控制与移动语义明确资源所有权

一、课程目标

前两课已经确认:对象离开作用域时会自动析构,RAII 可以把资源释放绑定到对象生命周期。但只写构造和析构还不够。一旦资源对象被复制或移动,就必须回答:资源是复制一份、共同管理、转移所有权,还是禁止操作?

本课完成后,应能:

  • 区分拷贝构造、拷贝赋值、移动构造和移动赋值;
  • 解释默认逐成员拷贝为什么可能造成浅拷贝;
  • 为独占资源类禁止复制并实现安全移动;
  • 为值类型资源类实现深拷贝;
  • 理解左值、右值、将亡值、右值引用及 std::move;
  • 理解 noexcept 对 std::vector 扩容策略的影响;
  • 区分 Rule of Three、Rule of Five 和 Rule of Zero;
  • 使用 ASan 和类型特征验证类的资源语义。

二、四种操作先分清

Type first;

Type second = first;            // 拷贝构造:创建新对象
second = first;                 // 拷贝赋值:目标对象已经存在

Type third = std::move(first);  // 移动构造:创建新对象
third = std::move(second);      // 移动赋值:目标对象已经存在

判断构造还是赋值,最直接的方法是看左侧对象是否在这一行刚刚定义:新对象是构造,已有对象是赋值。

操作目标对象是否已有资源通常行为
拷贝构造否创建一份独立副本
拷贝赋值是复制新内容,并妥善处理旧资源
移动构造否接管来源资源
移动赋值是先处理旧资源,再接管来源资源

三、实验01:默认浅拷贝与 double-free

3.1 问题代码

class FileHandle
{
public:
    explicit FileHandle(const char* path);
    ~FileHandle();

private:
    std::FILE* file_ = nullptr;
};
FileHandle first("test.bin");
FileHandle second = first;

没有声明拷贝构造时,编译器生成的行为近似为:

FileHandle::FileHandle(const FileHandle& other)
    : file_(other.file_)
{
}

它执行逐成员拷贝。指针成员的值是地址,因此复制后两个对象保存同一个地址:

first.file_  ──┐
               ├──> 同一个 FILE 资源
second.file_ ──┘

离开作用域时,second 和 first 按构造逆序析构,对同一资源调用两次 fclose()。ASan 报告:

ERROR: AddressSanitizer: attempting double-free

3.2 核心结论

RAII 没有失效,问题是类的所有权语义不完整。析构函数能保证对象释放自己认为拥有的资源,但默认拷贝让两个对象错误地认为自己都拥有同一资源。

四、实验02:用 = delete 禁止复制

文件句柄采用独占所有权时,复制没有合理含义,应在编译阶段禁止:

FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;

此时:

FileHandle second = first;

会得到:

error: use of deleted function
‘FileHandle::FileHandle(const FileHandle&)’

虽然初始化语法中出现了 =,但 second 是新对象,所以调用的是拷贝构造,不是拷贝赋值。

= delete 比旧式的私有声明更明确:函数参与重载选择,但任何地方都不允许调用,错误能在编译阶段直接说明原因。

五、实验03:移动构造转移所有权

禁止复制后,仍希望句柄能够按值返回、传递或放入容器。这需要移动构造:

FileHandle(FileHandle&& other) noexcept;
FileHandle::FileHandle(FileHandle&& other) noexcept
    : file_(other.file_)
{
    other.file_ = nullptr;
}

执行:

FileHandle second = std::move(first);

资源状态变化为:

移动前:first.file_  ──> FILE
移动后:first.file_  ──> nullptr
        second.file_ ──> FILE

移动不是把文件内容复制一遍,而是转移保存资源所有权的指针。来源对象仍然存在,而且必须能够安全析构,因此要把它置为不拥有资源的有效状态。

六、左值、右值和 std::move

6.1 左值

左值通常表示有稳定身份、之后仍能找到的对象:

FileHandle first("a.bin");

表达式 first 是左值。const 对象也可以是左值,左值的重点是身份,不是能否修改。

6.2 右值

临时结果通常是右值:

42
10 + 20
FileHandle("a.bin")

现代 C++ 进一步区分:

  • lvalue:有稳定身份的左值;
  • xvalue:仍有身份,但资源允许被接管的将亡值;
  • prvalue:用于产生值或初始化对象的纯右值。

std::move(first) 的结果是将亡值,属于广义右值。

6.3 std::move 不移动数据

std::move 近似执行:

static_cast<FileHandle&&>(first)

它只改变表达式的值类别,允许重载选择匹配移动函数。真正的资源转移发生在移动构造或移动赋值内部。

单独执行:

std::move(first);

通常不会改变 first,因为没有任何移动函数被调用。

6.4 有名字的右值引用变量仍是左值

FileHandle&& reference = std::move(first);
FileHandle second = reference;

reference 的声明类型是 FileHandle&&,但表达式 reference 有名字,所以是左值,第二行会尝试拷贝。要继续转交资源必须写:

FileHandle second = std::move(reference);

右值引用的绑定也不会创建第二个对象:reference 只是 first 的另一个名字。

6.5 const 对象通常不能移动

const FileHandle first("a.bin");
FileHandle second = std::move(first);

std::move(first) 的类型是 const FileHandle&&。普通移动构造接收 FileHandle&&,因为它需要把来源成员置空,不能修改 const 来源。如果拷贝可用,可能退回拷贝;如果拷贝被删除,则编译失败。

七、实验04:移动赋值

移动赋值的目标对象已经存在,可能拥有旧资源:

FileHandle& FileHandle::operator=(FileHandle&& other) noexcept
{
    if (this != &other)
    {
        close();

        file_ = other.file_;
        other.file_ = nullptr;
    }

    return *this;
}

执行:

FileHandle first("first.bin");
FileHandle second("second.bin");
second = std::move(first);

顺序是:

  1. 释放 second 原来的资源;
  2. second 接管 first 的资源;
  3. 把 first 置空;
  4. 返回 *this。

如果不先释放旧资源,旧地址被覆盖后就会泄漏。如果不把来源置空,两个对象又会共同管理同一地址,最终 double-free。

7.1 自移动

second = std::move(second);

此时 this == &other,二者都指向 second。当前实现通过判断跳过资源操作,使对象保持原状态。自移动判断不是“移动赋值”概念本身的必备语法,但资源类必须保证自移动不会破坏对象的不变量或引发未定义行为。

八、实验05:std::vector<FileHandle> 扩容

std::vector<FileHandle> files;
files.reserve(1);
files.emplace_back("first.bin");
files.emplace_back("second.bin");

第二次加入元素时,容量从 1 扩到 2。vector 分配新的连续空间,再用移动构造把旧位置的第一个 FileHandle 搬到新位置。

日志中的关键现象:

FileHandle acquired: 地址A
FileHandle acquired: 地址B
move constructor, take resource: 地址A
FileHandle released: 地址A
FileHandle released: 地址B

两次申请对应两次释放。旧位置的对象已被置空,析构时不会关闭文件。

九、实验06:noexcept 为什么重要

同时提供拷贝和移动的类型:

Item(const Item& other);
Item(Item&& other);             // 可能抛异常

vector 扩容时实际选择了拷贝:

copy construct: first

把移动构造改为:

Item(Item&& other) noexcept;

扩容改为移动:

move construct: first

原因是:如果移动一半时抛出异常,旧元素可能已经被改变,很难恢复原容器;复制失败时旧元素不变,容器更容易保持强异常安全保证。选择逻辑可近似理解为:

if (移动不抛异常 || 类型不能复制)
    移动;
else
    复制;

这不是“编译器在所有地方都优先调用 noexcept 移动”,而是标准容器为了异常安全做出的策略。

十、实验07:动态数组的深拷贝构造

有些资源类不是独占句柄,而是值类型:复制后,新旧对象都要保留完整且独立的数据。

class DynamicBuffer
{
public:
    explicit DynamicBuffer(std::size_t size);
    ~DynamicBuffer();
    DynamicBuffer(const DynamicBuffer& other);

private:
    int* data_ = nullptr;
    std::size_t size_ = 0;
};

深拷贝构造:

DynamicBuffer::DynamicBuffer(const DynamicBuffer& other)
    : data_(other.size_ > 0 ? new int[other.size_] : nullptr),
      size_(other.size_)
{
    if (size_ > 0)
    {
        std::copy(other.data_, other.data_ + size_, data_);
    }
}

结果:

first.data()  = 地址A
second.data() = 地址B

地址不同、内容初始相同。修改 second[0] 不影响 first[0],两个对象析构时分别释放自己的地址。

默认逐成员拷贝是否安全,取决于成员类型:

int* data_;               // 复制地址,通常是浅拷贝
std::vector<int> data_;   // 调用 vector 的深拷贝

十一、实验08:默认拷贝赋值仍然危险

实现拷贝构造不会自动让拷贝赋值也变成深拷贝:

DynamicBuffer first(3);
DynamicBuffer second(2);
second = first;

编译器生成的默认拷贝赋值近似为:

data_ = other.data_;
size_ = other.size_;

这导致:

  • second 原来的资源地址被覆盖,形成泄漏;
  • first 和 second 保存相同地址;
  • 析构时 ASan 报告 double-free。

这也纠正一个常见误解:声明拷贝构造不会阻止编译器生成默认拷贝赋值。正因为三个函数必须整体考虑,才有 Rule of Three。

十二、实验09:异常安全的拷贝赋值

DynamicBuffer& DynamicBuffer::operator=(const DynamicBuffer& other)
{
    if (this != &other)
    {
        int* newData = nullptr;

        if (other.size_ > 0)
        {
            newData = new int[other.size_];
            std::copy(other.data_, other.data_ + other.size_, newData);
        }

        delete[] data_;

        data_ = newData;
        size_ = other.size_;
    }

    return *this;
}

这里故意先申请和复制,再删除旧资源。如果 new 抛出 std::bad_alloc,当前对象的旧资源和状态都没有被修改。

自赋值:

first = first;

通过 this != &other 跳过所有资源操作,原数据保持不变。

十三、实验10:完成 DynamicBuffer 的 Rule of Five

移动构造:

DynamicBuffer::DynamicBuffer(DynamicBuffer&& other) noexcept
    : data_(other.data_),
      size_(other.size_)
{
    other.data_ = nullptr;
    other.size_ = 0;
}

移动赋值:

DynamicBuffer& DynamicBuffer::operator=(DynamicBuffer&& other) noexcept
{
    if (this != &other)
    {
        delete[] data_;

        data_ = other.data_;
        size_ = other.size_;

        other.data_ = nullptr;
        other.size_ = 0;
    }

    return *this;
}

最终五个函数是:

~DynamicBuffer();
DynamicBuffer(const DynamicBuffer&);
DynamicBuffer& operator=(const DynamicBuffer&);
DynamicBuffer(DynamicBuffer&&) noexcept;
DynamicBuffer& operator=(DynamicBuffer&&) noexcept;

移动后的来源对象为 {nullptr, 0},仍然可以查询、重新赋值和安全析构。delete[] nullptr 是合法的,什么也不会发生。

十四、Rule of Three、Five、Zero

14.1 Rule of Three

如果类需要自己处理以下任意一个,通常三个都必须考虑:

析构函数
拷贝构造函数
拷贝赋值运算符

14.2 Rule of Five

C++11 加入移动语义后,直接管理资源的类通常要考虑五个:

析构函数
拷贝构造函数
拷贝赋值运算符
移动构造函数
移动赋值运算符

“考虑”不等于全部实现。明确使用 = delete 禁止某项操作,也是在完成设计。

14.3 Rule of Zero

业务类不直接管理底层资源,尽量让成员类型完成资源管理:

class Buffer
{
public:
    explicit Buffer(std::size_t size)
        : data_(size)
    {
    }

private:
    std::vector<int> data_;
};

不手写五个特殊成员函数,编译器生成的逐成员操作会调用 vector 的正确拷贝、移动和析构。这通常是实际工程中最推荐的目标。

十五、实验11:Rule of Zero 验证

使用 std::vector<int> 后:

复制前后地址不同:vector 自动深拷贝
修改副本不影响原对象
移动前后资源地址相同:vector 转移内部存储
程序退出无 ASan 错误

Rule of Zero 不是没有资源,而是当前类不直接处理底层资源。

十六、特殊成员函数的生成规则

16.1 用户声明析构或拷贝操作会抑制隐式移动

如果用户声明了析构函数、拷贝构造或拷贝赋值,编译器通常不会再隐式生成移动构造和移动赋值。

class Record
{
public:
    ~Record() { }

private:
    std::string name_;
};

下面可能仍能编译:

Record second = std::move(first);

但可能实际调用 Record(const Record&)。std::move 允许移动,却不保证一定发生移动。

16.2 = default 与 = delete

明确要求编译器生成:

Record(Record&&) noexcept = default;
Record& operator=(Record&&) noexcept = default;

明确禁止操作:

Record(const Record&) = delete;
Record& operator=(const Record&) = delete;

= delete 仍然属于用户声明,会影响其他特殊成员函数的隐式生成。如果手动删除拷贝但想支持移动,需要显式声明移动操作。

16.3 不是“写一个就不生成其他所有函数”

规则需要分别判断。尤其要记住:

  • 用户声明析构、拷贝构造或拷贝赋值,会抑制隐式移动;
  • 用户声明拷贝构造,并不自动禁止默认拷贝赋值;
  • 用户声明移动构造或移动赋值,隐式拷贝操作通常会被定义为删除;
  • 最终函数是否可用,还取决于每个成员和基类是否支持相应操作。

因此不要只背一句模糊的“写了一个,其他都不生成”。

十七、实验12:用类型特征验证能力

std::is_copy_constructible<T>::value
std::is_copy_assignable<T>::value
std::is_move_constructible<T>::value
std::is_move_assignable<T>::value
std::is_nothrow_move_constructible<T>::value
std::is_nothrow_move_assignable<T>::value

实验结果:

类型拷贝构造拷贝赋值移动构造移动赋值不抛异常移动
FileHandle否否是是是
DynamicBuffer是是是是是
Buffer是是是是是

注意:is_move_constructible<T> 检查的是 T 能否从 T&& 表达式构造,不严格证明类存在独立的移动构造。若 const T& 拷贝构造能够绑定右值,该特征也可能为真。要确认实际调用,仍可通过日志、删除拷贝函数或进一步检查设计来判断。

十八、所有权设计方法

看到指针或句柄成员时,按以下顺序判断:

  1. 当前类是否拥有资源?
  2. 如果拥有,是独占还是共享?
  3. 如果不拥有,谁保证资源的生命周期?
  4. 对象复制时,资源应该深拷贝、共享、转移还是禁止复制?
语义常见表示
独占所有权std::unique_ptr<T>、专用 RAII 句柄类
共享所有权std::shared_ptr<T>
非拥有观察T*、T&;共享体系中可用 std::weak_ptr<T>
独立值副本std::vector、std::string 或自定义深拷贝

多个模块都“使用”资源,不代表都应拥有资源。只有多个模块确实共同决定资源生命周期时,才需要共享所有权。更常见的结构是一个明确所有者加若干非拥有观察者。

十九、返回值、RVO 与移动

DynamicBuffer createBuffer()
{
    DynamicBuffer result(1024);
    return result;
}

通常顺序是:

  1. 优先 NRVO,直接在调用者存储位置构造;
  2. 无法消除构造时,局部返回对象可隐式移动;
  3. 移动不可用时,才可能复制。

不要习惯性写:

return std::move(result);

它可能阻碍 NRVO。正常按值返回即可。

也不能返回局部对象的右值引用:

DynamicBuffer&& wrong()
{
    DynamicBuffer local(10);
    return std::move(local); // 返回悬垂引用
}

std::move 不会延长对象生命周期。

二十、最终知识检查

  1. std::move 本身不转移资源,只把表达式转换成将亡值。
  2. 有名字的变量表达式是左值,即使变量类型是 T&&。
  3. 移动构造的目标没有旧资源;移动赋值通常必须先处理目标旧资源。
  4. 来源对象在移动后仍必须有效、可安全析构和重新赋值。
  5. noexcept 让标准容器能够在保持异常安全的同时优先移动。
  6. 直接管理资源时考虑 Rule of Five;业务类优先追求 Rule of Zero。
  7. 默认逐成员操作是否正确,取决于成员类型的资源语义。
  8. 声明某个特殊成员函数后,要逐项检查其他特殊成员函数,而不是使用模糊的“一律生成”或“一律不生成”规则。

二十一、本课结论

移动语义的核心不是追求语法上的 &&,而是明确资源所有权如何转移。拷贝意味着产生独立价值,移动意味着原对象允许失去原内容,RAII 则保证最终拥有者在生命周期结束时完成释放。

设计资源类时,先确定所有权,再决定复制和移动策略;能使用标准 RAII 类型组合业务类时,优先采用 Rule of Zero。这样可以把运行时的泄漏、悬垂指针和重复释放,尽可能转化为编译器能够检查的类型规则。