By Matthew Wilson
树人 译
11.1非局部静态对象:Globals 尽管语言非常清楚地定义了初始化阶段和主流程之间的关系,但非局部静态对象的使用还是有若干的缺陷(section15.5)而且通常都不被推荐使用。其使用的主要问题涉及到次序,现在我们来看看吧。
次序问题有两个密切相关的方面组成。首先是在两个或多个静态变量之间可能存在循环的相互依赖性。这是一个基本的工程技术问题,对此你毫无解决方案,但存在着一些让其更具可检测性的方法。
第二个问题是:可能会在一个非局部静态变量被初始化之前或未初始化之后引用它。对开发者来说,这是使人惊愕的永恒起源,也是新闻组中谈论的一个永恒的话题,或者可以说这是C++的一个缺失吧。
Imperfection: C++ does not provide a mechanism for controlling global object ordering.
11.1.1内部编译单元次序
在一个给定变异单元中,全局对象的生存期遵从和栈对象一样的次序:它们以其定义的顺序被构造,以相反的顺序被销毁(C++-98:3.6.2;1)。注意是定义的顺序,不是声明,这非常重要。在Listing11.2中,构造的顺序是,析构的顺序是o4,o3,o2,o1。
Listing 11.2
class Object;
extern Object o2;
Object o1("o1");
Object o2("o2");
int main()
{
Object o3("o3");
Object o4("o4");
return 0;
}
当静态对象在进程的编译单元中时,它们会在main()入口之前被构造,而且在main()返回之后被销毁。
让一个全局对象依赖于另一个在同一链接单元中已被定义过的全局对象是完全合法而且正确的。因此o2被定义为o1的一个拷贝。
extern Object o2;
Object o1("o1");
Object o2(o1); // This is fine
尽管编译器会允许你这么做,依赖于另一个未被定义的是无效的,即使已经被声明过。下边的例子会导致未定义行为:
extern Object o2;
Object o1(o2); // Undefined!
Object o2("o2");
因为全局对象的作用域是被预分配的,而且被零初始化,o2的正确地址传给了o1,但是o2的成员都是零。依赖于不同的Object的定义,可能会引起崩溃或者仅仅是导致一个无声的错误。有些情况下,它可能会产生正确的行为,但依赖于它仍旧是一个严重的错误。
11.1.2交叉编译单元次序
当提到编译单元之间全局对象的次序的问题时,我们会坚定地认为是实现定义相关的。实际上,应该归结到链接器上。对于大多数的链接器来说,全局对象的顺序是依照编译单元的链接顺序来定的。考虑一下Listing11.3。
Listing 11.3.
// object.h
class Object { . . .};
extern Object o1;
extern Object o2;
extern Object o3;
// main.cpp
#include "object.h"
Object o0("o0");
Object o1("o1");
int main() { . . . }
// object2.cpp
#include "object.h"
Object o2("o2");
// object3.cpp
#include "object.h"
Object o3("o3");
规定目标文件对链接器的顺序是object1.cpp,object2.cpp,object3.cpp,几个编译器的次序如Table11.1所示:
Table 11.1.
Compiler/Linker
Order
Borland C/C++ 5.6
o0, o1, o2, o3
CodeWarrior 8
o0, o1, o2, o3
Digital Mars 8.38
o3, o2, o0, o1
GCC 3.2
o3, o2, o0, o1
Intel C/C++ 7.0
o0, o1, o2, o3
Visual C++ 6.0
o0, o1, o2, o3
Watcom C/C++ 12
o3, o2, o0, o1
很明显,这些编译器执行了两种清晰但相反的策略。Borland,CodeWarrior,Intel和Visual C++使全局对象以与目标文件链接顺序相应的顺序被构造出来。Digital Mars,GCC和Watom以相反的方式进行。
这个不一致性引起了一个问题。如果所有的编译器/链接器都支持一个标准的全局对象次序机制,在你的应用程序中,依赖于一种可预测的全局对象次序将是可能的。
尽管如此,原则上来说依赖于链接器控制的目标文件次序是一种完成静态对象初始化的方法。如果你对于你的开发团队和工具的稳定性充分自信,你可以选择提供这种技术。但在你的产品的生存期中,确认你的构建工程仍然会影响所需链接器次序的困难依然存在。
对此一个实用的测度是在每个编译单元中插入调试代码,至少是在调试构建中,以此来跟踪初始化顺序。由于我们知道在一个给定的编译单元中的顺序是固定的,而且所有的对象在main()之前被初始化,或者是在它们任何一个的第一次使用之前,所以我们所要做的就是在各个编译单元的起始处注入一个跟踪性的,非局部的静态对象,这样我们将能够清楚地决定链接器次序。
让我们来看看上述过程是如何运作的。CUTrace.h包含了函数CUTraceHelper()的声明,这个函数用来打印文件正被初始化,一条信息和一些可选参数。可能像这样的”…cu_ordering_test.cpp: Initialising.”另一个函数是CUTrace(),它就是简单地持有一条信息和参数,并把它们连同文件一起传给CUTraceHelper():
// CUTrace.h
extern void CUTraceHelper(char const *file, char const *msg, va_list args);
namespace
{
void CUTrace(char const *message, ...)
{
va_list args;
va_start(args, message);
CUTraceHelper(__BASE_FILE__, message, args);
va_end(args);
}
. . .
} // namespace
这里有两个重要的特征。首先,CUTrace()被定义在一个匿名名称空间中,这意味着各个文件都有这个函数的一个拷贝。但是,这并不重要,因为编译器可以很容易地把它优化成对函数CUTraceHelper()的调用。无论如何,这样的东西可能只被用在调试和测试版本中,而不会是完整的发布版本。如果没有static,连接器很抱怨有重定义,但是如果使用inline,会导致只有一个版本,其它所有的都会被链接器删除掉。
第二个特征就是非标准符号__BASE_FILE__的使用。Digital Mars和GCC都把这个符号定义为主实现文件的名称。在编译发生时,它通常是在命令行中命名的文件。所以,即使CUTrace()被定义在CUTrace.h中,它也会把主实现文件名传给函数CUTraceHelper()。
当然,因为这个符号是非标准的,所以这个技术在其它编译器的当前形式中毫无作为。解决方法就是为其它编译器提供一个__BASE_FILE__。无可否认它是多余的,但它可以工作,而且可以很容易地用一个Perl,Python或Ruby脚本来插入它。老实说:如果你的确需要依赖于链接器次序的超级测度,这一点附加代码在各个源文件中也就不算什么问题了。
// SomeImplFile.cpp
#ifndef __BASE_FILE__
static const char __BASE_FILE__[] = __FILE__;
#endif /* __BASE_FILE__ */
#include "CUTrace.h"
上面的最后一个部分再一次使用了内部链接,这次是为了确保我们得到类CUTracer的一个单一的拷贝。
// CUTrace.h
. . .
namespace
{
. . .
static CUTracer s_tracer;
} // namespace
注意如果你把CUTrace()和s_tracer声明为static也是可以的,但是只要你没必要支持任何旧编译器破车时,匿名名称空间是一个更好的选择。
即使你不想把链接器次序当作一种开发方法,坦率地说:谁又想写一些正确性依赖于特定行为的代码呢?这个技术仍然是一种非常有用的诊断辅助措施,所以即使我不建议你在你的工作中依赖于链接器次序,我还是建议你在大项目中包含它。
Recommendation: Don't rely on global object initialization ordering. Do utilize global object initialization ordering tracing mechanisms anyway.
11.1.3在main()中避免使用全局对象
很明显,你可以简单地通过不使用任何的全局变量来避免所有问题,但是某些时候你是不可能避免它们的。一个简单,虽然不雅的避免它们的方法就是把你的全局对象改为main()中的栈对象,这样你可以显式地控制它们的生存期,而且可以把指向它们的指针传递给程序中的其它代码。Listing11.4展示了它是如何完成的:
Listing 11.4.
// global1.h
class Global1
{
. . .
};
extern Global1 *g_pGlobal1;
// global2.h
class Global2
{
. . .
};
extern Global2 *g_pGlobal2;
// main.cpp
#include "global1.h"
#include "global2.h"
int main(. . .)
{
Global1 global1;
g_pGlobal1 = &global1;
Global2 global2;
g_pGlobal2 = &global2;
return g_pGlobal2->Run(. . .)
}
这里明显的缺点就是你必须通过指针来使用所有的全局变量,这将导致少量效率的损失和小量的语法不便。一个潜伏的缺点就是如果对象的客户端代码在main()以外以某种方式使用全局变量和基于main()的伪全局变量时,你的程序将一个堆中崩溃。但是如果你完全控制了你的静态对象的生存期和次序时,这将是可管理的,所以某些时候这些努力还是值得的。
11.1.4全局对象终结语:次序
一般而言,如果并非不可能,用一种可预测而且可移植的方式来管理全局对象次序是非常之困难的。但是,在下一节中我们将看到当涉及到单件(Singleton)时,事情会稍微易处理些。如果给定类型的每个全局对象与一个唯一的编译时标识符相关联的话,把一种解决单件次序问题的方案(计数API方案)应用到全局对象问题上是可能的,但是这超出了本章所讨论的范围。当然,解决这个问题需要跳跃如此多的“火圈”以至于最好还是退一步考虑一下问题是不是出在设计阶段。