我们要知道,在 C++98 中,如果一个类包含 堆资源(例如动态分配的内存、文件句柄等),为了管理这些资源,需要手动编写 拷贝构造函数赋值运算符,以实现 深拷贝。然而,深拷贝会带来性能开销,尤其是在处理临时对象时,可能导致不必要的资源申请和释放。为了解决这个问题,C++11 引入了 移动语义,允许我们高效地转移资源的所有权,避免不必要的拷贝操作。


一、为什么需要移动语义

1.1 深拷贝的代价

在传统的 C++98 中,如果一个类拥有指向堆内存的指针,需要实现深拷贝以避免 浅拷贝 带来的问题(如多次释放同一内存)。深拷贝会为每个对象创建自己独立的资源副本。

缺点:

  • 性能开销大:深拷贝涉及内存分配和数据复制,耗费时间和资源。
  • 资源浪费:对于临时对象,拷贝后立即销毁,导致资源的申请和释放没有实际意义。

1.2 移动语义的引入

C++11 引入了 移动语义(Move Semantics),允许对象的资源所有权在不发生深拷贝的情况下从一个对象转移到另一个对象。这对于临时对象尤为有用,因为临时对象在表达式结束后会被销毁,转移其资源可以避免不必要的拷贝。

优势:

  • 提高性能:避免了深拷贝的开销。
  • 资源高效利用:直接使用源对象的资源,无需重新分配。

二、左值、右值与右值引用

2.1 左值(Lvalue)与右值(Rvalue)

  • 左值(Lvalue):表示具名的、可持久存储的对象,可以取地址(&)。通常是变量、数组元素、对象成员等。
  • 右值(Rvalue):表示临时的、不可持久存储的值,通常是字面量、表达式计算结果、匿名对象等。

判断方法:

  • 能否取地址:能取地址的是左值,不能的是右值。
  • 是否具名:具名对象通常是左值。

2.2 右值引用(Rvalue Reference)

C++11 引入了 右值引用,语法为 Type&&,用于引用右值(临时对象)。

特点:

  • 只能绑定右值:右值引用只能绑定到右值。
  • 延长对象生命周期:右值引用可延长临时对象的生命周期。
  • 支持移动语义:通过右值引用,可以实现移动构造和移动赋值。
1
int&& rvalue_ref = 42; // 42 是右值

2.3 std::move() 的作用

std::move() 是一个标准库函数,用于将 左值 转换为 右值引用,指示对象可以被移动。

1
2
3
#include <utility>

Type&& rref = std::move(lvalue);

注意:

  • 使用 std::move() 后,源对象仍然存在,但其资源可能已被转移,需谨慎使用。
  • std::move() 并不移动对象,它只是进行类型转换。

三、移动构造函数与移动赋值运算符

为了实现移动语义,需要在类中定义 移动构造函数移动赋值运算符

3.1 移动构造函数

1
ClassName(ClassName&& source);

实现要点:

  • 参数为 右值引用,接受一个临时对象。
  • 转移资源所有权:将源对象的资源指针赋值给当前对象。
  • 置空源对象的指针:防止源对象在析构时释放资源。
  • 不进行深拷贝:避免性能开销。
1
2
3
4
ClassName(ClassName&& source) {
    this->ptr = source.ptr;
    source.ptr = nullptr;
}

3.2 移动赋值运算符

1
ClassName& operator=(ClassName&& source);

实现要点:

  • 检查 自我赋值,避免错误。
  • 释放当前对象的资源,防止内存泄漏。
  • 转移资源所有权:将源对象的资源指针赋值给当前对象。
  • 置空源对象的指针
1
2
3
4
5
6
7
8
ClassName& operator=(ClassName&& source) {
    if (this != &source) {
        delete this->ptr;
        this->ptr = source.ptr;
        source.ptr = nullptr;
    }
    return *this;
}

注意事项

  1. 异常安全:移动操作应确保异常安全,推荐在函数后加上 noexcept
  2. 自我赋值检查:防止对象与自身进行移动赋值。
  3. 资源管理:移动后源对象应处于可析构的安全状态。

四、实现移动语义的注意事项

4.1 使用 std::move() 将左值转换为右值引用

对于一些左值(如局部变量),其生命周期很短,使用移动语义可以提高性能。std::move() 可以将左值转换为右值引用,使其能够调用移动构造函数或移动赋值运算符。

1
2
ClassName obj1;
ClassName obj2 = std::move(obj1); // 调用移动构造函数

注意:

  • 经过 std::move() 转换后,源对象的资源可能已被转移,应避免再使用。
  • 源对象在离开作用域时才会析构。

4.2 提供拷贝和移动操作的配套实现

如果类中提供了 拷贝构造函数拷贝赋值运算符,而没有提供对应的 移动构造函数移动赋值运算符,编译器不会为其生成默认的移动操作。因此,为了充分利用移动语义,应为类提供完整的拷贝和移动操作实现。

4.3 标准库容器的移动语义支持

