Skip to content

2.3 值语义、复制与移动 ​

int 很容易理解:把一个整数赋给另一个整数后,两边是相互独立的值。

cpp
int first{42};
int second = first;
second = 10;
// first 仍然是 42

设计自己的类型时,也要决定复制、赋值和销毁究竟意味着什么。现代 C++ 倾向于让类型具有自然的值语义(value semantics):对象代表一个值,复制后得到相等但可以独立修改的对象。

值语义与引用语义

值语义:指对象在赋值或传递时,进行的是值的复制,每个对象都有自己独立的数据副本,赋值过后,操作源对象和目的对象互不影响。

引用语义:指对象在赋值或传递时,复制的是引用(指针或句柄),而不是实际的类型,因此多个引用可能指向同一个实际对象,通过其中一个引用修改对象时,其他引用也会看到这个变化。

特殊成员函数 ​

我们已经知道构造函数。除了构造函数之外,C++ 还有若干负责对象创建、复制、移动和销毁的特殊成员函数:

cpp
class Example {
  public:
    Example();                               // 默认构造函数
    Example(const Example &other);           // 复制构造函数
    Example &operator=(const Example &);     // 复制赋值运算符
    Example(Example &&other) noexcept;       // 移动构造函数
    Example &operator=(Example &&) noexcept; // 移动赋值运算符
    ~Example();                              // 析构函数
};

通过定义这些函数,我们可以控制一个对象在创建、复制、移动、回收时的行为。事实上在大部分情况下,这些函数并不需要我们手动定义。在满足规则时,编译器会自动隐式声明或定义这些操作。但理解这些函数的作用是十分必要的,否则标准库提供的各种简化工具会看起来像魔法!所以我们会先介绍这些函数如何工作,然后介绍在平时开发中如何利用语言特性与标准库简化我们的工作。

为了解释这一套机制,我们接下来自己动手写一个简单的定长 int 型数组类 MyArray:它记录数组长度并管理一段内存。

WARNING

再次说明:自行编写这个数组类的主要意义是介绍这些特殊成员函数如何工作。日常开发中不要自己重写一遍标准库已有的内容。

构造与析构 ​

我们知道,对象在作用域内声明并初始化时,会立即调用构造函数(constructor)。相对应地,C++ 提供了析构函数(destructor):当控制流离开作用域边界时,会按构造的逆序调用该作用域内所有已构造对象的析构函数。离开作用域的原因可以是正常执行、提前 return,也可以是异常传播。只要栈展开正常进行,已经构造完成的局部对象都会按规则析构。

因此,对于我们自己定义的 MyArray 类,我们可以在构造函数中动态申请内存,并在析构函数中释放:

cpp
class MyArray {
  public:
    explicit MyArray(std::size_t size)
        : data_{new int[size]}, size_{size} {}
    ~MyArray() { delete[] data_; }

    [[nodiscard]] std::size_t size() const { return size_; }
    [[nodiscard]] int *data() { return data_; }
    [[nodiscard]] const int *data() const { return data_; }
    [[nodiscard]] int &operator[](std::size_t index) {
        return data_[index];
    }
    [[nodiscard]] const int &operator[](std::size_t index) const {
        return data_[index];
    }

  private:
    int *data_;
    std::size_t size_;
};

MyArray arr(5);
for (std::size_t i{0}; i < arr.size(); i++)
    arr[i] = i;
for (std::size_t i{0}; i < arr.size(); i++)
    std::cout << arr[i] << ' '; // 0 1 2 3 4

如此,在 MyArray 类的实例创建时便会调用 new int[size_] 动态申请内存,在离开实例的作用域时便会调用 delete[] data_ 释放。

int &operator[] 是 C++ 中的运算符重载。运算符重载让我们可以像内置数据类型一样使用运算符操作自己定义的类型,从而增强代码的可读性和易用性。这里重载了 [] 运算符,使得 arr[i] 这样的语法能够生效。

资源不只有内存,文件、互斥锁、网络连接、窗口句柄等都需要在使用结束后执行相应的释放操作。把这些资源放入类,可以让析构函数自动释放资源。由此形成的编程范式称为 RAII(Resource Acquisition Is Initialization),通常译为「资源获取即初始化」。它的核心思想是:

  1. 对象构造成功时获得并持有资源;
  2. 对象保持有效时,资源也保持可用;
  3. 对象析构时自动释放资源。
