前言

类的六个特殊成员函数具体是:

1
2
3
4
5
6
7
8
9
class A {
public:
A() = default; // 默认构造函数
A(const A &) = default; // 拷贝构造函数
A &operator=(const A &) = default; // 拷贝赋值函数
A(A &&) noexcept = default; // 移动构造函数
A &operator=(A &&) noexcept = default; // 移动赋值函数
~A() noexcept = default; // 析构函数
};

当我们未显式定义这六个函数时,编译器默认会自动合成,但当它们的其中一些被显式定义或删除时,它们的其中一些不会被生成。这一直是一个非常头疼的点,因为它们之间的关系可以说非常复杂。本文就将介绍这些函数在何时合成,以及应对之法。

开始前的一个小芝士 - 如何自己做实验

这里我们还是问出了一个经典的问题,如何进行实验?基于实验我们可以自己验证一些问题。

从 C++ 11 开始,提出了类型特征(type traits)函数,它们在编译时确认,用于判定类型是否符合某些特征,例如最常用的就是 is_same_v,它用来判断两个类型是否相等,返回一个布尔类型。我们通常与模板元编程结合,用它进行一些断言或者编译时分支的确定。

这里我们需要用到的也正是这类类型特征函数,它们在 type_traits 库中。

1
2
3
4
5
6
7
8
// 其中 Cls 表示我们的自定义类
cout << std::is_default_constructible<Cls>::value << endl; // 是否具有默认构造函数
cout << std::is_copy_constructible<Cls>::value << endl; // 是否具有拷贝构造函数
cout << std::is_copy_assignable<Cls>::value << endl; // 是否具有拷贝赋值函数
cout << std::is_move_constructible<Cls>::value << endl; // 是否可以进行移动构造
cout << std::is_move_assignable<Cls>::value << endl; // 是否可以进行移动赋值
cout << std::is_destructible<Cls>::value << endl; // 是否具有析构函数
// 后面的代码示例中为了节约代码篇幅,不会说明我们实验的方法。

当然,这里也有一些坑点。注意我们在移动语义上的 “委婉” 用词:可能类没有移动函数,但当它具有拷贝函数时它也是可移动的,因为移动语义可以重载到拷贝语义上。这种情况我们就只能用打印到终端的方法了。


特殊成员函数的默认行为

  • 默认构造函数的默认行为:调用所有成员的默认构造函数(对于基本数据成员进行默认初始化)
  • 拷贝构造函数的默认行为:调用所有成员的拷贝构造函数(对于基本数据成员直接拷贝)
  • 拷贝赋值函数的默认行为:调用所有成员的拷贝赋值函数(对于基本数据成员直接拷贝)
  • 移动构造函数的默认行为:调用所有成员的移动构造函数(对于基本数据成员直接拷贝)
  • 移动赋值函数的默认行为:调用所有成员的移动赋值函数(对于基本数据成员直接拷贝)
  • 析构函数的默认行为:调用所有成员的析构函数(对于基本数据成员释放其栈空间)

注意: 对于默认构造函数,其实际与 A() {} 是等价的,因为构造函数被定义就无法禁止其成员进行构造(当然可以使用构造函数初始化列表重定义成员构造),{} 中主代码只能在构造完成后进行额外操作。对于析构函数,其实际与 ~A() {} 是等价的,因为析构函数被定义就无法禁止其成员进行自动析构(当然,可以删除析构函数),{} 中主代码只能在析构前进行额外操作,如堆空间的管理。


显式定义特殊成员函数

显式定义特殊成员函数的方法有三,我们以默认构造函数为例:

  • 显式使用默认行为:A() = default;,在一些场景默认行为不会隐式生成,需要用户显式指定。
  • 显式删除合成函数:A() = delete;,在一些场景默认合成,使用该方法删除它。
  • 自定义行为:A() : ... {...},也就是我们完全选择重写。

强制删除默认方法的场景

所谓强制删除默认方法是指,即使你显式指定为 default,该特殊成员函数依然是被删除的。另外,如果你将它们进行自定义,并按照默认行为重写也是错误的,因为强制删除的原因就是默认行为无法继续。

  • 如果一个类的成员相对应的特殊成员函数是删除的(或不可见的),则该特殊成员函数的默认行为是强制删除的。例如成员的拷贝构造函数是删除的,则类的拷贝构造函数的默认行为是强制删除的,当然自定义行为是允许的。这是因为注意到它们的默认行为是调用其成员的对应行为,如果成员的该行为被删除,则类自然不能完成其默认行为。
  • 如果一个类的析构函数是删除的(或不可见的(private)),则其所有构造函数都是强制删除的。注意不仅是特殊成员构造函数,还是其他自定义的构造函数都会被强制删除。(注意与第一条存在传递关系)
  • 如果一个类的成员是 const 或引用的,且该成员不完全实现类内初始化,则其默认构造函数是强制删除的。其作用实际是保证其 const 或引用成员正常完成初始化。
  • 如果一个类的成员是 const 或引用的,则其拷贝和移动赋值函数是删除的。这里无论其成员是否实现类内初始化,这样设计的意义与上一点相同。

下面是一些供参考的示例:

1
2
3
4
5
6
7
8
9
10
11
12
class Member {
public:
int x = 0;
int y;
};
class Class {
public:
Member mem1;
const Member mem2;
int &r;
Class() = default;
};

