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(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 文件花的时间!), 而 intint* 等内置类型拷贝代价很低, 反而移动代价很高, 因而 默认情况下即便我们在移动, 内置类型也会进行拷贝.

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, 因而关闭文件两次!

这正是 本问题的提问人所犯的错误!

如果需要自定义拷贝构造函数、拷贝赋值函数、(移动构造函数、移动赋值函数、) 析构函数, 总是定义它们所有. 此外, 你可以 用拷贝构造函数来实现拷贝赋值函数. 如果默认的行为仍然可行, 用 =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::fopenstd::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};