条款04:确定对象被使用前已先被初始化(2)
C++(www.cppentry.com) 有着十分固定的"成员初始化次序"。是的,次序总是相同:base classes更早于其derived classes被初始化(见条款12),而class的成员变量总是以其声明次序被初始化。回头看看ABEntry,其theName成员永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted。即使它们在成员初值列中以不同的次序出现(很不幸那是合法的),也不会有任何影响。为避免你或你的检阅者迷惑,并避免某些可能存在的晦涩错误,当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。
译注:上述所谓晦涩错误,指的是两个成员变量的初始化带有次序性。例如初始化array时需要指定大小,因此代表大小的那个成员变量必须先有初值。
一旦你已经很小心地将"内置型成员变量"明确地加以初始化,而且也确保你的构造函数运用"成员初值列"初始化base classes和成员变量,那就只剩唯一一件事需要操心,就是……呃……深呼吸……"不同编译单元内定义之non-local static对象"的初始化次序。
让我们一点一点地探钻这一长串词组。
所谓static对象,其寿命从被构造出来直到程序结束为止,因此stack和heap-based对象都被排除。这种对象包括global对象、定义于namespace作用域内的对象、在classes内、在函数内、以及在file作用域内被声明为static的对象。函数内的static对象称为local static对象(因为它们对函数而言是local),其他static对象称为non-local static对象。程序结束时static对象会被自动销毁,也就是它们的析构函数会在main()结束时被自动调用。
所谓编译单元(translation unit)是指产出单一目标文件(single object file)的那些源码。基本上它是单一源码文件加上其所含入的头文件(#include files)。
现在,我们关心的问题涉及至少两个源码文件,每一个内含至少一个 non-local static对象(也就是说该对象是global或位于namespace作用域内,抑或在class内或file作用域内被声明为static)。真正的问题是:如果某编译单元内的某个non-local static对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化,因为C++(www.cppentry.com) 对"定义于不同编译单元内的non-local static对象"的初始化次序并无明确定义。
实例可以帮助理解。假设你有一个FileSystem class,它让互联网上的文件看起来好像位于本机(local)。由于这个class使世界看起来像个单一文件系统,你可能会产出一个特殊对象,位于global或namespace作用域内,象征单一文件系统:
- class FileSystem { //来自你的程序库
- public:
- ...
- std::size_t numDisks() const;//众多成员函数之一
- ...
- };
- extern FileSystem tfs; //预备给客户使用的对象;
- //tfs代表 "the file system"
FileSystem对象绝不是一个稀松平常无关痛痒的(trivial)对象,因此你的客户如果在theFileSystem对象构造完成前就使用它,会得到惨重的灾情。
现在假设某些客户建立了一个class用以处理文件系统内的目录(directories)。很自然他们的class会用上theFileSystem对象:
- 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是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象。如何能够确定tfs会在tempDir之前先被初始化?
喔,你无法确定。再说一次,C++(www.cppentry.com) 对"定义于不同的编译单元内的non-local static对象"的初始化相对次序并无明确定义。这是有原因的:决定它们的初始化次序相当困难,非常困难,根本无解。在其最常见形式,也就是多个编译单元内的non-local static对象经由"模板隐式具现化, implicit template instantiations"形成(而后者自己可能也是经由"模板隐式具现化"形成),不但不可能决定正确的初始化次序,甚至往往不值得寻找"可决定正确次序"的特殊情况。
幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是:将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。Design Patterns迷哥迷姊们想必认出来了,这是Singleton模式的一个常见实现手法。
这个手法的基础在于:C++(www.cppentry.com) 保证,函数内的local static对象会在"该函数被调用期间""首次遇上该对象之定义式"时被初始化。所以如果你以"函数调用"(返回一个reference指向local static对象)替换"直接访问non-local static对象",你
就获得了保证,保证你所获得的那个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static对象的"仿真函数",就绝不会引发构造和析构成本;真正的non-local static对象可没这等便宜!
以此技术施行于tfs和tempDir身上,结果如下:
- class FileSystem { ... }; //同前
- FileSystem& tfs() //这个函数用来替换tfs对象;它在
- { //FileSystem class中可能是个static。
- static FileSystem fs; //定义并初始化一个local static对象,
- return fs; //返回一个reference指向上述对象。
- }
- class Directory { ... }; //同前
- Directory::Directory( params )//同前,但原本的reference to tfs
- { //现在改为tfs()
- ...
- std::size_t disks = tfs().numDisks( );
- ...
- }
- Directory& tempDir() //这个函数用来替换tempDir对象;
- { //它在Directory class中可能是个static。
- static Directory td; //定义并初始化local static对象,
- return td; //返回一个reference指向上述对象。
- }
这么修改之后,这个系统程序的客户完全像以前一样地用它,唯一不同的是他们现在使用tfs()和tempDir()而不再是tfs和tempDir。也就是说他们使用函数返回的"指向static对象"的references,而不再使用static对象自身。
这种结构下的reference-returning函数往往十分单纯:第一行定义并初始化一个local static对象,第二行返回它。这样的单纯性使它们成为绝佳的inlining候选人,尤其如果它们被频繁调用的话(见条款30)。但是从另一个角度看,这些函数"内含static对象"的事实使它们在多线程系统中带有不确定性。再说一次,任何一种non-const static 对象,不论它是local或non-local,在多线程环境下"等待某事发生"都会有麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段(single-threaded startup portion)手工调用所有reference-returning函数,这可消除与初始化有关的"竞速形势(race conditions)"。
当然啦,运用reference-returning函数防止"初始化次序问题",前提是其中
有着一个对对象而言合理的初始化次序。如果你有一个系统,其中对象A必须在对象B之前先初始化,但A的初始化能否成功却又受制于B是否已初始化,这时候你就有麻烦了。坦白说你自作自受。只要避开如此病态的境况,此处描述的办法应该可以提供你良好的服务,至少在单线程程序中。
既然这样,为避免在对象初始化之前过早地使用它们,你需要做三件事。第一,手工初始化内置型non-member对象。第二,使用成员初值列(member initialization lists)对付对象的所有成分。最后,在"初始化次序不确定性"(这对不同编译单元所定义的non-local static对象是一种折磨)氛围下加强你的设计。
请记住
为内置型对象进行手工初始化,因为C++(www.cppentry.com)不保证初始化它们。
构造函数最好使用成员初值列(member initialization list),而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
为免除"跨编译单元之初始化次序"问题,请以local static对象替换non-local static对象。