C++ 移动构造与移动赋值

当一个对象即将销毁或后续不再使用时,如果我们想省去重新分配内存和复制数据的开销,直接把它的内存资源转交给目标对象,就需要依靠类里面的两个特殊成员函数:移动构造函数和移动赋值运算符。

上一节学过的 std::move 只负责把左值转换为右值引用,相当于贴上一张 “允许搬走” 的标签。而真正动手把底层内存接管过来,并将原对象内部指针置空的,正是移动构造函数与移动赋值运算符。

C++ 移动构造函数

在 C++ 中,移动构造函数(Move Constructor)和拷贝构造函数一样,都是在 “创建新对象” 时自动调用的。区别在于,拷贝构造函数的形参是 const 类名&,会通过深拷贝复制出一份独立的内存。

而移动构造函数的形参是右值引用 类名&&,它不会重新申请内存,而是直接接管来源对象现有的内存资源。

语法:

class 类名 {
public:
    类名(类名&& other) noexcept {
        // 接管 other 的资源,并将源指针置为 nullptr
    }
};

说明:

移动构造函数的形参是右值引用 类名&& other,声明时需要注意以下 2 个细节:

  • 形参前面不能加 const:接管资源之后,我们需要把来源对象 other 内部的指针修改为空指针 nullptr,因此 other 必须是可修改的。
  • 函数头末尾推荐加上 noexcept:用来告诉编译器该移动操作只是转移指针地址、不会抛出异常,方便标准库容器安全调用。

假设类中有一个管理动态内存的指针成员 data,移动构造函数内部只需完成两步:先在初始化列表中直接拿 other.data 初始化新对象的 data,不再使用 new 开辟新内存。随后在函数体内立即执行 other.data = nullptr;,切断来源对象与原内存的联系。

手写移动构造函数时,小伙伴们千万不要漏掉把源对象指针置为 nullptr 的这一步:

// 错误方式
Buffer(Buffer&& other) noexcept : data(other.data) {}

// 正确方式
Buffer(Buffer&& other) noexcept : data(other.data) {
    other.data = nullptr;
}

如果不把 other.data 设为 nullptr,新对象和源对象的指针就会指向同一块动态内存。等到两个对象离开作用域先后销毁时,就会对同一块内存重复执行两次 delete 而引发崩溃。

而在 C++ 中,对空指针执行 delete nullptr; 是完全安全的,什么也不会发生。因此把 other.data 置空后,源对象就能安全走完析构流程。下面我们在 Buffer 类中同时定义深拷贝构造与移动构造,对比两者的地址变化。

示例 1:移动构造与深拷贝对比

#include <iostream>
#include <utility>

class Buffer {
public:
    int* data;

    Buffer(int val) : data(new int(val)) {}

    ~Buffer() {
        delete data;
    }

    // 1. 深拷贝构造函数
    Buffer(const Buffer& other)
        : data(new int(*other.data)) {
        std::cout << "调用拷贝构造函数\n";
    }

    // 2. 移动构造函数
    Buffer(Buffer&& other) noexcept : data(other.data) {
        other.data = nullptr;
        std::cout << "调用移动构造函数\n";
    }
};

int main() {
    Buffer buf1(100);
    std::cout << "初始 buf1.data 地址:" << buf1.data << "\n";

    // 传入左值,调用拷贝构造
    Buffer buf2 = buf1;
    std::cout << "拷贝后 buf2.data 地址:" << buf2.data << "\n";

    // 传入右值,调用移动构造
    Buffer buf3 = std::move(buf1);
    std::cout << "移动后 buf3.data 地址:" << buf3.data << "\n";
    std::cout << "移动后 buf1.data 地址:" << buf1.data << "\n";
    return 0;
}

运行结果如下。

初始 buf1.data 地址:0x55b4e1c02eb0
调用拷贝构造函数
拷贝后 buf2.data 地址:0x55b4e1c032e0
调用移动构造函数
移动后 buf3.data 地址:0x55b4e1c02eb0
移动后 buf1.data 地址:0

分析:

在这个例子中,执行 Buffer buf2 = buf1; 时,实参 buf1 是有名字的左值,因此匹配拷贝构造函数。程序使用 new 新开辟了一块地址不同的独立内存,buf1 原来的内存保持不变。

而执行 Buffer buf3 = std::move(buf1); 时,std::move(buf1) 把实参转换成了右值引用,优先匹配形参为 Buffer&& 的移动构造函数,让 buf3 直接接管了 buf1 原来的内存地址 0x55b4e1c02eb0。

