本文介绍了静态变量构造顺序和析构顺序所带来的一系列问题,由于这些问题的出现条件都比较特殊,它们在使用中常常会被忽略。但是在设计和开发中这些问题却是不可忽略的,最严重的可能导致程序崩溃。在开发时注意了这些问题后,你的代码会更加稳健。

静态初始化顺序

静态初始化顺序问题的英文名称是 static initialization order problem,这个问题所涉及到的对象有:类域中的静态变量、文件中的和命名空间中的(全局)变量。

通常,全局变量会在 main 函数之前初始化完成,但是多个全局变量之间并没有确定的初始化顺序。我们在编写代码时不能假设某个全局变量在另一个全局变量之前初始化完成,要保证全局变量在初始化时(构造函数中)不调用其他全局变量提供的接口。因为那个被调用的全局变量函数接口的对象有可能没有来得及初始化,这意味着构造函数未执行,成员变量没有初始化,各种资源可能没来得及申请。

The nightmare scenario occurs when there is an order dependency between initializations across different compilation units (that is, different .cpp files). This can be both dangerous andsubtle.

这段话摘自《C++ FAQs》,它给出了更加详细的阐述,当静态变量出现在不同的编译单元中(即不同的 *.cpp 文件中)时,它们的初始化过程间不应该有相互依赖关系。

同时它也隐含了另一层意思,处在同一个编译单元中的变量在初始化时是有序的,会按照变量声明的先后顺序进行初始化。即在一个作用域中,先出现的(靠上的)对象在初始化时要早于后出现的(靠下的)对象。

使用指针的单例模式

使用单例模式能够解决全局变量初始化问题。在单例模式中通常会有个静态函数用于返回单例类(class)的对象(object)的引用,这样就保证了在使用这个单例对象时该对象肯定是被初始化了的。

单例模式能够保证类仅有实例化出一个对象,在代码实现上还需要搭配私有的类构造函数。如果仅需要保证对象在使用前被初始化,我们可以仿照单例模式的写法,通过函数返回静态变量的引用,示例代码如下。

Singleton& Instance() {
    static Singleton* s_pObj = new Singleton;
    return *s_pObj;
}

这种仿单例模式的写法在解决了全局变量初始化顺序问题的同时又引入了另一个新的问题,即内存泄漏的资源释放问题。这个问题在大多数情况下并无大碍,因为静态对象的生存期随着程序的运行结束而结束,在程序结束时操作系统会自动回收程序运行时申请的内存。但是,在有些情况下需要显式调用析构函数完成资源释放,也可能是需要在对象析构时执行某些特定的操作。

使用对象的单例模式

直接使用静态的类对象就能够避免内存泄漏问题。与使用对象指针的写法不同,静态变量在程序退出前一定会销毁,其析构函数一定会被调用。示例代码如下所示。

Singleton& Instance() {
    static Singleton s_obj;
    return s_obj;
}

但是这样做也存在着问题,即多个静态对象的析构顺序是不确定的。当静态对象在析构时依赖了另一个静态对象就会出现问题,其原理与构造顺序不确定类似。即由于析构顺序是随机的,多个静态变量在销毁时不能存在相互依赖关系。

终极形式的单例模式

那么是否有一种单例模式的范例能够同时解决内存泄漏问题和析构顺序问题呢?答案是有的,只不过写法稍微复杂,并且存在一定的性能损失。示例代码如下:

singleton.h 文件内容:

class Singleton {
public:
    static Singleton& Instance();
    class Init;
private:
    Singleton();
    Singleton(const Singleton&) = delete;
private:
    friend Init;
    static Singleton* s_pObj;
};

class Singleton::Init {
public:
    Init();
    ~Init();
private:
    static unsigned s_count;
};

static Singleton::Init s_init; // 关键变量

singleton.cpp 文件内容如下

Singleton* Singleton::s_pObj = nullptr;
unsigned Singleton::Init::s_count = 0;

Singleton& Singleton::Instance() {
    return *s_pObj;
}
Singleton::Singleton() {
    printf("Singleton::Singleton\n");
}
void Singleton::Foo() {
    printf("Singleton::Foo\n");
}

Singleton::Init::Init() {
    if (s_count++ == 0) {
	Singleton::s_pObj = new Singleton;
    }
}
Singleton::Init::~Init() {
    if (--s_count == 0) {
	delete Singleton::s_pObj;
    }
}

每个使用了 singleton.h 头文件的 *.cpp 源文件都有其单独的 s_init 静态对象。由于这个静态对象出现在源文件比较靠前的位置,它会比其他的静态变量先初始化。准确地讲,它能够保证在源文件中其他的静态变量调用 Singleton::Instance 函数之前初始化完成(因为必须先引用头文件才能使用头文件中声明的函数)。

单例对象 s_pObj 在第一个 s_init 静态对象初始化时被创建出来,在最后一个 s_init 静态对象销毁时被销毁,这样同时解决了前节遇到的问题。

这种写法的问题是每次引用 singleton.h 头文件都会产生一个产生一个 s_init 静态对象,这意味这程序启动期间需要更多的内存。如果 singleton.h 这样的头文件被大量引用,这会显著增加程序的启动时间。

总结

下面对这几种方案进行了比较:

使用静态指针的单例模式:

  • 优点:容易记忆,使用方便,程序启动时效率高、安全,程序结束时安全。
  • 缺点:单例对象被遗弃在堆上,因此产生了内存泄漏。如果单例对象的析构函数必须执行,那么这个方法不适用。

使用静态对象的单例模式:

  • 优点:容易记忆,使用方便,程序启动时效率高、安全,并且单例对象最终被析构。
  • 缺点:在程序结束时不安全。如果多个单例对象在析构时相互依赖就会出现问题。

使用计数器的单例模式:

  • 优点:使用方便,程序启动时安全,程序结束时安全,并且单例对象最终被析构。
  • 缺点:写法复杂,不容易记忆。可能在程序启动和结束时产生性能问题。