C++里类有4种特殊的成员函数:
- 构造函数。
- 析构函数。
- 复制函数,包括复制构造函数和复制赋值函数。
- 移动函数,包括移动构造函数和移动赋值函数。
这些函数的特点是:有些时候,编译器会帮你生成这些函数;有些时候,编译器又会拒绝生成这些函数;还有些时候,编译器还会往你自己写的特殊函数中添加操作。鉴于这些特殊函数的重要性,我们有必要好好了解一下它们背后的故事。
本文介绍的是前两类,构造函数和析构函数。
注1:本文环境为Ubuntu 16.04,gcc5.4.0,使用c++14标准。
注2:本文大量内容来自《深入探索C++对象模型》。
构造函数
什么类没有构造函数
我们知道构造函数是一种非常重要的函数,也是C++诞生的一个主要原因。那么,第一个问题,每个类都有构造函数吗?
对于下面这个平凡类:
1 | struct Trivial { |
main
函数对应的汇编指令为(未开任何优化):
1 | 00000000004006b6 <main>: |
没有Trivial
的构造函数的影子。整个binary中也找不到Trivial
的构造函数。
事实上,平凡类就是没有构造函数的,或者说编译器会为它生成一个trivial的构造函数。而一个trivial的构造函数就类似于C中struct的初始化:什么都不做。因此编译器实际上不会为平凡类生成构造函数。而平凡类不允许有自定义的构造函数,结论就是平凡类就不可能有构造函数。
然而有一种情况下,编译器还真会给平凡类生成一个构造函数,那就是显式声明一个= default
的构造函数:
1 | struct Trivial { |
1 | 00000000004009e8 <_ZN7TrivialC1Ev>: |
虽然这个函数里明显什么事情都没做,但它确实存在了。
当然,加上“-O2”你会发现,它又没了。
实际上,所有没有自定义构造函数的,满足bitwise语义的类,都可能没有构造函数。这个范围会比平凡类大一些。
编译器生成默认构造函数的条件
第二个问题,编译器什么时候会为一个类生成一个默认构造函数?
编译器只会在必要的时候为一个类生成默认构造函数。所谓必要,指:
- 这个类没有自定义的构造函数或声明默认构造函数为
= default
,且 - 没有用
= delete
删除默认构造函数,且 - 代码中调用到了这个类的默认构造函数。
第一个条件很好理解,C++的编译器是充分相信程序员的,如果一个程序员写了随便一个构造函数,编译器会尊重Ta,不再为其生成默认构造函数。
第二个条件是指,编译器不会在看到这个类的定义时就为其生成一个默认构造函数,而是会推迟这个生成时机,直到有代码真的调用了才生成。
然而,即使满足上面的条件,如果类中默认构造函数没有声明为= default
,且编译器判断这个类可以bitwise构造,编译器仍然不会真的生成一个默认构造函数。
bitwise与memberwise
当我们说到构造函数时,一个不得不提的概念是bitwise与memberwise。实际上这两个概念更多的是用来描述拷贝,但在构造上也有着类似的效果。
一个类型,如果满足:
- 是标量类型,或
- 是自定义类型,且满足:
- 没有虚函数。
- 没有虚基类。
- 没有不符合bitwise语义的非静态成员变量。
- 没有不符合bitwise语义的基类。
- 没有自定义的构造函数。
那它就满足bitwise构造的条件,即它在构造时没有任何特殊的操作(除了给它分配内存外),它的默认构造函数就是trivial的,实际上编译器不会真的生成这个函数。
而反过来,不满足这个条件的类,它就需要依次初始化每个成员,即是memberwise。
trivial的构造函数要比自定义的构造函数低很多(什么都不做),但它伤害到了正确性,即类的成员是没有一个可预期的初始值的的。从这个角度讲,即使是满足上面条件的类,我们也不应放任编译器选择trivial的默认构造函数,而应该自己定义一个正确初始化每个成员值的默认构造函数。当然,如果你要定义一个POD类型的话,除外。
构造函数的内容
一个构造函数有哪些内容?比如,对于一个有着两个基类A和B的有虚函数的类型C,它的构造函数需要完成以下工作:
- 初始化基类A。
- 初始化基类B。
- 确保虚表指针指向正确的位置。
- 初始化每个成员。
- 依次调用构造函数体中的语句。
很显然,C要先完成A和B的构造,才能保证C自己的构造过程开始时,它已经“is a”A和B的对象。
1 | struct A { |
这个例子中,在A::A
中我们调用了一个虚函数F
,假如整个过程发生在C的构造中,它调用的是哪个F
?A::F
还是C::F
?
我们知道,在C去构造它的A的子对象时,它自己的成员都还没有初始化,那么此时去调用C::F
显然是不合理的。因此我们有一个结论:构造函数中虚函数没有动态绑定,只有静态绑定。
《深入探索C++对象模型》中提到,当时的编译器在初始化基类子对象时,是通过将虚表指针指向基类的虚表,来实现一种伪的静态绑定。这么做的理由是不区别对待构造函数与其它函数。但这显然会伤害到效率。因此现代的编译器都会区别对待构造函数,真的静态绑定其中每个成员变量的调用。
通常我们会显式的把基类的构造写到派生类的初始化列表中。但即使不这么做,编译器也会将基类子对象的构造插到派生类的每个构造函数的开头,当然这要求基类有一个默认构造函数,或编译器能为其生成一个默认构造函数。
OK,在成功地构造完基类子对象后,C开始忙自己的构造了。
首先,如果C有虚表,那么要把虚表设置到正确地位置上。
之后开始按声明顺序依次构造C的每个非静态成员变量。
这里的“声明”要加粗,是因为如果忽略这一点,我们很可能会得到一个编译器的警告。
在这个阶段,每个非静态成员变量的初始值可能会来自三个地方:
- 初始化列表中的表达式。
- 成员初始化式(C++11新增)中的表达式。
- 该成员的默认构造函数(如果非trivial)。
其优先级依次下降。其中最后一项不涉及顺序,而前两项都可能会涉及到不同成员变量间的构造顺序。
对于下面这个例子:
1 | struct S { |
当我们构造出一个S
的对象时,会发现它的x
和y
两个成员的值是未初始化的!这就是因为,x
依赖了它后面声明的y
,而y
依赖了它后面声明的z
,导致当它们进行初始化时,依赖的值都还没有初始化,自然会得到一个错误的值。
OK,初始化列表结束后,此时c已经是一个合法的,所有成员和函数都可用的C对象了。接下来要执行的就是构造函数体本身了。
这里有一个值得注意的点。执行到构造函数体前,类的所有成员变量都已经初始化过了,如果我们在构造函数体中再对其进行赋值,大概率浪费了前面的初始化:
1 | struct S { |
这个例子中,在整个构造过程中,name
执行了一次默认构造函数,和一次赋值。而如果将这次赋值放到初始化列表中:
1 | struct S { |
name
就只执行了一次复制构造函数。对于很多类型来说,后者的好处还是很明显的。
单参数构造函数要声明为explicit
某种说法认为C++不是强类型语言,因为它允许类型间的隐式转换。C++中的隐式转换有一部分是因为要兼容C而背的包袱,导致整型的重载无比混乱。而另一部分隐式转换就是C++自己设计的问题了。
在某些场景下,C++的隐式转换是很有用的,但在很可能多得多的场景下,如果滥用隐式转换,就会带来潜在的问题。
1 | struct S { |
这个例子中,Func
实际只接受const S&
类型的参数,但我们搞错了,传进去了100。我们预期的结果当然是编译器报错,找不到Func(int)
,但实际呢?程序编译通过,成功运行,结果是:
1 | v.size:100 |
发生了什么?隐式转换。编译器看到Func(100)
时,它首先会去找Func
,只找到了Func(const S&)
,没找到Func(int)
。于是编译器会找有没有哪种隐式转换,允许将一个int
转换为S
,还真有,S
正好有个构造函数是S(int)
!于是编译器这里就执行了S
的构造函数,构造出一个有着100个元素的对象。
怎么避免上面的场景发生?我们就要想办法禁止int
到S
的隐式转换,而explicit
就是这个作用。当它被用来修饰一个单参数的构造函数时,就会阻止编译器产生一种隐式转换的关系:
1 | struct S { |
然而当我们真的想将int
转换为S
时,该怎么办?两种方法:
- 显式构造:
Func(S(100))
。 - static_cast:
Func(static_cast<S>(100))
。
全局变量的初始化不要依赖其它编译单元的全局变量
这句话有两个前提:
- 全局变量的初始化发生在
main
函数之前,串行进行。 - 不同的实现文件(.cpp或.cc)属于不同的编译单元,而不同编译单元的全局变量的初始化顺序在链接时由链接器决定。
这就导致了一个类似于上面构造函数初始化列表的顺序问题,且它没有一个确定的顺序。
因此,如果一个全局变量在初始化时依赖了另一个编译单元的全局变量,很可能你会发现前者初始化时后者还没有初始化。
这里的全局变量也包括类的静态成员变量。
那么,如果真有这种全局变量的初始值依赖于其它变量,该怎么做呢:
相同编译单元的全局变量的初始化顺序是确定的,可依赖的。
如果必须跨编译单元依赖,那么把被依赖的变量放到一个函数里作为static变量。标准规定了函数中的static变量其初始化是在第一次调用时(运行到此行时),这是确定的,可依赖的:
1
2
3
4
5
6
7
8
9// a.cpp
TypeA gX(SomeFunc());
// b.h
const TypeB& SomeFunc();
// b.cpp
const TypeB& SomeFunc() {
static TypeB b;
return b;
}别担心这里构造static变量没加锁,C++11后标准中规定了这种构造是线程安全的。
析构函数
编译器生成析构函数的条件
与默认构造函数类似,编译器为一个类生成析构函数的条件为:
- 这个类没有自定义的析构函数或声明析构函数为
= default
,且 - 没有用
= delete
删除析构函数,且 - 代码中调用到了这个类的析构函数。
同样地,如果一个类符合bitwise析构的标准,编译器为它生成的析构函数就是trivial的,就是可以忽略的,此时这个类就没有析构函数了。
析构函数的内容
析构函数实际就是构造函数的逆过程。参考前面的类C,它的析构函数有以下内容:
- 依次调用析构函数体中的语句。
- 按声明逆序调用每个成员变量的析构函数。
- 按声明逆序调用每个基类子对象的析构函数。
同样地,析构函数中也会遇到虚函数的绑定问题。与构造函数类型,所有出现在析构函数中的虚函数,都是静态绑定,因为在析构基类子对象时,派生类自己的成员已经都析构掉了,此时再调用派生类改写的方法大概率会出问题。
有虚函数的类也需要一个虚析构函数的定义
这里有两个值得注意的点。
第一个,一个类有虚函数,但析构函数不是虚函数,会有大问题的。我们为一个类增加虚函数时,一定是准备实现运行期多态的(否则声明虚函数干什么)。而我们知道运行期多态是要靠基类的指针和引用来触发的。对于下面的例子:
1 | struct Base { |
1 | ~Base |
没有调用真正类型的析构函数,是个大问题!尤其是,Derived::name
也没有被析构,出现了内存泄漏!
因此,第一个结论:有虚函数的类,一定要有虚的析构函数。
第二个值得注意的点,纯虚基类,析构函数也要有定义,不能是纯虚函数:
1 | struct Base { |
它的问题在于,它的所有派生类在析构时都会调用到Base
的析构函数,然而发现这个函数没有定义!编译会因此失败。
因此,第二个结论:不能有纯虚的析构函数,也不能禁止生成析构函数(通过声明为= delete
或声明为private却不给定义),一定要给析构函数一个定义(或等待编译器为你生成一个)。
变量的析构时间
标准规定了一个local变量的析构时间是在它出scope时,这个规则很简单,但有些特殊场景还是要单独说一下:
全局变量、静态变量的构造时间是在
main
函数以前,而析构时间则是在main
函数以后。同样地,不同编译单元间全局/静态变量的析构函数也是不确定的。临时变量的析构时间为代码中其所在的最外层表达式执行完成后,即:
1
2const char* s = getStringObject().c_str();
// temp obj destructs here and s becomes danling!这里
getStringObject()
会返回一个临时的std::string
对象,这个对象的生命期会直到完成s
的赋值后,下一行调用开始前。但被赋值给const引用的临时变量,其生命期会与这个const引用保持一致,直到这个引用出scope才析构:
1
2
3
4{
const std::string& s = getStringObject();
//...
} // temp obj destructs here如果
goto
跳回到函数前面,则这段代码中定义的变量都会被析构:1
2
3
4RETRY:
std::string name;
std::vector<int> v;
goto RETRY; // name and v will be destructed.存在短路逻辑的表达式中,编译器需要插入一些代码才能确定临时对象的析构时间:
1
2
3if ((s + t) || (u + v)) {
// ...
}这里面
s + t
会产生临时对象,但u + v
只能说可能产生临时对象,因为如果表达式被短路,根本走不到后半截,就不会产生这个临时对象,那么这个临时对象也就不需要被析构。编译期怎么会知道这个表达式会不会被短路呢?因此编译器需要插入一些代码来产生不同分支。这也稍稍增加了些运行期的开销。
当一个函数有着很多出口时,想决定一个local变量的scope就变困难了,编译器需要在每个可能return
的地方都加上一些用于析构已经存在的变量,这也会增大binary的体积。
不要手动调用local变量的析构函数
1 | struct Base { |
上面是一个很tricky的例子,我们手动调用了b
的析构函数,又在其原地构造了一个派生类对象。此时再调用f
,调用的会是哪个版本?出scope时,调用的是谁的析构函数?
实际这都是未定义的问题,编译器很可能不会按我们的想法去实现。因此结论就是不要这么用。
异常场景
当有了异常之后,构造函数和析构函数就更复杂了:
- 构造函数抛了异常后,已经构造完的基类子对象和成员变量要析构,但未构造的成员不要析构,因此编译器需要插入大量代码。
- 构造函数如果在进入函数体之前抛异常,此时对象本身还不完整(有成员未构造完),那么就不能执行析构函数。
- 异常会导致函数栈unwind,期间每个还存活的对象都要析构,同样需要插入大量代码。
- 异常还可能会被catch住,此时unwind停止,不再析构存活对象,又要做一些判断。
- 当异常未被catch住时,如果unwind导致的析构抛了异常,同时存在两个异常会导致程序crash。
因此异常是一种比较昂贵的特性,想实现好异常安全也不那么容易,比如STL容器为了保证修改时的异常安全,做了非常多的事情。
但辩证的看,异常本身还是一种很有用的特性,至少我是支持使用异常的,只要知道上面这些开销,尽量避免错误的使用就好了。