在 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 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; } } };
|
何时调用析构函数:
- 变量离开作用域时
- 当一个对象被销毁时,其成员被销毁
- 容器(标准库容器以及数组)被销毁时,其元素被销毁
- 动态分配对象被
delete
时
- 对于临时对象,当创建它的完整表达式结束时被销毁
3. 拷贝构造函数
拷贝构造函数声明形式为:
1
| ClassName(const ClassName &s) noexcept { }
|
注意点:
- 拷贝构造函数是构造函数,无返回类型
- 拷贝构造函数的参数类型为 const 引用类型
- 拷贝构造函数一般需要动态申请内存,进行深拷贝工作,对于本
String
类,需要申请内存,并从参数字符串中拷贝数据
String
类的拷贝构造函数实现如下:
1 2 3 4
| String::String(const String &s) noexcept { PRINT("拷贝构造函数"); copy_str(s.data); }
|
何时调用拷贝构造函数:
- 用 = 定义变量时
- 将一个对象作为实参传递给一个非引用类型的形参(函数调用以值传递)
- 从一个返回类型为非引用类型的函数返回一个对象(函数返回)
- 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员
4. 拷贝赋值运算符
拷贝赋值运算符声明形式为:
1
| ClassName & operator= (const ClassName &s) noexcept { }
|
注意点:
- 拷贝赋值运算符是一种运算符重载,可以看作名字为:
operator=
的函数,其 参数类型为 const
引用类型,返回类型为引用类型
- 拷贝赋值运算符一般需要执行析构函数和构造函数的功能
- 需要考虑自赋值情况,即:
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; }
|
何时调用拷贝赋值运算符:
- 赋值符号右侧为一个左值时
5. 移动构造函数
移动构造函数声明形式为:
1 2
| ClassName(ClassName &&s) noexcept { }
|
注意点:
- 移动构造函数属于构造函数,无返回类型。其参数类型为右值引用,不取
const 类型
- 移动构造函数的特性在于“移动”,它一般执行“浅拷贝”,即不需要动态申请内存,而是将
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; } }
|
何时调用移动构造函数:
- 显式地从一个右值进行构造时,例如:利用标准库
move
函数返回一个右值进行构造
- 利用移动迭代器构造时
6. 移动赋值运算符
移动赋值运算符声明形式为:
1 2
| ClassName &operator= (ClassName &&s) noexcept { }
|
注意点:
- 移动赋值运算符同样是一种运算符重载,其返回类型为类的引用类型,参数类型为类的右值引用,不是
const 类型
- 移动赋值运算符特性同样在“移动”二字,它不执行拷贝,执行的动作一般拷贝析构本对象原有的动态内存,然后将参数对象动态内存指针赋值给本对象内指针,最后将参数对象指针赋值为空。正因为该函数需要改变参数,因此不声明为
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; }
|
何时调用移动赋值运算符:
- 赋值符号右侧为一个右值时
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 章 类和类层次结构