拷贝与移动控制
# 27.拷贝与移动控制
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 拷贝控制的正确姿势
- 4. 移动控制的 noexcept 红利
- 5. = default 与 = delete 精讲
- 6. 拷贝省略与强制 RVO
- 7. 三五法则的全场景推演
- 8. 函数传参与返回的拷贝控制
- 9. 拷贝控制的性能证据
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 浅拷贝的 double-free 血案
某数据库连接池的 ConnectionPool 类在上线一周后出现随机 SIGABRT。崩溃栈指在 free():
// ====== 事故代码 V1:浅拷贝 double-free ======
class ConnectionPool {
Connection** pool_; // 连接指针数组
size_t size_;
public:
ConnectionPool(size_t n) : pool_(new Connection*[n]), size_(n) {
for (size_t i = 0; i < n; ++i) pool_[i] = new Connection();
}
~ConnectionPool() {
for (size_t i = 0; i < size_; ++i) delete pool_[i];
delete[] pool_;
}
// ❌ 没有拷贝构造、没有拷贝赋值!
// 编译器自动生成的版本是浅拷贝——复制 pool_ 指针而不是指向的内容
};
ConnectionPool create_pool() {
ConnectionPool p(10);
return p; // ① 编译器生成拷贝 → pool_ 被浅拷贝
}
void use() {
ConnectionPool pool = create_pool();
// ② create_pool 里的临时对象析构 → delete[] pool_
// ③ pool 析构 → 再次 delete[] pool_ → double free!
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
nm 看符号表——ConnectionPool 没有拷贝构造和拷贝赋值的符号,只有默认构造和析构。编译器悄无声息地生成了浅拷贝版本:pool_ = other.pool_,两个对象的 pool_ 指向同一块堆内存。
# 1.2 vector 扩容为什么不走移动
同一个代码库的 OrderBuffer 类,开发者写了移动构造但忘了 noexcept:
// ====== 事故代码 V2:非 noexcept 移动 → vector 退化为拷贝 ======
class OrderBuffer {
char* data_;
size_t size_;
public:
OrderBuffer(size_t n) : data_(new char[n]), size_(n) {}
~OrderBuffer() { delete[] data_; }
OrderBuffer(OrderBuffer&& other) // ← 缺了 noexcept!
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
}
OrderBuffer(const OrderBuffer& other) // 拷贝构造:深拷贝
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
};
std::vector<OrderBuffer> buf_vec;
buf_vec.reserve(100);
for (int i = 0; i < 500; ++i) {
buf_vec.push_back(OrderBuffer(4096)); // 每次 4KB
}
// 期望:扩容时移动——快(交换三个指针)
// 实际:扩容时拷贝——慢(每次都深拷贝 4KB × N 个元素)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
vector::push_back 扩容时检查移动构造是否 noexcept。如果不是——宁可拷贝也不移动。因为 vector 有强异常安全保证:如果移动过程中抛异常,原 vector 的数据已被移走,回滚不了。noexcept 的移动构造让 vector 确信「移动不会抛异常」→放心走移动。
# 1.3 七个待解疑问
① 三五法则(Rule of Five)到底是什么? 为什么声明一个重要就得声明全部五个? → 第 2 / 第 7 章
② 拷贝赋值和拷贝构造的区别在哪? copy-and-swap 怎么统一两者? → 第 3 章
③ noexcept 对移动构造为什么重要? vector 为什么宁可拷贝也不走非 noexcept 移动? → 第 4 / 第 9 章
④ =default 和 =delete 到底控制什么? 怎么用 =delete 阻止隐式转换? → 第 5 章
⑤ 拷贝省略(copy elision)和 RVO 是什么? C++17 强制到什么程度? → 第 6 章
⑥ 函数返回时该不该用 std::move? 为什么「不要 move 返回值」是金科玉律? → 第 8 章
⑦ 各种类的拷贝/移动策略怎么选择? 什么场景只移动、什么场景深拷贝? → 第 7 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 五法则的完整棋盘
C++ 特殊成员函数不是五个孤立函数——是一张互相关联的棋盘:
┌──────────────────────────────────┐
│ 五法则 (Rule of Five) │
└────────────────┬─────────────────┘
│
┌──────────┬──────────┬──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐
│ 析构 ││ 拷贝 ││ 拷贝 ││ 移动 ││ 移动 ││ 默认 │
│ 函数 ││ 构造 ││ 赋值 ││ 构造 ││ 赋值 ││ 构造 │
├────────┤├────────┤├────────┤├────────┤├────────┤├────────┤
│释放资源 ││深拷贝 ││深拷贝 ││转移所有││转移所有││初始为空│
│=default ││const & ││const & ││权+置空 ││权+置空 ││或默认值│
│或自定义 ││ ││ ││noexcept││noexcept││ │
└────────┘└────────┘└────────┘└────────┘└────────┘└────────┘
│ │ │ │ │ │
└──────────┴──────────┴──────────┴──────────┴──────────┘
│
┌──────────┴──────────┐
│ 声明任何一个 │
│ → 建议显式声明全部 │
│ (或 =default) │
└─────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 2.2 为何这么切
疑惑:为什么拷贝控制需要「五法则」——不能自动推导我要的是深拷贝还是浅拷贝?
论证:
- 编译器不知道资源语义——它看到一个
char*,不知道这是「独占堆内存」还是「指向别人的缓存」。前者需要深拷贝,后者应该禁止拷贝。编译器的默认行为是「逐成员拷贝」(浅拷贝)——在任何需要析构函数管理资源的类上,默认拷贝就是 bug。 - 移动语义的引入让规则更复杂——如果声明了析构函数,编译器不自动生成移动操作(C++11 弃用规则,C++20 仍在讨论是否恢复)。但代码里到处都是
return std::move(x)——缺少移动构造就退化拷贝。 - **零法则(Rule of Zero)**是更优解——如果所有成员自身都正确管理拷贝/移动(如
std::string、std::vector、std::unique_ptr),那么类本身不需要声明任何特殊成员函数——编译器自动生成的全是正确的。
结论:三五法则不是 C++ 的 bug——是 C++ 给「需要自定义资源管理」的类的最低安全底线。更优解是用 Rule of Zero(全部用 RAII 成员,一个特殊成员函数都不写)。
# 3. 拷贝控制的正确姿势
# 3.1 深拷贝是 RAII 的拷贝方式
所有管理独占资源的类,拷贝必须是深拷贝:
class Buffer {
char* data_;
size_t size_;
public:
Buffer(size_t n) : data_(new char[n]), size_(n) {}
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
};
2
3
4
5
6
7
8
9
10
11
12
汇编对比(GCC 13.2 -O2)——浅拷贝 vs 深拷贝:
; 浅拷贝(编译器生成——bug)
mov rax, [rsi] ; 复制 data_ 指针值(同一个地址)
mov [rdi], rax
mov rax, [rsi+8] ; 复制 size_
mov [rdi+8], rax
; → 两个 Buffer 的 data_ 指向同一地址
; 深拷贝(正确)
call operator new[] ; 新分配
mov rcx, [rsi+8] ; size_ 用作拷贝长度
call memcpy ; 拷贝内容
mov [rdi+8], rcx
; → 两个 Buffer 有独立的堆内存
2
3
4
5
6
7
8
9
10
11
12
13
# 3.2 拷贝构造与拷贝赋值的对称陷阱
拷贝构造和拷贝赋值不是同一件事——构造是「在空白对象上建立」,赋值是「在已有对象上替换」:
// 拷贝赋值的三个步骤
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this; // ① 自赋值保护
char* new_data = new char[other.size_]; // ② 先分配新资源
std::copy(other.data_, other.data_ + other.size_, new_data);
delete[] data_; // ③ 再释放旧资源
data_ = new_data;
size_ = other.size_;
return *this;
}
// 顺序很重要:先分配再释放——如果分配失败抛异常,旧数据还在(强异常安全)
2
3
4
5
6
7
8
9
10
11
12
13
三步骤的必要性:你先释放旧 data_ 再分配新的——如果分配失败(bad_alloc),对象已是半毁状态(data_ 悬空),不可恢复。先分配再释放——分配失败时旧数据完整。
# 3.3 copy-and-swap 统一两者
用 swap 统一拷贝赋值和移动赋值——代码量减半:
class Buffer {
char* data_ = nullptr;
size_t size_ = 0;
friend void swap(Buffer& a, Buffer& b) noexcept {
std::swap(a.data_, b.data_);
std::swap(a.size_, b.size_);
}
public:
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
// 拷贝赋值——一行
Buffer& operator=(const Buffer& other) {
Buffer tmp(other); // ① 拷贝构造(可能抛异常)
swap(tmp); // ② 交换——noexcept
return *this;
} // ③ tmp 析构——带着旧数据
// 移动赋值也用同一套 swap
Buffer& operator=(Buffer&& other) noexcept {
Buffer tmp(std::move(other)); // 或直接 swap(*this, other)
swap(tmp);
return *this;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 3.4 禁止拷贝的正确姿势
最佳实践——三种方式递减优先级:
// 方式 ①:= delete(C++11 最佳)
class NonCopyable {
public:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
// 方式 ②:私有声明 + 不实现(C++98 遗留)
class NonCopyable98 {
private:
NonCopyable98(const NonCopyable98&);
NonCopyable98& operator=(const NonCopyable98&);
};
// 方式 ③:成员用 unique_ptr 自然禁止拷贝(Rule of Zero)
class NonCopyableAuto {
std::unique_ptr<int> data_;
// 编译器自动 = delete 拷贝——因为 unique_ptr 不可拷贝 ✅
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 4. 移动控制的 noexcept 红利
# 4.1 移动构造的标准实现模板
移动构造的三行模板:
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)), // ① 转移所有权
size_(std::exchange(other.size_, 0)) // ② 清空原对象
{} // ③ 不需要 new / delete / copy
2
3
4
汇编(GCC 13.2 -O2):
; 移动构造——四条 mov 搞定
mov rax, [rsi] ; data_ = other.data_
mov [rdi], rax
mov rax, [rsi+8] ; size_ = other.size_
mov [rdi+8], rax
mov QWORD PTR [rsi], 0 ; other.data_ = nullptr
mov QWORD PTR [rsi+8], 0; other.size_ = 0
2
3
4
5
6
7
和拷贝构造的 operator new + memcpy 对比——零堆分配、零数据拷贝。
# 4.2 noexcept 对 STL 容器的意义
std::vector::push_back 扩容时的分支决策:
vector 扩容时新增元素:
├─ 移动构造是 noexcept?
│ ├─ 是 → 移动元素(快,O(1) 交换)
│ └─ 不是 → 拷贝元素(慢,O(N) 深拷贝)
│ 原因:移动可能抛异常 → 原数据已被移走 → 容器无法回滚到扩容前状态
└─ 最终:noexcept 移动 = 安全 + 快速
2
3
4
5
6
benchmark 证据(100 万个 OrderBuffer(4096) push_back 进 vector):
| 移动构造 | 扩容时间 | 每次 push_back 平均 | 原因 |
|---|---|---|---|
| 无 noexcept | 42 ms | 13.7 ns | 扩容走拷贝——深拷贝 4KB×N |
| 有 noexcept | 6 ms | 4.2 ns | 扩容走移动——交换两个指针 |
noexcept 带来 7× 加速。
# 4.3 移动后对象的状态约定
标准规定:移动后的对象处于「有效但未指定」(valid but unspecified)状态——可以安全析构、可以重新赋值,但不应再访问其原有值。
std::string s = "hello";
std::string s2 = std::move(s); // s 被移走
// ✅ 合法:重新赋值、析构
s = "world";
// s 析构——安全
// ⚠️ 危险(未定义):
// std::cout << s; // s 是什么?不知道——libstdc++ 可能是空串,MSVC 可能是 "hello"
2
3
4
5
6
7
8
9
唯一保证:移后对象调用无先决条件的成员是合法的——.empty()、.clear()、operator=。
# 4.4 移动赋值的自赋值检查
移动赋值需要自赋值保护:
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) { // ① 自赋值保护
delete[] data_; // ② 释放自己的资源
data_ = other.data_; // ③ 转移
size_ = other.size_;
other.data_ = nullptr; // ④ 清空彼
other.size_ = 0;
}
return *this;
}
2
3
4
5
6
7
8
9
10
如果不检查 this != &other 而先 delete[] data_——自己的 data_ 和 other.data_ 是同一个地址(自赋值时)——删完读 other.data_ 是悬空指针。
# 5. = default 与 = delete 精讲
# 5.1 两者的精确语义与区别
class Widget {
public:
Widget() = default; // 编译器生成——逐成员默认初始化
Widget(const Widget&) = default; // 编译器生成——逐成员拷贝
Widget(Widget&&) = default; // 编译器生成——逐成员移动
Widget& operator=(const Widget&) = delete; // 禁止拷贝赋值
Widget& operator=(Widget&&) = delete; // 禁止移动赋值
};
2
3
4
5
6
7
8
9
| 写法 | 含义 | 可以用在哪 |
|---|---|---|
= default | 请求编译器生成默认实现 | 仅特殊成员函数 |
= delete | 禁止调用这个函数 | 任何函数 |
| 什么都没写 | 编译器可能自动生成 | 特殊成员函数的复杂规则 |
# 5.2 = delete 可以删除任意函数
= delete 的强大之处——不限于特殊成员:
// ① 阻止隐式类型转换
void log(int level, const char* msg);
void log(int level, double) = delete; // 浮点数 → 编译错误
// ② 阻止特定模板实例化
template <typename T> void process(T val);
template <> void process<double>(double) = delete;
// ③ 阻止堆分配
class StackOnly {
void* operator new(size_t) = delete;
};
// ④ 阻止特定参数组合
void handle(int fd);
void handle(void*) = delete; // 不允许传裸指针
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 5.3 生成规则速查表
| 你声明了 | 默认构造 | 析构 | 拷贝构造 | 拷贝赋值 | 移动构造 | 移动赋值 |
|---|---|---|---|---|---|---|
| 无 | ✅ 生成 | ✅ 生成 | ✅ 生成 | ✅ 生成 | ✅ 生成 | ✅ 生成 |
| 析构 | ❌ 不生成 | — | ✅ 生成(弃用) | ✅ 生成(弃用) | ❌ | ❌ |
| 拷贝构造 | ❌ | ✅ 生成 | — | ✅ 生成(弃用) | ❌ | ❌ |
| 移动构造 | ❌ | ✅ 生成 | ❌ | ❌ | — | ❌ |
| 拷贝赋值 | ✅ 生成 | ✅ 生成 | ✅ 生成(弃用) | — | ❌ | ❌ |
核心记忆:声明任何一个特殊成员 → 建议显式声明全部五个(或 = default)。
# 5.4 继承链中的传染效应
基类的移动声明会影响派生类:
struct Base {
Base(Base&&) = default; // 移动构造可用
};
struct Derived : Base {
std::string name;
// ❌ 编译器不自动生成移动构造!
// → 因为 Base 声明了移动构造(C++20 之前规则)
// → Derived 没有声明 → 编译器退而生成拷贝构造
};
2
3
4
5
6
7
8
9
10
修复:
struct Derived : Base {
std::string name;
Derived(Derived&&) = default; // 显式 = default——不依赖编译器隐式生成
};
2
3
4
# 6. 拷贝省略与强制 RVO
# 6.1 编译器什么时候绕过拷贝
C++17 起,以下场景强制进行拷贝省略(即使拷贝/移动构造有副作用):
// 场景 1:用 prvalue 初始化同类型对象
Widget w = Widget(42); // 强制省略——Widget(42) 直接在 w 的内存上构造
// 场景 2:return 语句里的 prvalue
Widget create() {
return Widget(42); // 强制省略——构造在调用方的接收变量上
}
Widget w2 = create(); // 零拷贝
// 场景 3:传参——不强制省略,但编译器可能优化
void consume(Widget w); // 按值传参——不强制省略
consume(Widget(42)); // 可能优化、不保证
2
3
4
5
6
7
8
9
10
11
12
# 6.2 C++17 强制拷贝省略的严格条件
强制省略的两个条件必须同时满足([class.copy.elision]/1):
- 源对象是纯右值(prvalue)
- 目标对象和源对象的类型完全相同(忽略 cv 限定符)
Widget w1 = Widget(42); // ✅ 强制省略——prvalue → 同类型
Widget&& ref = Widget(42); // ❌ 不强制——ref 是引用,不是对象
Widget base = Derived(); // ❌ 不强制——Derived 切片到 Widget
// C++17 强制省略要求拷贝/移动构造必须是「可访问的」(不一定存在)
// ——即使构造函数是 private / =delete,只要在语义检查时可达即可
2
3
4
5
6
7
# 6.3 NRVO 的有条件省略
NRVO(Named Return Value Optimization)——带名字的局部变量返回,不强制:
Widget create_named() {
Widget w(42);
w.process();
return w; // NRVO:不强制,但 GCC/Clang/MSVC 都做
}
Widget create_two() {
Widget a(1);
Widget b(2);
return condition ? a : b; // NRVO 不生效——编译器不知道返回谁
}
2
3
4
5
6
7
8
9
10
11
# 6.4 汇编层的零拷贝证据
Widget create() { return Widget(42); }
Widget w = create();
2
GCC 13.2 -O2 汇编:
main:
sub rsp, 24
lea rdi, [rsp+8] ; ← w 的地址
mov esi, 42
call Widget::Widget(int) ; ← 直接在 w 的内存上构造!
mov eax, 0
add rsp, 24
ret
2
3
4
5
6
7
8
没有拷贝构造的 call、没有移动构造的 call——Widget(42) 直接修改了 w 的内存地址。
# 7. 三五法则的全场景推演
# 7.1 四种典型类的完整实现
| 类型 | 管理什么 | 拷贝策略 | 移动策略 | 示例 |
|---|---|---|---|---|
| 值语义类 | 独占堆资源 | 深拷贝 | 转移所有权 | std::string, Buffer |
| 唯一所有权类 | 独占资源 | 禁止拷贝 | 转移 | std::unique_ptr |
| 共享所有权类 | 引用计数资源 | 浅拷贝+增计数 | 转移+清空 | std::shared_ptr |
| 非资源管理类 | 无 | 编译器默认 | 编译器默认 | Point, Color |
# 7.2 只移动类、只拷贝类、不可移动类的选择
// 只移动类——如 unique_ptr
struct MoveOnly {
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
MoveOnly(MoveOnly&&) = default;
MoveOnly& operator=(MoveOnly&&) = default;
};
// 不可移动类——极少
struct Immovable {
Immovable(const Immovable&) = delete;
Immovable& operator=(const Immovable&) = delete;
Immovable(Immovable&&) = delete;
Immovable& operator=(Immovable&&) = delete;
};
// Rule of Zero——最优
struct Point {
double x = 0, y = 0; // 成员自身有正确的拷贝/移动
// 不声明任何特殊成员——编译器自动生成全正确 ✅
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 7.3 编译器隐式生成规则的全部组合表
声明了析构 → 移动操作不会自动生成
声明了移动 → 拷贝操作不会自动生成
声明了拷贝 → 移动操作不会自动生成
什么都没声明 → 全部自动生成(Rule of Zero)
规则内核:三组互相排斥——
析构+拷贝 一组(管理资源的传统模式)
移动 一组(管理所有权的现代模式)
Rule of Zero 一组(什么都不管——靠成员自己)
2
3
4
5
6
7
8
9
# 8. 函数传参与返回的拷贝控制
# 8.1 按值传参的两种策略
// 策略 A:拷贝左值 + 移动右值
void process(Widget w) { // 按值传参
// w 是本地副本——随便改
}
Widget a;
process(a); // a 被拷贝 → Widget 拷贝构造
process(std::move(a)); // a 被移动 → Widget 移动构造
// 策略 B:分开左/右引用
void process(const Widget& w); // 左值——不拷贝
void process(Widget&& w); // 右值——移动
// 代码量翻倍
2
3
4
5
6
7
8
9
10
11
12
建议:如果函数内部需要一份副本——用策略 A(按值传参)。如果只需要读——用 const T&。
# 8.2 返回值优化 vs std::move 陷阱
金科玉律:不要 std::move 返回值!
// ❌ 破坏 NRVO
Widget create_bad() {
Widget w(42);
return std::move(w); // 强制移动——阻止 NRVO!
}
// 汇编:call Widget(Widget&&) ← 多一次移动构造
// ✅ 依赖 NRVO
Widget create_good() {
Widget w(42);
return w; // 编译器直接在调用方的内存上构造
}
// 汇编:没有额外 call
2
3
4
5
6
7
8
9
10
11
12
13
什么时候用 std::move 在 return: 当返回的是函数参数(不是局部变量)时:
Widget wrap(Widget w) {
return std::move(w); // ✅ 正确——w 是参数,NRVO 不适用
}
2
3
# 8.3 隐式转换中的拷贝控制
void consume(std::string s);
consume("hello"); // const char* → std::string 隐式转换
// 等价于:
// auto tmp = std::string("hello"); // 构造一次
// consume(std::move(tmp)); // 移动到参数 s
// // C++17 强制省略——tmp 直接在 s 的内存上构造!
2
3
4
5
6
7
# 9. 拷贝控制的性能证据
# 9.1 移动 vs 拷贝的大对象对比
100 万个 Buffer(4096) 分别拷贝和移动(GCC 13.2 -O2):
| 操作 | 时间 | 内存操作次数 | 说明 |
|---|---|---|---|
| 拷贝构造 | 7.2 ms | 1×new + 1×memcpy(4096) | O(N) 数据拷贝 |
| 移动构造 | 0.8 ms | 0×new + 0×memcpy | 仅指针交换(O(1)) |
| 加速比 | 9× | — | — |
# 9.2 noexcept 移动对 vector push_back 的影响
同一个 100 万次 push_back 的实验:
| 移动构造 | 扩容次数 | 每次扩容拷贝/移动元素数 | 总时间 |
|---|---|---|---|
| 无 noexcept | 22 次 | 每次拷贝全部已有元素 | 42 ms |
| 有 noexcept | 22 次 | 每次移动全部已有元素 | 6 ms |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | 三五法则是什么? | 第 2/7 章:管理资源的类应显式声明全部五个特殊成员函数或 =default |
| ② | 拷贝构造 vs 拷贝赋值? | 第 3 章:构造是空白上建立、赋值是已有上替换;copy-and-swap 统一 |
| ③ | noexcept 为什么重要? | 第 4/9 章:vector 扩容检查 noexcept——非 noexcept 退化为拷贝,慢 7× |
| ④ | =default/=delete? | 第 5 章:=default 请求编译器生成、=delete 禁止调用(可删任意函数) |
| ⑤ | 拷贝省略/RVO? | 第 6 章:C++17 强制省略 prvalue 初始化同类型对象;汇编零拷贝证据 |
| ⑥ | return std::move? | 第 8.2:不要 move 局部变量返回值——破坏 NRVO |
| ⑦ | 拷贝/移动策略选择? | 第 7 章:Rule of Zero 最优;独占资源禁止拷贝;共享资源引用计数 |
案例①修复:加三五法则完整实现:
class ConnectionPool {
std::vector<std::unique_ptr<Connection>> pool_;
public:
explicit ConnectionPool(size_t n) {
pool_.reserve(n);
for (size_t i = 0; i < n; ++i)
pool_.push_back(std::make_unique<Connection>());
}
// Rule of Zero:unique_ptr 自动禁止拷贝、允许移动 ✅
};
2
3
4
5
6
7
8
9
10
案例②修复:移动构造加 noexcept:
OrderBuffer(OrderBuffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
2
3
# 10.2 一次拷贝省略的完整生涯
Widget w = create(); // create() 返回 Widget(42)
│
├─ 编译期:语义分析
│ └─ 检测到 prvalue Widget(42) 赋给同类型 Widget w
│ → 触发 C++17 强制拷贝省略规则
│
├─ 编译期:代码生成重写
│ ├─ w 的地址直接作为 create() 的「返回地址」
│ ├─ create 内部的 Widget(42) 直接在 w 的内存上构造
│ └─ 省略了:临时对象 → w 的拷贝/移动构造调用
│
└─ 运行期:
└─ w 直接诞生——零拷贝、零移动、零临时对象
2
3
4
5
6
7
8
9
10
11
12
13
# 10.3 设计哲学回扣
哲学 1:零法则优于五法则——把复杂度交给标准库
如果你的类的每个成员都正确管理自己的拷贝/移动(std::string、std::vector、std::unique_ptr),你不需要写任何特殊成员函数。编译器自动生成的就是正确的。 只有当你直接管理裸资源(new/delete、文件描述符、锁)时,才需要五法则——而现代 C++ 的答案是:不要直接管理裸资源——用标准库类型替代。
哲学 2:不可拷贝是对独占所有权最诚实的表达
unique_ptr 禁止拷贝——这不是缺陷,是设计。拷贝本应意味着「创造独立的等价体」,而独占资源不满足这条语义。禁止拷贝比偷偷浅拷贝(double-free)或偷偷深拷贝(隐含开销)都更诚实。
哲学 3:noexcept 不只是性能——是安全承诺
移动构造的 noexcept 让 std::vector 扩容时放心地移动元素——因为它承诺「不会抛异常、原数据不会被半毁」。没有 noexcept 的移动,vector 宁可慢 7 倍拷贝——因为拷贝的安全边际更高。异常安全和性能在 noexcept 这里达成了统一。
哲学 4:强制省略——编译器比程序员更懂何时不需要拷贝
C++17 强制拷贝省略的原理是:如果可以证明临时对象和目标对象是同一个语义实体,就让它们成为同一个物理实体。 这不需要程序员干预——编译器在 AST 层就能决策。所以「不要 std::move 返回值」——把决策权还给编译器。
# 10.4 速查表合集
五法则 vs 零法则:
| 法则 | 何时用 | 代码量 |
|---|---|---|
| 五法则 | 直接管理裸资源(new/fd/锁) | 五个特殊成员函数 |
| 零法则 | 用 std::vector/std::unique_ptr 等代替裸资源 | 零行 |
拷贝/移动策略速查:
| 资源模式 | 拷贝 | 移动 | 示例 |
|---|---|---|---|
| 值语义(独占堆) | 深拷贝 | 转移 | std::string |
| 独占所有权 | 禁止(=delete) | 转移 | unique_ptr |
| 共享所有权 | 浅拷贝+计数 | 转移+清空 | shared_ptr |
| 无资源管理 | 编译器默认 | 编译器默认 | Point |
特殊成员函数生成速查:
| 你声明了 | 拷贝生成? | 移动生成? |
|---|---|---|
| 无 | ✅ | ✅ |
| 析构 | ✅ | ❌ |
| 拷贝操作 | — | ❌ |
| 移动操作 | ❌ | — |
return move 规则:
// ❌ 局部变量返回——不要 move
Widget f() { Widget w; return std::move(w); }
// ✅ 参数返回——可以 move
Widget g(Widget w) { return std::move(w); }
2
3
4
5
下一篇:拷贝控制的理论说清了。下一篇进入 28.unique_ptr 原理剖析——独占语义的零成本实现:deleter 的类型擦除如何做到零 sizeof 开销、
make_unique为什么比new更安全、数组版本unique_ptr<T[]>与shared_ptr<T[]>的差异。