类的六个特殊成员函数的自动合成
前言
类的六个特殊成员函数具体是:
1 | class A { |
当我们未显式定义这六个函数时,编译器默认会自动合成,但当它们的其中一些被显式定义或删除时,它们的其中一些不会被生成。这一直是一个非常头疼的点,因为它们之间的关系可以说非常复杂。本文就将介绍这些函数在何时合成,以及应对之法。
开始前的一个小芝士 - 如何自己做实验
这里我们还是问出了一个经典的问题,如何进行实验?基于实验我们可以自己验证一些问题。
从 C++ 11 开始,提出了类型特征(type traits
)函数,它们在编译时确认,用于判定类型是否符合某些特征,例如最常用的就是 is_same_v
,它用来判断两个类型是否相等,返回一个布尔类型。我们通常与模板元编程结合,用它进行一些断言或者编译时分支的确定。
这里我们需要用到的也正是这类类型特征函数,它们在 type_traits
库中。
1 | // 其中 Cls 表示我们的自定义类 |
当然,这里也有一些坑点。注意我们在移动语义上的 “委婉” 用词:可能类没有移动函数,但当它具有拷贝函数时它也是可移动的,因为移动语义可以重载到拷贝语义上。这种情况我们就只能用打印到终端的方法了。
特殊成员函数的默认行为
- 默认构造函数的默认行为:调用所有成员的默认构造函数(对于基本数据成员进行默认初始化)
- 拷贝构造函数的默认行为:调用所有成员的拷贝构造函数(对于基本数据成员直接拷贝)
- 拷贝赋值函数的默认行为:调用所有成员的拷贝赋值函数(对于基本数据成员直接拷贝)
- 移动构造函数的默认行为:调用所有成员的移动构造函数(对于基本数据成员直接拷贝)
- 移动赋值函数的默认行为:调用所有成员的移动赋值函数(对于基本数据成员直接拷贝)
- 析构函数的默认行为:调用所有成员的析构函数(对于基本数据成员释放其栈空间)
注意: 对于默认构造函数,其实际与 A() {}
是等价的,因为构造函数被定义就无法禁止其成员进行构造(当然可以使用构造函数初始化列表重定义成员构造),{}
中主代码只能在构造完成后进行额外操作。对于析构函数,其实际与 ~A() {}
是等价的,因为析构函数被定义就无法禁止其成员进行自动析构(当然,可以删除析构函数),{}
中主代码只能在析构前进行额外操作,如堆空间的管理。
显式定义特殊成员函数
显式定义特殊成员函数的方法有三,我们以默认构造函数为例:
- 显式使用默认行为:
A() = default;
,在一些场景默认行为不会隐式生成,需要用户显式指定。 - 显式删除合成函数:
A() = delete;
,在一些场景默认合成,使用该方法删除它。 - 自定义行为:
A() : ... {...}
,也就是我们完全选择重写。
强制删除默认方法的场景
所谓强制删除默认方法是指,即使你显式指定为 default
,该特殊成员函数依然是被删除的。另外,如果你将它们进行自定义,并按照默认行为重写也是错误的,因为强制删除的原因就是默认行为无法继续。
- 如果一个类的成员相对应的特殊成员函数是删除的(或不可见的),则该特殊成员函数的默认行为是强制删除的。例如成员的拷贝构造函数是删除的,则类的拷贝构造函数的默认行为是强制删除的,当然自定义行为是允许的。这是因为注意到它们的默认行为是调用其成员的对应行为,如果成员的该行为被删除,则类自然不能完成其默认行为。
- 如果一个类的析构函数是删除的(或不可见的(
private
)),则其所有构造函数都是强制删除的。注意不仅是特殊成员构造函数,还是其他自定义的构造函数都会被强制删除。(注意与第一条存在传递关系) - 如果一个类的成员是
const
或引用的,且该成员不完全实现类内初始化,则其默认构造函数是强制删除的。其作用实际是保证其const
或引用成员正常完成初始化。 - 如果一个类的成员是
const
或引用的,则其拷贝和移动赋值函数是删除的。这里无论其成员是否实现类内初始化,这样设计的意义与上一点相同。
flowchart TD subgraph 成员或派生类 10(拷贝赋值函数缺失) 11(移动赋值函数缺失) subgraph 12["是类的 const 或引用成员"] 12.0(其他) 12.1("且不完全实现类内初始化\n(从类获取参数而确定)") end 13(默认构造函数缺失) 14(析构函数缺失) 15(拷贝构造函数缺失) 16(移动构造函数缺失) end subgraph 类 0(存在 const 或引用成员) 21(拷贝赋值函数缺失) 22(移动赋值函数缺失) 23(其他构造函数缺失) 24(默认构造函数缺失) 25(析构函数缺失) 26(拷贝构造函数缺失) 27(移动构造函数缺失) end 12 --> 0 -->|强制删除| 21 & 22 10 -->|强制删除| 21 11 -->|强制删除| 22 12.1 <-->|强制删除| 24 13 -->|强制删除| 24 14 -->|强制删除| 25 -->|强制删除| 24 & 23 & 26 & 27 15 -->|强制删除| 26 16 -->|强制删除| 27
下面是一些供参考的示例:
1 | class Member { |
在上面这个示例中:mem1
成员不是 const
或引用的,因此我们无需考虑它;mem2
成员是 const
的,且其成员 x
实现了类内初始化,而其成员 y
没有实现,因此 Member
不完全实现类内初始化,实际上已经满足强制删除的条件;最后,r
成员是基本数据类型的引用,它也没有实现类内初始化(即绑定)。因此 Class
的默认构造函数被强制删除。
现在我们将代码修改如下:
1 | class Member { |
这样的示例 Class
是具有默认构造函数的。我们来分析一下 mem2
,虽然 mem2
没有显式类内初始化,但它通过自定义的默认构造函数可以完成完全的类内初始化,这也是我们在图中提到的向成员传递参数。
这里以拷贝构造函数为例。
1 | class Member { |
- Case 1 :
Class
的拷贝构造函数的默认行为是删除的,因为成员不具有拷贝构造函数。 - Case 2 :
Class
的拷贝构造函数的默认行为是删除的,因为成员的拷贝构造函数对Class
是不可见的。这里需要注意这与mem
对类Class
外部是否可见无关。 - Case 3 :
Class
将具有拷贝构造函数,注意这与成员的拷贝构造函数是默认行为还是自定义行为无关,只要具有即可。
析构函数缺失是非常致命的,它将导致所有构造方法都不被允许。因为一个函数如果栈空间都不被允许释放,那么它将被禁止以任何方式构造。
1 | class Member { |
任何不可析构或包含不可析构成员的类都不允许进行任何构造。
那么不可析构类存在的意义是什么
不可析构通常很少被使用,只有当希望其析构被其他方法托管时。
- 析构函数被删除的类,可以使用
reinterpret_cast
获取该类的指针,同时它天然不允许任何构造方法存在。例如直接从内存读取的二进制数据只能使用reinterpret_cast
得到可用的该类,同时它的析构方法被原指针持有。 - 析构函数被隐藏的类,可用于创建单例模式。单例通过
static
的函数创建,并且可以避免用户使用delete
显式析构单例。
隐式删除默认方法的场景
后面我们将会介绍三 / 五法则,因此你可能不需要过多关注此章节的复杂规则。
隐式删除默认方法是指,该特殊成员函数不会默认合成,但可以通过显式定义(default
/ 自定义)生成。
- 如果任何构造函数被显式定义(
delete
/default
/ 自定义),则默认构造函数将被隐式删除。注意不仅是特殊成员构造函数,还是其他自定义的构造函数都会隐式删除默认构造函数。 - 如果拷贝构造 / 赋值函数被显式定义,则移动构造 / 赋值函数将被隐式删除;相反地,如果移动构造 / 赋值函数被显式定义,则拷贝构造 / 赋值函数将被隐式删除。
- 对于移动操作,如果移动构造函数被显式定义,则移动赋值函数也将被隐式删除;相反地,如果移动赋值函数被显式定义,则移动构造函数也将被隐式删除。拷贝构造 / 赋值函数则没有此性质。
- 对于移动操作,如果析构函数被显式定义,则移动构造函数和移动赋值函数被隐式删除。
特别提醒一处可能的疑惑:虽然拷贝构造函自定义的,不会隐式删除拷贝赋值函数,但此时拷贝赋值函数不会使用仿照拷贝构造函数的方法,它依然使用默认行为,这是实际编程中容易忽视的;反之亦然。
flowchart RL 0(默认构造函数) subgraph " " 1(拷贝构造函数) 2(移动构造函数) 3(其他构造函数) end subgraph " " 4(移动赋值函数) 5(拷贝赋值函数) end 6(析构函数) 1 & 2 & 3 -.->|隐式删除| 0 4 <-.->|隐式删除| 2 4 <-.->|隐式删除| 1 5 <-.->|隐式删除| 2 2 <-.->|隐式删除| 1 5 <-.->|隐式删除| 4 6 -.->|隐式删除| 2 & 4
(此图中,虚线箭头表示的关系是,如果前者被显式定义(delete
/ default
/ 自定义),则后者将被隐式删除。)
三 / 五法则
三五法则 中,三是指默认构造函数、拷贝构造函数、拷贝赋值函数,五是指默认构造函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数,它们被统称为 拷贝控制函数 。这里之所以区分三五,在于你实现的类是否关注移动语义,对于许多类你确实可以抛弃移动语义。
三五法则很简单:当你想要显式定义(delete
/ default
/ 自定义)拷贝控制函数中的任一时,你必须显式定义所有的拷贝控制函数。这其实就是因为隐式删除的场景十分复杂,而且容易遗忘,你必须通过三五法则认真考虑每个函数的实现。
另外,如果不遵循三五法则,那么可能还会因为其不明显的拷贝控制函数定义,在所有将该类作为成员的类中导致一些意料之外的强制删除问题。
六个特殊成员函数的总关系图
(图中,强制删除(实线)是指:如果前者被删除(delete
),则后者的自动合成方法被删除,即使显式自动(default
)也被编译器忽视;隐式删除(虚线)是指:如果前者被显式定义(delete
/ default
/ 自定义),则后者的自动合成方法被隐式删除,但用户可以显式定义(default
/ 自定义)后者。)
其中,隐式删除最好遵循三五法则,无需记忆。
flowchart TD subgraph 成员或派生类 10(拷贝赋值函数缺失) 11(移动赋值函数缺失) subgraph 12["是类的 const 或引用成员"] 12.0(其他) 12.1("且不完全实现类内初始化\n(从类获取参数而确定)") end 13(默认构造函数缺失) 14(析构函数缺失) 15(拷贝构造函数缺失) 16(移动构造函数缺失) end subgraph 类 0(存在 const 或引用成员) 21(拷贝赋值函数缺失) 22(移动赋值函数缺失) 23(其他构造函数缺失) 24(默认构造函数缺失) 25(析构函数缺失) 26(拷贝构造函数缺失) 27(移动构造函数缺失) end 12 --> 0 -->|强制删除| 21 & 22 10 -->|强制删除| 21 11 -->|强制删除| 22 12.1 <-->|强制删除| 24 13 -->|强制删除| 24 14 -->|强制删除| 25 -->|强制删除| 24 & 23 & 26 & 27 15 -->|强制删除| 26 16 -->|强制删除| 27
flowchart RL 0(默认构造函数) subgraph " " 1(拷贝构造函数) 2(移动构造函数) 3(其他构造函数) end subgraph " " 4(移动赋值函数) 5(拷贝赋值函数) end 6(析构函数) 1 & 2 & 3 -.->|隐式删除| 0 4 <-.->|隐式删除| 2 4 <-.->|隐式删除| 1 5 <-.->|隐式删除| 2 2 <-.->|隐式删除| 1 5 <-.->|隐式删除| 4 6 -.->|隐式删除| 2 & 4
最后,我们将上面的两张表放在一起:
flowchart TD subgraph 成员或派生类 13(默认构造函数缺失) 14(析构函数缺失) 15(移动构造函数缺失) 16(拷贝构造函数缺失) 17(移动赋值函数缺失) 18(拷贝赋值函数缺失) subgraph 19["是类的 const 或引用成员"] 19.0(其他) 19.1("且不完全实现类内初始化\n(从类获取参数而确定)") end end subgraph 类 0(存在 const 或引用成员) 23(默认构造函数) 24(析构函数) subgraph " " 25(其他构造函数) 26(拷贝构造函数) 27(移动构造函数) end subgraph " " 28(拷贝赋值函数) 29(移动赋值函数) end end 13 -->|强制删除| 23 14 -->|强制删除| 24 -->|强制删除| 23 & 25 & 26 & 27 16 -->|强制删除| 26 15 -->|强制删除| 27 19 --> 0 -->|强制删除| 29 & 28 18 -->|强制删除| 28 17 -->|强制删除| 29 19.1 <-->|强制删除| 23 25 & 26 & 27 -.->|隐式删除| 23 27 & 29 <-.->|隐式删除| 26 & 28 27 <-.->|隐式删除| 29 24 -.->|隐式删除| 27 & 29 %% Mermaid 啥时候出个关系图啊, flowchart 不是很适合画这玩意啊。