【杂记】踩坑——为什么单例模式之饿汉模式实现可以不在类外初始化静态变量?

关于单例模式的实现,详细请见《五、日志模块的原理及实现 by TEnth丶》。

回到本篇重点,先列出如下两种饿汉式单例模式的实现代码:

//实现方法1
class singletonHungry{
    singletonHungry(){};
    ~singletonHungry(){};
public:
    static singletonHungry* getInstance(){
        return instance;
    }
private:
    static singletonHungry* instance;
};
singletonHungry* singletonHungry::instance = new singletonHungry();

//实现方法2
class singletonHungry{
    singletonHungry(){};
    ~singletonHungry(){};
public:
    //直接初始化这个静态变量并返回它的地址
    static singletonHungry* getInstance(){
        static singletonHungry instance;
        return &instance;
    }
private:
    static singletonHungry* instance;
};
复制代码

我们可以看到,在方法一中,使用了类外初始化静态变量,也是我们最常用的饿汉式实现方法; 那么对于方法二,为什么可以实现饿汉式呢?

这种方法是出自《Effective C++》(Item 04),书中的提出一种更优雅的单例模式实现,使用函数内的局部静态对象,这种方法不用加锁和解锁操作。

在item.04中,明确表示尽量使用初始化列表来代替在构造函数中对成员变量进行初始化,因为这样会更加高效。并且初始化顺序和初始化列表的书写顺序无关,而是和成员变量在类中声明的顺序相同。因此,要特别注意这些变量初始化的顺序,避免其中的初始化依赖性而导致的BUG。

但是对于全局对象的初始化,会有如下情况:

class FileSystem { /* ... */ };
extern FileSystem fileSystem; //给客户暴露的接口。


//客户代码可能如下:
class Directory
{
    std::size_t disks;
    //...
public:
    Directory() : disks(fileSystem.getDisks()) /* , ... */ 
    { 
        // ...
    }
};
extern Directory tmpDirectory; //客户创建的一个临时文件,用于保存运行时信息。

复制代码

上述代码中,Directory对象的构造依赖于我们提供的FileSystem对象的构造;也就是说,在理论上如果我们暴露给用户的全局fileSystem对象尚未被初始化,那么Directory对象就不应该被构造出来。 但是,C++中并不保证全局对象的初始化顺序。也就是说,根据编译器的实现不同,很有可能是客户的临时文件对象tmpDirectory先被构造,而后才构造我们提供的fileSystem对象。这就意味着,tmpDirectory对象是非法的。

为什么不在类外初始化静态变量?

因为你无法确定局部静态变量的初始化顺序!你并不能完全保证(虽然你已经很努力了,就像使用普通指针保证不能导致内存溢出一样)在对暴露在外的接口调用时,你一定初始化了局部静态变量。 《Effective C++》这样说到:

喔,你无法确定。再说一次,C++ 对“定义于不同的编译单元内的non-local static对象”的初始化相对次序并无明确定义。这是有原因的:决定它们的初始化次序相当困难,非常困难,根本无解。在其最常见形式,也就是多个编译单元内的non-local static对象经由“模板隐式县现化,implicit template instantiations" 形成(而后者自己可能也是经由“模板隐式具现化”形成),不但不可能决定正确的初始化次序,甚至往往不值得寻找“可决定正确次序”的特殊情况。

使用函数内local static对象

而解决这种问题的办法:

幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是: 将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static 对象被local static 对象替换了。DesignPatterns迷哥迷姊们想必认出来了,这是Singleton模式的一一个常见实现手法。这个手法的基础在于: C++保证,函数内的local static对象会在“该函数被调用期间” “首次遇上该对象之定义式”时被初始化。所以如果你以“函数调用”(返回一个reference指向local static对象)替换“直接访问non-local static对象”,你就获得了保证,保证你所获得的那个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static 对象的“仿真函数”,就绝不会引发构造和析构成本;真正的non-local static对象可没这等便宜!

也就是说:

class FileSystem { /* ... */ };
FileSystem &GetFileSystem() 
{ 
    static FileSystem fileSystem;
    return fileSystem;
}

class Directory { /* ... */ };
Directory &GetTmpDirectory() 
{
    static Directory dir(GetFileSystem()/* , ... */);
    return dir;
}

复制代码

之所以可以这样写,是因为C++提供了这样的保证:局部静态对象会在第一次遇到或者使用它的时候被初始化。 这好似并不能算是饿汉式?这种行为模式更类似于懒汉式

那我们思考如下一个情况,既然它类似于懒汉式,那又未对其加锁,我们怎么保证使用时不会出现线程安全问题?实际上,我们并不能保证,书上写到:

这种结构下的reference-returning 函数往往十分单纯:第一行定义并初始化一个local static 对象,第二行返回它。这样的单纯性使它们成为绝佳的inlining 候选人,尤其如果它们被频繁调用的话(见条款30)。但是从另一个角度看,这些函数“内含static 对象”的事实使它们在多线程系统中带有不确定性。再说一次,任何一种non-const static 对象,不论它是local或non-local,在多线程环境下“等待某事发生”都会有麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段(single-threaded startup portion)手工调用所有reference-returning 函数,这可消除与初始化有关的“竞速形势(race conditions)”。

所以,使用这种模式虽然是好的,但是在服务启动过程中,一定要在单线程环境下调用一次get_Instance函数以对其进行初始化,而在这样的流程中,这种单例模式就更类似于饿汉式了。

当然,对于某些糟糕的情况,这种方式也无法完成需求。例如,A类对象的构造依赖于B类对象的构造;然而B类对象的构造又反过来依赖于A类对象的构造。这种情况下,不应该寻找语法特性来解决问题,而是应该修改这种糟糕的设计。

猜你喜欢

转载自juejin.im/post/7100763852614139940