第4项:
确保对象在使用前得到初始化
C++
在对象初始值问题上显得变化多端。比如说,你写下了下面的代码:
在许多情况下,
x
会确保得到初始化(为零),但是另一些情况下则不会,如果你这样编写:
class Point {
int x, y;
};
...
Point p;
p
的数据成员在一些情况下会确保得到初始化(为零),但是另一些情况就不会了。如果你以前学习的语言没有对象初始化的概念,那么请你注意了,因为这很重要。
读取未初始化的数据时,程序将呈现出无法预知的行为。在一些语言平台中,通常情况下读取未初始化的数据将使你的程序无法运行。更可能的情况时,也许会得到内存中某些位置上的半随机的数据,这些数据将会“污染”需要赋值的对象,最终,程序的行为将变得十分令人费解,你也会陷入令人恼火的除错工作。
现在,人们制定了规则来规定:对象在什么时候确保会得到初始化,以及什么时候不会。但是遗憾的是,这些规则太过复杂了——在我看来,你根本没必要去记忆它们。整体上讲,如果你正在使用
C++
中
C
语言的一部分(参见第
1
项),那么初始化会引入一些额外的运行时开销,这一部分中对象不会确保初始化。但当你使用
非
C
的
C++
部
分时,情况就有所改变。这便可以解释为什么数组(
C++
中的
C
语言)不会确保得到初始化,而一个
vector
(
C++
中的
STL
)会。
解决这类表面上的不确定性问题最好的途径就是:总是在使用对象之前对它们进行初始化。对于内建类型的非成员对象,你需要手动完成这一工作。请看下边的示例:
int x = 0; //
手动初始化一个
int
值
const char * text = "A C-style string"; //
手动初始化一个指针(见第
3
项)
double d;
std::cin >> d
; //
通过读取输入流进行“初始化”
对于其他大多数情况而言,初始化的重担就落在了构造器的肩上。这里的规则很简单:确保所有的构造器初始化了对象中的所有东西。
遵守这一规则是件很容易的事情,但是还有件重要的事:不要把赋值和初始化搞混了。请看下边的示例,你可以看到表示通讯录中一个条目的类的构造器:
class PhoneNumber { ... };
class ABEntry { // ABEntry = "Address Book Entry"
public:
ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
};
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
{
theName = name; //
以下这些是赋值,而不是初始化
theAddress = address;
thePhones = phones
numTimesConsulted = 0;
}
上边的做法可以使得
ABEntry
的对象包含你所期望的值,但是这仍不是最优的做法。
C++
的规
则约定一个对象的数据成员要在进入构造器内部之前得到初始化。在
ABEntry
的构造器内部,
theName
、
theAddress
以及
thePhones
并不是得到了初始化,而是被赋值了。初始化工作应该在更早的时候进行:在进入
ABEntry
构造器内部之前,这些数据成员的默认构造器应该自动得到调用。注意这对于
numTimesConsulted
不成立,因为它是内建数据类型的。对它而言,在被赋值以前,谁也不能确保它得到了初始化。
编写
ABEntry
的构造器的更好的办法是使用成员初始化表,而不是为它们一一赋值:
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address), //
现在这些是初始化
thePhones(phones),
numTimesConsulted(0)
{} //
现在构造器内部是空的
如果仅看运行结果,上面的构造器与更靠前一些的那个是等价的,但是后者的效率更高些。为数据成员赋值的版本首先调用了
theName
、
theAddress
以及
thePhones
的默认构造器来初始化它们,在默认构造器已经为它们分配好了值之后,立即又为它们重新赋了一遍值。于是默认构造器的所有工作就都白费了。使用成员初始化表的方法可以避免这一浪费,这是因为:初始化表中的参数对于各种数据成员均使用构造器参数的形式出现。这样,
theName
就通过复制
name
的值完成了构造,
theAddress
通过复制
address
的值完成构造,
thePhones
通过复制
phones
的值完成构造。对于大多数类型而言,通过单一的调用拷贝构造器更加高效——在一些情况下尤其明显——相对于首先调用默认构造器,然后再调用拷贝运算符而言。
对于内建类型的对象,比如
numTimeConsulted
,初始化与赋值的开销是完全相同的,但是为了保证持久性,最好在初始化时不要忘记这类成员。类似地,即使你期望让默认构造器来构造一个数据成员,你仍可以使用成员初始化表,只是不为初始化参数指定一个具体的值而已。比如,如果
ABEntry
拥有一个无参构造器,它可以这样实现:
ABEntry::ABEntry()
:theName(), //
调用
theName
的默认构造器;
theAddress(), // theAddress
和
thePhones
thePhones(), //
做同样的工作;
numTimesConsulted(0) //
但是
numTimesConsulted
{} //
一定要显性初始化为零
这是因为:当用户定义类型的数据成员没有构造器列在成员初始化表中的时候,编译器会自动为其调用默认构造器,一些程序员认为这样做有些过分了。这可以理解。但是“总将每个数据成员列在初始化表中”这一策略可以使你不必在出现疏忽以后,返回去查找哪些数据成员没有进行初始化——疏忽是不存在的。比如说,如果你因为
numTimesConsulted
是内建数据类型的,就不将其列入成员初始化表中,那么你的代码便极有可能呈现出无法预知的行为。
有些时候必须使用初始化表,即使是对于内建类型。举例说,
const
或者引用的数据成员必须得到初始化。它们不能被赋值(另请参看第
5
项)。对于那些既可以初始化又可以赋值的数据成员,为了省去记忆何时必须使用成员初始化表来初始化它们,最简便的选择就是永远都使用初始化表。一些时候初始化表是必须的,在更多情况下这样做是为了获得比赋值更高的效率。
许多类设计有多个构造器,每个构造器都有自己的成员初始化表。如果有非常多的数据成员和
/
或基类时,就会存在多个初始化表,这时列表中将存在不少无意义的重复,程序员们也会变得十分厌烦。在这种情况下,你也可以考虑忽略表中的一些项目,这些忽略的数据成员应符合这一条件:对它们进行赋值还是真正的初始化没有什么差别。可以把这些赋值语句放在一个单一(当然是私有的)的函数里,并让所有的构造器在必要的时候调用这个函数。这一方法在数据成员要接收的真实的初始化数据保存在一个文件中,或者要到一个数据库中去查找时,尤其有用。但是大致上讲,真正的成员初始化终究要比通过赋值进行伪初始化要好。
C++
还是存在
稳定的方面的,其中之一就是:对象中数据的初始化的顺序是恒定的。这个次序通常情况下是这样的:基类应在派生类之前得到初始化(另参见第
12
项),在类的内部,数据成员应以它们声明的顺序得到初始化。比如说在
ABEntry
内部,
theName
永远都是第一个得到初始化的,
theAddress
第二,
thePhones
第三,
numTimesConsulted
最后。即使它们在成员初始化表中的排列顺序不同于声明次序,(这样做看上去不应该算作法,但不幸的是事实不是这样。)上述初始化顺序也会得到遵循。为了不使读者陷入困惑,也为了避免日后出现让人难以理解的
bug
,你应该保证初始化表中成员的顺序与它们被声明时的顺序严格一致。
在你完成了对内建类型的非成员对象的显式初始化,并且确保了构造器使用成员初始化表对基类和数据成员进行了初始化之后,需要你关心的工作就仅剩下了一个,那就是(先长舒一口气):在不同的置换单元中,非局部静态对象的初始化次序是怎样的。
让我们一步一步地解决这个问题:
一个静态对象在被构造之后,它的寿命一直延续到程序结束。保存在栈或堆中的对象都不是这样。静态对象包括:全局对象、名字空间域对象、类内部的
static
对象、函数内部的
static
对象,文件域的
static
对象。函数内部的静态对象通常叫做局部静态对象(这是因为它们对于函数而言是局部的),其它类型的静态对象称为非局部静态对象。静态对象在程序退出的时候会被自动销毁,换句话说,在
main
中止运行的时候,静态对象的析构器会自动得到调用。
一个置换单元是这样一段源代码:由它可以生成一个目标文件。通常一个置换单元是以单一一个代码文件为基础,还要包括所有被
#include
进来的文件。
于是,我们所要解决的问题中,至少包含两个需要单独编译的源码文件,每一个都至少包含一个非局部静态对象(换句话说,是一个全局的,或者名字空间域的,抑或类内部或者文件域的
static
对象)。问题的本质在于:如果一个置换单元内的一个非局部静态对象的初始化工作利用了另一个置换空间内的另一个非局部静态变量,那么所使用的对象应该是未经初始化的,这是因为:定义在不同置换单元内的非静态对象的初始化工作的顺序是未定义的。
这里一个示例可以帮助我们理解这一问题。假设你编写了一个
FileSystem
类,它可以让
Internet
上的文件看上去像是本地的。由于你的类要使得整个世界看上去像是一个单一的文件系统,你应该创建一个专门的类来代表这个单一的文件系统,让这个类拥有全局的或者名字空间的作用域:
class FileSystem { //
来自你的库
public:
...
std::size_t numDisks() const; //
许多成员函数中的一个
...
};
extern FileSystem tfs; //
供客户端使用的对象
// "tfs" = "the file system"
一个
FileSystem
对象绝对是重量级的,所以说在
tfs
对象被构造之前使用它会带来灾难性后果。
现在设想一下,一些客户端程序员为文件系统创建了一个文件夹的类。很自然地,他们的类会使用
tfs
对象。
class Directory { //
由客户端程序员创建
public:
Directory( params );
...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); //
使用
tfs
对象
...
}
进一步设想,
客户端程序员
可能会为临时文件创建
一个单独的
Directory
对象:
Directory tempDir( params ); //
存放临时文件的文件夹
现在,出示化次序的重要性已然浮出水面:除非
tfs
在
tempDir
得到初始化,
tempDir
的构造器将会尝试在
tfs
被初始化之前使用它。但是
tfs
和
tempDir
是由不同的人、在不同的时间、在不同的源码文件中创建的——这两者都是非局部静态对象,它们定义于不同的置换单元中。那么你如何保证
tfs
在
tempDir
之前得到初始化呢?
事实上这是不可能的。重申一遍,
定义在不同置换单元内的非静态对象的初始化工作的顺序是未定义的
。当然这是有理由的:为非局部静态对象确定“恰当的”初始化顺序是一件很有难度的工作。非常有难度。根本无法解决。在其大多数形式——由隐式模板实例化产生的多个置换单元和非局部静态对象(也许它们是自己产生的,只是产生的过程借助了隐式模板实例化的力量)——这不仅使得确认初始化的顺序变得不可能,甚至寻找一种可行的初始化顺序的特殊情况,都显得毫无意义。
幸运的是,一个小小的方法可以完全排除这个难题。所要做的仅仅是把每个非局部静态对象移入为它创建的专用函数中,函数要声明为
static
的。这些函数返回一个它们所包含的对象的引用。于是客户端程序员就可以调用这些函数,而不是直接使用那些对象。也就是说,非局部静态对象被局部静态对象取代了。(设计模式迷们很容易发现,这是
Singleton
模式一个通用实现。)
这一方法基于
C++
的一个约定,那就是:对
于局部静态对象来说,
在其被上述函数调用的时候,程序中第一次引入了对
该对象的定义,它在此时就一定会得到初始化。所以说对于局部静态对象,如果你不使用直接访问,而改用“通过函数返回的引用来调用”,你就保证了你得到的这一引用所引用的是一个经初始化的对象。作为奖励,如果你从未调用过模仿非局部静态对象的函数,你的程序就永远不会引入对这类对象进行构造和析构的开销,而这对于真正的非局部静态对象来说是不可能的。
下面是对这一技术的应用,以
tfs
和
tempDir
为示例:
class FileSystem { ... }; //
同上
FileSystem& tfs()
//
这一函数代替了
tfs
对象;它在
// FileSystem
类中应该是
static
的
{
static FileSystem fs;
//
对局部静态对象的定义和初始化
return fs;
//
返回该对象的引用
}
class Directory { ... }; //
同上
Directory::Directory( params )//
同上,但对
tfs
的引用现在为对
tfs()
{
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir()
//
这个函数取代了
tempDir
对象;它在
it
// Directory
类中可以是
static
的
{
static Directory td;
//
对局部静态对象的定义和初始化
return td;
//
返回该对象的引用
}
这一改进系统不需要客户端程序员做出任何改变,除了他们所引用的是
tfs()
和
tempDir()
而不是
tfs
和
tempDir
。也就是说,他们使用的是函数返回的引用而不是直接使用对象本身。
编写这一类返回引用的函数所需要遵循的方针总是十分简单的
:在第
1
行定义和初始化一个局部静态对象,在第
2
行返回它的引用。如
此的简单易用使得这类函数非常适合作为内联函数,尤其是对它们的调用非常频繁时(参见第
30
项)。另外,这些函数中包含着静态对象,在多线程系统中它们也许会遇到问题。在此声明,任何种类的非
const
静态对象,无论是局部的还是非局部的,它们面对多线程都会碰到这样那样的问题。解决这一问题的方法之一是:在程序还以单线程状态运行时,手动调用所有的这类返回引用的函数。这可以排除与初始化相关的竞争状态的出现。
当然,使用此类返回引用的函数来防止初始化次序问题的理念,首先基于此处存在一个合理的初始化次序。如果你的
系统要求对象
A
必须在对象
B
之前得到初始化,但是
A
的初始化需要以
B
的初始化
为前提,你将会面临一个问题,坦白说,你是咎由自取。然而,如果你能够驾驭这一不正常的境况,这里介绍的解决方法仍然可以良好的为你服务,至少对于单线程应用程序来说是这样的。
为了避免在对象初始化之前使用它,你仅仅需要做三件事。第一,手动初始化基本类型的非成员对象。第二,使用成员初始化表来初始化对象的每一部分。最后,初始化次序的不确定性会使定义于不同置换单元中的非局部静态对象之间产生冲突,要避免这样的设计。
需要记住的
l
由于
C++
只在某些情况下对于基本类型对象进行初始化,所以对它们要进行手动初始化。
l
对于构造器,要尽量使用成员初始化表,避免在构造器内部进行复制。初始化表中的次序要与成员在类中被声明的次序相一致。
l
要避免跨置换单元的初始化次序问题发生,可以使用局部静态对象来代替非局部静态对象。