单例模式:茴香豆的七种写法

之前看一个朋友在学Java,顺口问他,你会设计模式吗?
他说知道一点吧。
然后我就问,那你会几种单例模式的写法吗?
他说,你这个孔乙己。

起篇

首先澄清一下,这篇文章并不是“茴香豆的茴字有X种写法”,文章题目只是调侃一下而已。一个字有多种写法,它们之间的功能是一样的并列的,你只需要会一个最通用,大家都认得的字就可以了。

但是,线程安全和线程不安全的单例模式的写法,你能说他们仅仅只是不同的写法而已吗?功能都不一样对吧?
还有效率高低不同(比如暴力加锁就效率很低),也是不一样的写法吧?
最后还有易读性和易维护性的区别,这个见仁见智吧。

最后,再澄清一下,我并不怎么会Java,只是因为做过一点安卓,所以略知一二而已,并没有深入研究过。然后,本文的例子代码都使用C++

开始讨论前的几点考量

1. 加锁?

加锁仿佛在需要同步互斥时,是最简单暴力的做法,但它在多线/进程的环境下的效率不高,不到万不得已,就不考虑它了。

2. 返回值类型?

我们一般都是这样写:

static TYPE getInstance() {
    // ...
}

那么TYPE应该是什么?
一个对象副本?这违背了单例模式的初衷……并且如果复制构造函数代价太高的话,也很不好。
一个指针?可以!
说到指针,肯定就少不了引用了,引用也是可以的,并且引用更加自然(毕竟是直接通过对象的别名来直接操纵对象)。

单例模式“格式”

这里的“格式”并不是说这种写法值得学习,是范例,而是给一个单例模式书写的框架,后续的各种改进版,都基于它的结构。注意,这里贴了Singleton类的声明和实现,后续的写法只贴getInstance函数的实现,而不再重复其它结构了,基本都一样的!

class Singleton
{
public:
    static Singleton* getInstance();

private:
    // 可以有很多类型的构造函数,这里只是简单举例
    Singleton(const string& initialName);

    // private + 只声明而不定义,从而可以禁止构造时的复制和赋值时的复制,保持单例
    Singleton(const Singleton&);
    Singleton& operator = (const Singleton&);

    static Singleton* obj;
    string name;    // 非原始类型的数据成员,代表需要在程序结束时主动释放的资源
};

而cpp文件则这样实现:

#ifndef __SINGLETON_H__
#define __SINGLETON_H__

Singleton* Singleton::obj = NULL;

Singleton::Singleton(const string& initialName)
: name(initialName) {
    cout << "Singleton create: " << name << endl;
}

Singleton* Singleton::getInstance() {
    if (obj == NULL) {
        obj = new Singleton("name given in code");
    }
    return obj;
}

#endif

注意两个点:

1. 构造函数声明为private

这个意图应该是很明显的,因为我们只想外界通过getInstance来获取对象的实例,而不是能够使用构造函数来自定义产生对象。

2. 复制构造函数和赋值运算符都只声明不实现,且为private

这是阻止复制对象的做法!参考《Effective C++》第三版的条款6。我解释一下,声明为private,原因和上面相同,不让外部调用;而不实现,一方面是因为本身根本不会用到,没必要实现;另一方面,为了防止友元类或友元函数来调用!因为如果它们调用了,你没有实现函数体,那么就会在编译器的链接阶段报错,成功阻止!!!

简单但非线程安全

如果不了解线程安全这个概念,可以读一下wiki上的定义:

线程安全是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。——来自维基百科

这种写法是:

Singleton* Singleton::getInstance() {
    if (obj == NULL) {
        obj = new Singleton("name given in code");
    }
    return obj;
}

这种写法为何不是线程安全的?
假设有两个线程A和B,它们的指令执行序列是这样的:

初始时:obj的值为NULL
A: if (obj == NULL)    // 结果为真
B: if (obj == NULL)    // 结果为真
A: obj = new Singleton("name given in code");
B: obj = new Singleton("name given in code");
...

这是什么结果?还是单例吗?内存泄露了知道不?(解释一下,第一个new出来的对象本来由obj指向,但obj被第二个new出来的指针覆盖了,所以没有任何指针指向第一个对象,它变成了一个“野对象”,没爹疼,没娘爱,真可怜)

抢先初始化

有人看到上面的分析,就会想,既然是因为在运行的时候初始化,才造成可能存在的多个线程之间的竞争,那么如果在main函数开始执行之前就已经初始化好了,会怎么样呢?不就没有竞争了吗?比如:

Singleton* Singleton::obj = new Singleton("name given in code");

Singleton* Singleton::getInstance() {
    return obj;
}

没错,这样“抢先初始化”的方法是对的,它确实会在main函数的主体执行之前执行!要知道,程序总是单线程启动的(这一点我没做过考证啊……so sad,不过按照常识,确实是这样的) ,在main函数开始执行之后,才有可能开始多线/进程工作。

但是,有没有发现,这种写法,需要你在代码里就确定好要给对象怎样构造,比如上面的例子就是在代码里给定了它的名字。那如果我这个名字,是得等到运行后,或者由用户输入,或者由其它条件给出的呢?

更自然的写法

上一步的写法的缺点就是,初始化的时机太早了,还需要其它的外部条件才能够进行初始化,所以这里就:

// 贴一下完整的代码,下面要改版类声明了
#include <iostream>
#include <string>
using namespace std;


class Singleton
{
public:
    static Singleton* getInstance();

private:
    // 可以有很多类型的构造函数,这里只是简单举例
    Singleton(const string& initialName);

    // private+只声明而不定义,从而可以禁止构造时的复制和赋值时的复制,保持单例
    Singleton(const Singleton&);
    Singleton& operator = (const Singleton&);

    static Singleton* obj;
    string name;    // 非原始类型的数据成员,代表需要在程序结束时主动释放的资源
};



Singleton::Singleton(const string& initialName)
: name(initialName) {
    cout << "Singleton create: " << name << endl;
}


const string& getNameFromOuter() {
    static string name;
    cin >> name;
    return name;
}

// 这是个不好的示例,因为使用了全局函数来给定类的名字,耦合性太高
Singleton* Singleton::obj = new Singleton(getNameFromOuter());;


Singleton* Singleton::getInstance() {
    return obj;
}


int main() {
    cout << "main function begin" << endl;
    Singleton* obj1 = Singleton::getInstance();
    Singleton* obj2 = Singleton::getInstance();
    Singleton* obj3 = Singleton::getInstance();

    return 0; 
}

这里其实只是用函数来代替常量来做初始化而已,方法虽土,但是可行就好,更多分析请参考《Effective C++》第三版,条款4。

更好的做法?

注意在程序退出的时候,系统会回收各种资源,但是如果我们想做一些自己的回收工作(其实就是执行自己定义的destructor),比如你有一些资源是动态申请的,需要及时归还,想做一些日志记录,等等。

上面的写法能够做到吗?上面都是用到指针,你觉得能做到吗?答案是否定的。

从开篇关于“返回值”的讨论,还剩下一个“引用”,试一下用引用,会怎么样?

// 由于这次的类声明已经改变很多了,所以贴了所有代码
#include <iostream>
#include <string>
using namespace std;


class Singleton
{
public:
    static Singleton& getInstance();

private:
    // 可以有很多类型的构造函数,这里只是简单举例
    Singleton(const string& initialName);

    // private+只声明而不定义,从而可以禁止构造时的复制和赋值时的复制,保持单例
    Singleton(const Singleton&);
    Singleton& operator = (const Singleton&);

    string name;    // 非原始类型的数据成员,代表需要在程序结束时主动释放的资源
};


Singleton::Singleton(const string& initialName)
: name(initialName) {
    cout << "Singleton create: " << name << endl;
}