在上面这个示例中:mem1 成员不是 const 或引用的,因此我们无需考虑它;mem2 成员是 const 的,且其成员 x 实现了类内初始化,而其成员 y 没有实现,因此 Member 不完全实现类内初始化,实际上已经满足强制删除的条件;最后,r 成员是基本数据类型的引用,它也没有实现类内初始化(即绑定)。因此 Class 的默认构造函数被强制删除。

现在我们将代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Member {
public:
int x = 0;
int y;
Member() : y(10) {};
};
int i = 9;
class Class {
public:
Member mem1;
const Member mem2;
int &r = i;
Class() = default;
};

这样的示例 Class 是具有默认构造函数的。我们来分析一下 mem2,虽然 mem2 没有显式类内初始化,但它通过自定义的默认构造函数可以完成完全的类内初始化,这也是我们在图中提到的向成员传递参数。

这里以拷贝构造函数为例。

1
2
3
4
5
6
7
8
9
10
11
class Member {
// Member &operator=(const Member &) = default; // Case 2
public:
Member &operator=(const Member &) = delete; // Case 1
// Member &operator=(const Member &) { ...; return *this; } // Case 3
};
class Class {
Member mem;
public:
Member &operator=(const Member &) = default;
}
  • Case 1Class 的拷贝构造函数的默认行为是删除的,因为成员不具有拷贝构造函数。
  • Case 2Class 的拷贝构造函数的默认行为是删除的,因为成员的拷贝构造函数对 Class 是不可见的。这里需要注意这与 mem 对类 Class 外部是否可见无关。
  • Case 3Class 将具有拷贝构造函数,注意这与成员的拷贝构造函数是默认行为还是自定义行为无关,只要具有即可。

析构函数缺失是非常致命的,它将导致所有构造方法都不被允许。因为一个函数如果栈空间都不被允许释放,那么它将被禁止以任何方式构造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Member {
public:
~Member() noexcept = delete;
// 默认构造函数强制删除
// 拷贝构造函数强制删除
// 移动构造函数强制删除
};
class Class {
public:
// 析构函数强制删除
// 默认构造函数强制删除 // 这里存在传递关系
// 拷贝构造函数强制删除 // 这里存在传递关系
// 移动构造函数强制删除 // 这里存在传递关系
}

任何不可析构或包含不可析构成员的类都不允许进行任何构造。

那么不可析构类存在的意义是什么

不可析构通常很少被使用,只有当希望其析构被其他方法托管时。

  • 析构函数被删除的类,可以使用 reinterpret_cast 获取该类的指针,同时它天然不允许任何构造方法存在。例如直接从内存读取的二进制数据只能使用 reinterpret_cast 得到可用的该类,同时它的析构方法被原指针持有。
  • 析构函数被隐藏的类,可用于创建单例模式。单例通过 static 的函数创建,并且可以避免用户使用 delete 显式析构单例。

隐式删除默认方法的场景

后面我们将会介绍三 / 五法则,因此你可能不需要过多关注此章节的复杂规则。

隐式删除默认方法是指,该特殊成员函数不会默认合成,但可以通过显式定义(default / 自定义)生成。

  • 如果任何构造函数被显式定义(delete / default / 自定义),则默认构造函数将被隐式删除。注意不仅是特殊成员构造函数,还是其他自定义的构造函数都会隐式删除默认构造函数。
  • 如果拷贝构造 / 赋值函数被显式定义,则移动构造 / 赋值函数将被隐式删除;相反地,如果移动构造 / 赋值函数被显式定义,则拷贝构造 / 赋值函数将被隐式删除。
  • 对于移动操作,如果移动构造函数被显式定义,则移动赋值函数也将被隐式删除;相反地,如果移动赋值函数被显式定义,则移动构造函数也将被隐式删除。拷贝构造 / 赋值函数则没有此性质。
  • 对于移动操作,如果析构函数被显式定义,则移动构造函数和移动赋值函数被隐式删除。

特别提醒一处可能的疑惑:虽然拷贝构造函自定义的,不会隐式删除拷贝赋值函数,但此时拷贝赋值函数不会使用仿照拷贝构造函数的方法,它依然使用默认行为,这是实际编程中容易忽视的;反之亦然。

(此图中,虚线箭头表示的关系是,如果前者被显式定义(delete / default / 自定义),则后者将被隐式删除。)


三 / 五法则

三五法则 中,三是指默认构造函数、拷贝构造函数、拷贝赋值函数,五是指默认构造函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数,它们被统称为 拷贝控制函数 。这里之所以区分三五,在于你实现的类是否关注移动语义,对于许多类你确实可以抛弃移动语义。

三五法则很简单:当你想要显式定义(delete / default / 自定义)拷贝控制函数中的任一时,你必须显式定义所有的拷贝控制函数。这其实就是因为隐式删除的场景十分复杂,而且容易遗忘,你必须通过三五法则认真考虑每个函数的实现。

另外,如果不遵循三五法则,那么可能还会因为其不明显的拷贝控制函数定义,在所有将该类作为成员的类中导致一些意料之外的强制删除问题。


六个特殊成员函数的总关系图

(图中,强制删除(实线)是指:如果前者被删除(delete),则后者的自动合成方法被删除,即使显式自动(default)也被编译器忽视;隐式删除(虚线)是指:如果前者被显式定义(delete / default / 自定义),则后者的自动合成方法被隐式删除,但用户可以显式定义(default / 自定义)后者。)

其中,隐式删除最好遵循三五法则,无需记忆。

最后,我们将上面的两张表放在一起: