c++单例模式

单例模式保证一个类只有一个实例,并且提供了一个访问他的全局访问点。

我曾经在用 unity 写一个小游戏时,需要记录玩家的信息和分数。在 unity 中的一个方法是创建一个物体,将保存着玩家信息和分数的脚本挂载在这个物体上,然后在场景切换时指定物体不进行析构。
在使用了 DontDestroyOnLoad 这个 API 后,每次游戏场景切换时,虽然原本的物体不会析构了,但是可能会重新创建一个新的同名的物体从而引起错误。

显然玩家的信息和分数只有一份,如果同一个玩家的信息有多份,那么无论从逻辑上还是实现上都是不正确的,而且在任何时候都有可能需要这份信息,所以我们应该设计一个全局的类。

这些需求引出了单例模式这样的一个设计模式。单例模式保证了一个类只有唯一的一个实例。在有实例的情况下,任何试图新创建或者新复制一个实例的行为都会被拒绝。并且他提供了一个全局的得到唯一实例的入口。

单例模式分为两种类型:懒汉模式和饿汉模式。

懒汉模式

对象只有在第一次用到时被创建。叫做延迟初始化。

//1.0
class LazySingleton
{
private:
    static LazySingleton *instance;          //指向唯一实例的指针
    LazySingleton() {}               //禁用构造函数
    LazySingleton(const LazySingleton &) {}      //禁用复制构造函数
    LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
    ~LazySingleton();

public:
    static LazySingleton *GetInstance()
    {
        if (instance == nullptr) //如果没有实例则创建一个
            instance = new LazySingleton();
        return instance; //获得唯一实例
    }
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化

非常简单,注释也写的十分清晰了。

问题一:内存管理

但是有个问题,在 GetInstance()new 出来的对象并没有析构。
这时我们想起来cpp\ primer\ plus中的一句话,在构造函数中new出来的对象要在析构函数中 delete
那我们大胆尝试一下:在析构函数中 deletenew 出来的指针会怎么样:

//bad
#include <bits/stdc++.h>
using namespace std;

class LazySingleton
{
private:
    static LazySingleton *instance;          //指向唯一实例的指针
    LazySingleton() {}               //禁用构造函数
    LazySingleton(const LazySingleton &) {}      //禁用复制构造函数
    LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符

public:
    static LazySingleton *GetInstance()
    {
        if (instance == nullptr) //如果没有实例则创建一个
            instance = new LazySingleton();
        return instance; //获得唯一实例
    }
    ~LazySingleton()
    {
        cout << "Destructer" << endl;
        if (instance != nullptr)
            delete instance;
    }
};
LazySingleton *LazySingleton::instance = nullptr;

int main()
{
    LazySingleton *p = LazySingleton::GetInstance();
    delete p;
}

运行后发现程序不断输出 Destructer 直到奔溃,说明这样写析构函数肯定是不对的。在一开始学习时我也有这样一个疑问,查阅资料并且仔细分析后发现,这个静态指针是指向自己的对象,当调用 delete 时,首先会调用自己的析构函数,在析构函数中再次 delete ,那么又会再次调用自己的析构函数,这样就形成了无穷递归。
实际上和复制构造函数传参的道理差不多,在自己的函数中调用自己。

那么既然无法直接在析构函数中 delete ,我们有其他的两种手段:

  1. 智能指针
  2. 使用静态的嵌套对象

其实思想是差不多的,既然自己无法析构自己,那么我们可以通过其他的一个类来析构。

//1.1
class LazySingleton
{
private:
    static LazySingleton *instance;          //指向唯一实例的指针
    LazySingleton() {}               //禁用构造函数
    LazySingleton(const LazySingleton &) {}      //禁用复制构造函数
    LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
    ~LazySingleton();

