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);
顺序是:
- 释放
second原来的资源; second接管first的资源;- 把
first置空; - 返回
*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& 拷贝构造能够绑定右值,该特征也可能为真。要确认实际调用,仍可通过日志、删除拷贝函数或进一步检查设计来判断。
十八、所有权设计方法
看到指针或句柄成员时,按以下顺序判断:
- 当前类是否拥有资源?
- 如果拥有,是独占还是共享?
- 如果不拥有,谁保证资源的生命周期?
- 对象复制时,资源应该深拷贝、共享、转移还是禁止复制?
| 语义 | 常见表示 |
|---|---|
| 独占所有权 | 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;
}
通常顺序是:
- 优先 NRVO,直接在调用者存储位置构造;
- 无法消除构造时,局部返回对象可隐式移动;
- 移动不可用时,才可能复制。
不要习惯性写:
return std::move(result);
它可能阻碍 NRVO。正常按值返回即可。
也不能返回局部对象的右值引用:
DynamicBuffer&& wrong()
{
DynamicBuffer local(10);
return std::move(local); // 返回悬垂引用
}
std::move 不会延长对象生命周期。
二十、最终知识检查
std::move本身不转移资源,只把表达式转换成将亡值。- 有名字的变量表达式是左值,即使变量类型是
T&&。 - 移动构造的目标没有旧资源;移动赋值通常必须先处理目标旧资源。
- 来源对象在移动后仍必须有效、可安全析构和重新赋值。
noexcept让标准容器能够在保持异常安全的同时优先移动。- 直接管理资源时考虑 Rule of Five;业务类优先追求 Rule of Zero。
- 默认逐成员操作是否正确,取决于成员类型的资源语义。
- 声明某个特殊成员函数后,要逐项检查其他特殊成员函数,而不是使用模糊的“一律生成”或“一律不生成”规则。
二十一、本课结论
移动语义的核心不是追求语法上的 &&,而是明确资源所有权如何转移。拷贝意味着产生独立价值,移动意味着原对象允许失去原内容,RAII 则保证最终拥有者在生命周期结束时完成释放。
设计资源类时,先确定所有权,再决定复制和移动策略;能使用标准 RAII 类型组合业务类时,优先采用 Rule of Zero。这样可以把运行时的泄漏、悬垂指针和重复释放,尽可能转化为编译器能够检查的类型规则。