cpp
void write_report() {
    ResourceHandle resource;
    use(resource);
} // 离开作用域,resource 的析构函数释放资源

由此,资源的所有权由具有确定生命周期的对象表达,不再依赖程序员主动想起来才释放。

复制 ​

复制构造用于从已有对象初始化新的对象,复制赋值用于替换已经存在的对象:

cpp
Example first;
Example second{first}; // 复制构造
// 或 Example second = first;

Example third;
third = first; // 复制赋值

此前我们一直在提「初始化和赋值是不同的」,却没有详细解释。原因就在这里 —— 初始化时的行为由复制构造函数 Example(const Example& other) 决定,而先初始化再赋值会先调用默认构造函数 Example()、再调用复制赋值运算符 Example& operator=(const Example&)。

在值语义下,Example second{first}; 或 third = first; 产生的 second 和 third 理应是与 first 相互独立的,修改它们不应该改变 first,反过来修改 first 也不应该改变它们。

对于我们这里定义的 MyArray 类,这就需要在复制构造函数与复制赋值运算符中定义完整复制的过程:

cpp
class MyArray {
  public:
    // ...
    MyArray(const MyArray &other)
        : data_{new int[other.size_]}, size_{other.size_} {
        for (std::size_t i{0}; i < size_; i++)
            data_[i] = other.data_[i];
    }
    MyArray &operator=(const MyArray &other) {
        if (this == &other)
            return *this;

        int *new_data = new int[other.size_];
        for (std::size_t i{0}; i < other.size_; i++)
            new_data[i] = other.data_[i];

        delete[] data_;
        data_ = new_data;
        size_ = other.size_;
        return *this;
    }
    // ...
};

MyArray arr(5);
for (std::size_t i{0}; i < arr.size(); i++)
    arr[i] = i;

MyArray arr_copy = arr;
arr[0] = 10;

for (std::size_t i{0}; i < arr.size(); i++)
    std::cout << arr[i] << ' '; // 10 1 2 3 4

std::cout << '\n';

for (std::size_t i{0}; i < arr_copy.size(); i++)
    std::cout << arr_copy[i] << ' '; // 0 1 2 3 4

移动 ​

移动是本节的重点。下面的内容需要花费一些精力理解。

左值与右值 ​

从字面意义上看,「左值」是能放在等号左边的值,「右值」是值能放在等号右边的值。但这么说不太准确。我们在这里先记住一个更有用的近似理解:

  • 左值通常指一个有身份、可以稳定找到的对象;
  • 右值通常是计算产生的临时结果,它的资源往往可以被接管。

const 对象是左值却不能被赋值,某些右值也可以有成员函数调用。

考虑这样一段代码:

cpp
int foo{123};
int bar{foo + 1};
foo = somefunc();

在这里,表达式 foo 和 bar 是左值;123、foo + 1 和返回普通值的 somefunc() 调用表达式是右值。值类别是表达式的属性,不是某个变量永久的标签。

可以看到,左值常常是具名的变量,右值常常是字面量、表达式这样的临时值。注意「临时」这个特性:它一般用完就丢弃了。

右值引用与移动 ​

考虑下面的场景:

cpp
MyArray fetch_data() {
    MyArray arr(1000);
    for (std::size_t i{0}; i < arr.size(); i++)
        arr[i] = i;
    return arr;
}

MyArray arr{fetch_data()};

fetch_data() 这个调用表达式产生临时结果。如果真的需要把结果转移到 arr 里,逐个复制数组元素会十分浪费,如果 arr 能直接接管它持有的内存就好了。

实际上,编译器有一种复制消除机制,这里不会发生复制或移动;我们暂时忽略这项优化,用它来观察移动构造的机制。

cpp
class MyArray {
  public:
    // ...
    MyArray(MyArray &temp_array, bool move) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 提前置空其 data_ 以防止 temp_array 析构时 delete
        temp_array.data_ = nullptr;
    }
    // ...
};

但这样是无法实现的!

cpp
MyArray arr(fetch_data(), true);
// cannot bind non-const lvalue reference of type 'MyArray&' to an rvalue
// of type 'MyArray'

这是因为非 const 左值引用 T& 不能绑定右值。const T& 可以绑定右值,但不能通过它修改源对象,也就无法安全地取走资源。