    class Garbo                 //类内的嵌套类
    {
    public:
        ~Garbo()
        {
            cout << "Garbo!" << endl;
            if (LazySingleton::instance != nullptr)
                delete instance;
        }
    };
    static Garbo garbo;

public:
    static LazySingleton *GetInstance()
    {
        if (instance == nullptr) //如果没有实例则创建一个
            instance = new LazySingleton();
        return instance; //获得唯一实例
    }
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化

静态成员变量只有在程序结束时析构。但是这样是完全没有必要的,因为程序在结束时会自动摧毁所有变量,所以这个也没什么意义。实际上在 c++11 及以后已经有了一种完全正确且又方便好写的单例模式,后面也会提到。

问题二:多线程环境下

上面的 1.01.1 在单线程环境下都是正确的,但是在多线程环境下可能会出错。
试想两个线程 AB ,以 1.0 的程序为例,一开始指针为空,线程 A 进入 GetInstance() 的判断中,判断为正确,准备迎接新 new 出来的实例,注意这时候 instance 依然为空。这时被线程B抢占,同样通过了判断,准备迎接另一个 new 的实例。
这样就出现了两个实例,产生了错误。

容易想到加锁来保证只有一个线程进入创建实例的步骤。于是能得到下面的代码:

//2.0
mutex mu;
class LazySingleton
{
private:
    static LazySingleton *instance;          //指向唯一实例的指针
    LazySingleton() {}               //禁用构造函数
    LazySingleton(const LazySingleton &) {}      //禁用复制构造函数
    LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
    ~LazySingleton();

public:
    static LazySingleton *GetInstance()
    {
        lock_guard<mutex> lock(mu);
        if (instance == nullptr) //如果没有实例则创建一个
            instance = new LazySingleton();
        return instance; //获得唯一实例
    }
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化

这是普通的加锁,当另一个进程进入后如果没有获取到锁则会被堵塞。
但是这样对性能产生的影响会很大,如果多个线程在同时获取实例时,同一时间只有一个线程能获取到。
注意到原来可能会发生错误的时候只有在创建第一个实例时才可能发生,那么我们可以进行优化,创建完第一个实例后如果再进行获取就不需要加锁了,从而得到了双检测锁模式。

//2.1
mutex mu;
class LazySingleton
{
private:
    static LazySingleton *instance;          //指向唯一实例的指针
    LazySingleton() {}               //禁用构造函数
    LazySingleton(const LazySingleton &) {}      //禁用复制构造函数
    LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
    ~LazySingleton();

public:
    static LazySingleton *GetInstance()
    {
        if (instance == nullptr)
        {
            lock_guard<mutex> lock(mu);
            if (instance == nullptr) //如果没有实例则创建一个
                instance = new LazySingleton();
        }
        return instance; //获得唯一实例
    }
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化

看上去非常完美了,实际上之前很长一段时间内各种专家也是这样认为的,直到后来有大神发现编译器在处理这段代码时可能会有 reorder 现象,也就是编译器在编译的时候并不一定会像人们通常想象那样产生指令。
可能会出现这种情况:
线程 A 正在 new 一个新的对象,指令执行时系统为对象分配了空间,然后把指向这段空间的指针返回给 instance ,但是这个时候还没来得及调用类的构造器对这段空间初始化,线程 B 抢占了时间片,这时的 instance 已经不为空了,于是返回了一个指向还没初始化完的空间的指针。

所以上面的代码依然是有问题的,下面给出一个 cpp 的跨平台的实现,java 可以通过 volatile 实现。

//2.2
//从课件上抄来的
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;
//保证先分配内存,再调用构造器,最后返回内存地址
Singleton* Singleton::getInstance() {
    Singleton* tmp = m_instance.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);//获取内存fence
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = m_instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton;
            std::atomic_thread_fence(std::memory_order_release);//释放内存fence
            m_instance.store(tmp, std::memory_order_relaxed);
        }
    }
    return tmp;
}

可以看到较为繁琐,好在 c++11 已经要求函数内静态变量 (local\ static) 的线程安全性。现在最通用也是最优美的实现方法如下:

//2.3
class LazySingleton
{
private:
    LazySingleton() {}
    LazySingleton(const LazySingleton &) {}
    LazySingleton &operator=(const LazySingleton &) = delete;

public:
    static LazySingleton &GetInstance()
    {
        static LazySingleton instance;
        return instance;
    }
};

这里返回了引用,实际上返回指针也行,差不多的。

  1. c++\ 11保证了 local\ static 的线程安全性,而且这个变量会自动在第一次调用时产生,省去了指针的判断,省时省力。
  2. 赋值构造函数声明为 =delete 意思是指定不产生默认的复制构造函数,从语言的层面杜绝赋值。
  3. 析构函数没写,直接用默认的,因为从逻辑上考虑,既然单例模式的对象是全局的,那么也不应该手动析构,在程序结束时会自动析构掉,所以也不需要写什么析构函数了。当然这条并不是绝对的,应该随机应变。

可以说这样的一个程序是目前较为优美的一个解决方案了。

饿汉模式

对象在程序运行前被初始化。
与懒汉模式相比,饿汉模式是用空间换取时间。
在程序运行前就初始化完成,每次调用时也不用判断或者新创建一个对象了,直接返回就完事,但是代价就是时刻占用这一个对象的内存。

//3.1
class EagerSingleton
{
private:
    static const EagerSingleton *instance;
    EagerSingleton() {}
    EagerSingleton(const EagerSingleton &);
    EagerSingleton &operator=(const EagerSingleton &);

public:
    static const EagerSingleton *GetInstance()
    {
        return instance;
    }
};
const EagerSingleton *EagerSingleton::instance = new EagerSingleton();//记得在主函数前初始化

相比懒汉模式,饿汉模式相对简单。

参考资料

https://zhuanlan.zhihu.com/p/37469260
大话设计模式

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,509评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,806评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,875评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,441评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,488评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,365评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,190评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,062评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,500评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,706评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,834评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,559评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,167评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,779评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,912评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,958评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,779评论 2 354