浅谈 C++ 类的拷贝控制

在 C++ 语言的学习过程中,类的拷贝控制是一个较为繁杂的知识点。虽然它的难度不是很大,但是细节很多,需要理解记忆。本文介绍 C++ 类的拷贝控制基本内容,即:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数这 5 个函数的写法。

为了便于介绍,我们自己实现一个简单的 string 类,命名为 String 。它只包含一个私有的数据成员:char *data ,关键函数成员包括:两个构造函数和五大拷贝控制函数。

类的定义(部分辅助函数在类内定义,下文介绍)如下:

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
#include <cstring>
#include <iostream>

#define PRINT(message) \
std::cout << message << "\t" << __LINE__ << ":\t" << __FUNCTION__ << std::endl

class String {
private:
void error();

void copy_str(const char *s) noexcept;

void release_memory() noexcept;

public:
String() = default;

explicit String(const char *) noexcept;

String(const String &) noexcept; // 拷贝构造函数

String &operator=(const String &) noexcept; // 拷贝赋值运算符

String(String &&) noexcept; // 移动构造函数

String &operator=(String &&) noexcept; // 移动赋值运算符

~String() noexcept; // 析构函数

private:
char *data{nullptr};
};

构造函数与拷贝构造相关的 5 个函数都应该声明为 noexcept ,以让编译器更好地进行编译优化。同时这也意味着:一般情况下,构造函数、析构函数、拷贝/移动相关的函数都不应该抛出异常,一旦这些函数运行时出错,将直接退出程序。

1. 构造函数

为了介绍五大拷贝控制函数,需要先看类的两个构造函数。

首先是默认构造函数:

1
String() = default;

注意,这里在类内直接初始化数据成员:

1
2
3
4
class String {
private:
char *data{nullptr};
};

而不是在默认构造函数中使用如下形式初始化:

1
String() : data{nullptr} {}

因为前一种形式直接对数据成员进行初始化,代码具有更好的局部性和可读性。C++ Core Guideline 推荐采用前一种写法。

第二个构造函数接收一个 const char * 类型指针,动态申请内存并从参数中拷贝构造字符串。代码实现如下:

1
2
3
4
String::String(const char *s) noexcept {
PRINT("构造函数");
copy_str(s);
}

构造函数中包括一个用于追踪函数调用的宏,打印函数调用信息,以及真正执行构造的 copy_str 函数,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class String {
private:
void error() {
std::cerr << "Error!" << std::endl;
exit(-1);
}

void copy_str(const char *s) noexcept {
if (s == nullptr) {
data = nullptr;
return;
}
int length = strlen(s);
data = new char[length + 1];
if (data != nullptr) {
strcpy(data, s);
} else {
error();
}
}
};

函数内进行动态内存分配及字符串拷贝,并处理可能出现的内存不足错误。

接下来正式进入拷贝控制的内容。

2. 析构函数

先从最简单的析构函数开始:

析构函数的声明形式为:

1
~ClassName() noexcept {  /* 函数体 */  }

析构函数一般进行类的资源释放,对于本 String 类,需要释放 data 指针对应的内存,代码如下:

1
2
3
4
String::~String() noexcept {
PRINT("析构函数");
release_memory();
}

析构函数内调用 release_memory 函数释放动态分配的内存:

1
2
3
4
5
6
7
8
class String {
void release_memory() noexcept {
if (data != nullptr) {
delete[] data;
data = nullptr;
}
}
};

何时调用析构函数:

  1. 变量离开作用域时
  2. 当一个对象被销毁时,其成员被销毁
  3. 容器(标准库容器以及数组)被销毁时,其元素被销毁
  4. 动态分配对象被 delete
  5. 对于临时对象,当创建它的完整表达式结束时被销毁

3. 拷贝构造函数

拷贝构造函数声明形式为:

1
ClassName(const ClassName &s) noexcept {  /* 函数体 */   }

注意点:

  1. 拷贝构造函数是构造函数,无返回类型
  2. 拷贝构造函数的参数类型为 const 引用类型
  3. 拷贝构造函数一般需要动态申请内存,进行深拷贝工作,对于本 String 类,需要申请内存,并从参数字符串中拷贝数据

String 类的拷贝构造函数实现如下:

1
2
3
4
String::String(const String &s) noexcept {
PRINT("拷贝构造函数");
copy_str(s.data);
}

何时调用拷贝构造函数:

  1. 用 = 定义变量时
  2. 将一个对象作为实参传递给一个非引用类型的形参(函数调用以值传递)
  3. 从一个返回类型为非引用类型的函数返回一个对象(函数返回)
  4. 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员

4. 拷贝赋值运算符

拷贝赋值运算符声明形式为:

1
ClassName & operator= (const ClassName &s) noexcept {  /* 函数体 */   }

注意点:

  1. 拷贝赋值运算符是一种运算符重载,可以看作名字为:operator= 的函数,其 参数类型为 const 引用类型返回类型为引用类型
  2. 拷贝赋值运算符一般需要执行析构函数和构造函数的功能
  3. 需要考虑自赋值情况,即:a = a ,确保自赋值情况拷贝赋值运算符正确执行。为了保证自赋值正确执行,一般采取:先申明临时变量,然后将参数拷贝到临时变量,最后将临时变量拷贝到本对象的步骤

String 类的拷贝赋值运算符实现如下:

