K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 14 讲:移动语义

理解左值、右值、移动构造与 std::move,避免无意义的大对象复制。

CS106L 第 14 讲:移动语义 的封面
C++ · CLASS-C

本课程根据 CS106L Spring 2026 Lecture 14: Move Semantics 重构,并对课件中的课堂化简表达进行了必要修正。

本节课要解决的核心问题

上一节课学习了特殊成员函数(special member function)。当一个类自己管理动态内存时,我们可以通过复制构造函数、复制赋值运算符和析构函数,让对象得到正确的深复制行为。

这解决了“复制后两个对象错误地共享同一块内存”的问题,却留下了一个新的问题:

有些对象马上就要被销毁,我们真的还需要把它拥有的全部数据复制一遍吗?

假设一张照片包含 3840 × 2160 个像素。如果每个像素用一个 int 保存,那么一次深复制需要处理超过八百万个整数。

有时我们会遇到这样的过程:

1. 函数创建一张大照片
2. 为接收返回值的变量重新申请同样大的内存
3. 把全部像素复制过去
4. 立即销毁函数返回的临时照片

第二步和第三步花了大量时间,但第四步告诉我们:原对象马上就没用了。

更合理的做法是:

1. 让新对象直接接管原对象的像素内存
2. 把原对象改为空状态
3. 原对象销毁时不再释放那块内存

这就是本节课的主角:

移动语义(move semantics)

学完本节课后,应当能够回答这些问题:

  1. 复制构造与移动构造有什么区别?
  2. 为什么函数返回值适合被移动?
  3. 什么是左值和右值?
  4. T&T&& 分别能绑定什么表达式?
  5. std::move 到底做了什么?
  6. 移动后的对象还能不能使用?
  7. 怎样正确编写移动构造函数和移动赋值运算符?
  8. 什么是零法则、三法则和五法则?
  9. 实际项目中是否应该经常手写这些函数?

学习本节课所需的前置知识

本节课会用到上一节课中的以下内容:

  • 类与对象;
  • 构造函数;
  • 成员初始化列表;
  • 复制构造函数;
  • 复制赋值运算符;
  • 析构函数;
  • 指针;
  • 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)。

问题包括:

  1. 修改 b 的像素也会影响 a
  2. ab 都认为自己拥有这块内存;
  3. 两个对象析构时会对同一地址执行两次 delete[]
  4. 程序产生未定义行为。

因此,我们需要复制构造函数执行深复制(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_ ───────────────────→ [新像素数组]
└──────────────────────┘

两个数组的内容相同,但内存地址不同。

复制构造函数的工作包括:

  1. 创建一个新对象;
  2. 复制宽度和高度;
  3. 申请新内存;
  4. 复制全部像素。

这保证了正确性,但成本可能很高。


2.4 复制赋值运算符

初始化与赋值不是同一个过程。

Photo a(500, 500);
Photo b = a;  // 初始化:复制构造函数

而:

Photo a(500, 500);
Photo b(100, 100);

b = a;        // 赋值:复制赋值运算符

第二段代码中,b 已经存在,并且已经拥有自己的像素内存。

因此,复制赋值必须:

  1. 处理自赋值;
  2. 准备新数据;
  3. 清理 b 原来的数据;
  4. 复制 a 的数据;
  5. 返回当前对象。

课件中的基础版本先删除旧数据,再申请新数据:

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
}

在暂时不考虑复制消除,并且类还没有移动操作的情况下:

  1. A 会调用什么?
  2. B 会调用什么?
  3. 临时对象什么时候析构?

答案与解释

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;

这个记忆方式对基础内建类型经常有效,但不能作为正式定义。

原因包括:

  1. 左值也经常出现在赋值号右侧:

    int b = a;
    
  2. 有些左值不能被修改:

    const int x = 10;
    

    x 是左值,但不能写:

    x = 20;
    
  3. 运算符可以重载,语法位置并不能完整决定值类别。

因此,更可靠的初学直觉是:

左值:表达式指向一个有持续身份的对象
右值:表达式表示临时结果或允许被消耗的结果

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();

判断:

  1. 4
  2. a
  3. {1, 2, 3}
  4. c[1]
  5. &c[2]
  6. c.size()

哪些是左值,哪些是右值?


答案与解释

1. 4

int a = 4;

4 是一个整数纯右值。

它只表示计算结果 4,不表示一个具名、可持续识别的整数对象。

2. a

int& b = a;

a 是左值表达式。

它表示已经存在的具名变量 ab 绑定到这个对象,而不是创建一个新的整数副本。

关系是:

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);

bconst 左值,只能选择:

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);

它会:

  1. 保存 variable 原来的值;
  2. variable 改成 new_value
  3. 返回原来的值。

因此移动构造可以写成:

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 可能已经拥有自己的像素数组。

因此移动赋值需要:

  1. 处理自移动;
  2. 释放目标对象当前资源;
  3. 接管来源对象的资源;
  4. 把来源对象清空;
  5. 返回 *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);

判断:

  1. 代码能否通过编译?
  2. 第三行是否安全?
  3. 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 最常用于:

  1. 已经有名字的左值;
  2. 程序员明确知道它的旧内容不再需要;
  3. 希望把它交给接收所有权的对象或函数;
  4. 类的移动操作确实有意义。

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;
};

如果 Photostd::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 “编译器会自动生成移动函数”不能无条件成立

编译器生成特殊成员函数的规则比较复杂。

尤其是当你已经手动声明:

  • 析构函数;
  • 复制构造函数;
  • 复制赋值运算符;

隐式移动操作可能不会再自动生成。

因此,不要只凭“编译器应该会帮我移动”猜测行为。

初学阶段可遵循:

  1. 成员能自动管理资源:使用零法则;
  2. 直接管理裸资源:完整考虑五法则;
  3. 不允许复制:显式删除复制操作;
  4. 不确定时优先使用标准库资源管理类型。

二十四、零法则、三法则、五法则如何选择

可以按照下面的问题链判断。

情况一:类只包含普通值和标准库类型

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;
}

问题:

  1. photo 的声明类型是什么?
  2. 表达式 photo 是左值还是右值?
  3. local 使用复制还是移动?
  4. 怎样改成移动?

答案与解释

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_;
};

要求:

  1. 普通构造申请 size 个整数;

  2. 复制操作进行深复制;

  3. 移动操作转移指针;

  4. 移动后的来源对象满足:

    size_ = 0
    data_ = nullptr
    
  5. 处理复制自赋值和移动自赋值;

  6. 可以使用 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
  • 零法则;
  • 只可移动类型;
  • 标准容器怎样利用移动语义。

移动语义并不是为了鼓励我们写更多裸指针代码。

它真正的价值在于:

让具有所有权的安全类型既能自动管理资源,又能在需要转移所有权时保持高效。