剑客
关注科技互联网

说说设计模式中的单例

单例(Singleton)差不多算是设计模式种最简单的一种了,属于创建型模式,但是突然码起来感觉还有些不知所措。当然网上关于设计模式的范例比比皆是,但大多是限于简单说明设计模式本身,但是考究在生产环境中使用的话还是有不少其他讲究的。刚好网上搜到《Modern C++ Design: Generic Programming and Design Patterns Applied》这本书中,有一节是详细讲单例的,看了觉得不错。

单例模式的定义是指:单例模式,是保证一个类仅有一个实例,并提供一个访问它的全局访问点。通过把构造函数设置成private的,防止外部创建对象,同时提供一个公共的GetInstance方法,决定是否已经实例化过,如果没有就调用私有的构造方法创建一个实例。

文章主要是按照单例模式下三个最重要的方面:创建生成、寿命、多线程模型来考量的。当然C++讲求的是面向对象和代码重用,单例模式不能简单的通过继承来实现重用,因为构造函数是private的,所以通常单例模式可以写成模板的方式来重用,泛化比较简单,这里就不阐述了。

一、创建生成

最容易迷惑初学者(包括我自己)的是单例模式很容易和“静态类(成员函数和成员变量都是静态的类)”相混淆,乍一看确实极为相似。其实他们最大的区别是单例模式是一个实实在在正常的类,所以有对象(只有一个),也就有this指针和虚函数;而在“静态类”中没有对象,就没有引用、this指针,也没有虚函数,所以如果在你的类中有继承的话,首先遇到的就是析构函数就没法是虚函数,那么你用基类的指针或者引用析构的话,派生类的对象都没法被释放!其他成员函数的多态也就更无从谈起了。还有一点就是,“静态类”的成员初始化顺序是不确定的,这在静态成员具有复杂依赖关系的情况下更为的致命,而单例具有正常的构造函数,根据C++标准,成员初始化的顺序就是在类中声明的顺序,是可以得到保证的。

要实现单例模式的对象唯一性,那么这些成员必须是private的:构造函数、拷贝构造函数、赋值运算符,同时给予封装的需要,析构函数最好也是private的,防止用户意外删除对象。而且,GetInstance()公共方法最好返回引用的方式,原因跟上面一样的。

创建对象的方式,主流的有两种:Gamma和Meyers:

a. Gamma方式:是最常见的通过new T的方式在堆空间动态创建对象,但是这种方式创建的对象需要使用delete显式销毁,程序结束时虽然操作系统会回收所有的内存资源,但是析构函数并不会被自动调用,意味着所有的资源回收都依赖于操作系统默认行为。

b. Meyers方式:通过在函数中创建静态对象的方式在静态内存区创建对象,这里C++规定如果函数中的局部静态变量是内置类型且用常量初始化的,那么这个初始化发生在程序开始运行加载期间,而如果初始化值是非常量类型或者变量类型是具有构造函数的类型,那么初始化发生在运行期间第一次调用该函数的时候,所以:(1)new出来的对象不能自动析构,但是程序中的静态对象,在程序退出的时候可以自动析构,所以可以在析构函数中做额外的事情;(2)这个对象不是程序启动的时候创建,而是只在调用函数需要对象的时候进行创建,这在多个编译单元有依赖关系的时候更为的有效,因为编译器没法保证编译单元的处理顺序,而这种方法总是能保证在需要对象的时候能够被创建,同时懒汉创建也节省资源。

二、寿命

设计模式中对单例对象的生命周期没有过多的阐述,所以也容易被忽略,通常单例对象的生命周期是很明确的:产生的时候是需要访问这个对象的时候,结束的时候通常是程序(正常或者异常)退出的时候。

在程序退出的时候,C++规定,对象的析构以LIFO后创建者先析构的顺序进行(new/delete管理的对象不受此规则),但是根据上面的方式,单例是在首次访问的时候创建对象的,所以我们无法预估和安排程序退出的时候各个单例对象的析构顺序,而尤其当这些析构对象互相调用的时候,情况将变得更加复杂。

比如我们希望一个日志单例能够在最后一刻被析构,但是某种顺序可能先就被析构了,这时候别的对象通过Instance访问的对象就是析构后无效的对象,这就是”dead-reference”问题。解决这个问题的方法,就是通过增加一个静态的成员变量destroyed_,默认为false,而在对象的析构函数中将其设置为true。这样在请求这个单例对象的时候,如果destroyed_为true,就表示对象已经被析构掉了,这个时候就需要使用placement new操作在原地再次重建这个对象,从而实现只要有用户,即使死掉也能复生的单例。

voidSingleton::OnDeadReference() {
 Create();
new(pInstance_) Singleton;
 atexit(KillPhoenixSingleton);
 destoryed_ = false;
}

voidSingleton::KillPhoenixSingleton() {
 pInstance_->~Singleton();
}

作者后面还提到可以建立一种设置和追踪对象生命周期长度的机制,让某些对象“活的更长一些”,问题搞的更加复杂了,此处就不深究了。

三、多线程模型

多线程环境下,单例的创建涉及到一个竞争问题。如果直接把判断和创建的代码用一个互斥单元保护,那么每次调用对象的时候都要请求互斥量,造成了不必要的性能浪费。最经典的方式是”Double-Checked Locking”(DCL)。

但是就像在 C++11开发中的Atomic原子操作
中所描述的,在C++11发布确定内存模型之前,对这个静态变量访问和修改在各个线程之间的可见是没有保证的,只能显式使用内存屏障保证。同时,作者还给出的方法是把这个变量设置为volatile的,查阅发现:某些编译器会保证volatile变量是一种内存屏障,阻止编译器和CPU重新安排读入和写出语义,比如Visual C++ 2005之后就做出了此类保证。

我是简单在变量的读写加了acquire和release的thread_fench,如果你想深入了解这个机制,或者想写出你认为的最高效的代码,可以参照附录中的文献。不过我觉得这里过于的纠结没什么意义,因为这个竞争条件只在创建的过程中会发生,创建之后都不会存在竞争条件了,这里过于的纠结优化这个问题真是钻牛角尖了。

考虑到简单实用,然后自用的一个Singleton像 这个样子

本文完!

参考

Modern C++ Design: Generic Programming and Design Patterns Applied

Double-Checked Locking is Fixed In C++11

C++ In Theory: The Singleton Pattern, Part I

C++ In Theory: The Singleton Pattern, Part 2

The Singleton Pattern Revisited

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址