1
2
3
4
5
6
String &String::operator=(const String &s) noexcept {
PRINT("拷贝赋值运算符");
release_memory();
copy_str(s.data);
return *this;
}

何时调用拷贝赋值运算符:

  1. 赋值符号右侧为一个左值时

5. 移动构造函数

移动构造函数声明形式为:

1
2

ClassName(ClassName &&s) noexcept { /* 函数体 */ }

注意点:

  1. 移动构造函数属于构造函数,无返回类型。其参数类型为右值引用不取 const 类型
  2. 移动构造函数的特性在于“移动”,它一般执行“浅拷贝”,即不需要动态申请内存,而是将 s 的动态内存指针赋值给本对象的指针,然后将 s 的指针赋值为空

String 类的移动构造函数实现:

1
2
3
4
5
6
7
8
String::String(String &&s) noexcept {
PRINT("移动构造函数");
if (this != &s) // 排除自赋值情况
{
data = s.data; // 直接赋值 不申请内存
s.data = nullptr; // 将赋值运算符右侧对象指针置为空
}
}

何时调用移动构造函数:

  1. 显式地从一个右值进行构造时,例如:利用标准库 move 函数返回一个右值进行构造
  2. 利用移动迭代器构造时

6. 移动赋值运算符

移动赋值运算符声明形式为:

1
2

ClassName &operator= (ClassName &&s) noexcept { /* 函数体 */ }

注意点:

  1. 移动赋值运算符同样是一种运算符重载,其返回类型为类的引用类型,参数类型为类的右值引用,不是 const 类型
  2. 移动赋值运算符特性同样在“移动”二字,它不执行拷贝,执行的动作一般拷贝析构本对象原有的动态内存,然后将参数对象动态内存指针赋值给本对象内指针,最后将参数对象指针赋值为空。正因为该函数需要改变参数,因此不声明为 const 类型

String 类移动赋值运算符实现:

1
2
3
4
5
6
7
8
9
10
String &String::operator=(String &&s) noexcept {
PRINT("移动赋值运算符");
if (this != &s) // 排除自赋值情况
{
release_memory();
data = s.data; // 直接赋值 不申请内存
s.data = nullptr; // 将赋值运算符右侧对象指针置为空
}
return *this;
}

何时调用移动赋值运算符:

  1. 赋值符号右侧为一个右值时

7. 运行结果

完整测试函数:

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
98
99
100
101
102
103
104
105
106
#include <cstring>
#include <iostream>

#define PRINT(message) \
std::cout << message << "\t" << __LINE__ << ":\t" << __FUNCTION__ << std::endl

class String {
private:
void error() {
std::cerr << "Error!" << std::endl;
exit(-1);
}

void copy_str(const char *s) noexcept {
if (s == nullptr) {
data = nullptr;
return;
}
int length = strlen(s);
data = new char[length + 1];
if (data != nullptr) {
strcpy(data, s);
} else {
error();
}
}

void release_memory() noexcept {
if (data != nullptr) {
delete[] data;
data = nullptr;
}
}

public:
String() = default;

explicit String(const char *) noexcept;

String(const String &) noexcept; // 拷贝构造函数

String &operator=(const String &) noexcept; // 拷贝赋值运算符

String(String &&) noexcept; // 移动构造函数

String &operator=(String &&) noexcept; // 移动赋值运算符

~String() noexcept; // 析构函数

private:
char *data{nullptr};
};

String::String(const char *s) noexcept {
PRINT("构造函数");
copy_str(s);
}

String::~String() noexcept {
PRINT("析构函数");
release_memory();
}

String::String(const String &s) noexcept {
PRINT("拷贝构造函数");
copy_str(s.data);
}

String &String::operator=(const String &s) noexcept {
PRINT("拷贝赋值运算符");
release_memory();
copy_str(s.data);
return *this;
}

String::String(String &&s) noexcept {
PRINT("移动构造函数");
if (this != &s) // 排除自赋值情况
{
data = s.data; // 直接赋值 不申请内存
s.data = nullptr; // 将赋值运算符右侧对象指针置为空
}
}

String &String::operator=(String &&s) noexcept {
PRINT("移动赋值运算符");
if (this != &s) // 排除自赋值情况
{
release_memory();
data = s.data; // 直接赋值 不申请内存
s.data = nullptr; // 将赋值运算符右侧对象指针置为空
}
return *this;
}

int main() {
String s1; // 默认构造函数
String s2("I'm String.\n"); // 由字符串构造

String s3 = s2; // 拷贝构造函数
s1 = s2; // 拷贝赋值运算符
String s4(std::move(s1)); // 移动构造函数
s2 = String("hhh"); // 移动赋值运算符
std::cout << "-----------------" << std::endl;
return 0;
}

运行结果:

在 ubuntu 22.04 上使用 11.4.0 版本的 GCC 编译器,使用编译命令 g++ test.cpp -o test 编译运行,运行结果如下:

根据输出结果,可以追踪到各个函数在什么时候被调用。



参考文献

  • 《C++ Primer 第五版》 第 13 章 拷贝控制
  • 《C++ Core Guidelines 解析》第 5 章 类和类层次结构

浅谈 C++ 类的拷贝控制
https://arcsin2.cloud/2024/01/14/浅谈-C-类的拷贝控制/
作者
arcsin2
发布于
2024年1月14日
许可协议