const string& getNameFromOuter() {
    static string name;
    cin >> name;
    return name;
}


// 实质上是等到第一次调用getInstance才会执行constructor的!
Singleton& Singleton::getInstance() {
    static Singleton obj(getNameFromOuter());
    return obj;
}


int main() {
    cout << "main function begin" << endl;
    Singleton& obj1 = Singleton::getInstance();
    Singleton& obj2 = Singleton::getInstance();
    Singleton& obj3 = Singleton::getInstance();

    return 0; 
}

注意两个点:

1. 初始化的时机

这样的写法,是等到第一次调用getInstance才会执行constructor的!
为什么和上面的不同,不是在main函数开始执行前初始化的吗?

A. 前面的本质是利用:C++对静态成员变量的初始化时机在main函数执行之前。
B. 但是这里没有静态成员变量了,换成一个静态成员函数里的一个局部静态变量,所以自然就是等到main函数开始执行后才会进行初始化的!

那么问题来了,假设在第一次调用getInstance之前,程序已经变成多线程的了,然后有两个线程A和B,它们“同时”调用getInstance(这里的同时不是真的同时,因为在同一个进程中的多个线程,同一时刻里,最多只有一个线程在执行指令,造成它们“同时”的假像是由于CPU的调度,参考最上面的例子),会发生什么事情呢?

这个涉及到C++编译器怎么处理函数内部的static变量的初始化了,说实话,我现在还不懂。

不过,从最坏的情况来看,即使会出现两次初始化,也并不会造成坏的影响(比如最前面说到的构建了两个对象,内存泄露等)。

为什么呢?因为static变量的存储区域在编译时就确定的了,即使有多次初始化,也都是在那片内存区域上进行的,不会有内存泄露的问题!

而重复初始化的话,其实也并没有差别,就是这样的过程:

int a = 1;
a = 1;
a = 1;
...

a的值始终是1,有坏的影响吗?
如果有,也就是重复构造的代价而已。
这种重复构造会很多次吗?
不会,因为两个线程“同时”进行初始化,本来就是概率很低的情况了,而一旦初始化过后,以后都不会再进行了,所以这样的开销(假设万一有)是小概率且一次性的。

2. 它会自动析构吗

不忘初心,当初想要这种写法,是出于在程序退出时能够自动释放资源的需求,那么这样写,真的能够吗?

毫无疑问啊,因为声明的是函数内部的局部静态变量,C++规定在程序退出时会自动析构它的!

更多的考虑

1. 验证?

其实上面的所有东西都是纸上谈兵而已,可以弄个多线程环境来验证吗?咕~~(╯﹏╰)b,我觉得不太现实,目前还没想到有方法可以构造出竞争条件(没法控制cpu调度啊)。

如果有朋友知道怎么验证的,请务必告诉我,真心好奇!

2. 继承?

用单例模式写出来的类是没法继承的吗?
对啊,因为你的构造函数是private的。
真的没法继承吗?
可以啊,只需要把构造函数声明为protected而不是private就好了!

3. 代码重用

如果工程里用到单例模式的地方较多,可以考虑将其封装成为一个模板,然后通过实例化、继承,来实现代码重用。

4. 不同语言的实现

首先在java里写单例模式,跟C++就很不一样了,因为语言特性狠不一样,我还接触过pyhton的写法,更加“奇葩”。

所以我觉得,对于单例模式,不应该纠结于怎么写,而更应该关注,为什么要这样写

我写这篇文章也是一直按照“为什么要这样写”的思路来写的,分析了上一种的利弊,然后提出改进需求,最后实现完善。

参考资料

  1. 陈皓博客:深入浅出单实例Singleton设计模式
  2. 博客:C++ Singleton (单例) 模式最优实现
    原文作者:Jacketinsysu
    原文地址: https://blog.csdn.net/Jacketinsysu/article/details/52563686
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