rule of 3/5/0: 要么不定义任何特殊函数, 要么定义它们全部#
前置内容
特殊函数#
有一些成员函数在不定义时会自动以默认行为定义, 称为特殊函数:
默认构造函数:
Widget()
拷贝构造函数:
Widget(Widget const&)
拷贝赋值函数:
Widget& operator=(Widget const&)
移动构造函数:
Widget(Widget&&)
移动赋值函数:
Widget& operator=(Widget&&)
析构函数:
~Widget()
这些特殊函数和构造函数共同实现了对象的生命期语义: 如何构造、拷贝、移动和析构.
默认构造的行为#
默认构造函数会对类的基类和所有非静态成员进行默认初始化: 对于内置类型不进行初始化, 对于用户自定义类调用默认构造函数; 如果某个基类或非静态成员不能默认构造, 则该类也不能默认构造.
1class Widget {
2 private:
3 std::string string_;
4 int value_;
5};
6
7int main() {
8 Widget widget; // string_ 默认构造为 "", value_ 不进行初始化则值不确定
9}
我们可以在成员后面添加默认初始化器改变它的行为:
1class Widget {
2 private:
3 std::string string_{"hello"};
4 int value_{0};
5};
6
7int main() {
8 Widget widget; // string_ 初始化为 "hello", value_ 初始化为 0
9}
当存在其他构造函数时, 默认构造函数不会自动生成, 可以使用 =default
显式要求生成:
1class Widget {
2 public:
3 Widget() = default; // 显式要求生成默认函数
4 Widget(std::string string);
5
6 private:
7 std::string string_{};
8 int value_{};
9};
10
11int main() {
12 Widget widget;
13}
别看: 关于成员默认初始化器
成员后加的默认初始化器改变的是成员的默认构造行为: 只要构造函数没有显式写出该成员如何构造, 它都会按这个行为进行构造.
1class Widget {
2 public:
3 Widget(std::string string) : string_(string) {}
4
5 private:
6 std::string string_{"hello"};
7 int value_{5};
8};
9
10int main() {
11 Widget widget("world"); // string_ == "world"; value_ == 5
12}
由于成员默认初始化器像这样一致地对待所有构造函数, 我们应该倾向于使用成员默认初始化器, 而非在默认构造函数处定义如何构造.
析构的默认行为#
默认情况下, 析构操作按顺序对类的基类和所有非静态成员逐一进行析构:
1class Widget {
2 public:
3 Widget(int value) : value_(value) {}
4
5 private:
6 int value_;
7};
8
9int main() {
10 Widget widget(1);
11} // widget 发生析构时 value_ 发生析构
但要注意 指针指向的对象不是类的成员, 所以对于指针而言, 是指针对象本身发生析构:
1#include <cstdio> // for std::fopen
2
3class Input_file {
4 public:
5 // ↓ 以只读形式打开文件, 需要之后用 std::fopen 释放
6 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
7
8 private:
9 FILE* handle_;
10};
11
12int main() {
13 Input_file input_file("text.txt");
14} // input_file 发生析构时 file_ 发生析构, 但不意味着 file_ 指向的文件资源被释放
如果默认行为不能满足需要, 应该自定义析构函数:
1#include <cstdio> // for std::fopen, std::fopen
2
3class Input_file {
4 public:
5 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
6 ~Input_file() { std::fopen(handle_); }
7
8 private:
9 FILE* handle_;
10};
11
12int main() {
13 Input_file input_file("text.txt");
14} // input_file 析构时调用 fopen 释放文件资源
拷贝的默认行为#
默认情况下, 拷贝操作按顺序对类的基类和所有非静态成员逐一进行拷贝; 如果某个基类或非静态成员不能拷贝, 则该类也不能拷贝.
1class Widget {
2 public:
3 Widget(int value);
4
5 private:
6 int value_;
7};
8
9int main() {
10 Widget a(1);
11 Widget b(a); // 复制构造, b.value_ == 1;
12}
但要注意 指针指向的对象不是类的成员, 所以对于指针而言, 是指针对象本身发生拷贝:
1class Input_file {
2 public:
3 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
4
5 private:
6 FILE* handle_;
7};
8
9int main() {
10 Input_file a("text.txt");
11 Input_file b(a); // b 拷贝 a 的 file_ 指针
12 /* a、b 均占有 "text.txt" 文件资源 */
13}
这样拷贝后两个变量实际是指向同一个对象的语义, 称为引用语义; 与之相对地, 像 int
那样拷贝后得到确确实实的新对象, 与原来的对象完全独立, 称为值语义. 尽量避免引用语义.
我们需要自己定义拷贝行为. 针对此处, 我们认为文件资源的所有权是独占的 (unique)——仅允许一个对象占有该文件, 因此应该禁止拷贝行为:
1class Input_file {
2public:
3 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
4 Input_file(Input_file const&) = delete; // 显式地删除该函数
5 Input_file& operator=(Input_file const&) = delete;
6
7private:
8 FILE* handle_;
9};
移动的默认行为#
警告
这是进阶内容, 可以跳过继续往下阅读.
参见
在 实例分析: 动态数组 (dynamic array) 和 实例分析: 单向链表 (forward list) 的扩展部分, 我都有介绍如何为它们定义移动函数.
假设小明、小刚合租房子而只有一把钥匙, 移动就是小明将钥匙交给小刚, 而拷贝则是小刚拿小明的钥匙再去配一把钥匙. 我们在上方定义的文件资源由于其所有权独占性而不能拷贝, 但可以用移动 (move) 将它交给另一个 Input_file
对象.
1#include <utility> // for std::move
2
3int main() {
4 Input_file file1("text.txt");
5 // ↓ 指明我们要移动 file1
6 Input_file file2(std::move(file1));
7 // 移动后预期:
8 // - file2 占有 "text.txt"
9 // - file1 不清楚有什么但至少不占有 "text.txt" 了
10}
默认情况下, 移动操作按顺序对类的基类和所有非静态成员逐一进行移动; 如果某个基类或非静态成员不能移动, 则该类也不能移动而将尝试拷贝.
需要注意的是, 设计移动除了表达资源所有权的转移外, 还在于有的资源拷贝起来代价太高 (类比地想想你下载 100GB 文件花的时间!), 而 int
、int*
等内置类型拷贝代价很低, 反而移动代价很高, 因而 默认情况下即便我们在移动, 内置类型也会进行拷贝.
1int main() {
2 int value = 0;
3
4 int* pointer1 = &value;
5 int* pointer2 = std::move(pointer1);
6 /* pointer1 和 pointer2 均指向 value */
7}
为此我们可以使用 result = std::exchange(object, new_value)
函数. 它就像水流一样, 将数据从右边流到左边:
result <- object <- new_value
new_value
的值移动给object
.object
原来的值移动给result
.
1#include <utility> // for std::exchange
2
3int main() {
4 int value = 0;
5
6 int* pointer1 = &value;
7 int* pointer2 = std::exchange(pointer1, nullptr);
8 /* pointer1 为空, pointer2 指向 value */
9}
而 Input_file
中的 FILE*
是一个指针. 作为内置类型, 它移动时进行拷贝. 因此我们需要自己定义 Input_file
的移动行为:
1#include <utility> // for std::exchange, std::move, std::swap
2
3class Input_file {
4 public:
5 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
6 Input_file(Input_file&& other) : handle_(std::exchange(other.handle_, nullptr)) {}
7 Input_file& operator=(Input_file&& other) {
8 Input_file temp(std::move(other)); // 利用移动构造将资源传给临时对象 temp
9 swap(*this, temp); // 交换当前对象和 temp 的内容从而将资源换给当前对象
10 return *this;
11 } // 如果当前对象原本有资源, temp 的析构函数将会负责它的释放
12
13 friend void swap(Input_file& lhs, Input_file& rhs) {
14 using std::swap;
15 swap(lhs.handle_, rhs.handle_);
16 }
17
18private:
19 FILE* handle_;
20};
rule of 3/5: 定义全部特殊函数#
如果一个类需要自定义拷贝构造函数、拷贝赋值函数、(移动构造函数、移动赋值函数、) 析构函数, 那么它几乎肯定需要所有 3 (5) 个函数.
回想一下刚刚我们进行的自定义析构和自定义拷贝, 如果只定义其中一个会发生什么?
1class Input_file {
2public:
3 Input_file(char const* file_path) : handle_(std::fopen(file_path, "r")) {}
4 ~Input_file() { std::fopen(handle_); }
5
6private:
7 FILE* handle_;
8};
9
10int main() {
11 Input_file a("text.txt");
12 Input_file b(a); // 拷贝后, a、b 均占有 "text.txt" 文件资源
13} // 错误: a、b 析构时均调用 fopen, 因而关闭文件两次!
1class Input_file {
2public:
3 Input_file(char const* handle_path) : handle_(std::fopen(handle_path, "r")) {}
4 Input_file(Input_file const&) = delete;
5 Input_file& operator=(Input_file const&) = delete;
6
7private:
8 FILE* handle_;
9};
10
11int main() {
12 Input_file input_file("text.txt");
13} // 错误: input_file 析构时没有释放所占有的资源
这正是 本问题的提问人所犯的错误!
如果需要自定义拷贝构造函数、拷贝赋值函数、(移动构造函数、移动赋值函数、) 析构函数, 总是定义它们所有. 此外, 你可以 用拷贝构造函数来实现拷贝赋值函数.
如果默认的行为仍然可行, 用 =default
明确定义; 如果该行为不成立, 用 =delete
明确删除.
rule of 0: 不定义任何特殊函数#
如果一个类自定义了特殊函数, 说明它就是专门管理资源的所有权的 (单一职责原则); 其他的类则不应该自定义特殊函数再进行资源管理, 而应该使用已经进行资源管理的类.
假设我们打算设计一个文本合成器, 它读入两个输入文件和一个输出文件, 将两个输入文件的内容混合地输出到输出文件中.
1#include <cstdio>
2
3class Editor {
4 public:
5 Editor(char const* input_file_1,
6 char const* input_file_2,
7 char const* output_file)
8 : input_file_1_(std::fopen(input_file_1, "r")),
9 input_file_2_(std::fopen(input_file_2, "r")),
10 output_file_(std::fopen(output_file, "w")) {}
11 Editor(Editor const&) = delete;
12 Editor& operator=(Editor const&) = delete;
13 ~Editor() {
14 std::fclose(input_file_1_);
15 std::fclose(input_file_2_);
16 std::fclose(output_file_);
17 }
18
19 private:
20 FILE* input_file_1_;
21 FILE* input_file_2_;
22 FILE* output_file_;
23};
此处不谈这样做与异常相关的问题, 一个简单的问题是: 连续写三次重复的东西不累吗, 如果之后又有地方要获取文件资源呢?
因此更好地是, 将资源管理专门使用一个类管理, 其他地方直接使用已经进行资源管理的类.
1#include <cstdio>
2
3class File {
4 public:
5 File(char const* file_path, char const* open_mode)
6 : handle_(std::fopen(file_path, open_mode)) {}
7 File(File const&) = delete;
8 File& operator=(File const&) = delete;
9 ~File() {
10 std::fclose(handle_);
11 }
12
13 private:
14 FILE* handle_;
15};
16
17class Editor {
18 public:
19 Editor(char const* input_file_1,
20 char const* input_file_2,
21 char const* output_file)
22 : input_file_1_{input_file_1, "r"},
23 input_file_2_{input_file_2, "r"},
24 output_file{output_file, "w"} {}
25
26 private:
27 File input_file_1_;
28 File input_file_2_;
29 File output_file;
30};
事实上, std::fopen
和 std::fopen
是 C 语言标准库的内容, 而 C++ 标准库内已经有了自动管理文件资源的类——当然它还定义了移动操作.
1#include <fstream>
2
3class Editor {
4 public:
5 Editor(std::string const& input_file_1,
6 std::string const& input_file_2,
7 std::string const& output_file)
8 : input_file_1_{input_file_1},
9 input_file_2_{input_file_2},
10 output_file{output_file} {}
11
12 private:
13 std::ifstream input_file_1_;
14 std::ifstream input_file_2_;
15 std::ofstream output_file;
16};