10.4 静态初始化的相依性
在一个指定的翻译单元中,静态对象的初始化顺序严格按照对象在该单元中定义出现的顺序。而清除的顺序则与初始化的顺序正好相反。
但是,对于作用域为多个翻译单元的静态对象来说,不能保证严格的初始化顺序,也没有办法来指定这种顺序。这可能会引起一些问题。下面的例子如果包含一个文件就会立即引起灾难(它会暂停一些简单的操作系统的运行,中止进程)。
另一个文件在它的初始表达式之一中用到了out对象:
这个程序可能运行,也可能不能运行。如果在建立可执行文件时第一个文件先初始化,那么就不会有问题,但如果第二个文件先初始化,Oof的构造函数依赖于out的存在,而此时out还没有创建,于是就会引起混乱。
这种情况只会在相互依赖的静态对象的初始化时出现。在一个翻译单元内的一个函数的第一次调用之前,但在进入main()之后,这个翻译单元内的静态对象都被初始化。如果静态对象位于不同的文件中,则不能确定这些静态对象的初始化顺序。
在ARM[1]中可以看到一个更微妙的例子,在一个文件中:
在另一个文件中
对所有的静态对象,连接装载机制在程序员指定的动态初始化发生前保证一个静态成员初始化为零。在前一个例子中,fstream out对象的存储空间赋零并没有特别的意义,所以它在构造函数调用前确实是未定义的。然而,对内建数据类型,初始化为零是有意义的,所以如果文件按上面的顺序被初始化,y开始被初始化为零,所以x变成1,而后y被动态初始化为2。然而,如果初始化的顺序颠倒过来,x被静态初始化为零,y被初始化为1,而后x被初始化为2。
程序员必须意识到这些,因为他们可能会在编程时遇到互相依赖的静态变量的初始化问题,程序可能在一个平台上工作正常,当把它移到另一个编译环境时,突然莫名其妙地不工作了。
10.4.1 怎么办
有三种方法来处理这一问题:
1)不用它,避免初始化时的互相依赖。这是最好的解决方法。
2)如果实在要用,就把那些关键的静态对象的定义放在一个文件中,这样只要让它们在文件中顺序正确就可以保证它们正确的初始化。
3)如果确信把静态对象放在几个不同的翻译单元中是不可避免的—如在编写一个库时,
这时无法控制那些使用该库的程序员—这可以通过两种程序设计技术加以解决。
10.4.1.1 技术一
这是由Jerry Schwarz在创建iostream库(因为cin、cout和cerr是静态的且定义在不同的文件中)时首创的一种技术。它实际上没有第二种技术好,但是因为它的生存期比较长,这样可能会遇到很多代码使用了它。知道它的工作原理还是很重要的。
这一技术要求在库头文件中加上一个额外的类。这个类负责库中的静态对象的动态初始化。下面是一个简单的例子:
x、y的声明只是表明这些对象的存在,并没有为它们分配存储空间。然而initializer init的定义为每个包含此头文件的文件分配那些对象的存储空间,因为名字是static的(这里控制可见性而不是指定存储类型,因为默认时是在文件作用域内)它只在本翻译单元可见,所以连接器不会报告一个多重定义错误。
下面是一个包含x、y和init_Count定义的文件:
(当然,当一个文件包含头文件时,它的init静态实例也放在该文件中。)假设库的使用者产生了两个其他的文件:
以及
现在哪个翻译单元先初始化都没有关系。当第一次包含Initializer.h的翻译单元被初始化时,initCount为零,这时初始化就已经完成了(这是由于任何动态初始化进行之前,静态存储区已被设置为零)。对其余的翻译单元,initCount不会为零,并忽略初始化操作。清除将按相反的顺序发生,且~Initializer()可确保它只发生一次。
这个例子用内建类型作为全局静态对象,这种方法也可以用于类,但其对象必须用initializer类动态初始化。一种方法就是创建一个没有构造函数和析构函数的类,而是带有不同名字的用于初始化和清除的成员函数。当然更常用的做法是在initializer()函数中,设定有指向对象的指针,用new创建它们。
10.4.1.2 技术二
在技术一使用很久之后才有人(我不知道是谁)提出了本小节将要说明的技术二。与技术一相比,这种技术更简单,也更清晰。之所以在技术一出现这么久之后才有技术二是因为C++太复杂。
这一技术基于这样的事实:函数内部的静态对象在函数第一次被调用时初始化,且只被初始化一次。需要记住的是,在这里真正想要解决的不是静态对象什么时候被初始化(这可以个别地加以控制),而是确保正确的初始化顺序。
这种技术很灵巧。对于任何初始化依赖因素来说,可以把一个静态对象放在一个能返回对象引用的函数中。使用这种方法,访问静态对象的惟一途径就是调用这个函数。如果该静态对象需要访问其他依赖于它的静态对象时,就必须调用那些对象的函数。函数第一次被调用时,它强迫初始化发生。静态初始化的正确顺序是由设计的代码而不是由连接器任意指定顺序来保证的。
为了给出一个例子,这里有两个相互依赖的类。第一个类包含一个bool类型的成员,它只由构造函数初始化,所以能够知道该类的一个静态实例是否调用了构造函数(在程序开始时,静态存储区被初始化为零,如果没有调用构造函数的话,会对bool成员产生一个false值)。
构造函数也显示它是什么时候被调用的,为了知道对象是否被初始化,可以通过print()函数打印出对象的状态。
第二个类初始化由第一个类的一个对象来完成,这将会导致初始化相互依赖。
构造函数显示它自己并打印出对象d1的状态,所以能够知道当构造函数被调用时,d1是否已经初始化了。
为了说明会出现什么错误,下面的文件首先以一种不正确的顺序定义静态对象,如果在对象Dependency1之前连接器碰巧初始化对象Dependency2,错误就会出现。如果定义的顺序恰好正确,那么就会以相反的顺序的显示说明它是如何正常工作的。这样,说明技术二是可靠的。
为了有更多的可读性的输出,增加separator()函数。诀窍就是不能全局地调用一个函数,除非该函数用来执行一个变量的初始化操作,所以separator()函数返回一个哑元值用来初始化两个全局变量。
函数d1()和d2()包含类Dependency1和Dependency2的静态对象。现在,访问这些静态对象的惟一方法就是调用这两个函数,并在第一次函数调用时强迫进行静态初始化,这可以保证初始化的正确性,通过这种方法,可以知道程序什么时候运行以及输出什么结果。
下面的代码使用了技术二。通常,静态对象在单独的文件中定义(由于某些原因,必须这样做;不过要记住在单独的文件中定义静态对象也会出现问题),而不是在单独的文件中定义一个包含静态对象的函数。但是需要在头文件中声明。
实际上,关键字“extern”对于函数声明来说是多余的。下面是第二个头文件:
在前面的实现文件中,有静态对象定义,现在,改为在包装的函数定义中定义静态对象:
其他的代码也可以放在这些头文件中,下面是另外一个文件:
现在有两个文件,这两个文件可以以任意的顺序连接。如果它们只包含普通的静态对象,那么可以产生任意顺序的初始化。在这里因为它们包含定义静态对象的函数,所以不会出现不正确的初始化:
当运行这个程序时,将会发现Dependency1类的静态对象的初始化总是发生在类Dependency2的静态对象的初始化之前。所以,从中可以看出这种方法要比第一种技术简单得多。
我们也许想在函数d1()和d2()的头文件中把它们声明为内联函数,但是我们必须明确地知道这样做不行。内联函数在它出现的每一个文件中都会有一份副本—这种副本包括静态对象的定义。因为内联函数自动地默认为内部连接,所以这将导致多个重复的静态对象,且它们作用域为多个编译单元,这当然会出现问题。所以必须确保每一个定义了静态对象的函数只有一份定义,这就意味着不能把定义了静态对象的函数作为内联函数。
[1]《The Annotated C++Reference Manual》,Bjarne Stroustrup和Margaret Ellis著,1990年,20~21页。