Item 4: 确定一个对象在使用前已经被初始化
C++ 看上去在对象的初始化方面变化无常。例如,如果你这样做,
int x;
在某些情形下,x 会被初始化(0),但是在其它情形下,也可能没有。如果你这样做,
class Point {
int x, y;
};
...
Point p;
p 的数据成员有时会被初始化(0),但有时没有。如果你从一个不存在未初始化对象的语言来到 C++,请注意这个问题,因为它非常重要。
读取一个未初始化的值会引起未定义行为。在一些平台上,读一个未初始化的值会引起程序中止,更可能的情况是得到一个你所读的那个位置上的随机值,最终导致不可预测的程序行为和恼人的调试。
现在,有一些关于什么时候进行对象的初始化什么时候不进行的规则的描述。不幸的是,这些规则很复杂——我觉得它复杂得无法记住。通常,如果你使用 C++ 的 C 部分(参见 Item 1),而且初始化可能会花费一些运行时间,它就不一定会进行。如果你使用 C++ 的 non-C 部分,事情会有些变化。这就是为什么一个数组(C++ 的 C 部分)不能确保它的元素被初始化,但是一个 vector(C++ 的 STL 部分)就能够确保。
处理这种表面上看起来不确定的事情的最好方法就是总是在使用之前初始化你的对象。对于内建类型的非成员对象,需要你手动做这件事。例如:
int x = 0; // manual initialization of an int
const char * text = "A C-style string"; // manual initialization of a
// pointer (see also Item 3)
double d; // "initialization" by reading from
std::cin >> d; // an input stream
除此之外的几乎全部情况,初始化的重任就落到了构造函数的身上。这里的规则很简单:确保所有的构造函数都初始化了对象中的每一样东西。
这个规则很容易遵守,但重要的是不要把赋值和初始化搞混。考虑下面这个表现一个通讯录条目的类的构造函数:
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 num TimesConsulted;
};
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
{
theName = name; // these are all assignments,
theAddress = address; // not initializations
thePhones = phones;
numTimesConsulted = 0;
}
这样做虽然使得 ABEntry 具有了你所期待的值,但还不是最好的做法。C++ 的规则规定数据成员的初始化在进入构造函数的函数体之前进行。在 ABEntry 的构造函数内,theName,theAddress 和 thePhones 不是被初始化,而是被赋值。初始化进行得更早——在进入 ABEntry 的构造函数的函数体之前,他们的缺省的构造函数已经被调用。但不包括 numTimesConsulted,因为它是内建类型。不能保证它在被复制之前被初始化。
一个更好的方法是用成员初始化列表(member initialization list)来代替赋值:
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address), // these are now all initializations
thePhones(phones),
numTimesConsulted(0)
{} // the ctor body is now empty
这个构造函数的最终结果和前面那个相同,但是通常它有更高的效率。基于赋值的版本会首先调用缺省的构造函数初始化 theName,theAddress 和 thePhones,然而很快又用新的值覆盖了缺省构造函数的值。缺省构造函数所做的工作被浪费了。而成员初始化列表的方法避免了这个问题,因为初始化列表中的参数就可以作为各种数据成员的构造函数所使用的参数。在这种情况下,theName 从 name 中拷贝构造,theAddress 从 address 中拷贝构造,thePhones 从 phones 中拷贝构造。对于大多数类型来说,只调用一次拷贝构造函数的效率比先调用一次缺省构造函数再调用一次拷贝赋值运算符的效率要高(有时会高很多)。
对于 numTimesConsulted 这样的内建数据类型,初始化和复制没有什么不同,但为了统一性,最好是通过成员初始化列表来初始化每一件东西。类似地,当你只想调用一个数据成员的缺省构造函数时也可以使用成员初始化列表,只是不指定参数而已。例如,如果 ABEntry 有一个无参数的构造函数,它可以像这样实现:
ABEntry::ABEntry()
:theName(), // call theName's default ctor;
theAddress(), // do the same for theAddress;
thePhones(), // and for thePhones;
numTimesConsulted(0) // but explicitly initialize
{} // numTimesConsulted to zero
因为对于那些在成员初始化列表中没有初始化的用户自定义类型的数据成员,编译器会自动调用其缺省构造函数,所以一些程序员会认为上面的方法有些过分。这也不难理解,但是一个方针是:在初始化列表中总是列出每一个数据成员,这就可以避免一旦发生疏漏就必须回忆起可能是哪一个数据成员没有被初始化。例如,因为 numTimesConsulted 是一个内建类型,如果将它从成员初始化列表中删除,就为未定义行为打开了方便之门。
有时,即使是内建类型,初始化列表也必须使用。比如,数据成员是必须被初始化的const或引用,他们不能被赋值(参见 Item 5)。为了避免记忆什么时候数据成员必须在成员初始化列表中初始化,而什么时候又是可选的,最简单的方法就是总是使用初始化列表。它有时是必须的,而且它通常都比赋值更有效率。
很多类有多个构造函数,而每一个构造函数都有自己的成员初始化列表。如果有很多数据成员和/或基类,成倍增加的初始化列表的存在引起郁闷的重复(在列表中)和厌烦(在程序员中)。在这种情况下,不能不讲道理地从列表中删除那些赋值和初始化一样工作的数据成员,而是将赋值移到一个单独的(当然是私有的)函数中,以供每个构造函数调用。这个方法对于那些真正的初值是从文件中读入或从数据库中检索出来的数据成员没有特别的帮助。通常情况下,无论如何,真正的成员初始化(通过一个初始化列表)比通过赋值来进行初始化更可取。
C++ 并非变幻莫测的方面是一个对象的数据被初始化的顺序。这个顺序总是相同的:基类的初始化在派生类之前(参见 Item 12),在一个类内部,数据成员按照他们被声明的顺序进行初始化。例如,在 ABEntry 中,theName 总是首先被初始化,theAddress 是第二个,thePhones 第三,numTimesConsulted 最后。即使在成员初始化列表中的顺序与此不同(不幸合法),这个顺序也不会变。为了避免读者混淆以及一些模糊不清的行为引起错误的可能性,初始化列表中列出的成员的顺序应该总是与它们在类中被声明的顺序保持一致。
一旦明确处理了内建类型的非成员对象的初始化,而且确信你的构造函数使用成员初始化列表初始化了它的基类和数据成员,那就只剩下一件事情需要费心了。那就是——先深呼吸——定义在不同转换单元(translation units)中的非局部静态(non-local static)对象的初始化顺序。
让我们一片一片地把这个词组拆开。
一个静态对象的生存期是从它创建开始直到程序结束。基于堆栈的对象就被排除了。包括全局对象,命名空间内定义的对象,类内声明为静态的对象,函数内声明为静态的对象和在文件范围内被声明为静态的对象。函数内的静态对象以局部静态对象(因为它局部于函数)为人所知,其他各种静态对象以非局部静态对象为人所知。程序结束时静态对象会自动销毁,也就是当主程序停止执行时会自动调用它的析构函数。
一个转换单元(translation units)可以形成一个单独的目标文件(object file)。基本上是一个单独的源文件,加上它全部的 #include 文件。
我们关心的问题是这样的:至少有两个分别编译的源文件,每一个中都至少包含一个非局部静态对象(也就是说,全局的,命名空间范围的,类内静态的或文件范围的对象)。实际的问题是这样的:如果其中一个转换单元内的一个非局部静态变量的初始化用到另一个转换单元内的非局部静态变量,它所用到的对象可能没有被初始化,因为定义在不同转换单元内的非局部静态对象的初始化顺序是没有定义的。
让一个例子来帮助我们。假设你有一个 FileSystem 类,可以使一个在互联网上的文件看起来就像在本地。因为你的类认为世界只有一个文件系统,你可以在全局或命名空间范围内创建一个特殊的对象来代表这个唯一的文件系统:
class FileSystem { // from your library
public:
...
std::size_t numDisks() const; // one of many member functions
...
};
extern FileSystem tfs; // object for clients to use;
// "tfs" = "the file system"
一个 FileSystem 对象自然没有太大作用,为了使用这个 FileSystem 对象,在创建它之前,还有很多事情要做。
现在假设一些客户为一个文件系统中的目录创建了一个类,他们的类自然会用到这个 FileSystem 对象。
class Directory { // created by library client
public:
Directory( params );
...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); // use the tfs object
...
}
更进一步,假设这个客户决定为临时文件创建一个单独的目录对象:
Directory tempDir( params ); // directory for temporary files
现在初始化的顺序显然变得重要了:除非 tfs 在 tempDir 之前初始化,否则,tempDir 的构造函数就会在 tfs 被初始化之前试图使用它。但是,tfs 和 tempDir 是被不同的人在不同的时间不同的源文件中创建的——这就是定义在不同转换单元(translation units)中的非局部静态(non-local static)对象。你怎么能确信 tfs 一定会在 tempDir 之前被初始化呢?
你不能。重申一遍,定义在不同转换单元中的非局部静态对象的初始化的相关顺序是未定义的。这是有原因的。决定非局部静态对象的“恰当的”初始化顺序是困难的,非常困难,以至于是一个无法完成的任务。在最常见的形式下——多个转换单元和非局部静态变量通常通过隐式模板实例化产生(这本身可能也是由于隐式模板实例化引起的)——不仅不可能确定一个正确的初始化顺序,甚至不值得去寻找可能确定正确顺序的特殊情况。
幸运的是,一个小的设计变更会从根本上解决这个问题。全部要做的就是将非局部静态对象移到他自己的函数中,在那里它被声明为静态。这个函数返回它所包含的对象的引用。客户可以调用这些函数来代替直接涉及到那些对象。换一种说法,就是用局部静态对象取代非局部静态对象。(设计模式迷们会认出这是 Singleton 模式的普通实现)。
这个方法建立在 C++ 保证局部静态对象的初始化发生在因为调用那个函数而第一次遇到那个对象的定义的时候。所以,如果你用调用返回局部静态对象的引用的函数的方法取代直接访问非局部静态对象的方法,你将确保你取回的引用引向已初始化的对象。作为一份额外收获,如果你从不调用这样一个仿效非局部静态对象的函数,你就不会付出创建和销毁这个对象的代价,而一个真正的非局部静态对象则不会有这样的效果。
以下就是这项技术在 tfs 和 tempDir 上的应用:
class FileSystem { ... }; // as before
FileSystem& tfs() // this replaces the tfs object; it could be
{ // static in the FileSystem class
static FileSystem fs; // define and initialize a local static object
return fs; // return a reference to it
}
class Directory { ... }; // as before
Directory::Directory( params ) // as before, except references to tfs are
{ // now to tfs()
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir() // this replaces the tempDir object; it
{ // could be static in the Directory class
static Directory td; // define/initialize local static object
return td; // return reference to it
}
这个改良系统的客户依然可以按照他们已经习惯的方法编程,只是他们现在应该用 tfs() 和 tempDir() 来代替 tfs 和 tempDir。也就是说,他们应该使用函数返回的对象引用来代替使用对象自身。
按照以下步骤来写返回引用的函数总是很简单:在第 1 行定义并初始化一个局部静态对象,在第 2 行返回它。这样的简单使它们成为内联函数的极好的候选者,特别是在它们经常被调用的时候(参见 Item 30)。在另一方面,这些函数包含静态对象的事实使它们在多线程系统中会出现问题。更进一步,任何种类的 non-const static 对象——局部的和非局部的——在多线程存在的场合都会发生麻烦。解决这个麻烦的方法之一是在程序的单线程启动部分手动调用所有的返回引用的函数。以此来避免与初始化相关的混乱条件。
当然,用返回引用的函数来防止初始化顺序问题的想法依赖于你的对象在它第一次出现的地方有一个合理的初始化顺序。如果你有一个系统要求对象 A 必须在对象 B 之前初始化,但是 A 的初始化又依赖于 B 已经被初始化,你将遇到问题,坦白地讲,你遇到大麻烦了。如果你避开了这种病态的境遇,这里描述的方法会很好地为你服务,至少在单线程应用程序中是这样。
避免在初始化之前使用对象,你只需要做三件事。首先,手动初始化内建类型的非成员对象。第二,使用成员初始化列表初始化对象的每个部分。最后,围绕定义在不同的转换单元中的非局部静态变量初始化顺序不确定的问题进行设计。
Things to Remember
手动初始化内建类型的对象,因为 C++ 只在某些时候才会自己初始化它们。在构造函数中,用成员初始化列表代替函数体中的赋值。初始化列表中数据成员的排列顺序要与他们被声明的顺序相同。通过用局部静态变量代替非局部静态变量来避免不同转换单元的初始化顺序问题。