C++ 补完计划(五):C++ 设计模式
单例模式
理解单例模式
什么是单例模式?为什么要使用单例模式?
单例模式是保证类只有一个实例,并提供一个访问该实例的全局节点。该实例被所有程序模块共享。
单例模式是为了解决如下两个问题:
-
控制类实例的个数,保证类只有一个实例。例如对于数据库或者文件这种共享资源,保证资源类只有一个实例实际上就是控制了访问权限,同时时间只能有一个应用程序去访问;
-
有时候我们会使用全局变量去存储一些信息,但是很不安全,而且不方便管理,因为任何代码、任何地点都有可能改变全局变量的内容。可以利用单例模式为代替全局变量提供一个全局访问的节点,而不是将解决同一个问题的代码分散在程序各处。
所有单例的实现都包含以下两个相同的步骤:
-
构造函数和析构函数为私有类型,目的是禁止外部构造和析构。拷贝构造函数和赋值构造函数是私有类型,目的是禁止外部拷贝和赋值,确保实例的唯一性。
-
新建一个静态构建方法作为“构建”函数 (作为获取实例的唯一接口)。该函数会“偷偷”调用私有构造函数来创建对象,并将其保存在一个静态成员变量中。此后所有对于该函数的调用都将返回这一缓存对象。
如果你的代码能够访问单例类,那它就能调用单例类的静态方法。无论何时调用该方法,它总是会返回相同的对象。单例模式分为如下三种:
-
饿汉式(Eager Singleton)(线程安全,但是存在潜在风险)
系统一运行,就初始化创建实例,当需要时,直接调用即可。这种方式本身就线程安全,没有多线程的线程安全问题。
-
懒汉式 (Lazy Singleton)(需要加锁保证线程安全)
系统运行中,实例并不存在,只有当需要使用该实例时,才会去创建并使用实例。这种方式要考虑线程安全。
-
Meyers’ Singleton(线程不安全,最优雅)
接下来,我们来看—下上述三种单例模式的代码实现。
单例模式实现
饿汉式(Eager Singleton)
饿汉式单例模式是指单例实例在程序运行时被立即执行初始化。
1 | class Singleton |
由于在main
函数之前初始化,所以没有线程安全的问题。但是潜在问题在于no-local static
对象 (函数外的static
对象) 在不同编译单元中的初始化顺序是未定义的。也即,static Singletoninstance;
和static Singleton& getInstance()
二者的初始化顺序不确定,如果在初始化完成之前调用getInstance()
方法会返回一个未定义的实例。
懒汉式(Lazy Singleton)
懒汉式单例实例是在第一次使用时才进行初始化,这叫做延时初始化。
1 | struct Singleton |
- 问题1:Lazy Singleton存在内存泄露的问题,有两种解决方法:
- 使用智能指针
- 使用静态的嵌套类对象
使用智能指针的代码示例:
1 | struct Singleton |
使用静态的嵌套类对象代码示例:
1 | // 使用静态的嵌套类对象 |
在程序运行结束时,系统会调用静态成员delector
的析构函数,该析构函数会删除单例的唯一实例。使用这种方法释放单例对象有以下特征:
-
在单例类内部定义专有的嵌套类。
-
在单例类内定义私有的专门用于释放的静态成员。
-
利用程序在结束时析构全局变量的特性,选择最终的释放时机。
-
问题2:懒汉式单例模式是线程不安全的,因此上述的代码示例都是线程不安全的,在多线程情况下会出现
race condition
。要使其线程安全,能在多线程环境下实现单例模式,我们首先想到的是利用同步机制来正确的保护我们的shared data
。可以使用双检测锁模式(DCL: Double-CheckedLocking Pattern)。
1 | static Singleton* getInstance() |
注意,线程安全问题仅出现在第一次初始化(new)过程中,而在后面获取该实例的时候并不会遇到,也就没有必要再使用lock。双检测锁很好地解决了这个问题,它通过加锁前检测是否已经初始化,避免了每次获取实例时都要首先获取锁资源。
加入DCL后,其实还是有问题的,关于memory model
。在某些内存模型中(虽然不常见)或者是由于编译器的优化以及运行时优化等等原因,使得instance
虽然已经不是nullptr
但是其所指对象还没有完成构造,这种情况下,另一个线程如果调用getInstance()
就有可能使用到一个不完全初始化的对象。换句话说,就是代码中第3行: if(instance == nullptr)
和第8行instance = new Singleton();
没有正确的同步,在某种情况下会出现new
返回了地址赋值给instance
变量而
Singleton
此时还没有构造完全,当另一个线程随后运行到第3行时将不会进入if
从而返回了不完全的实例对象给用户使用,造成了严重的错误。
在C++11
没有出来的时候,只能靠插入两个memory barrier
(内存屏障) 来解决这个错误,但是C++11
引进了memory model
,提供了atomic
实现内存的同步访问,即不同线程总是获取对象修改前或修改后的值,无法在对象修改期间获得该对象。
因此,在有了C++11
后就可以正确的跨平台的实现DCL
模式了,利用atomic
,代码如下:
1 | atomic<singleton*> Singleton::pInstance{ nullptr }; |
Meyer’s Singleton
C++11
规定了local static
在多线程条件下的初始化行为,要求编译器保证了内部静态变量的线程安全性。在C++11
标准下,《Effective C++》提出了一种更优雅的单例模式实现,使用函数内的 local static
对象。这样,只有当第一次访问getInstance()
方法时才创建实例。这种方法也被称为Meyers’ Singleton。C++0x
之后该实现是线程安全的,C++0x
之前仍需加锁。
1 | class Singleton |
C++静态对象的初始化
non-local static
对象 (函数外)
C++
规定,non-local static
对象的初始化发生在main
函数执行之前,也即main
函数之前的单线程启动阶段,所以不存在线程安全问题。但C++
没有规定多个non-local static
对象的初始化顺序,尤其是来自多个编译单元的non-local static
对象,他们的初始化顺序是随机的。
local static
对象 (函数内)
对于local static
对象,其初始化发生在控制流第一次执行到该对象的初始化语句时。多个线程的控制流可能同时到达其初始化语句。
在C++11
之前,在多线程环境下local static
对象的初始化并不是线程安全的。具体表现就是:如果一个线程正在执行local static
对象的初始化语句但还没有完成初始化,此时若其它线程也执行到该语句,那么这个线程会认为自己是第一次执行该语句并进入该local static
对象的构造函数中。这会造成这个local static
对象的重复构造,进而产生内存泄露问题。所以,local static
对象在多线程环境下的重复构造问题是需要解决的。
而C++11
则在语言规范中解决了这个问题。C++11
规定,在一个线程开始local static
对象的初始化后到完成初始化前,其他线程执行到这个local static
对象的初始化语句就会等待,直到该local static
对象初始化完成。
工厂模式
工厂模式属于创建型模式,它提供了一种创建对象的最佳方式。在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象。工厂模式的终极目的是为了解耦,实现创建者和调用者的分离。
简单来说,使用了C++多态的特性,将存在继承关系的类,通过一个工厂类创建对应的子类(派生类)对象。在项目复杂的情况下,可以便于子类对象的创建。
工厂模式的实现方式可分别简单工厂模式、工厂方法模式、抽象工厂模式,每个实现方式都存在优和劣。
简单工厂模式
一个工厂生产多种产品,要指定产品的名字进行生产;
1 | // 鞋子抽象类 |
1 | enum SHOES_TYPE |
简单工厂模式虽然简单明了,但是如果需要增加类的话,就得去修改生产工厂类的内容了,这就违反了开闭原则,所以衍生了工厂方法模式。
工厂方法模式
将产品生产分配给多个工厂,但是每个工厂只生产一种产品;
1 | // 总鞋厂 |
-
工厂方法模式的特点:
- 工厂方法模式抽象出了工厂类,提供创建具体产品的接口,交由子类去实现。
- 工厂方法模式的应用并不只是为了封装具体产品对象的创建,而是要把具体产品对象的创建放到具体工厂类实现。
-
工厂方法模式的缺陷:
- 每新增一个产品,就需要增加一个对应的产品的具体工厂类。相比简单工厂模式而言,工厂方法模式需要更多的类定义。
- 一条生产线只能一个产品。
抽象工厂模式
将产品生产分配给多个工厂,每个工厂可以生产多种产品;
1 | // 基类 衣服 |
1 | // 总厂 |
-
抽象工厂模式的特点:
提供一个接口,可以创建多个产品族中的产品对象。如创建耐克工厂,则可以创建耐克鞋子产品、衣服产品、裤子产品等。 -
抽象工厂模式的缺陷:
同工厂方法模式一样,新增产品时,都需要增加一个对应的产品的具体工厂类。