cpp
int &failed_ref{123};
// error: cannot bind non-const lvalue reference of type 'int&' to an
// rvalue of type 'int'
const int &const_ref{123}; // OK

那要怎么办呢?C++ 提供了右值引用 T&&,它可以绑定这种即将被丢弃的值。

cpp
int &&ref_a_right{5}; // OK

int a{5};
int &&ref_a_left{a};
// cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'

ref_a_right = 6; // OK

右值引用让函数可以专门接收这类值,并修改它所引用的对象。这里有名字的引用变量在表达式中成为了左值。这样我们就可以通过移动构造和移动赋值来接管资源:

cpp
class MyArray {
  public:
    // ...
    MyArray(MyArray &&other) noexcept
        : data_{other.data_}, size_{other.size_} {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    MyArray &operator=(MyArray &&other) noexcept {
        if (this == &other)
            return *this;

        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }
    // ...
};

这样就实现了「移动语义」。新对象直接接管原对象的内存,不需要重新分配和逐项复制。源对象留在可以安全析构和重新赋值的状态。移动赋值与移动构造不同:被赋值的对象原本可能已经拥有资源,所以接管之前必须先释放旧资源。

Rule of Zero ​

我们刚才花了很多功夫「再造」了一个数组。事实上我们前面介绍过的标准库容器,例如 std::array、std::vector、std::string 等都已经实现了复制和移动语义,我们直接使用即可。

可是,我们此前似乎都没有定义过这些复制和移动函数,为什么代码也能正常工作呢?这是因为编译器会自动处理它们。考虑第一章使用的学生:

cpp
struct Student {
    int id{};
    std::string name;
    Score score;
};

编译器会为它生成逐成员复制操作:int 和 Score 被复制,std::string 也复制自己的文本:

cpp
Student first{1001, "Alice", Score{85}};
Student second = first;
second.name = "Bob";

// first.name 仍然是 "Alice"

std::string 内部可能使用动态内存,但它已经正确实现资源管理和复制。所以 Student 不需要知道这些细节,只需要让编译器自动处理即可。

Rule of Zero(零法则)是现代类设计最重要的默认选择:

如果类的成员都能正确管理自身资源,就不要自己定义析构、复制和移动操作。

例如:

cpp
#include <string>
#include <utility>
#include <vector>

class GradeBook {
  public:
    void add(Student student) { students_.push_back(std::move(student)); }

  private:
    std::string course_name_;
    std::vector<Student> students_;
};

string 和 vector 已经实现了正确的特殊成员函数,因此 GradeBook 默认就可以安全复制、移动和销毁。这正是组合资源管理类型的价值。

Rule of Five ​

如果一个类确实直接管理资源,并且需要自定义析构,那么另外几种操作往往也需要一起审视。这称为 Rule of Five(五法则)。

反面教材 ​

下面是一个错误的数组包装:

cpp
class BadArray {
  public:
    explicit BadArray(std::size_t size)
        : size_{size}, data_{new int[size]} {}
    ~BadArray() { delete[] data_; }

  private:
    std::size_t size_{};
    int *data_{};
};

它的析构函数能够释放内存,但编译器提供的默认复制只会逐成员复制,因此指针 data_ 只是被复制了一份,两个对象的 data_ 指向的是同一片内存:

cpp
BadArray first{100};
BadArray second = first;

这导致析构时会重复 delete[]。如果想让它具有值语义,就必须深复制元素;如果不允许复制,就必须明确删除复制操作。

检查清单 ​

当确实需要自己处理资源,问自己如下几个问题:

  • 复制是深复制、共享资源,还是应被禁止?
  • 赋值前如何处理目标对象已经拥有的资源?
  • 移动后,源对象怎样保持可析构、可赋值?
  • 自赋值是否安全?
  • 某一步失败时,原对象是否仍然有效?

如果只是希望禁止复制,可以显式写出:

cpp
class Session {
  public:
    Session() = default;

    Session(const Session &) = delete;
    Session &operator=(const Session &) = delete;

    Session(Session &&) noexcept = default;
    Session &operator=(Session &&) noexcept = default;

