设计模式之单例模式
我们从 GoF 说起,书中写到单例模式(Singleton)的意图是:
保证一个类仅有一个实例,并提供一个访问它的全局访问点。
在什么时候会用到单例模式呢?开发过程中常见的应用举例:
- 日志类,一个应用往往只对应一个日志实例:丑陋的写法是在每个场景都实例化一个日志对象使用。
- 配置类,应用的配置集中管理,并提供全局访问:丑陋的写法是在每次使用配置时都实例化一个配置对象,然后访问配置文件读取其中的配置项。
- 管理器,比如我们写了一个 windows 服务,然后我们要封装一个启停此服务的管理器。
- 共享资源类,加载资源需要较长时间,使用单例可以避免重复加载资源,并被多个地方共享访问。比如文件连接,数据库连接等
下面来看具体的实现。在 C++ 中,虽然将一个对象定义成全局变量可以使其被全局访问,但它不能防止你实例化多个对象,即无法“保证一个类仅有一个实例”。如果你坚持如此使用也能实现功能,但并不优雅。
更好的办法是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建(通过截取创建新对象的请求),并且它可以提供一个访问该实例的办法。
以上描述中涉及 3 个功能点,我们来看看在 C++ 中具体是如何实现的?
1 | class Singleton { |
上述定义中未体现类的唯一实例怎么创建的。而恰恰是如何创建实例区分出了以下几种实现:
- Lazy Singleton,命名来源于其中使用了惰性(Lazy)初始化
- 双检测锁模式,在上述基础上为线程安全作出修改
- Eager Singleton,不使用惰性初始化
- Meyers Singleton,在新标准下支持多线程,最优雅的一种实现。
需要强调的是,虽然上述几种实现都是由 Singleton 类创建实例,但这并不是必须的。GoF 书中描述 Singleton 类的职责时也只是“可能负责创建它自己的唯一实例”,事实上,在书中讲述单件注册表时就明确提到了:(详细内容请翻看原著)
Singleton 类不再负责创建单件。
为了保证唯一实例,还需要删除拷贝构造函数(和拷贝赋值操作符)。另外,用户获得指针之后可能会以为他负责释放,进而对 Singleton::Instance()
的结果执行 delete
操作,如果我们并不希望如此,可以将析构函数声明为私有的,或者不使用指针(改用引用)。
1 | Singleton* pSingleton = Singleton::Instance(); |
建议将移动构造函数、移动赋值操作符也禁用,减少错误使用的可能性。否则,在某个模块中将单例移走之后,另一模块大概率会错误使用。
单线程 Lazy Singleton
在 GoF 书中给出的实现如下:
1 | Singleton* Singleton::_instance = 0; |
这种实现被称为“Lazy Singleton”,它使用了惰性初始化:它的返回值直到被第一次访问时才创建和保存。
惰性初始化:实例直到用到的时候(用户通过 Singleton::Instance()
要获取实例时)才创建,在此之前实例对象是不存在的,避免了潜在浪费资源的可能。对比非惰性初始化的 Eager Singleton 理解,提前把对象初始化,但应用的执行逻辑可能就没有用到此实例。
但正如 GoF 在第一章引言中指出的一样,上述实现也存在局限。
书中没有讨论与并发或分布式或实时程序设计有关的模式,也没有收录面向特定应用领域的模式。
最突出的就是非线程安全。另外在部分使用场景中,我们还需要考虑 Singleton 的释放问题。
线程安全
Lazy Singleton不是线程安全的,比如现在有线程 A 和线程 B,都通过 _instance == 0
的判断,那么线程 A 和 B 都会创建新实例。单例模式保证生成唯一实例的规则被打破了。
双检测锁模式(Double-Checked Locking Pattern)
Lazy Singleton的一种线程安全改造是在 Instance()
中每次判断是否为 NULL
(0
) 前加锁,但是加锁是很慢的。而实际上只有第一次实例创建时的 _instance==NULL
判断才需要加锁,创建之后 _instance==NULL
判断不再需要加锁。所以多在加锁之前多加一层判断,需要判断两次所有叫 Double-Checked。
1 | static Singleton *Instance() |
理论上问题解决了,但是在实践中有很多坑,如指令重排、多核处理器等问题让 DCLP 实现起来比较复杂,比如需要使用内存屏障,详细的分析可以阅读:
- C++ and the Perils of Double-Checked Locking,如果阅读英文吃力,在
- c++11单实例(singleton)初始化的几种方法(memory fence,atomic,call_once) 博客中也有详细分析。
扩展:在C++11中有全新的内存模型和原子库,可以很方便的用来实现 DCLP。这里不展开。有兴趣可以阅读:
- 这篇文章《Double-Checked Locking is Fixed In C++11》。另外,
- 在 C++ Concurrency in Action 一书中也有系统的讲解。
C++11 的并发特性提供了很多语法来实现线程安全的单例模式,其中值得推荐的有两种(在 最简洁的单例模式 有整理):
- 使用
call_once
- 使用 static local variable,即下文提到 Meyers Singleton
Eager Singleton
这种实现在程序开始的时候(静态属性 instance
初始化)就完成了实例的创建。这正好和上述的 Lazy Singleton 相反。
1 | //头文件中 |
由于在 main 函数之前初始化,所以没有线程安全的问题,但是潜在问题在于 no-local static对象(函数外的static对象)在不同编译单元(可理解为cpp文件和其包含的头文件)中的初始化顺序是未定义的。如果在初始化完成之前调用 Instance()
方法会返回一个未定义的实例,比如一个全局变量的构造函数中调用了此方法。关于此问题在 C++ Singleton (单例) 模式最优实现 中有较为详细的分析。
Meyers Singleton
Scott Meyers 在《Effective C++》(Item 04)中的提出另一种更优雅的单例模式实现,使用 local static 对象(函数内的 static 对象)。当第一次访问 Instance()
方法时才创建实例。
1 | class Singleton |
C++0X 以后,要求编译器保证内部静态变量 初始化 的线程安全,所以C++0x之后该实现是线程安全的,is-meyers-implementation-of-the-singleton-pattern-thread-safe。
返回引用在大多数场景中都是建议的。但返回指针的方式并不是一无是处:
1 | class Singleton |
在 DLL 中使用单例模式时,主函数结束后析构全局变量或 static
变量会出现问题(比如线程无法退出)。此时提供 shutdown()
供用户显式调用,并不比直接暴露指针让用户 delete sington_ptr
高明。
无论是返回引用,还是返回指针都需要确保:
- 构造函数、析构函数
private
- 拷贝构造函数
= delete
; 拷贝赋值操作符= delete
- 移动构造函数
= delete
; 移动赋值操作符= delete
实例销毁
在上述几种实现中,大多使用了 new
操作符实例化对象。我们一般的编程观念是,new
操作符是需要和 delete
操作进行匹配的——这种观念是正确的。接下来我们说一个例外。
针对单例模式中的唯一实例,在实际项目中,特别是客户端开发,其实是不在乎这个实例的销毁的。因为,全局就这么一个变量,全局都要用,它的生命周期伴随着软件的生命周期,软件结束了,它也就自然而然的结束了,因为一个程序关闭之后,它会释放它占用的内存资源的,所以,也就没有所谓的内存泄漏了。但针对这种特殊性也有例外的地方:
在类中,有一些文件锁,文件句柄,数据库连接等等。相对应的,在类的析构行为中有必须的操作,比如关闭文件,关闭数据库连接等。如果 new
之后不进行显式的 delete
(执行析构),就会造成潜在威胁,比如数据库连接数持续增加。
最直接的方法就是在程序结束时调用 Instance()
,并对返回的指针调用 delete
操作。或者将 delete
的操作封装在 Singleton 类中,然后在程序结束时调用这个类方法。
1 | static void DestoryInstance() |
这样做可以实现功能,但也很丑陋,而且容易出错。因为这样的附加代码很容易被忘记,而且也很难保证在 delete
之后,没有代码再调用 Instance()
函数。
垃圾工人 RAII
一个妥善的方法是让这个类自己知道在合适的时候把自己删除,或者说把删除自己的操作挂在操作系统中的某个合适的点上,使其在恰当的时候被自动执行。
程序在结束的时候,系统会自动析构所有的全局变量。事实上,系统也会析构所有的类的静态成员变量,就像这些静态成员也是全局变量一样(静态变量和全局变量在内存中,都是存储在静态存储区的,所以在析构时,是同等对待的)。利用这个特征,我们可以在单例类中定义一个这样的静态成员变量,而它的唯一工作就是在析构函数中删除单例类的实例。
如下面的代码中的 CGarbo 类(Garbo 意为垃圾工人):
1 | class Singleton |
此时我们不再需要用户销毁实例(避免用户 delete
实例带来的不确定性),应该将析构函数声明为私有的。
Meyers Singleton
如果没有 new
,当然也就不需要 delete
。
对于 2.3 节 Meyers Singleton 实现,其 Singleton::Instance()
函数中的局部静态变量会自动析构,所以 Singleton 类的析构函数会顺利执行。
不同类型的单例 vs. 多个单例
实现方法:继承,或模板
构造函数私有就无法继承了。如果有继承的需求,还是改为 protected
修饰。
如果是相关的单例类,比如 GoF 原著中的正常迷宫工厂、魔法迷宫工厂、炸弹迷宫工厂,可以通过继承父类的方式创建单例。因为从业务逻辑上讲,这三种工厂是“互斥”的,并不会同时存在,而单例类(父类及其延伸出来的子类)保证唯一实例,无法在一个系统中创建一个正常工厂实例和一个魔法工厂实例。
如果是不相关的单例类,比如系统中存在数据库连接类、配置管理类,此时是要求有各自的唯一的实例的,通过继承实现显然是不合适的(甚至是不可能的)。而这种场景恰恰是经常碰到的,如果每次都要重新写一次单例类的逻辑是很麻烦的。
如何使用模板实现一个通用的基类。
1 | template<class S> |
boost 中的单例模式
关于Boost Singleton do_nothing()
的那点事
参考来源
第二章多线程安全部分主要参考自 单例模式(Singleton)及其C++实现;第三章实例销毁参考自 C++设计模式——单例模式。当然,最主要的参考还是 GoF 的经典著作《设计模式:可复用面向对象软件的基础》。
这里再重复一次,在书中提到了“单件注册表方法”,此方法与上述所有实现最大的一个区别在于:Singleton 类不再负责创建单件实例,而上述实现无论返回单件指针还是引用都是在 Singleton 类中创建。