一个cpper眼中的singleton

在众多的设计模式中,singleton(单件或者单例)绝对是另类的一个。在实现单件的过程中,我深深体会到了“细节是魔鬼”的道理。在看似一览无遗的湖面下,尼斯湖水怪的阴影却屡驱不散。

初识单件是在GoF的设计模式经典著作里面。GoF给出的定义很简单:让一个类只有一个实例,并为实例提供一个全局访问点。而且类图里面只有一个类。太简单了,顿时有种手到擒来的飘飘然。现在回想起来,当时学习设计模式有问题,太注重类图和实现,反倒把应用场景和优缺点忽略了。结果走了不少弯路。

于是,真到要用的时候,赶紧祭出单件(代码如下),还颇引以为傲的介绍为什么Instance要返回引用,而不是指针。引用可以告诫使用者这个对象不应该删除,也不应该保存起来。不过,不应该跟不能还是有区别的,可以把引用转成指针后,删除或者保存。

class Singleton
{
private:
	Singleton();
	~Singleton();
	Singleton(const Singleton & rhs);
	Singleton & operator=(const Singleton & rhs);
public:
	static Singleton & Instance()
	{
		if (<span style="font-family: Arial, Helvetica, sans-serif;">m_Instance == NULL</span><span style="font-family: Arial, Helvetica, sans-serif;">)</span>
		{
			m_Instance = new Singleton();
		}

		return m_Instance;
	}

private:
	static Singleton * m_Instance;
public:
	void OtherMethod();
};

乍一看,有点被漂亮的OO方法闪花了眼。讨厌的全局变量用OO包裹了起来,变成了一个类的私有静态变量。别人看不到它,只能通过该类的公有静态方法访问。这又一次证明了软件的所有问题都可以用增加中间层解决。但是,没高兴一会儿,尼斯湖水怪就浮出水面了。

首先,多线程下是不安全的,有内存漏泄的风险。当m_Instance还没new时,线程A先调用Singleton::Instance(),正要new时,切换到线程B,线程B发现m_Instance是空的,索性就new了一个。切回到线程A后,线程A又多new了一个。这是典型的竞争条件,闪过脑海的第一个方法就是加锁。

<span style="font-family: Arial, Helvetica, sans-serif;">Singleton</span><span style="font-family: Arial, Helvetica, sans-serif;"> & </span><span style="font-family: Arial, Helvetica, sans-serif;">Singleton::</span><span style="font-family: Arial, Helvetica, sans-serif;">Instance()</span>
{
<span style="white-space:pre">	</span>Lock guard; // Lock类构造时take信号量,析构时give信号量
<span style="white-space:pre">	</span>if (<span style="font-family: Arial, Helvetica, sans-serif;">m_Instance == NULL</span><span style="font-family: Arial, Helvetica, sans-serif;">)</span>
<span style="white-space:pre">	</span>{
<span style="white-space:pre">		</span>m_Instance = new Singleton();
<span style="white-space:pre">	</span>}

<span style="white-space:pre">	</span>return m_Instance;
}
但是这招杀伤力太大,当m_Instance是空的时候,还行。当m_Instance已经new出来了,还加锁就得不偿失了。于是,坊间又流传出双重检测锁定模式(Double Check Locking Pattern):

<span style="font-family: Arial, Helvetica, sans-serif;">Singleton</span><span style="font-family: Arial, Helvetica, sans-serif;"> & </span><span style="font-family: Arial, Helvetica, sans-serif;">Singleton::</span><span style="font-family: Arial, Helvetica, sans-serif;">Instance()</span>
{
	if (m_Instance == NULL) // 第一次检测
	{
		Lock guard;
		if (m_Instance == NULL) // 第二次检测
		{
			m_Instance = new Singleton();
		}
	}

	return m_Instance;
}
第一次检测是粗略的检测,这可以排除掉m_Instance已经new的情况,此时就不用加锁了。第二次检测是精确的检测,这次是发生在m_Instance还是空的情况下,这就要加锁互斥了。不过, Andrei Alexandrescu在《Modern C++ Design》中指出因为RISC指令重排,这也不一定是线程安全的,所以除了在m_Instance前加上volatile之外,还要加上第三次检测,查阅编译器手册看看怎么消除这种情况。

有些复杂,对吧?再回头想想,Instance()把初始化和运行时查询放在了一起,有点不符合单一职责原则。如果把这两个处理拆分开,就不会有问题了。可以把m_Instance定义成Singleton的静态对象,而不是指针,就不需要new了。但是C++中静态对象有两种:非局部静态变量(全局变量/类静态变量)和局部静态变量,你还得选(知道杨朱为啥泣岐了吧)。两者的实现方式不同:前者在类定义中把m_Instance改成静态对象:

	static Singleton m_Instance;
后者则干脆不要成员变量,而把m_Instance放在Instance()函数中:

<span style="font-family: Arial, Helvetica, sans-serif;">Singleton</span><span style="font-family: Arial, Helvetica, sans-serif;"> & </span><span style="font-family: Arial, Helvetica, sans-serif;">Singleton::</span><span style="font-family: Arial, Helvetica, sans-serif;">Instance()</span>
{
	static Singleton s_Instane;
	return s_Instance;
}
两者的初始化时机不同:前者是在main()函数执行之前初始化的,由于不同编译单元的非局部静态变量的初始化顺序未定义,如果涉及静态变量之间的依赖,这个方案不可行。

后者是第一次调用Instance()时初始化,伪代码如下:

