设计模式之单例模式

什么是单例模式

我们希望类的实例对象有且仅有一个,比如数据库连接配置、应用设置时,系统中需要一个全局对象,所有模块共享同一配置。再比如数据库连接池也需要全局管理,避免频繁创建与销毁。

感觉上述例子并不直观,举个通俗的例子------家里的电表。家里的电表记录整个家庭的用电量。无论你在厨房、卧室还是客厅用电,电表都是同一个。一般不会给每个房间都装一个独立的电表,因为那样使得整个家里的用电信息没有共享,还需要做第二步的汇总,实属多此一举。

我们希望实例对象有且只有一个,此时就需要用到单例模式了。单例模式保证一个类有且仅有一个实例,并且会提供一个全局访问点。

如何实现让一个类的实例只有一个呢?

那么就需要在构造函数 上做点"手脚"。如果构造函数是public(本文主要从C++语言的角度来讲述单例模式,不同的编程语言都是互通的,Java和C++都有public和private属性,印象里python貌似没有),那么就没有任何限制地可以构造对象了。

因此,需要将构造函数私有化 即设置为private属性。另外C++11及之后还可以使用= delete来禁用一个函数,因此还可以

cpp 复制代码
class Singleton {
    Singleton() = delete;
    ...
};

另外需要对外提供一个接口 ,便于外界通过这个接口获取到全局统一实例化的对象,并且这个接口得是静态 的即static关键字修饰的接口。

如果不使用static关键字修饰,那么调用这个接口就需要创建对象,而构造函数又被私有化了,无法创建对象。因此这个接口得是静态的。

在C++中类中的静态方法可以通过类名::方法名的方式调用,无需对象。

此外,还需要禁用拷贝构造函数和复制运算符,代码如下,不再细讲。对于拷贝构造函数可以看下方的补充,复制运算符另写文章补充。

cpp 复制代码
class Singleton {
		...
		Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
		Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
		...
}

如何实现单例模式

单例模式实现有两种方式,懒汉方式和饿汉方式。

懒汉方式

懒汉方式,顾名思义,很"懒",只有用到了才实例化对象并返回(调用了对外的接口才会实例化对象),也就是"懒加载",可以节省一部分系统资源,节省从系统启动到第一次调用对外接口这部分的资源。

就C++语言来说,单例模式的懒汉实现有两种写法,为了更好地适配更多的语言,先讲第一种写法

写法一

现在,我们可以得到如下单例模式的懒汉实现,请你阅读下述代码,看是否满足要求

cpp 复制代码
class Singleton {
private:
		static Singleton* instance;	// 静态成员变量,类内声明,类外初始化
		Singleton() {};	// 构造函数属性设置为私有
		Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
		Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符

public:
	static Singleton& getInstance()
	{
		if (instance == nullptr)
			instance = new Singleton();
		return *instance;
	}
};
Singleton* Singleton::instance=nullptr; // 初始化静态变量

上述实现是线程不安全的,原因在于getInstance方法中的条件判断和实例创建操作不是原子的。在多线程环境下,可能会导致多个线程同时创建实例,从而破坏单例模式的唯一性。

在 getInstance 方法中,以下代码是问题的根源

cpp 复制代码
if (instance == nullptr) 
    instance = new Singleton();

因为多个线程可能同时进入 if (instance == nullptr) 的判断,如果 instance 为 nullptr,多个线程会同时执行 instance = new singleClass(),导致创建多个实例。最终,会实例化多个对象,破坏了单例模式的唯一性。

通过以下代码获取两个单例对象,并打印它们的地址,有时会发现它们的地址不同。

cpp 复制代码
int main()
{
	Singleton& singlep1 = Singleton::getInstance();
	Singleton& singlep2 = Singleton::getInstance();
	cout << &singlep1 << endl;
	cout << &singlep2 << endl;
 
	return 0;
}

上述问题是在多线程环境下出现的问题,解决多线程问题,首当其冲的当然是"锁"啦。每次在getInstance方法里都上锁,然后判断instance == nullptr和实例化,然后解锁,返回对象。

于是就有了以下方案

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	static mutex mtx;
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		mtx.lock();
		if (instance == nullptr)
			instance = new Singleton();
		mtx.unlock();
		return *instance;
	}
};
Singleton* Singleton::instance = nullptr; // 初始化静态变量
mutex Singleton::mtx; // 类外初始化

非常简单直观,但是这种实现方法有很明显的缺点,每次调用getInstance都需要加锁,性能开销较大。事实上,出现多线程竞争的问题是在实例化时,而实例化是在instance == nullptr时才进行,因此可以先判断instance == nullptr,为nullptr时再加锁,这样在实例化过instance之后就不需要再加锁了。

那以下实现可以吗?

cpp 复制代码
static Singleton& getInstance()
{
	if (instance == nullptr) {
		mtx.lock();
		instance = new Singleton();
		mtx.unlock();
	}
	return *instance;
}

