《Effective C++》<3> 确定对象使用前初始化
概述:《Efficient C++》第 3 条,确定对象使用前初始化
未初始化的对象
在初始化对象的值方面,C++ 似乎相当善变。如下面示例代码,g_var
为全局变量,自动初始化为 0,而 a 是一个随机值。p 的成员变量有时候被初始化为 0,有时候不会,从运行结果来看两次都已经初始化为 0。
而其他变成语言中用一个 “无初值对象” 是不存在的,这很重要。
1 |
|
1 |
|
C++初始化
通常如果你使用类 C 语法部分而未初始化可能招致运行期成本,那么就不保证发生初始化。一旦进入纯 C++ 部分,规则有些变化。这就很好地解释了为什么数组不保证其内容被初始化,而 vector
却能保证初始化。
基础数据类型初始化
1 |
|
类初始化
至于内置类型以外的任何其他对象,初始化责任落在了构造函数。规则很简单,确保每一个构造函数都将对象的每一个成员初始化。
这个规则很简单,重要的是别混淆了赋值和初始化。
1 |
|
1 |
|
这将产生具有你期望的值的 ABEntry
对象,但这仍然不是最好的方法。C++ 的规则规定,对象的数据成员在进入构造函数的主体之前被初始化。在 ABEntry
构造函数中,theName
、theAddress
和 thePhones
没有被初始化,它们只是被赋值。初始化发生在进入 ABEntry
构造函数正文之前,即自动调用它们的默认构造函数时进行的。在进入 ABEntry
构造函数的主体之前,已经进行了初始化。这对于 numTimesConsulted
不是这样,因为它是一个内置类型。对它来说,不能保证它在被赋值之前就被初始化了。
初始化列表
ABEntry
构造函数的一个较佳写法是使用初始化列表进行初始化。
初始化列表使用方法
1 |
|
1 |
|
示例代码中构造函数与在构造函数中赋值的最终结果相同,但它往往更有效率。基于赋值的版本首先调用默认构造函数来初始化 theName
、theAddress
和 thePhones
,然后立即在默认构造的基础上分配新的值。因此,所有在这些默认构造中进行的工作都被浪费了。成员初始化列表的方法避免了这个问题,因为初始化列表中的参数被用作各种数据成员的构造器参数。在这个例子中,theName
是由 name
拷贝构造的,theAddress
是由 address
复制构造的,thePhones
是由 phones
拷贝构造的。
对大多数类型而言,比起先调用默认构造函数然后再调用拷贝赋值操作符,单只调用一次赋值构造函数是比较高效的,有时甚至高效得多。对于内置型对象如 numTimesConsulted
,其初始化和赋值的成本相同,但为了一致性最好也通过成员初值列来初始化。同样道理,甚至当你想要默认构造一个成员变量,你都可以使用成员初值列,只要指定 0 或者空作为初始化实参即可。
1 |
|
初始化说明
当用户定义类型的数据成员在成员初始化列表中没有初始化时,编译器会自动调用这些数据成员的默认构造器,所以有些程序员认为上述方法是多余的是可以理解的,但是如果有一个总是在初始化列表中列出每个数据成员的策略,就可以避免记住哪些数据成员在被省略后可能会被未初始化。例如,由于 numTimesConsulted
是一个内置的类型,如果没有在初始化列表中遗漏没有初值时就会导致不明确行为。
有些情况下即使面对的成员变量属于内置类型(其初始化与赋值的成本相同),也一定得使用初值列。是的,如果成员变量是常量或引用时,它们就一定需要初值,不能被赋值。为避免需要记住成员变量何时必须在成员初值列中初始化,何时不需要,最简单的做法就是:总是使用成员初值列。这样做有时候绝对必要,且又往往比赋值更高效。
许多类拥有多个构造函数,每个构造函数有自己的成员初值列。如果这种类存在许多成员变量或基类,多份成员初值列的存在就会导致不受欢迎的重复(在初值列内)和无聊的工作(对程序员而言)。这种情况下可以合理地在初值列中遗漏那些 “赋值表现像初始化一样好” 的成员变量,改用它们的赋值操作,并将那些赋值操作移往某个函数(通常是私有的),供所有构造函数调用。这种做法在 “成员变量的初值系由文件或数据库读入” 时特别有用。然而,比起经由赋值操作完成的 “伪初始化”(pseudo-initialization),通过成员初值列完成的 “真正初始化” 通常更加可取。
C++ 有着十分固定的 “成员初始化顺序”。顺序总是相同:基类更早于其子类被初始化,而类的成员变量总是以其声明次序被初始化。回头看看 ABEntry
,其 theName
成员永远最先被初始化,然后是 theAddress
,再是 thePhones
,最后是 numTimesConsulted
。即使它们在成员初值列中以不同的顺序出现(很不幸那是合法的),也不会有任何影响。为避免你或你的检查代码的人迷惑,并避免某些可能存在的晦涩错误,当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。
初始化静态对象
静态(static)对象寿命从被构造出来直到程序结束为止,因此栈和堆对象都被排除。包括全局对象、命名空间内的对象、类、函数和文件内被声明静态的对象。函数内的静态对象称为局部静态对象(因为它们对函数而言是局部的),其他静态对象称为非局部静态对象。程序结束时静态对象会被自动销毁,也就是它们的析构函数会在 main()
结束时被自动调用。
初始化顺序
如下示例代码,我们关心的问题涉及至少两个源码文件,每一个内含至少一个非局部静态对象(也就是说该对象是全局或位于命名空间内,抑或在类内或源码文件内被声明为静态)。真正的问题是:如果某编译单元内的某个非局部静态对象的初始化动作使用了另一编译单元内的某个非局部静态对象,它所用到的这个对象可能尚未被初始化,因为 C++ 对 “定义于不同编译单元内的非局部静态对象” 的初始化次序并无明确定义。
1 |
|
1 |
|
1 |
|
1 |
|
现在,初始化次序的重要性显现出来了:除非 tfs
在 tempDir
之前先被初始化,否则 tempDir
的构造函数会用到尚未初始化的 tfs
。但 tfs
和 tempDir
是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的非局部静态对象。如何能够确定 tfs
会在 tempDir
之前先被初始化?
C++ 对 “定义于不同的编译单元内的非局部静态对象” 的初始化相对次序并无明确定义。这是有原因的:决定它们的初始化次序相当困难,根本无解。在其最常见形式,也就是多个编译单元内的非局部静态对象经由 “模板隐式具现化” 形成(而后者自己可能也是经由 “模板隐式具现化” 形成),不但不可能决定正确的初始化次序,甚至往往不值得寻找 “可决定正确次序” 的特殊情况。
单例模式
幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是:将每个 非局部静态对象搬到自己的专属函数内(该对象在此函数内被声明为静态)。这些函数返回一个引用指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,非局部静态对象被局部静态对象替换了。设计模式的迷哥迷妹们想必认出来了,这是单例模式的一个常见实现手法。
这个手法的基础在于:C++ 保证,函数内的局部静态对象会在该函数被调用期间首次遇上该对象之定义式时被初始化。所以如果你以 “函数调用”(返回一个引用指向局部静态对象)替换直接访问非局部静态对象,你就获得了保证,保证你所获得的那个引用将指向一个历经初始化的对象。更棒的是,如果你从未调用非局部静态对象的 “仿真函数”,就绝不会引发构造和析构成本;真正的非局部静态对象可没这等便宜!
1 |
|
1 |
|
这么修改之后,这个系统程序的客户完全像以前一样地用它,唯一不同的是他们现在使用 tfs()
和 tempDir()
函数,而不再是 tfs
和 tempDir
对象。也就是说他们使用函数返回的 “指向静态对象” 的引用,而不再使用对象自身。
这种结构下的返回引用函数往往十分单纯:第一行定义并初始化一个局部静态对象,第二行返回它。这样的单纯性往往使用内联函数,尤其如果它们被频繁调用的话(见实验《透彻内联》)。但是从另一个角度看,这些函数 “内含静态对象” 的事实使它们在多线程系统中带有不确定性。再说一次,任何一种非常量静态对象,不论它是局部或非局部,在多线程环境下往往都会很麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段手工调用所有返回引用函数,这可消除与初始化有关的 “竞速条件(race conditions)”。
为避免在对象初始化之前过早地使用它们,你需要做三件事:
- 手动初始化内置型非成员变量。
- 使用初始化列表初始化成员变量。
- 在初始化顺序不确定情况下加强设计。