C++ / 2026-08-01
CS106L 第 14 讲:移动语义
理解左值、右值、移动构造与 std::move,避免无意义的大对象复制。
本课程根据 CS106L Spring 2026 Lecture 14: Move Semantics 重构,并对课件中的课堂化简表达进行了必要修正。
本节课要解决的核心问题
上一节课学习了特殊成员函数(special member function)。当一个类自己管理动态内存时,我们可以通过复制构造函数、复制赋值运算符和析构函数,让对象得到正确的深复制行为。
这解决了“复制后两个对象错误地共享同一块内存”的问题,却留下了一个新的问题:
有些对象马上就要被销毁,我们真的还需要把它拥有的全部数据复制一遍吗?
假设一张照片包含 3840 × 2160 个像素。如果每个像素用一个 int 保存,那么一次深复制需要处理超过八百万个整数。
有时我们会遇到这样的过程:
1. 函数创建一张大照片
2. 为接收返回值的变量重新申请同样大的内存
3. 把全部像素复制过去
4. 立即销毁函数返回的临时照片
第二步和第三步花了大量时间,但第四步告诉我们:原对象马上就没用了。
更合理的做法是:
1. 让新对象直接接管原对象的像素内存
2. 把原对象改为空状态
3. 原对象销毁时不再释放那块内存
这就是本节课的主角:
移动语义(move semantics)
学完本节课后,应当能够回答这些问题:
- 复制构造与移动构造有什么区别?
- 为什么函数返回值适合被移动?
- 什么是左值和右值?
T&与T&&分别能绑定什么表达式?std::move到底做了什么?- 移动后的对象还能不能使用?
- 怎样正确编写移动构造函数和移动赋值运算符?
- 什么是零法则、三法则和五法则?
- 实际项目中是否应该经常手写这些函数?
学习本节课所需的前置知识
本节课会用到上一节课中的以下内容:
- 类与对象;
- 构造函数;
- 成员初始化列表;
- 复制构造函数;
- 复制赋值运算符;
- 析构函数;
- 指针;
new[]与delete[];- 深复制与浅复制;
- 引用;
- 函数重载。
如果其中某些内容还不熟悉,不必暂停学习。下面会使用同一个 Photo 类重新建立必要的上下文。
一、为什么复制数据值得专门优化
我们平时说“复制一个变量”,很容易想到这样的代码:
int a = 10;
int b = a;
这里只复制了一个整数,成本几乎可以忽略。
但对于一个大型对象,复制可能意味着:
- 重新申请一块很大的内存;
- 逐个复制几百万个元素;
- 增加内存访问;
- 增加缓存压力;
- 增加设备能耗;
- 复制完成后还要释放旧内存。
课件用两项研究作为开场动机:
- 一项手机工作负载研究认为,数据移动可能占据相当一部分设备能耗,截图中给出的平均值为 35%;
- 另一项科学计算研究指出,从内存移动数据的成本可能比一次寄存器浮点运算高两个数量级。
这里不需要记住具体数字。真正重要的是:
对大型资源而言,“搬运数据”本身可能比我们想象的昂贵。
这不仅影响手机电量,也影响:
- 游戏中的大型纹理;
- 图像和视频处理;
- 神经网络张量;
- 数据库结果集;
- 网络数据包;
- 大型字符串;
std::vector;- 文件缓冲区;
- 竞赛程序中的大数组和容器。
因此,我们接下来要研究的不是“怎样让所有复制都消失”,而是:
怎样识别那些原对象马上就没用的情况,并把复制替换成资源转移?
二、回顾:Photo 类为什么必须深复制
先构造一个用于教学的照片类。每个像素暂时用一个 int 表示。
class Photo {
public:
Photo(int width, int height);
Photo(const Photo& other);
Photo& operator=(const Photo& other);
~Photo();
private:
int width_;
int height_;
int* data_;
};
对象内部的关系可以画成:
photo
┌────────────────────────┐
│ width_ = 500 │
│ height_ = 500 │
│ data_ ──────────────────────→ 动态数组
└────────────────────────┘ [像素][像素][像素]...
Photo 对象本身保存三个成员:
width_:宽度;height_:高度;data_:指向动态像素数组的指针。
真正庞大的像素数据不直接存放在 Photo 对象内部,而是由 data_ 指向。
2.1 普通构造函数
Photo::Photo(int width, int height)
: width_(width),
height_(height),
data_(new int[width * height]{}) {
}
执行:
Photo photo(500, 500);
大致发生以下过程:
1. 创建 photo 对象
2. width_ 初始化为 500
3. height_ 初始化为 500
4. 在动态内存中申请 500 × 500 个 int
5. data_ 保存这块内存的地址
对象状态:
photo
┌────────────────────────┐
│ width_ = 500 │
│ height_ = 500 │
│ data_ = 0x1000 ───────────────→ [250000 个像素]
└────────────────────────┘
成员真正的初始化顺序由它们在类中的声明顺序决定,而不是由初始化列表的书写顺序决定。
这里的声明顺序是:
int width_;
int height_;
int* data_;
所以它们也会按照这个顺序初始化。
2.2 为什么编译器生成的复制构造函数不够用
假设编译器只是逐个复制成员:
width_ 复制
height_ 复制
data_ 指针地址复制
那么:
Photo a(500, 500);
Photo b = a;
可能变成:
a.data_ ─────┐
├────→ 同一块像素内存
b.data_ ─────┘
这叫浅复制(shallow copy)。
问题包括:
- 修改
b的像素也会影响a; a和b都认为自己拥有这块内存;- 两个对象析构时会对同一地址执行两次
delete[]; - 程序产生未定义行为。
因此,我们需要复制构造函数执行深复制(deep copy)。
2.3 复制构造函数
Photo::Photo(const Photo& other)
: width_(other.width_),
height_(other.height_),
data_(new int[width_ * height_]) {
std::copy(
other.data_,
other.data_ + width_ * height_,
data_
);
}
调用:
Photo b = a;
状态由:
a
┌──────────────────────┐
│ data_ ───────────────────→ [原像素数组]
└──────────────────────┘
变为:
a
┌──────────────────────┐
│ data_ ───────────────────→ [原像素数组]
└──────────────────────┘
b
┌──────────────────────┐
│ data_ ───────────────────→ [新像素数组]
└──────────────────────┘
两个数组的内容相同,但内存地址不同。
复制构造函数的工作包括:
- 创建一个新对象;
- 复制宽度和高度;
- 申请新内存;
- 复制全部像素。
这保证了正确性,但成本可能很高。
2.4 复制赋值运算符
初始化与赋值不是同一个过程。
Photo a(500, 500);
Photo b = a; // 初始化:复制构造函数
而:
Photo a(500, 500);
Photo b(100, 100);
b = a; // 赋值:复制赋值运算符
第二段代码中,b 已经存在,并且已经拥有自己的像素内存。
因此,复制赋值必须:
- 处理自赋值;
- 准备新数据;
- 清理
b原来的数据; - 复制
a的数据; - 返回当前对象。
课件中的基础版本先删除旧数据,再申请新数据:
Photo& Photo::operator=(const Photo& other) {
if (this == &other) {
return *this;
}
delete[] data_;
width_ = other.width_;
height_ = other.height_;
data_ = new int[width_ * height_];
std::copy(
other.data_,
other.data_ + width_ * height_,
data_
);
return *this;
}
这个版本能够表达本节课的核心思想,但有一个健壮性问题:
如果删除旧数据后,
new申请新内存失败并抛出异常,对象已经丢失了原来的数据。
更稳妥的顺序是先准备新内存,成功后再删除旧内存:
Photo& Photo::operator=(const Photo& other) {
if (this == &other) {
return *this;
}
const int count = other.width_ * other.height_;
int* new_data = count == 0 ? nullptr : new int[count];
if (count != 0) {
std::copy(
other.data_,
other.data_ + count,
new_data
);
}
delete[] data_;
width_ = other.width_;
height_ = other.height_;
data_ = new_data;
return *this;
}
原课件想表达的是“先清理自己的旧资源,再取得另一对象的副本”。改进后的版本没有改变这个教学重点,只是让失败情况下的对象状态更加安全。
2.5 析构函数
Photo::~Photo() {
delete[] data_;
}
对象离开作用域时,析构函数释放像素数组。
需要记住:
delete[] nullptr;
是安全的,什么也不会发生。
这个规则会在实现移动语义时发挥重要作用。
三、问题出现了:临时照片为什么还要完整复制
假设有一个拍照函数:
Photo takePhoto();
使用它:
int main() {
Photo selfie = takePhoto(); // A
Photo retake(0, 0);
retake = takePhoto(); // B
}
先暂时忽略编译器优化,按照上一节课的复制模型思考。
3.1 位置 A:用返回值初始化新对象
Photo selfie = takePhoto();
可以想象为:
1. takePhoto() 创建临时 Photo
2. 临时 Photo 拥有一大块像素数据
3. 使用复制构造函数创建 selfie
4. 为 selfie 重新申请同样大的内存
5. 复制全部像素
6. 临时 Photo 被析构
7. 临时 Photo 的原像素内存被释放
内存状态如下。
复制前:
takePhoto() 的临时对象
┌────────────────────────┐
│ width_ = 3840 │
│ height_ = 2160 │
│ data_ = 0x1000 ───────────────→ [大量像素]
└────────────────────────┘
复制后:
临时对象
┌────────────────────────┐
│ data_ = 0x1000 ───────────────→ [大量像素 A]
└────────────────────────┘
selfie
┌────────────────────────┐
│ data_ = 0x9000 ───────────────→ [大量像素 B]
└────────────────────────┘
像素 A 和像素 B 内容相同。
然后临时对象析构:
临时对象销毁
像素 A 被释放
selfie
┌────────────────────────┐
│ data_ = 0x9000 ───────────────→ [像素 B]
└────────────────────────┘
我们刚刚把全部像素从 A 复制到 B,紧接着就删除了 A。
这正是无意义复制的典型场景。
3.2 位置 B:把返回值赋给已经存在的对象
retake = takePhoto();
如果只有复制赋值运算符,则过程是:
1. takePhoto() 创建临时对象
2. retake 删除自己原来的像素
3. retake 重新申请内存
4. 临时对象的全部像素复制到 retake
5. 临时对象析构
6. 临时对象的像素内存被释放
同样存在“复制完马上删除原数据”的浪费。
练习:A 和 B 会调用什么特殊成员函数
Photo takePhoto();
int main() {
Photo selfie = takePhoto(); // A
Photo retake(0, 0);
retake = takePhoto(); // B
}
在暂时不考虑复制消除,并且类还没有移动操作的情况下:
- A 会调用什么?
- B 会调用什么?
- 临时对象什么时候析构?
答案与解释
A 使用一个对象创建新对象,因此调用复制构造函数:
Photo::Photo(const Photo& other);
B 中的 retake 已经存在,因此调用复制赋值运算符:
Photo& Photo::operator=(const Photo& other);
两个 takePhoto() 产生的临时对象都会在所属完整表达式(full-expression)结束时析构。
对这段代码而言,可以暂时理解为在分号处:
Photo selfie = takePhoto();
// ↑ 这个完整表达式结束
retake = takePhoto();
// ↑ 这个完整表达式结束
课件把它简化成“下一行开始之前销毁”。对于这些普通语句,这个直觉通常够用;更准确的语言规则使用的是“完整表达式结束”,而不是真正按照源代码的物理行数判断。
四、先别急着数复制:返回值优化会改变实际调用次数
现代 C++ 编译器会进行返回值优化(Return Value Optimization,RVO)。
例如:
Photo takePhoto() {
return Photo(3840, 2160);
}
int main() {
Photo selfie = takePhoto();
}
在 C++17 及之后的一些场景中,返回对象可以直接在 selfie 的存储位置构造。
概念上不再是:
构造临时对象
↓
复制或移动到 selfie
↓
销毁临时对象
而是:
直接在 selfie 的位置构造返回结果
因此,运行程序时可能看不到任何复制构造或移动构造调用。
如果返回的是具名局部变量:
Photo takePhoto() {
Photo result(3840, 2160);
return result;
}
编译器通常会进行具名返回值优化(Named Return Value Optimization,NRVO),但这类优化不一定在所有情形下强制发生。
如果没有执行复制消除:
- 类有移动构造函数时,通常优先移动;
- 类没有移动构造函数时,才退回复制。
RVO 并没有让移动语义失去意义,因为移动仍然大量出现在:
- 对已经存在的对象赋值;
- 容器扩容;
- 容器内部重新排列元素;
- 显式转移资源;
- 无法进行复制消除的函数调用;
- 返回优化未发生的情况。
本节课接下来仍然会把“临时对象被转移”画成一次移动,以便观察移动语义本身。实际运行时,编译器可能进一步把这次移动也消除。
五、解决思路:不要复制像素,只转移所有权
我们希望把下面的深复制:
临时对象.data_ ───→ 像素数组 A
selfie.data_ ───→ 像素数组 B
把 A 的所有像素复制到 B
替换为:
selfie.data_ 直接取得 A 的地址
初始状态:
临时对象
┌────────────────────────┐
│ data_ = 0x1000 ───────────────→ [像素数组]
└────────────────────────┘
第一步,让 selfie 取得相同的指针:
临时对象.data_ = 0x1000 ───┐
├──→ [像素数组]
selfie.data_ = 0x1000 ────┘
但现在不能直接结束。
如果临时对象接下来执行:
delete[] data_;
它就会释放 selfie 正在使用的数据。
所以还需要第二步:
临时对象.data_ = nullptr
selfie.data_ = 0x1000
最终状态:
临时对象
┌────────────────────────┐
│ width_ = 0 │
│ height_ = 0 │
│ data_ = nullptr │
└────────────────────────┘
selfie
┌────────────────────────┐
│ width_ = 3840 │
│ height_ = 2160 │
│ data_ = 0x1000 ───────────────→ [像素数组]
└────────────────────────┘
临时对象析构时:
delete[] nullptr;
什么也不会发生。
像素数组最终只由 selfie 管理。
整个过程没有:
- 重新申请一份同样大的数组;
- 逐个复制像素;
- 立即删除刚刚被复制的原数组。
这个过程就是“移动”。
移动不是把对象的每个字节搬到另一个地址
“移动”这个名字可能让人误以为:
把对象本身从内存地址 A 搬到内存地址 B
但对于 Photo,真正发生的是:
复制几个很小的管理信息:
- width_
- height_
- data_ 指针
然后修改原对象:
- width_ = 0
- height_ = 0
- data_ = nullptr
大型像素数组从始至终都留在原来的内存地址。
发生变化的是“谁拥有它”。
因此,移动语义更接近:
转移资源所有权,而不是复制资源内容。
六、复制与移动的根本区别:原对象之后还需不需要使用
考虑一个已经存在的对象:
Photo pic(3840, 2160);
Photo selfie = pic;
pic 是一个有名字、可能在后续继续使用的对象。
Photo selfie = pic;
pic.get_pixel(10, 20);
如果 selfie 直接偷走了 pic 的数据,那么第二行就无法正常读取像素。
所以这里必须复制:
pic 拥有自己的像素
selfie 拥有另一份像素
再看:
Photo selfie = takePhoto();
takePhoto() 的返回结果只为了初始化 selfie。在这个表达式结束后,这个临时结果不再需要继续保存原内容。
因此可以移动:
takePhoto() 的资源 → 转移给 selfie
可以建立这样的初步直觉:
| 情况 | 通常采用的语义 | 原对象之后 |
|---|---|---|
| 从具名变量创建对象 | 复制 | 仍保留原内容 |
| 从函数返回的临时结果创建对象 | 移动 | 即将销毁 |
| 从字面量或临时容器创建对象 | 移动 | 即将销毁 |
| 明确表示不再使用某个变量 | 可以显式移动 | 进入已移动状态 |
课件使用“组装电脑”的类比:
- 复制语义:我还要继续使用旧电脑,所以新电脑需要另一套零件;
- 移动语义:旧电脑以后不再使用,可以把它的零件拆给新电脑。
这个类比能够帮助理解所有权转移,但要注意:
- C++ 移动后的对象依然存在;
- 它仍然会执行析构函数;
- 它必须保持可以安全析构的状态;
- 它并不是从内存中立即消失。
七、为什么不能自动移动所有对象
考虑:
void foo(Photo whoAmI) {
Photo selfie = whoAmI;
whoAmI.get_pixel(21, 24);
}
如果第一行复制:
whoAmI
┌──────────────────────┐
│ data_ ───────────────────→ [像素 A]
└──────────────────────┘
selfie
┌──────────────────────┐
│ data_ ───────────────────→ [像素 B]
└──────────────────────┘
之后 whoAmI.get_pixel(...) 可以正常使用像素 A。
如果第一行擅自移动:
whoAmI
┌──────────────────────┐
│ data_ = nullptr │
└──────────────────────┘
selfie
┌──────────────────────┐
│ data_ ───────────────────→ [原来的像素 A]
└──────────────────────┘
接下来:
whoAmI.get_pixel(21, 24);
如果 get_pixel 没有检查空状态,可能解引用空指针,产生未定义行为。
所以编译器不能只看“移动更快”,还需要判断:
当前表达式是否表示一个可以被消耗的对象?
为此,C++ 使用表达式的值类别(value category)。
在本节课的初学阶段,我们重点讨论:
- 左值(lvalue);
- 右值(rvalue)。
八、左值与右值:讨论的是表达式,不是变量类型
首先建立最重要的认识:
左值和右值是表达式的性质。
不是简单地说“某个变量是左值类型”,而是说:
pic
这个表达式是左值表达式。
例如:
Photo pic(100, 100);
Photo a = pic;
Photo b = takePhoto();
右侧的两个表达式不同:
pic // 左值表达式
takePhoto() // 右值表达式
初学阶段可以使用这样的直觉:
左值通常表示一个有身份、能够在之后继续找到的对象。
右值通常表示一个临时结果,或者一个被允许转移资源的对象。
8.1 左值通常有稳定身份
Photo pic(100, 100);
表达式:
pic
指向一个具名对象。
在作用域内,我们可以反复使用它:
pic.get_pixel(0, 0);
pic.get_pixel(1, 1);
pic.get_pixel(2, 2);
也可以取得它的地址:
Photo* pointer = &pic;
因此,pic 是左值表达式。
8.2 函数返回的临时结果通常是右值
takePhoto()
它产生一个临时结果,没有供后续语句反复使用的名字。
下面的代码通常不合法:
Photo* pointer = &takePhoto();
不过,课件中“左值有地址,右值没有地址”的说法只是帮助入门的近似直觉。
更准确地说:
- 某些右值对象在物理上当然也占有存储空间;
- C++ 的右值内部还包含纯右值和将亡值等分类;
- 内建取地址运算符要求操作数满足相应的值类别规则;
- 不能写
&takePhoto(),不等于临时对象在机器上从未占据内存。
本节课不需要展开完整的值类别树,只需掌握:
普通具名对象表达式 → 通常按左值处理
临时结果 → 通常按右值处理
8.3 “在等号左边还是右边”不是正式定义
课件提供了一个传统记忆方式:
x = y;
y = 5;
y 可以出现在赋值号两侧,所以它是左值。
x = 5;
字面量 5 只能出现在右侧,不能写:
5 = y;
这个记忆方式对基础内建类型经常有效,但不能作为正式定义。
原因包括:
左值也经常出现在赋值号右侧:
int b = a;有些左值不能被修改:
const int x = 10;x是左值,但不能写:x = 20;运算符可以重载,语法位置并不能完整决定值类别。
因此,更可靠的初学直觉是:
左值:表达式指向一个有持续身份的对象
右值:表达式表示临时结果或允许被消耗的结果
8.4 生命周期也不能简单等同于“作用域”和“一行”
课件把它简化为:
左值的生命周期持续到作用域结束
右值的生命周期持续到这一行结束
对于普通局部变量和普通临时对象,这个直觉经常成立,但严格规则更复杂。
例如:
- 动态分配对象的生命周期不由当前局部作用域直接决定;
- 临时对象一般持续到完整表达式结束;
- 临时对象绑定到某些引用时,生命周期可能被延长;
- 返回值优化可能让临时对象根本不独立存在。
现阶段可以使用:
具名局部变量通常更持久
普通临时结果通常很短暂
但不要把它当作覆盖所有 C++ 场景的语言定义。
练习:判断右侧表达式的值类别
观察每条语句等号右侧的部分:
int a = 4;
int& b = a;
std::vector<int> c = {1, 2, 3};
int d = c[1];
int* e = &c[2];
std::size_t f = c.size();
判断:
4a{1, 2, 3}c[1]&c[2]c.size()
哪些是左值,哪些是右值?
答案与解释
1. 4
int a = 4;
4 是一个整数纯右值。
它只表示计算结果 4,不表示一个具名、可持续识别的整数对象。
2. a
int& b = a;
a 是左值表达式。
它表示已经存在的具名变量 a。b 绑定到这个对象,而不是创建一个新的整数副本。
关系是:
a ──┐
├──→ 同一个 int 对象
b ──┘
3. {1, 2, 3}
std::vector<int> c = {1, 2, 3};
课件为了入门把它标记成右值,但严格来说,花括号初始化列表本身不是普通表达式,因此不具有通常意义上的左值或右值类别。
更适合初学阶段的理解是:
它提供了一组临时初始化数据,用于构造
c。
4. c[1]
int d = c[1];
std::vector<int>::operator[] 返回 int&,所以 c[1] 是左值表达式。
它表示容器中真实存在的一个元素。
可以写:
c[1] = 100;
也可以取得地址:
int* p = &c[1];
不过在初始化 d 时,c[1] 的整数值被复制到 d。
5. &c[2]
int* e = &c[2];
c[2] 是左值。
对它取地址后,表达式:
&c[2]
产生一个指针值。这个指针结果本身是右值。
注意区分:
c[2] → 左值,表示容器元素
&c[2] → 右值,计算出一个地址值
6. c.size()
std::size_t f = c.size();
c.size() 按值返回一个大小结果,因此它是右值。
最终分类:
| 表达式 | 分类 |
|---|---|
4 |
右值 |
a |
左值 |
{1, 2, 3} |
严格说不是普通表达式;可暂时理解为临时初始化数据 |
c[1] |
左值 |
&c[2] |
右值 |
c.size() |
右值 |
九、引用如何区分左值与右值
之前学过普通引用:
Photo& reference = pic;
这种引用现在有了更完整的名称:
左值引用(lvalue reference)
语法是:
Type&
C++11 又引入了另一种引用:
右值引用(rvalue reference)
语法是:
Type&&
例如:
Photo&& reference = takePhoto();
两个 & 不是“引用的引用”,而是右值引用的专门语法。
9.1 左值引用
void upload(Photo& pic);
可以传入左值:
Photo selfie = takePhoto();
upload(selfie);
这里的参数 pic 引用 selfie,不会复制照片。
selfie ──┐
├──→ 同一个 Photo 对象
pic ─────┘
但普通的非 const 左值引用不能绑定到临时右值:
upload(takePhoto());
如果只有:
void upload(Photo& pic);
编译器会报错,因为 takePhoto() 是右值,而 Photo& 要求一个非 const 左值。
9.2 右值引用
定义另一个重载:
void upload(Photo&& pic);
现在可以调用:
upload(takePhoto());
takePhoto() 是右值,因此匹配:
void upload(Photo&& pic);
如果同时存在:
void upload(Photo& pic);
void upload(Photo&& pic);
那么:
Photo selfie = takePhoto();
upload(selfie); // 调用 Photo&
upload(takePhoto()); // 调用 Photo&&
编译器根据实参表达式是左值还是右值选择重载。
这正是移动构造函数能够与复制构造函数共存的基础。
9.3 const Photo& 又是什么
如果函数只需要读取照片,而不需要复制、修改或取得所有权,更常见的接口是:
void display(const Photo& pic);
const Photo& 可以绑定:
- 普通左值;
const左值;- 右值临时对象。
例如:
Photo selfie = takePhoto();
display(selfie);
display(takePhoto());
两次调用都不会复制照片。
但函数不能通过 pic 修改对象:
void display(const Photo& pic) {
// pic.set_pixel(...); // 如果 set_pixel 不是 const 成员函数,则不能调用
}
三种常见引用可以这样比较:
| 参数类型 | 可接收普通左值 | 可接收右值 | 可通过参数修改对象 | 常见用途 |
|---|---|---|---|---|
const T& |
是 | 是 | 否 | 只读观察 |
T& |
是 | 否 | 是 | 修改已有对象 |
T&& |
否 | 是 | 是 | 消耗临时对象、转移资源 |
9.4 对课件中“可以对 T&& 做任何事”的修正
课件用比较夸张的语言描述右值引用:
它是临时对象,所以我们可以随便处理它的资源。
这个说法表达了“右值参数允许被消耗”的核心直觉,但不能理解成可以破坏语言规则。
移动操作之后,原对象仍然必须:
- 能够安全析构;
- 能够被重新赋值;
- 不发生重复释放;
- 不持有悬空所有权;
- 满足类自己承诺的基本不变量。
标准库对象移动后通常处于:
有效但状态未指定(valid but unspecified state)
“有效”表示可以安全销毁,也可以执行标准明确允许的操作。
“未指定”表示不要依赖它还保存原来的内容。
练习:会选择哪个重载
void inspect(const Photo& pic);
void inspect(Photo&& pic);
Photo a(100, 100);
const Photo b(100, 100);
判断:
inspect(a);
inspect(b);
inspect(takePhoto());
inspect(std::move(a));
分别选择哪个重载?
答案与解释
inspect(a);
a 是非 const 左值。
Photo&& 不能绑定普通左值,因此选择:
void inspect(const Photo& pic);
虽然也可以专门增加:
void inspect(Photo& pic);
但当前只有 const Photo& 和 Photo&&。
inspect(b);
b 是 const 左值,只能选择:
void inspect(const Photo& pic);
inspect(takePhoto());
takePhoto() 是右值。两个重载理论上都能接收,但 Photo&& 是对右值更直接的匹配,因此选择:
void inspect(Photo&& pic);
inspect(std::move(a));
std::move(a) 把表达式转换为可以匹配右值引用的形式,因此选择:
void inspect(Photo&& pic);
但这不代表 inspect 一定真的移动了 a。是否发生资源转移,要看 inspect 的函数体做了什么。
十、为特殊成员函数增加右值重载
复制构造函数是:
Photo::Photo(const Photo& other);
它接收左值,也可以接收右值,但只能把 other 当作不可修改对象读取,因此必须复制资源。
我们增加一个接收右值引用的构造函数:
Photo::Photo(Photo&& other);
这就是:
移动构造函数(move constructor)
复制赋值运算符是:
Photo& Photo::operator=(const Photo& other);
增加:
Photo& Photo::operator=(Photo&& other);
这就是:
移动赋值运算符(move assignment operator)
现在类拥有四种复制或移动操作:
Photo(const Photo& other); // 复制构造
Photo& operator=(const Photo& other); // 复制赋值
Photo(Photo&& other); // 移动构造
Photo& operator=(Photo&& other); // 移动赋值
编译器根据右侧表达式的值类别选择:
Photo a = existing; // existing 是左值 → 复制构造
Photo b = takePhoto(); // 临时结果是右值 → 移动构造或直接消除
a = existing; // 左值 → 复制赋值
a = takePhoto(); // 右值 → 移动赋值
十一、实现移动构造函数
移动构造函数要创建一个新对象,并接管 other 的资源。
Photo::Photo(Photo&& other) noexcept
: width_(other.width_),
height_(other.height_),
data_(other.data_) {
other.width_ = 0;
other.height_ = 0;
other.data_ = nullptr;
}
逐步分析。
调用前:
other
┌────────────────────────┐
│ width_ = 3840 │
│ height_ = 2160 │
│ data_ = 0x1000 ───────────────→ [像素]
└────────────────────────┘
初始化新对象:
width_(other.width_)
height_(other.height_)
data_(other.data_)
中间状态:
other.data_ = 0x1000 ───┐
├──→ [像素]
this->data_ = 0x1000 ───┘
此时两个指针暂时相同,所以必须立刻撤销 other 的所有权:
other.width_ = 0;
other.height_ = 0;
other.data_ = nullptr;
最终:
other
┌────────────────────────┐
│ width_ = 0 │
│ height_ = 0 │
│ data_ = nullptr │
└────────────────────────┘
新对象
┌────────────────────────┐
│ width_ = 3840 │
│ height_ = 2160 │
│ data_ = 0x1000 ───────────────→ [像素]
└────────────────────────┘
以后 other 析构:
delete[] nullptr;
不会影响新对象。
11.1 使用 std::exchange 写得更紧凑
C++ 提供了 std::exchange:
old_value = std::exchange(variable, new_value);
它会:
- 保存
variable原来的值; - 把
variable改成new_value; - 返回原来的值。
因此移动构造可以写成:
Photo::Photo(Photo&& other) noexcept
: width_(std::exchange(other.width_, 0)),
height_(std::exchange(other.height_, 0)),
data_(std::exchange(other.data_, nullptr)) {
}
例如:
data_(std::exchange(other.data_, nullptr))
等价于:
data_ = other.data_;
other.data_ = nullptr;
它把“取得资源”和“清空来源”放在同一个表达式中,能够降低忘记清空来源的风险。
需要头文件:
#include <utility>
11.2 为什么移动构造函数常写 noexcept
Photo(Photo&& other) noexcept;
noexcept 表示这个函数承诺不会抛出异常。
移动 Photo 只是在转移:
- 两个整数;
- 一个指针。
它不需要申请新内存,所以可以合理地声明为 noexcept。
这对标准容器也很重要。例如 std::vector 扩容时,需要把旧元素转移到新内存。如果某个类型的移动构造可能抛异常,而复制构造可用,容器为了维持异常安全,有时会选择复制而不是移动。
所以资源管理类的移动操作如果确实不会抛异常,通常应当写:
noexcept
十二、实现移动赋值运算符
移动赋值与移动构造不同。
移动构造时,新对象还没有旧资源:
Photo target = std::move(source);
移动赋值时,左侧对象已经存在:
target = std::move(source);
target 可能已经拥有自己的像素数组。
因此移动赋值需要:
- 处理自移动;
- 释放目标对象当前资源;
- 接管来源对象的资源;
- 把来源对象清空;
- 返回
*this。
Photo& Photo::operator=(Photo&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
width_ = std::exchange(other.width_, 0);
height_ = std::exchange(other.height_, 0);
data_ = std::exchange(other.data_, nullptr);
return *this;
}
调用前:
target
┌──────────────────────┐
│ data_ ───────────────────→ [旧像素 T]
└──────────────────────┘
source
┌──────────────────────┐
│ data_ ───────────────────→ [像素 S]
└──────────────────────┘
释放目标旧资源:
[旧像素 T] 被删除
接管来源:
target
┌──────────────────────┐
│ data_ ───────────────────→ [像素 S]
└──────────────────────┘
source
┌──────────────────────┐
│ data_ = nullptr │
└──────────────────────┘
12.1 为什么要考虑自移动
下面的代码虽然少见,但语法上可能出现:
photo = std::move(photo);
此时:
this == &other
如果先执行:
delete[] data_;
然后再从 other.data_ 取得资源,实际上来源和目标是同一个对象,其数据已经被删除。
使用:
if (this == &other) {
return *this;
}
可以让这个实现对自移动更稳健。
并不是所有标准类型都承诺自移动后保留原值,但自定义资源类至少不应该因此发生重复释放或悬空访问。
练习:找出错误的移动构造函数
Photo::Photo(Photo&& other)
: width_(other.width_),
height_(other.height_),
data_(other.data_) {
}
这个函数看起来已经把指针交给新对象了。它有什么问题?
答案与解释
它没有清空 other.data_。
移动后:
other.data_ ───┐
├──→ 同一块像素
new.data_ ─────┘
当 other 析构时:
delete[] other.data_;
像素内存被释放。
但新对象的 data_ 仍然保存这个旧地址:
new.data_ ───→ 已经被释放的内存
这个指针变成悬空指针。
之后读取像素会产生未定义行为;新对象析构时还可能再次删除同一地址。
正确实现必须撤销来源对象的所有权:
other.width_ = 0;
other.height_ = 0;
other.data_ = nullptr;
或者使用:
data_(std::exchange(other.data_, nullptr))
十三、一个很反直觉的规则:具名右值引用本身是左值表达式
观察移动构造函数:
Photo::Photo(Photo&& other) {
// ...
}
参数类型是:
Photo&&
但函数体中的表达式:
other
有名字。
因此:
other这个表达式是左值。
这条规则十分重要。
假设 Photo 还有一个成员:
std::vector<std::string> keywords_;
移动构造函数这样写:
Photo::Photo(Photo&& other) {
keywords_ = other.keywords_;
}
因为 other.keywords_ 是左值表达式,所以调用的是 std::vector 的复制赋值。
全部关键词字符串仍然会被复制。
为了调用成员的移动赋值,需要写:
Photo::Photo(Photo&& other) {
keywords_ = std::move(other.keywords_);
}
更适合构造函数的写法是放进初始化列表:
Photo::Photo(Photo&& other) noexcept
: keywords_(std::move(other.keywords_)) {
}
完整推导是:
函数实参是右值
↓
绑定到 Photo&& other
↓
other 是一个具名参数
↓
表达式 other 是左值
↓
必须用 std::move(other) 再次表示“允许从它转移”
这也是 std::move 在移动构造函数中频繁出现的原因。
十四、std::move 实际上并不会移动任何数据
看到:
Photo moved = std::move(original);
很容易误以为 std::move 自己执行了:
- 复制指针;
- 清空原对象;
- 转移内存。
但它没有做这些工作。
std::move 的核心作用是:
把一个表达式转换为能够匹配右值引用的表达式。
对初学阶段,可以把:
std::move(original)
近似理解为:
static_cast<Photo&&>(original)
它向重载解析系统表达:
我允许把
original当作可被消耗的对象。
之后是否真正移动,要看接收方有没有移动操作。
例如:
Photo target = std::move(original);
候选函数包括:
Photo(const Photo& other);
Photo(Photo&& other);
std::move(original) 能更好地匹配:
Photo(Photo&& other);
于是移动构造函数被调用。
真正执行资源转移的是:
Photo::Photo(Photo&& other)
不是 std::move。
14.1 只有复制操作时,std::move 可能仍然复制
class Legacy {
public:
Legacy(const Legacy& other);
};
没有移动构造函数。
那么:
Legacy a;
Legacy b = std::move(a);
std::move(a) 是右值,但复制构造函数的参数:
const Legacy&
也可以绑定右值。
最终仍然调用复制构造函数。
所以:
出现 std::move
不等于:
一定发生移动
14.2 const 对象通常也移动不了
const Photo original(100, 100);
Photo target = std::move(original);
std::move(original) 的结果带有 const。
普通移动构造函数需要:
Photo(Photo&& other);
它不能绑定 const Photo,因为移动需要修改来源对象,例如把其指针设为 nullptr。
于是编译器通常退回:
Photo(const Photo& other);
结果是复制。
因此,资源所有权转移通常不能从 const 对象进行。
14.3 为什么 std::move 是一种“自愿承担风险”
普通左值意味着:
这个对象以后可能还要使用
写下:
std::move(object)
相当于程序员告诉编译器:
我确认不再依赖 object 当前拥有的内容
编译器不会替你验证后面的代码是否又使用了它。
例如:
Photo selfie = std::move(whoAmI);
whoAmI.get_pixel(21, 24);
第二行可能出问题。
因此,不应为了“看起来更快”而到处添加 std::move。
十五、移动后的对象到底处于什么状态
课件中有时把移动后的对象称为“无效状态”或“未知状态”。
更准确的说法是:
移动后的对象应当保持有效,但其具体值通常不应被依赖。
对于我们编写的 Photo,移动后明确设置为:
width_ = 0
height_ = 0
data_ = nullptr
这是一个可预测的空状态。
允许:
Photo moved = std::move(original);
original = Photo(100, 100);
因为移动后的对象仍然存在,可以重新赋值。
允许:
// original 离开作用域
它可以正常析构。
不应直接假设:
original.get_pixel(0, 0);
仍然能够访问移动前的像素。
如果 get_pixel 检查空状态,它可以抛出异常;如果没有检查,则可能解引用空指针。
一个实用原则是:
对象移动后,除非类的文档明确承诺某种状态,否则只对它执行析构、重新赋值,或文档明确允许的操作。
练习:这是编译错误还是运行风险
Photo photo(100, 100);
Photo destination = std::move(photo);
photo.get_pixel(0, 0);
判断:
- 代码能否通过编译?
- 第三行是否安全?
photo是否已经被销毁?
答案与解释
代码通常能够通过编译。
C++ 不会禁止使用被移动过的变量。
第三行是否安全,取决于 Photo::get_pixel 和移动后的状态。
在我们的设计中:
photo.data_ = nullptr
如果 get_pixel 直接执行:
return data_[index];
就会解引用空指针,产生未定义行为。
如果 get_pixel 先检查:
if (data_ == nullptr) {
throw std::logic_error("empty photo");
}
程序会抛出异常,而不是发生未定义行为。
photo 并没有被销毁。
它仍然是一个存在的对象,只是资源被转移了。它会在作用域结束时正常调用析构函数。
十六、什么时候需要显式使用 std::move
通常,编译器能够自动识别右值:
Photo a = takePhoto();
Photo b;
b = takePhoto();
如果类定义了移动操作,编译器会自动选择它们。
不需要写:
Photo a = std::move(takePhoto());
这不仅多余,还可能影响某些优化或让代码意图变得混乱。
显式 std::move 最常用于:
- 已经有名字的左值;
- 程序员明确知道它的旧内容不再需要;
- 希望把它交给接收所有权的对象或函数;
- 类的移动操作确实有意义。
16.1 容器中移动元素
假设一个照片集合需要在中间插入元素。
原本的移动过程可能写成:
for (std::size_t i = elems.size() - 1; i > pos; --i) {
elems[i] = elems[i - 1];
}
右侧:
elems[i - 1]
是左值,因此调用复制赋值。
但循环从后向前移动位置。
在每一步中,旧位置的元素马上会被下一步覆盖:
把位置 4 的元素放到位置 5
接下来位置 4 会被位置 3 覆盖
把位置 3 的元素放到位置 4
接下来位置 3 会被位置 2 覆盖
旧值不再需要,所以可以写:
for (std::size_t i = elems.size() - 1; i > pos; --i) {
elems[i] = std::move(elems[i - 1]);
}
现在调用移动赋值。
状态变化类似:
移动前:
[照片 A][照片 B][照片 C][空位]
↑ 插入位置
第一次:
[照片 A][照片 B][已移动 C][照片 C 的资源]
第二次:
[照片 A][已移动 B][照片 B 的资源][照片 C 的资源]
被移动的槽位随后会被覆盖,所以不会继续依赖其中的旧内容。
16.2 对课件插入代码的修正
课件中的示意代码最后写成了类似:
elems[i] = pic;
但循环变量 i 如果声明在 for 内,循环结束后已经离开作用域。而真正应该插入的位置也是 pos。
修正后的核心逻辑应当是:
void insert(const Photo& pic, std::size_t pos) {
elems.emplace_back();
for (std::size_t i = elems.size() - 1; i > pos; --i) {
elems[i] = std::move(elems[i - 1]);
}
elems[pos] = pic;
}
这里最后仍然复制 pic,因为参数是:
const Photo& pic
调用者可能还需要继续使用原照片。
如果函数本身要取得一份照片,并允许从右值高效传入,可以改成按值接收:
void insert(Photo pic, std::size_t pos) {
elems.emplace_back();
for (std::size_t i = elems.size() - 1; i > pos; --i) {
elems[i] = std::move(elems[i - 1]);
}
elems[pos] = std::move(pic);
}
调用:
Photo photo(100, 100);
collection.insert(photo, 0); // 先复制到参数 pic
collection.insert(std::move(photo), 0); // 移动到参数 pic
collection.insert(takePhoto(), 0); // 移动或直接构造参数
函数内部拥有自己的参数 pic,最后可以安全地把它移动进容器。
16.3 不要对返回局部变量随意写 std::move
下面的写法经常是多余的:
Photo makePhoto() {
Photo result(100, 100);
return std::move(result);
}
更推荐:
Photo makePhoto() {
Photo result(100, 100);
return result;
}
直接返回局部变量可以让编译器尝试 NRVO。
显式写 std::move(result) 可能阻止某些返回值优化。即使最终仍能移动,也可能比直接消除构造多一次操作。
因此:
返回局部变量时,通常直接 return variable;
不要习惯性地写:
return std::move(variable);
十七、完整的 Photo 类:复制、移动与安全空状态
下面是一份可以使用 C++20 独立编译的完整示例。
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <utility>
class Photo {
public:
Photo(int width = 0, int height = 0)
: width_(width),
height_(height),
data_(allocate(pixel_count(width, height))) {
std::cout << "ordinary constructor\n";
}
Photo(const Photo& other)
: width_(other.width_),
height_(other.height_),
data_(allocate(other.size())) {
if (other.size() != 0) {
std::copy_n(other.data_, other.size(), data_);
}
std::cout << "copy constructor\n";
}
Photo& operator=(const Photo& other) {
std::cout << "copy assignment\n";
if (this == &other) {
return *this;
}
int* new_data = allocate(other.size());
if (other.size() != 0) {
std::copy_n(other.data_, other.size(), new_data);
}
delete[] data_;
width_ = other.width_;
height_ = other.height_;
data_ = new_data;
return *this;
}
Photo(Photo&& other) noexcept
: width_(std::exchange(other.width_, 0)),
height_(std::exchange(other.height_, 0)),
data_(std::exchange(other.data_, nullptr)) {
std::cout << "move constructor\n";
}
Photo& operator=(Photo&& other) noexcept {
std::cout << "move assignment\n";
if (this == &other) {
return *this;
}
delete[] data_;
width_ = std::exchange(other.width_, 0);
height_ = std::exchange(other.height_, 0);
data_ = std::exchange(other.data_, nullptr);
return *this;
}
~Photo() {
delete[] data_;
std::cout << "destructor\n";
}
void set_pixel(int x, int y, int value) {
check_position(x, y);
data_[index(x, y)] = value;
}
int get_pixel(int x, int y) const {
check_position(x, y);
return data_[index(x, y)];
}
bool empty() const noexcept {
return data_ == nullptr;
}
void print_state(const char* name) const {
std::cout << name << ": "
<< width_ << 'x' << height_
<< ", empty=" << std::boolalpha << empty()
<< '\n';
}
private:
int width_ = 0;
int height_ = 0;
int* data_ = nullptr;
static std::size_t pixel_count(int width, int height) {
if (width < 0 || height < 0) {
throw std::invalid_argument(
"width and height must be non-negative"
);
}
return static_cast<std::size_t>(width) *
static_cast<std::size_t>(height);
}
static int* allocate(std::size_t count) {
if (count == 0) {
return nullptr;
}
return new int[count]{};
}
std::size_t size() const noexcept {
return static_cast<std::size_t>(width_) *
static_cast<std::size_t>(height_);
}
std::size_t index(int x, int y) const {
return static_cast<std::size_t>(y) *
static_cast<std::size_t>(width_) +
static_cast<std::size_t>(x);
}
void check_position(int x, int y) const {
if (empty()) {
throw std::logic_error(
"this Photo has no pixel data"
);
}
if (x < 0 || y < 0 ||
x >= width_ || y >= height_) {
throw std::out_of_range(
"pixel position is out of range"
);
}
}
};
int main() {
Photo original(3, 2);
original.set_pixel(1, 1, 42);
// original 是左值,因此复制。
Photo copied = original;
std::cout << "copied pixel = "
<< copied.get_pixel(1, 1)
<< '\n';
// 显式允许转移 original 的资源。
Photo moved = std::move(original);
original.print_state("original after move");
moved.print_state("moved");
Photo target(1, 1);
// copied 是左值,因此复制赋值。
target = copied;
// 临时 Photo 是右值,因此移动赋值。
target = Photo(2, 2);
// 已移动对象可以重新赋值。
original = Photo(1, 1);
original.print_state("original after reassignment");
}
编译:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
运行:
./main
一种可能的输出是:
ordinary constructor
copy constructor
copied pixel = 42
move constructor
original after move: 0x0, empty=true
moved: 3x2, empty=false
ordinary constructor
copy assignment
ordinary constructor
move assignment
destructor
ordinary constructor
move assignment
destructor
original after reassignment: 1x1, empty=false
destructor
destructor
destructor
destructor
这里:
0x0
不是十六进制地址,而是输出:
width_ = 0
字符 'x'
height_ = 0
也就是尺寸 0 x 0。
17.1 创建 original
Photo original(3, 2);
调用普通构造函数:
original
┌──────────────────────┐
│ width_ = 3 │
│ height_ = 2 │
│ data_ ───────────────────→ [6 个 int]
└──────────────────────┘
17.2 复制 original
Photo copied = original;
original 是左值,调用复制构造函数。
original.data_ ───→ [数组 A]
copied.data_ ───→ [数组 B]
数组 B 是新申请的,内容与 A 相同。
因此:
copied.get_pixel(1, 1)
得到 42。
17.3 移动 original
Photo moved = std::move(original);
std::move(original) 让表达式匹配移动构造函数。
执行后:
original
┌──────────────────────┐
│ width_ = 0 │
│ height_ = 0 │
│ data_ = nullptr │
└──────────────────────┘
moved
┌──────────────────────┐
│ width_ = 3 │
│ height_ = 2 │
│ data_ ───────────────────→ [原数组 A]
└──────────────────────┘
没有复制六个像素;对于真实大照片,也不需要复制几百万个像素。
17.4 复制赋值
target = copied;
copied 是左值,所以调用:
Photo& operator=(const Photo& other);
target 原来的数组被替换为 copied 数据的深复制。
17.5 移动赋值
target = Photo(2, 2);
右侧是临时对象,因此调用:
Photo& operator=(Photo&& other);
target 先释放旧资源,再接管临时对象的像素数组。
临时对象的 data_ 被设为 nullptr,随后析构不会释放 target 的新数据。
十八、复制构造与移动构造的完整对比
18.1 调用条件
复制构造:
Photo a = existing;
移动构造:
Photo a = takePhoto();
Photo b = std::move(existing);
18.2 参数类型
复制构造:
Photo(const Photo& other);
移动构造:
Photo(Photo&& other) noexcept;
18.3 是否申请新资源
复制构造通常需要:
new int[count]
移动构造通常不需要新申请大资源。
18.4 是否复制全部数据
复制构造需要:
std::copy(...)
移动构造只转移指针或句柄。
18.5 来源对象的状态
复制后:
来源对象保持原内容
移动后:
来源对象仍然存在,但旧资源已被转移
18.6 时间复杂度直觉
假设照片有 n 个像素。
复制构造通常需要处理所有像素:
O(n)
移动构造只处理固定数量的成员:
O(1)
这里使用了复杂度符号:
O(n):数据越多,耗时通常越多;O(1):处理的数据成员数量固定,不随像素总数增长。
这个复杂度结论适用于“对象通过一个小型指针管理大型资源”的设计。
对于只包含几个整数的小对象,移动可能并不比复制明显更快。
十九、复制赋值与移动赋值的完整对比
复制赋值:
target = source;
来源 source 之后仍要保持原值。
过程:
1. 准备 target 的新资源
2. 复制 source 的全部数据
3. 清理 target 的旧资源
4. source 保持不变
移动赋值:
target = std::move(source);
过程:
1. 清理 target 的旧资源
2. target 接管 source 的资源
3. source 被改为空状态
需要特别区分:
Photo target = source;
这是初始化,调用构造函数。
target = source;
这是赋值,调用赋值运算符。
即使语法中都出现 =,对象是否已经存在决定了它们的含义。
二十、特殊成员函数从三个增加到五个
上一节课重点关注:
Photo(const Photo& other);
Photo& operator=(const Photo& other);
~Photo();
本节课增加:
Photo(Photo&& other);
Photo& operator=(Photo&& other);
现在与资源管理最相关的五个特殊成员函数是:
~Photo();
Photo(const Photo& other);
Photo& operator=(const Photo& other);
Photo(Photo&& other);
Photo& operator=(Photo&& other);
课件用零法则、三法则和五法则帮助决定应该定义哪些函数。
二十一、零法则:最推荐的现代 C++ 设计
零法则(Rule of Zero):如果类的成员已经能够自动管理自己的资源,就不要手写析构、复制和移动操作。
我们当前的 Photo 使用:
int* data_;
裸指针本身不知道:
- 它是否拥有内存;
- 应该使用
delete还是delete[]; - 复制指针时应该深复制还是共享;
- 移动后应该如何清空来源。
因此必须手动实现资源管理。
但标准库的 std::vector 已经正确实现了:
- 析构;
- 复制构造;
- 复制赋值;
- 移动构造;
- 移动赋值。
所以真实项目中的 Photo 更适合写成:
#include <vector>
class Photo {
public:
Photo(int width, int height)
: width_(width),
height_(height),
data_(
static_cast<std::size_t>(width) *
static_cast<std::size_t>(height)
) {
}
private:
int width_;
int height_;
std::vector<int> data_;
};
我们没有定义任何复制、移动或析构函数。
编译器生成的操作会分别调用成员自己的操作。
复制:
Photo 的复制
↓
复制 width_
复制 height_
调用 vector 的复制构造
↓
vector 深复制全部元素
移动:
Photo 的移动
↓
移动 width_
移动 height_
调用 vector 的移动构造
↓
vector 转移内部存储
析构:
Photo 析构
↓
vector 自动析构
↓
释放动态数组
这就是零法则。
21.1 Post 为什么不需要自己写五个函数
struct Post {
Photo photo;
std::string caption;
};
如果 Photo 和 std::string 已经正确支持复制和移动,Post 通常不需要重新实现这些逻辑。
编译器生成的复制构造会:
复制 photo
复制 caption
编译器生成的移动构造会:
移动 photo
移动 caption
编译器生成的析构会:
析构 caption
析构 photo
因此,成员本身会管理资源时,优先依赖组合和自动生成的特殊成员函数。
二十二、三法则:手动管理资源时,复制三件套要一起考虑
三法则(Rule of Three):如果一个类需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,通常三个都需要定义。
三个函数是:
~Type();
Type(const Type& other);
Type& operator=(const Type& other);
为什么?
假设一个类需要自定义析构:
~Photo() {
delete[] data_;
}
这表明它直接管理资源。
如果不定义复制构造,编译器可能只复制指针:
两个对象指向同一块内存
如果不定义复制赋值,同样可能发生浅复制,并且目标原资源还可能泄漏。
因此:
需要析构
↓
说明类管理资源
↓
默认成员复制通常不够
↓
也需要复制构造和复制赋值
三法则首先保证正确性。
二十三、五法则:在三法则基础上补充高效移动
五法则(Rule of Five):如果类需要自定义三法则中的函数,通常也应该考虑移动构造和移动赋值。
五个函数是:
~Type();
Type(const Type& other);
Type& operator=(const Type& other);
Type(Type&& other);
Type& operator=(Type&& other);
移动操作对于正确性不一定是必需的。
如果没有移动操作,右值通常仍然可以绑定到:
const Type&
于是程序退回复制。
代码可能仍然正确,但会出现不必要的深复制。
所以:
三法则主要解决正确性
五法则进一步解决性能
23.1 “编译器会自动生成移动函数”不能无条件成立
编译器生成特殊成员函数的规则比较复杂。
尤其是当你已经手动声明:
- 析构函数;
- 复制构造函数;
- 复制赋值运算符;
隐式移动操作可能不会再自动生成。
因此,不要只凭“编译器应该会帮我移动”猜测行为。
初学阶段可遵循:
- 成员能自动管理资源:使用零法则;
- 直接管理裸资源:完整考虑五法则;
- 不允许复制:显式删除复制操作;
- 不确定时优先使用标准库资源管理类型。
二十四、零法则、三法则、五法则如何选择
可以按照下面的问题链判断。
情况一:类只包含普通值和标准库类型
class Student {
private:
int id_;
std::string name_;
std::vector<int> scores_;
};
使用:
零法则
不要手写复制、移动或析构。
情况二:类直接拥有裸指针资源
class Buffer {
private:
std::size_t size_;
int* data_;
};
如果这个指针表示所有权,通常需要:
五法则
至少必须保证三法则正确,并考虑移动优化。
情况三:类拥有不能复制的资源
例如某些:
- 文件句柄;
- 互斥锁;
- 独占资源;
std::unique_ptr。
可能需要禁止复制,只允许移动:
class Resource {
public:
Resource(const Resource&) = delete;
Resource& operator=(const Resource&) = delete;
Resource(Resource&&) noexcept = default;
Resource& operator=(Resource&&) noexcept = default;
};
这里的 = delete 和 = default 是现代 C++ 控制特殊成员函数的语法。
本节只需要建立直觉:
资源能否复制?
资源能否转移?
不同答案会形成不同的类接口。
二十五、初学者最容易出现的错误
25.1 只复制指针,没有深复制
错误:
Photo::Photo(const Photo& other)
: width_(other.width_),
height_(other.height_),
data_(other.data_) {
}
后果:
两个对象共同拥有一块内存
重复释放
25.2 移动后忘记清空来源
错误:
Photo::Photo(Photo&& other)
: width_(other.width_),
height_(other.height_),
data_(other.data_) {
}
后果:
来源析构时释放目标正在使用的数据
25.3 在移动构造函数中仍然复制成员
错误:
Photo::Photo(Photo&& other)
: keywords_(other.keywords_) {
}
other.keywords_ 是左值表达式,调用复制。
修正:
Photo::Photo(Photo&& other) noexcept
: keywords_(std::move(other.keywords_)) {
}
25.4 认为 std::move 会立刻清空对象
std::move(photo);
单独写这一句通常什么资源转移都不会发生。
它只产生一个转换后的表达式,随后没有任何函数使用这个结果。
真正发生移动需要接收方:
Photo target = std::move(photo);
25.5 移动后继续依赖原值
Photo target = std::move(source);
source.get_pixel(0, 0);
编译器通常不会报错,但可能产生逻辑错误或运行时错误。
25.6 为所有函数都增加 T&&
函数只需要读取对象时:
void show(const Photo& photo);
已经能够接收左值和右值,而且不会复制。
不需要为了“支持右值”写两个版本:
void show(Photo& photo);
void show(Photo&& photo);
只有当左值和右值需要不同处理方式时,重载才有意义。
25.7 把移动当成永远更快
对于:
struct Point {
int x;
int y;
};
复制两个整数已经是固定成本。
即使编译器生成移动构造,它通常也只是复制两个整数,未必有任何实际收益。
移动语义最有价值的对象通常包含:
- 动态内存;
- 大型容器;
- 文件或系统资源;
- 具有所有权的句柄;
- 复制昂贵但所有权容易转移的内容。
25.8 在复制赋值中先删除旧资源,再申请新资源
基础版本:
delete[] data_;
data_ = new int[count];
如果 new 失败,对象旧内容已经丢失。
更稳妥的是:
int* new_data = new int[count];
// 复制成功后
delete[] data_;
data_ = new_data;
二十六、综合练习一:判断复制还是移动
给出:
Photo createPhoto();
void consume(Photo photo);
int main() {
Photo a(100, 100);
Photo b = a; // 1
Photo c = createPhoto(); // 2
Photo d = std::move(a); // 3
b = c; // 4
b = createPhoto(); // 5
consume(c); // 6
consume(std::move(c)); // 7
consume(createPhoto()); // 8
}
假设:
- 没有考虑复制消除;
Photo同时拥有复制和移动操作;consume按值接收。
判断每处使用复制还是移动。
答案与解释
1
Photo b = a;
a 是左值。
调用复制构造函数。
2
Photo c = createPhoto();
返回结果是右值。
如果没有复制消除,调用移动构造函数。
实际现代编译器可能直接构造 c,不调用复制或移动。
3
Photo d = std::move(a);
std::move(a) 使表达式能够绑定到 Photo&&。
调用移动构造函数。
之后不要依赖 a 的旧像素内容。
4
b = c;
b 已存在,所以是赋值。
c 是左值,调用复制赋值运算符。
5
b = createPhoto();
右侧是右值,调用移动赋值运算符。
返回对象随后析构,但它已经被清空。
6
consume(c);
consume 按值接收,需要创建参数对象。
c 是左值,所以使用复制构造创建参数。
函数返回时参数析构,c 保留原内容。
7
consume(std::move(c));
使用移动构造创建参数。
函数调用后,c 仍然存在,但处于已移动状态。
8
consume(createPhoto());
右值用于创建参数,通常移动或直接构造参数。
编译器可能进行复制消除。
二十七、综合练习二:分析具名右值引用
void forwardPhoto(Photo&& photo) {
Photo local = photo;
}
问题:
photo的声明类型是什么?- 表达式
photo是左值还是右值? local使用复制还是移动?- 怎样改成移动?
答案与解释
photo 的声明类型是:
Photo&&
但它是一个具名参数,所以表达式:
photo
是左值。
因此:
Photo local = photo;
调用复制构造函数。
要表达“允许从参数转移”,必须写:
Photo local = std::move(photo);
完整版本:
void forwardPhoto(Photo&& photo) {
Photo local = std::move(photo);
}
执行后,参数所引用的原对象进入已移动状态。
二十八、综合练习三:修正移动赋值
下面的代码有多个问题:
Photo& Photo::operator=(Photo&& other) {
width_ = other.width_;
height_ = other.height_;
data_ = other.data_;
}
找出问题并修正。
答案与解释
问题一:没有释放当前对象原来的资源。
this->data_ 原来指向的数组失去地址
导致内存泄漏
问题二:没有清空 other.data_。
两个对象认为自己拥有同一资源
导致悬空指针和重复释放
问题三:没有返回 *this。
函数返回类型是:
Photo&
必须返回当前对象。
问题四:没有考虑自移动。
修正:
Photo& Photo::operator=(Photo&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
width_ = std::exchange(other.width_, 0);
height_ = std::exchange(other.height_, 0);
data_ = std::exchange(other.data_, nullptr);
return *this;
}
二十九、综合练习四:自己实现一个可移动缓冲区
实现下面的 IntBuffer:
class IntBuffer {
public:
explicit IntBuffer(std::size_t size);
IntBuffer(const IntBuffer& other);
IntBuffer& operator=(const IntBuffer& other);
IntBuffer(IntBuffer&& other) noexcept;
IntBuffer& operator=(IntBuffer&& other) noexcept;
~IntBuffer();
private:
std::size_t size_;
int* data_;
};
要求:
普通构造申请
size个整数;复制操作进行深复制;
移动操作转移指针;
移动后的来源对象满足:
size_ = 0 data_ = nullptr处理复制自赋值和移动自赋值;
可以使用
g++ -std=c++20编译。
参考实现与讲解
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
class IntBuffer {
public:
explicit IntBuffer(std::size_t size)
: size_(size),
data_(size == 0 ? nullptr : new int[size]{}) {
}
IntBuffer(const IntBuffer& other)
: size_(other.size_),
data_(other.size_ == 0
? nullptr
: new int[other.size_]) {
if (size_ != 0) {
std::copy_n(other.data_, size_, data_);
}
}
IntBuffer& operator=(const IntBuffer& other) {
if (this == &other) {
return *this;
}
int* new_data =
other.size_ == 0
? nullptr
: new int[other.size_];
if (other.size_ != 0) {
std::copy_n(
other.data_,
other.size_,
new_data
);
}
delete[] data_;
size_ = other.size_;
data_ = new_data;
return *this;
}
IntBuffer(IntBuffer&& other) noexcept
: size_(std::exchange(other.size_, 0)),
data_(std::exchange(other.data_, nullptr)) {
}
IntBuffer& operator=(IntBuffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
size_ = std::exchange(other.size_, 0);
data_ = std::exchange(other.data_, nullptr);
return *this;
}
~IntBuffer() {
delete[] data_;
}
std::size_t size() const noexcept {
return size_;
}
bool empty() const noexcept {
return data_ == nullptr;
}
int& operator[](std::size_t index) {
return data_[index];
}
const int& operator[](std::size_t index) const {
return data_[index];
}
private:
std::size_t size_ = 0;
int* data_ = nullptr;
};
int main() {
IntBuffer first(3);
first[0] = 10;
first[1] = 20;
first[2] = 30;
IntBuffer copied = first;
std::cout << copied[0] << ' '
<< copied[1] << ' '
<< copied[2] << '\n';
IntBuffer moved = std::move(first);
std::cout << std::boolalpha;
std::cout << "first.empty() = "
<< first.empty() << '\n';
std::cout << "moved.size() = "
<< moved.size() << '\n';
first = IntBuffer(2);
std::cout << "first.size() = "
<< first.size() << '\n';
}
编译:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
输出:
10 20 30
first.empty() = true
moved.size() = 3
first.size() = 2
复制后:
first.data_ ───→ [10][20][30]
copied.data_ ──→ [10][20][30]
是两块不同内存。
移动后:
first.data_ = nullptr
moved.data_ ───→ [10][20][30]
原数组没有被逐个复制。
三十、综合练习五:把手写资源管理改成零法则
前面的 IntBuffer 使用裸指针,需要五个特殊成员函数。
尝试改成使用 std::vector<int>,使类不再手写复制、移动和析构。
答案与解释
#include <cstddef>
#include <vector>
class IntBuffer {
public:
explicit IntBuffer(std::size_t size)
: data_(size) {
}
std::size_t size() const noexcept {
return data_.size();
}
bool empty() const noexcept {
return data_.empty();
}
int& operator[](std::size_t index) {
return data_[index];
}
const int& operator[](std::size_t index) const {
return data_[index];
}
private:
std::vector<int> data_;
};
现在没有:
~IntBuffer();
IntBuffer(const IntBuffer&);
IntBuffer& operator=(const IntBuffer&);
IntBuffer(IntBuffer&&);
IntBuffer& operator=(IntBuffer&&);
但类仍然能够正确:
- 复制;
- 移动;
- 释放资源。
原因是 std::vector 已经完成了资源管理。
这不是“功能减少”,而是把底层工作交给更可靠的标准库组件。
在真实代码中,这通常优于手写五法则。
三十一、本节课的完整知识串联
本节课从复制成本出发,完整推导过程如下:
类拥有动态资源
↓
默认成员复制只复制指针
↓
需要深复制
↓
复制构造和复制赋值保证正确性
↓
但深复制大型资源很昂贵
↓
有些来源对象马上就要销毁
↓
没有必要复制其资源内容
↓
可以转移资源所有权
↓
来源对象必须被改成可安全析构的状态
↓
这形成移动语义
编译器还需要知道何时允许转移:
具名、持久的对象表达式
↓
左值
↓
通常复制
临时、可消耗的表达式
↓
右值
↓
通常移动
引用系统负责区分它们:
T& 接收非 const 左值
T&& 接收右值
特殊成员函数据此重载:
T(const T&) → 复制构造
T(T&&) → 移动构造
operator=(const T&) → 复制赋值
operator=(T&&) → 移动赋值
std::move 的角色是:
左值表达式
↓
std::move
↓
转换成允许匹配右值引用的表达式
↓
移动操作可能被调用
它自己不移动资源。
最后,设计类时应优先考虑:
成员能够自动管理资源
↓
零法则
直接管理裸资源
↓
三法则保证复制正确
↓
五法则补充移动效率
三十二、最后需要真正记住的判断方式
看到一段代码时,不要只搜索有没有 std::move,而要依次问:
第一个问题:左侧对象是否已经存在
Photo a = expression;
创建新对象,考虑构造函数。
a = expression;
对象已经存在,考虑赋值运算符。
第二个问题:右侧表达式是左值还是右值
existing
具名对象,通常是左值。
makePhoto()
临时结果,通常是右值。
std::move(existing)
被显式转换成允许移动的表达式。
第三个问题:类是否提供相应的移动操作
Photo(Photo&&);
Photo& operator=(Photo&&);
如果没有,右值也可能退回复制。
第四个问题:编译器是否直接消除了复制或移动
函数返回新对象时,RVO 可能让复制和移动都不发生。
第五个问题:来源对象之后是否还要使用
如果还要保留原值,不要移动。
如果不再需要旧内容,才考虑转移资源。
三十三、本节知识如何自然连接到下一步
移动语义已经让我们能够高效转移资源,但手写下面这些内容仍然很容易出错:
delete[] data_;
other.data_ = nullptr;
if (this == &other) ...
任何一个细节遗漏,都可能造成:
- 内存泄漏;
- 重复释放;
- 悬空指针;
- 移动后错误使用;
- 异常安全问题。
因此,这一节真正留下的问题是:
能否让资源的所有权关系直接由类型表达,而不是依靠程序员手动维护裸指针?
从知识依赖上,最自然的下一步是学习现代 C++ 的资源自动管理方式,例如:
- RAII;
std::vector;std::string;std::unique_ptr;std::shared_ptr;- 零法则;
- 只可移动类型;
- 标准容器怎样利用移动语义。
移动语义并不是为了鼓励我们写更多裸指针代码。
它真正的价值在于:
让具有所有权的安全类型既能自动管理资源,又能在需要转移所有权时保持高效。