答案是No!原因是多个线程可能同时进入if (instance == nullptr)的内部,然后只有一个线程才能获取到锁,其他锁只能等待锁的释放。当获取到锁的那个线程,执行完instance = new Singleton();后会释放锁,然后其他线程就可以获取锁,进而执行instance = new Singleton();,导致重复实例化对象。

因此在获取到锁后,还需要再次判断if (instance == nullptr),这样就可以避免后续获取锁的线程重复实例化,看到这里,那么恭喜你已经学会双重校验加锁,即两次检查和一次加锁

第一次检查时不加锁,检查实例是否已经创建。如果已经创建,直接返回实例,避免加锁的开销。

第二次检查时需要先加锁再检查,如果实例未创建,加锁后再次检查实例是否为空。如果仍然为空,则创建实例。

一方面只有在实例未创建时才加锁,避免了每次调用 getInstance 都加锁的性能开销,另一方面就是线程安全,非常Nice。

代码如下(使用mutex或者lock_guard中的一个就行)

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	static mutex mtx;
	Singleton() {};  // 构造函数属性设置为私有
    Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
    Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		if (instance == nullptr) {
			// mtx.lock();
			lock_guard<mutex> lock(mtx);
			if (instance == nullptr)
				instance = new Singleton();
			// mtx.unlock();
		}
		return *instance;
	}
};
Singleton* Singleton::instance = nullptr; // 初始化静态变量
mutex Singleton::mtx; // 类外初始化

写法二

单例模式的懒汉实现的写法二是基于C++语言特性的,其他语言可能不支持。

C++11 标准规定,静态局部变量的初始化是线程安全的 。因此可以使用静态局部变量初始化来实现懒汉模式。这样无需手动管理锁和静态成员变量,代码更简洁,无需考虑复杂的线程安全问题,因为静态局部变量只会被初始化一次,并且C++11以上标准来保证线程安全。

另外,静态局部变量的生命周期从第一次执行到它的声明语句开始,直到程序结束。

代码实现如下,但是实际面试时,如果面试官问到,大概率是想问写法一,可以在回答写法一后,顺带提一嘴写法二,来一个锦上添花。

cpp 复制代码
class Singleton {
private:
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		static Singleton instance;
		return instance;
	};
};

饿汉方式

接下来介绍饿汉实现方法。

饿汉模式是不管调不调用对外接口,都直接实例化对象,这样在调用外部接口时,就没有实例化操作,因为在调用外部接口之前就已经实例化好了。

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		return *instance;
	}
};
Singleton* Singleton::instance = new Singleton(); // 初始化静态变量,C++要求静态成员变量类内声明,类外初始化

补充拷贝构造函数

复习一下拷贝构造函数。

拷贝构造函数是C++中的一个特殊成员函数,用于创建一个新对象,并将其初始化为另一个同类型对象的副本。它在以下情况下会被调用:

  • 用一个对象初始化另一个对象时。
  • 将对象作为参数按值传递给函数时。
  • 从函数按值返回对象时。

拷贝构造函数的典型形式如下:

cpp 复制代码
class MyClass {
public:
    // 拷贝构造函数
    MyClass(const MyClass& other) {
        // 拷贝函数逻辑
    }
};

拷贝构造函数的参数是一个常量引用(const MyClass&),表示被拷贝的对象。

拷贝构造函数常见问题是浅拷贝的问题,不再多说啦。

对于复制运算符,另写博客说明。

补充一下C++中的锁

上述实现中对于互斥信号量 的使用的是mutex。其实也可以使用lock_guard或者unique_lock来实现,它俩是更高级的封装,支持构造时自动加锁,析构时自动解锁。而直接使用mutex可以做到更精细的调控罢了,更加灵活。

mutex需要手动加锁和解锁,也需要显式调用解锁。如果忘记调用 mtx.unlock(),会导致锁未被释放,可能引发死锁。可以在代码的任何地方加锁和解锁,非常适合需要精细控制锁的场景。

lock_guard自动加锁和解锁,是一个 RAII(资源获取即初始化 resouce acquire is initialized)封装类,在构造时自动加锁,在析构时自动解锁,并且无需手动解锁,即使函数提前返回或抛出异常,lock_guard 也能保证锁被释放。代码更加简洁,不易出错。unique_locklock_guard类似,有细微差别,另写文章细讲。

特性 lock_guard lock(mtx) mtx.lock() 和 mtx.unlock()
加锁方式 构造时自动加锁 手动加锁
解锁方式 析构时自动解锁 手动解锁
异常安全 安全,即使抛出异常也能解锁 不安全,需要手动处理异常
代码简洁性
灵活性
相关推荐
张小姐的猫14 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
杨充17 小时前
10.可测试性实战设计
设计模式·开源·代码规范
杨充17 小时前
9.重构十二式的实战
设计模式·开源·代码规范
杨充17 小时前
6.设计原则的全景图
设计模式·开源·全栈
杨充17 小时前
2.面向对象的特性
设计模式
杨充17 小时前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充17 小时前
8.反模式与坏味道
设计模式·开源·代码规范
杨充17 小时前
3.接口vs抽象类比较
设计模式
咖啡八杯20 小时前
文法、BNF与AST
java·设计模式·解释器模式·ast·文法