01-单例模式
单例模式
概念
单例模式(singleton)的理念非常简单,即应用程序中只能有一个特定组件的实例。
经典实现
单例模式的经典实现如下:
class Singleton
{
protected:
Singleton() { /* 初始化... */ }
public:
static Singleton& getInstance()
{
// 从C++11开始, 这种写法是线程安全的
static Singleton instance;
return instance;
}
// 显式删除多余的构造函数和赋值运算符, 防止意外创建实例
Singleton(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator= (const Singleton&) = delete;
Singleton& operator= (Singleton&&) = delete;
};
单例与线程安全
多线程单例
从C++11开始,采用上面的写法是线程安全的,这意味着如果多个线程同时调用getInstance()
,我们永远不会碰到创建两次实例的情况。
在C++11之前,需要使用一种称为 双重校验锁 的方式来实现单例模式:
// atomic<Singleton*> m_instance;
Singleton& getInstance()
{
// 第一次校验
if (!m_instance)
{
Lock lock;
// 第二次校验
if (!m_instance)
{
m_instance = new Singleton;
}
}
}
每线程单例
可能的情况是,程序中所有线程间不需要共享一个单例,而是 每个线程都需要一个单例。
构建过程和之前的经典实现一样,只是我们现在要为静态函数中的变量加上thread_local
声明,让每个线程都拥有一个单例:
static Singleton& getInstance()
{
thread_local Singleton instance;
return instance;
}
每线程单例解决了某些特殊问题:
假设有一个依赖关系图如下:
1 A --依赖--> B --依赖--> C 2 A --依赖--> C
其中C是单例对象。现在有20个线程,每个线程都创建了一个A的实例,组件A依赖C两次(直接依赖和通过B间接依赖)。如果单例对象C有状态,且在每个线程中的状态均不同,那么单例对象C就不应该是全局的,而是每线程单例的。
在每线程单例中,我们不必担心多线程单例的线程安全问题。因此可以使用
map
而不必使用concurrent_hash_map
(好像是tbb
里的东西)。
单例与控制反转
显式将某个组件变为单例的方式具有明显的侵入性,而如果决定在某一时刻不再将某个类作为单例,最终又会付出高昂的代价。这里可以采用一种约定:负责组件的函数并不直接控制组件的生命周期,而是外包给控制反转(Inversion of Control, IoC)容器。
如果使用Boost.DI
的依赖注入框架,定义单例组件的代码如下:
auto injector = di::make_injetor(
di::bind<IFoo>.to<Foo>.in(di::singleton),
// 其他配置...
);
每当需要具有IFoo
类型成员的组件时,我们使用Foo
的单例实例来初始化该组件。
单例与环境上下文
单例还可以与环境上下文(Context)思想结合。例如我们要管理某个数据,它是全局的且不同时间的状态可能不同,这时候就需要用到单例+环境上下文。如果要返回上一个状态,则需要用栈来存储上下文。
- 单例模式并不完全令人厌恶,但是如果不小心使用,它们会破坏应用程序的可测试性和可重构性。
- 如果必须使用单例模式,请尝试避免直接使用它,将其指定为依赖项,并保证所有依赖项都是从应用程序的某个唯一的位置(如控制反转容器)获取或初始化的。
单态模式
单态模式(Monostate)是单例模式的一种变体。单态模式行为上类似于单例,但看起来像一个普通的类:
class Printer
{
public:
int get_id() const {return m_sId;}
void set_id(int val) {m_sId = val;}
private:
static int m_sId;
}
可以发现这个类操作的都是静态数据,并通过getter和setter使用。
单态模式允许继承和多态,开发者可以更容易地定义和控制其生命周期。其最大的优势在于,它允许我们使用并修改在当前系统中已经使用的对象,使其以单态模式的方式在系统中运行。
单态模式的缺点是:它是一种侵入性方法,并且静态成员的使用意味着它总是会占据内存空间。最大的缺点在于,它做了过于乐观的假设,即外界总是会通过getter和setter方法来访问单态类的成员。如果直接访问它们,重构实现几乎注定要失败。
参考资料
- 《C++20设计模式 可复用的面向对象设计方法》
- youngyangyang04/kama-DesignPattern: 卡码网-23种设计模式精讲,每种设计模式都配套代码练习题,支持 Java,CPP,Python,Go (github.com)