C++11 中的所有标准库容器(如 std::vectorstd::string 等)都实现了移动语义,能够高效地处理含有资源的对象,避免无谓的拷贝。

4.4 移动语义对基本类型无效

对于基本类型(如 intdouble),移动和拷贝的代价相同,使用移动语义没有意义。移动语义主要适用于拥有资源的对象。


五、示例代码解析

下面通过一个完整的示例,展示如何在类中实现移动语义,以及移动语义带来的性能提升。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <iostream>
#include <cstring>  // 包含 memset 和 memcpy

class AA {
public:
    int* m_data = nullptr;  // 指向堆区资源的指针

    // 默认构造函数
    AA() = default;

    // 分配内存
    void alloc() {
        m_data = new int;
        memset(m_data, 0, sizeof(int));
    }

    // 拷贝构造函数(深拷贝)
    AA(const AA& a) {
        std::cout << "调用了拷贝构造函数。\n";
        if (a.m_data) {
            m_data = new int;
            memcpy(m_data, a.m_data, sizeof(int));
        }
    }

    // 移动构造函数
    AA(AA&& a) noexcept {
        std::cout << "调用了移动构造函数。\n";
        m_data = a.m_data;
        a.m_data = nullptr;
    }

    // 拷贝赋值运算符(深拷贝)
    AA& operator=(const AA& a) {
        std::cout << "调用了赋值运算符。\n";
        if (this != &a) {
            if (m_data) delete m_data;
            if (a.m_data) {
                m_data = new int;
                memcpy(m_data, a.m_data, sizeof(int));
            } else {
                m_data = nullptr;
            }
        }
        return *this;
    }

    // 移动赋值运算符
    AA& operator=(AA&& a) noexcept {
        std::cout << "调用了移动赋值运算符。\n";
        if (this != &a) {
            if (m_data) delete m_data;
            m_data = a.m_data;
            a.m_data = nullptr;
        }
        return *this;
    }

    // 析构函数
    ~AA() {
        if (m_data) {
            delete m_data;
            m_data = nullptr;
        }
    }
};

int main() {
    AA a1;          // 创建对象 a1
    a1.alloc();     // 分配堆区资源
    *a1.m_data = 3; // 赋值
    std::cout << "a1.m_data = " << *a1.m_data << std::endl;

    AA a2 = a1;     // 调用拷贝构造函数
    std::cout << "a2.m_data = " << *a2.m_data << std::endl;

    AA a3;
    a3 = a1;        // 调用赋值运算符
    std::cout << "a3.m_data = " << *a3.m_data << std::endl;

    // 返回 AA 对象的 lambda 函数
    auto f = [] {
        AA aa;
        aa.alloc();
        *aa.m_data = 8;
        return aa; // 返回临时对象(右值)
    };

    AA a4 = f();    // 调用移动构造函数
    std::cout << "a4.m_data = " << *a4.m_data << std::endl;

    AA a5;
    a5 = f();       // 调用移动赋值运算符
    std::cout << "a5.m_data = " << *a5.m_data << std::endl;

    return 0;
}

代码解析

  1. AA 的定义

    • 成员变量int* m_data,指向堆区资源的指针。
    • 构造函数
      • 默认构造函数 AA():使用 = default,编译器生成默认实现。
      • 拷贝构造函数 AA(const AA& a):实现深拷贝,分配新内存并复制数据。
      • 移动构造函数 AA(AA&& a) noexcept:转移资源所有权,置空源对象指针。
    • 赋值运算符
      • 拷贝赋值运算符 operator=(const AA& a):实现深拷贝,注意自我赋值检查和释放旧资源。
      • 移动赋值运算符 operator=(AA&& a) noexcept:转移资源,释放旧资源,置空源对象指针。
    • 析构函数:释放堆区资源,防止内存泄漏。
  2. main 函数

    • 创建对象 a1 并分配资源,赋值 3
    • 拷贝构造 a2AA a2 = a1;,调用拷贝构造函数,深拷贝资源。
    • 赋值操作 a3a3 = a1;,调用赋值运算符,深拷贝资源。
    • 移动构造 a4
      • f() 返回一个临时对象(右值)。
      • AA a4 = f(); 调用移动构造函数,转移资源,无需分配新内存。
    • 移动赋值 a5
      • a5 = f();,调用移动赋值运算符,转移资源,释放旧资源。

运行结果

1
2
3
4
5
6
7
8
9
a1.m_data = 3
调用了拷贝构造函数。
a2.m_data = 3
调用了赋值运算符。
a3.m_data = 3
调用了移动构造函数。
a4.m_data = 8
调用了移动赋值运算符。
a5.m_data = 8

说明

  • 拷贝构造函数赋值运算符在处理 a1 时被调用,进行了深拷贝。
  • 移动构造函数在处理 a4 时被调用,直接转移资源,无需分配内存。
  • 移动赋值运算符在处理 a5 时被调用,释放旧资源,转移新资源。