与此同时,buf1.data 被置为了 nullptr,因此打印出来的空指针地址变成了 0,在部分编译器下也会显示为 0x0。

移动函数与 noexcept

可能有些小伙伴会好奇:编写移动构造函数和移动赋值运算符时,就算不写 noexcept 代码也能正常编译运行,为什么实际开发中总是推荐加上它呢?

这主要与 std::vector 等容器的自动扩容有关。当容器装满需要把旧元素搬移到新内存时,为防止中途发生异常破坏原有数据,只有在确认移动构造函数标记了 noexcept 时才会放心调用它。如果漏写,扩容时就会退回较慢的深拷贝。

C++ 移动赋值运算符

在 C++ 中,移动赋值运算符(Move Assignment Operator)与移动构造函数的区别,依然在于 “等号左边的目标对象在这一行代码之前是否已经存在”。

当两个对象都已经创建完成,比如执行 buf2 = std::move(buf1); 把一个对象的资源转移给另一个已存在对象时,调用的就是移动赋值运算符,这与前面学过的拷贝赋值运算符是一一对应的。

语法:

class 类名 {
public:
    类名& operator=(类名&& other) noexcept {
        if (this != &other) {
            // 释放自身旧资源,接管 other 的资源并将源指针置空
        }
        return *this;
    }
};

说明:

为什么移动赋值运算符比移动构造函数多出了 “释放自身旧资源” 这一步呢?因为移动构造面对的是刚创建的新对象,手里还没有任何旧内存。而移动赋值面对的是已经存在的对象,内部原本就已经持有一块动态内存了。

因此,以指针成员 data 为例,移动赋值运算符需要按先后顺序完成以下 4 步操作:

  • 检查自移动:先通过 if (this != &other) 判断左右两边是不是同一个对象,防止出现 buf1 = std::move(buf1); 自己移动给自己的情况。如果不加检查直接执行下一步的 delete,就会把自己唯一的数据释放掉。
  • 释放目标对象原有的旧内存:执行 delete data;,在接管新地址之前先把当前对象原本持有的旧内存释放掉,否则直接覆盖指针会导致旧内存无法释放而造成内存泄漏。
  • 接管来源对象的资源:执行 data = other.data;,直接把来源对象 other 的指针地址赋给当前对象的指针成员。
  • 将源指针置空并返回当前对象:执行 other.data = nullptr; 切断来源对象与该块内存的联系,最后通过 return *this; 返回当前对象的引用。

下面我们结合代码来演示移动赋值运算符的完整执行过程。

示例 2:使用移动赋值运算符

#include <iostream>
#include <utility>

class Buffer {
public:
    int* data;

    Buffer(int val) : data(new int(val)) {}

    ~Buffer() {
        delete data;
    }

    // 定义移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept {
        std::cout << "调用移动赋值运算符\n";
        if (this != &other) {
            delete data;             // 释放原有旧内存
            data = other.data;       // 接管来源地址
            other.data = nullptr;    // 将源指针置空
        }
        return *this;
    }
};

int main() {
    Buffer buf1(200);
    Buffer buf2(50);

    std::cout << "移动前 *buf2.data = " << *buf2.data << "\n";

    buf2 = std::move(buf1);          // 移动赋值给 buf2

    std::cout << "移动后 *buf2.data = " << *buf2.data << "\n";
    std::cout << "移动后 buf1.data 地址:" << buf1.data << "\n";
    return 0;
}

运行结果如下。

移动前 *buf2.data = 50
调用移动赋值运算符
移动后 *buf2.data = 200
移动后 buf1.data 地址:0

分析:

在执行 buf2 = std::move(buf1); 之前,buf2 持有值为 50 的动态内存,buf1 持有值为 200 的动态内存。进入移动赋值运算符后,程序先通过 delete data; 释放了 buf2 存放 50 的旧内存。

接着 buf2 直接接管了 buf1 存放 200 的内存地址,并将 buf1.data 置为 nullptr,这样 main() 结束时两个对象都能安全析构。

另外,如果直接把临时右值赋给已存在的对象,比如写出 buf2 = Buffer(300);,同样会自动调用移动赋值运算符。

五法则与零法则