    // ...
};

= delete 表示不允许该操作,= default 表示要求编译器生成默认实现。

用移动语义节省开销 ​

我们刚才提到,移动语义的诞生是为了避免高开销对象在转移时的多余复制。当实参是右值,而类型又提供了合适的移动操作时,重载解析会优先选择它。但有的时候,一个有名字的左值在某次操作后也不再需要了。考虑下面的代码:

cpp
#include <iostream>
#include <string>
#include <vector>

std::vector<std::string> names{};
int count{};
std::cin >> count;
names.reserve(count);
for (int i = 0; i < count; i++) {
    std::string name;
    std::cin >> name;
    names.push_back(name);
}

在每次循环时新建了 name,然后 push_back,再进入下一个循环。看上去好像没有任何问题,但是你有没有想过:push_back 是复制还是移动?我们做一个小测试。

cpp
std::string name{"Hello world!"};
std::vector<std::string> names{};
names.push_back(name);

name[0] = 'h';
std::cout << name << '\n';     // hello world!
std::cout << names[0] << '\n'; // Hello world!

是复制。改动 name 不会影响 names[0]。但是在前面的代码中,for 循环里 name 输入、push_back 之后就没有用了。额外复制这一次岂不浪费?如果 push_back 可以直接用移动语义把 name 移动过去就好了。可是 name 是左值而非右值,编译器如何知道它可以安全地移动?

std::move ​

为此,C++ 提供了 std::move:

cpp
#include <utility>

names.push_back(std::move(name));

std::move 本身不移动任何东西,std::move 本身不移动任何东西,std::move 本身不移动任何东西!重要的事情说三遍。

std::move 的作用是把表达式转换成允许选择移动操作的形式。也就是说 std::move(name) 能让 push_back 认为这东西是一个右值,从而触发移动操作。它表达的是「后续不再依赖这个值,可以让接收者取走其资源」。

因此 std::move 也未必都能节省开销。一方面,对 int 之类的简单类型,本来开销就低,即使右值语义也是复制;另一方面,移动语义依赖对象上定义的移动操作,如果没有良好定义移动,最终还是复制。

移动之后? ​

这里还涉及到一个问题:被移动之后变量会怎么样?

对标准库类型进行移动后,源对象通常处于「有效但值未指定」的状态。对象仍然可以析构,也可以执行不依赖当前值的操作,例如重新赋值。它不一定为空。

cpp
std::string source{"Alice"};
std::string target{std::move(source)};

source = "Bob"; // 合法:为移动后的对象赋予新值

不同的对象行为并不一定一致。最佳实践是,既然已经移动了,就不要再碰这个对象了。如果后续还需要使用,就不应该使用 std::move。

不需要手工移动的场景 ​

cpp
Student make_student() {
    Student result{1001, "Alice", Score{85}};
    return result;
}

这里编译器可以通过返回值优化直接在调用者的位置构造 result。写成 return std::move(result); 反而可能阻碍编译器的优化,多此一举。

noexcept 与移动、析构 ​

一些容器在重新分配时,需要在「移动旧元素」和「复制旧元素」之间选择。如果移动构造可能抛出异常,而复制可用,容器可能选择复制,以便在失败时保留原有元素。因此,一个确实不会失败的自定义移动操作通常应声明为 noexcept。但这必须是真实的 —— 如果成员的移动可能抛出,就不能为了性能谎称不会抛出,否则异常逃出时程序会调用 std::terminate。

自定义 RAII 类型通常在析构函数中释放资源。析构函数应当可靠、快速,并且通常不能让异常逃出。否则在另一个异常传播期间再次抛出异常,程序可能直接终止。析构也不应悄悄执行需要向用户报告结果的复杂业务事务。可以显式提供 commit()、close() 等操作处理可能失败的步骤,再让析构完成不能遗漏的清理。

在 Rule of Zero 下,编译器会根据成员类型推导默认操作的异常说明,通常不需要手工干预。

小结 ​

  • 值语义让复制后的对象相等但彼此独立
  • RAII 指的是资源有效期绑定到对象生命周期
  • 标准库资源类型可以组合出正确的默认复制与移动
  • 优先遵循 Rule of Zero,不要机械实现特殊成员函数
  • 直接管理资源时,析构、复制和移动必须一起审视
  • std::move 只是允许选择移动操作,不会自行移动
  • 移动不保证更快,移动后的值也不一定为空
  • 返回局部对象时直接 return result
  • 只有能兑现保证时才把移动操作声明为 noexcept

延伸阅读:C++ Core Guidelines:默认操作。