Singleton & Singleton::Instance()
{
<span style="white-space:pre">	</span>extern void __DestroySingleton();
<span style="white-space:pre">	</span>static bool __is_instance_inited = false;
<span style="white-space:pre">	</span>static char __buffer[sizeof(Singleton)];
<span style="white-space:pre">	</span>if (!__is_instance_inited)
<span style="white-space:pre">	</span>{
<span style="white-space:pre">		</span>new(__buffer) Singleton;  // placement new
<span style="white-space:pre">		</span>__is_instance_inited = true;
<span style="white-space:pre">		</span>atexit(__DestroySingleton);
<span style="white-space:pre">	</span>}


<span style="white-space:pre">	</span>return *reinterpret_cast<Singleton *>(__buffer);
}

void __DestroySingleton()
{
<span style="white-space:pre">	</span>reinterpret_cast<Singleton *>(__buffer)->~Singleton();
}
后者相当于把判断s_Instance是否构造的判断留给编译器实现,所以可能会出现多个线程调用多次构造函数的情况,因为用的是placement new,不会new多个对象。

除了把指针改成对象,还可以在成员函数上做文章,比如可以再定义一个全局变量来强制Instance()函数在初始化时调用:

Singleton & force_init = Singleton::Instance();
又比如,专门增加一个初始化接口:

void Singleton::Create()
{
	Lock guard;
	if (m_Instance == NULL)
	{
		m_Instance = new Singleton;
	}
}
static Singleton & Instance()
{
	return m_Instance;
}
初始化一般是在单线程环境下完成的,所以Create()甚至可以把锁去掉。

说了这么多,到底应该用哪个方案呢?答曰:依实际场景而定。如果没有静态变量的依赖,构造开销很小,而且生命周期无限,可以用静态成员对象。
另一个让我头疼的问题是单件代码的复用。



收益能超过成本吗?

单件的优点如下:

1、对类实例化的控制。能确保只有一个实例。这点全局变量做不到,但是,既然有一个全局变量,谁还会去new一个新的对象呢?

2、延迟求值。这需要一点技巧。上面的代码就是一个很好的例子,把实例化推迟到第一次调用Instance()的地方。但并不是所有单件都能延迟实例化,比如,下面这样实现就不能延迟求值。

class Singleton
{
public:
	static Singleton & Instance()
	{
		return m_Singleton;
	}

private:
	static Singleton m_Singleton;
};

全局变量能不能做到呢?可以,但不是很完美,因为不知道第一次访问全局变量的确切时间,可能出现访问全局变量的时候,全局变量还没有初始化。

3、对类的初始化顺序的控制。毕竟把全局变量的访问点封闭到函数里面了,在函数体内部可以做任何事件,比如把依赖的对象都给初始化了,再初始化自己。全局变量对此爱莫能助。

4、提供优雅的访问接口。至少从形式上,看上去很符合OO的要求。

5、内聚比较高。如果是全局变量,初始化和访问点是分离的。Bob大叔在《Clean Code》里面提到类的内聚性的一个衡量标准:类的成员变量被越多的类方法访问,类的内聚性就越高。从这一点看,单件的内聚性不是很高,因为m_Singleton只被Instance()访问。


单件的缺点:

1、很难抽象化。其它的设计模式,比如观察者模式,只需要定义一个接口类,然后以此为起点,派生出实现类就行了。如果定义一个Singleton的抽象类S,然后把想到单件化的类D继承自S,类D是不是单件呢?显然不是。但是在《Modern C++ Design》一书中,Andrei Alexandrescu介绍了用模板实现单件基类,然后把具体类的类名,构造方法和生命周期管理方法通过模板参数引入模板基类中。他用了整整一章的篇幅详细的介绍单件,说明什么?单件的技巧性很高,有点像拿着杆子走钢丝。模板化的单件有一个限制,代码共享只能基于源代码级别的共享,而不能基于二进制级别。于是模板化的单件更多的是模块内部使用,很少作为DLL的接口。

2、很难继承,Singleton的子类很难也是Singleton。或者说技巧性也很高。首先要把父类的构造函数改为protected,允许子类调用。这样父类其实就不是单件了。而且会产生反向依赖,即父类要看到子类,在C++中,由于protected方法的单向性(父类不能访问子类的protected方法),父类还必须是子类的友元。

3、违反了Single Responsibility原则。有人甚至认为单件是反模式。我觉得单件从某种程度上,违反了基于接口编程。

4、效率问题。在多线程环境中,尽管有double checked技术,但是每次Instance调用还是有至少一次的if判断。

5、析构问题。这个问题跟使用全局变量一样,你永远不知道有谁还抓着Singleton引用不放。当然可以加上引用计数,但是你不觉得太复杂了吗?


说了这么多,到底该不该单件呢?这是菜鸟喜欢问的问题。高手一般这么问:应该在什么场景下使用单件呢?知道差距了吧。没有一个设计模式是放之四海皆准的,不然也不会有这么多的设计模式了。存在即是合理的。单件也有它特定的应用场景。就像辣椒酱一样,你不会所有菜里面都会放吧。

我觉得单件应该受限使用,毕竟单件说白了,也是访问全局变量。如果在代码中大量使用单件,也就是说,大量使用全局变量。这是一种设计的坏味道。这时候,你需要回过头来,想想设计有没有问题?类的职责划分有没有问题?没有吗?真的没有吗?再想想。

那么,单件的替代方案是什么呢?我想有两个:

1、MONOSTATE模式。把类的所有成员变量定义成静态成员变量,不也就是单件了吗?有趣吧。这个模式最有趣的地方是访问者根本不知道MONOSTATE类是单件。

2、依赖倒置,也有人叫依赖注入。比如,A类要访问B类的接口,在A类中定义一个B类的对象指针和一个Set接口,然后由第三方的工厂对象把B类的实例(或者是B的子类的实例)构造出来,再通过A类的Set接口,把B类的实例注入到A类中。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值