学到这里,我们已经把 C++ 类中与资源管理相关的 5 个特殊成员函数全部认识完了。在实际开发中,什么时候编译器会自动生成移动操作,什么时候需要手写,什么时候又完全不用操心呢?现代 C++ 将这些经验总结成了 “五法则” 与 “零法则”。

1. 五法则

和默认拷贝类似,如果一个类没有显式定义析构函数、拷贝构造函数、拷贝赋值运算符以及移动操作,编译器会自动为它生成默认的移动构造函数和移动赋值运算符,按成员依次移动数据。

不过需要注意,只要我们手动定义了析构或拷贝函数中的任意一个,编译器就不再自动生成默认移动操作了。反过来,一旦手动定义了移动构造或移动赋值,编译器也会自动禁用默认的拷贝操作。

前面学习深拷贝时我们认识过 “三法则”,在 C++11 引入移动构造和移动赋值之后,它就自然扩展成了 “五法则(Rule of Five)”。这 5 个特殊成员函数的职责如下表所示。

五种资源管理成员函数
特殊成员函数 声明格式 触发时机 资源操作
析构函数 ~类名() 对象销毁时 释放当前对象持有的资源
拷贝构造函数 类名(const 类名& other) 使用左值创建新对象 分配新内存并复制数据
拷贝赋值运算符 类名& operator=(const 类名& other) 将左值赋给已存在对象 释放旧资源,分配新内存并复制
移动构造函数 类名(类名&& other) noexcept 使用右值创建新对象 直接接管源资源并将源指针置空
移动赋值运算符 类名& operator=(类名&& other) noexcept 将右值赋给已存在对象 释放旧资源,接管源资源并置空

五法则的意思是:当一个底层类因为直接管理裸指针或系统句柄,需要手动编写上面 5 个特殊成员函数中的某一个时,通常需要把其余 4 个函数的行为也一并考虑清楚,避免漏掉移动操作而退回深拷贝,或者因浅拷贝引发重复释放。

2. 零法则(推荐)

看到五法则,可能有些小伙伴会问:难道以后每写一个类,都要把这 5 个函数手写一遍吗?

恰恰相反,在现代 C++ 日常开发中,我们最常用的是 “零法则(Rule of Zero)”:普通业务类不要自己去写裸指针和 new / delete,而是直接使用 std::string、std::vector 或智能指针作为成员变量。

借助前面学过的 RAII 机制自动管理内存后,上面那 5 个特殊成员函数我们 “一个都不需要手写”。下面通过一个例子来验证一下。

示例 3:遵循零法则的业务类

#include <iostream>
#include <string>
#include <utility>
#include <vector>

class Student {
private:
    std::string name;
    std::vector<int> scores;

public:
    Student(const std::string& n, const std::vector<int>& s)
        : name(n), scores(s) {}

    // 无需手写拷贝与移动函数

    void setFirstScore(int val) {
        if (!scores.empty()) {
            scores[0] = val;
        }
    }

    void show() const {
        if (scores.empty()) {
            std::cout << "成绩数组已为空\n";
            return;
        }
        std::cout << name << ",成绩:" << scores[0] << "\n";
    }
};

int main() {
    Student s1("小明", {90, 95});

    // 1. 自动调用默认拷贝构造
    Student s2 = s1;
    s2.setFirstScore(100);
    std::cout << "拷贝后 s1:";
    s1.show();
    std::cout << "修改后 s2:";
    s2.show();

    // 2. 自动调用默认移动构造
    Student s3 = std::move(s1);
    std::cout << "移动后 s3:";
    s3.show();
    std::cout << "移动后 s1:";
    s1.show();
    return 0;
}

运行结果如下。

拷贝后 s1:小明,成绩:90
修改后 s2:小明,成绩:100
移动后 s3:小明,成绩:90
移动后 s1:成绩数组已为空

分析:

在这个例子中,Student 类只使用了 std::string 和 std::vector<int> 保存数据,那 5 个资源管理成员函数一个都没有手写,编译器会自动根据成员变量的能力配齐默认拷贝和移动操作。

执行 Student s2 = s1; 时会自动调用各成员的深拷贝,修改 s2 不会影响 s1。执行 Student s3 = std::move(s1); 时则自动调用各成员的移动构造,直接转走字符串和数组内存,因此 s1 的成绩数组变为空了。

同理,如果在类中定义了独占智能指针 std::unique_ptr 成员,外层类不用写任何额外代码,也会自动变成 “禁止拷贝、但支持移动” 的类型,用起来非常省心。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号