[设计模式]C++单例模式的几种写法以及通用模板
之前在这篇文章中简单的介绍了一下单例模式的作用和应用C++中单例模式详解_c++单例模式的作用-CSDN博客,今天我将在在本文梳理单例模式从C++98到C++11及以后的演变过程,探讨其不同实现方式的优劣,并介绍在现代C++中的最佳实践。
什么是单例模式?
简单来说,单例模式(Singleton Pattern)是一种设计模式,它能保证一个类在整个程序运行期间,只有一个实例存在 。
这种唯一性的保证在特定场景下至关重要。例如,对于一个数据库连接管理器 Manager,如果系统中存在多个实例,不同模块可能会通过不同实例进行操作,从而引发数据状态不一致或资源竞争的问题 。通过将 Manager 设计为单例,所有模块都通过唯一的访问点来与数据库交互,这不仅能保证数据和状态的统一,还能有效规避资源浪费 。
总结而言,单例模式主要具备两大价值:
- · 控制实例数量:节约系统资源,避免因多重实例化导致的状态冲突 。
- · 提供全局访问点:为不同模块提供一个统一的、可协调的访问接口 。
因此,该模式广泛应用于配置管理、日志系统、设备驱动、数据库连接池等需要全局唯一实例的场景中 。
单例模式的几种写法
方式一:局部静态变量(最简洁的现代写法)
//通过静态成员变量实现单例
//懒汉式
class Single2
{
private:Single2(){}Single2(const Single2 &) = delete;Single2 &operator=(const Single2 &) = delete;public:static Single2 &GetInst(){static Single2 single;return single;}
};
它的核心原理就是利用了函数局部静态变量的特性:它只会被初始化一次 。无论你调用 GetInst() 多少次,single 这个静态实例只会在第一次调用时被创建。
调用代码:
void test_single2(){//多线程情况下可能存在问题cout << "s1 addr is " << &Single2::GetInst() << endl;cout << "s2 addr is " << &Single2::GetInst() << endl;}
程序输出:
s1 addr is 0x7f8a1b402a10
s2 addr is 0x7f8a1b402a10
可以看到,两次获取到的实例地址是完全一样的。
需要注意的是,在 C++98 的年代,这种写法在多线程环境下是不安全的,可能会因为并发导致创建出多个实例 。但是随着 C++11 标准的到来,编译器对这里做了优化,保证了局部静态变量的初始化是线程安全的 。所以,在 C++11 及之后的版本,这已成为实现单例最受推崇的方式之一,兼具简洁与安全。
方式二:静态成员变量指针(饿汉式)
这种方式定义一个静态的类指针,并在程序启动时就立刻进行初始化,因此被称为“饿汉式”。
由于实例在主线程启动、其他业务线程开始前就已完成初始化,它自然地避免了多线程环境下的竞争问题。
//饿汉式
class Single2Hungry{
private:Single2Hungry(){}Single2Hungry(const Single2Hungry &) = delete;Single2Hungry &operator=(const Single2Hungry &) = delete;public:static Single2Hungry *GetInst(){if (single == nullptr){single = new Single2Hungry();}return single;}private:static Single2Hungry *single;
};
初始化和调用:
//饿汉式初始化,在.cpp文件中
Single2Hungry *Single2Hungry::single = Single2Hungry::GetInst();void thread_func_s2(int i){cout << "this is thread " << i << endl;cout << "inst is " << Single2Hungry::GetInst() << endl;
}void test_single2hungry(){cout << "s1 addr is " << Single2Hungry::GetInst() << endl;cout << "s2 addr is " << Single2Hungry::GetInst() << endl;for (int i = 0; i < 3; i++){thread tid(thread_func_s2, i);tid.join();}
}int main(){test_single2hungry();
}
程序输出:
s1 addr is 0x7fb3d6c00f00
s2 addr is 0x7fb3d6c00f00
this is thread 0
inst is 0x7fb3d6c00f00
this is thread 1
inst is 0x7fb3d6c00f00
this is thread 2
inst is 0x7fb3d6c00f00
饿汉式的优点是实现简单且线程安全。但其缺点也很明显:无论后续是否使用,实例在程序启动时都会被创建,可能造成不必要的资源开销。此外,通过裸指针 new 创建的实例,其内存释放时机难以管理,在复杂的多线程程序中极易引发内存泄漏或重复释放的严重问题。
方式三:静态成员变量指针(懒汉式与双重检查锁定)
与“饿汉”相对的就是“懒汉”,即只在第一次需要用的时候才去创建实例 。这能节省资源,但直接写在多线程下是有问题的。为解决其在多线程下的安全问题,一种名为双重检查锁定(Double-Checked Locking)的优化技巧应运而生。
//懒汉式指针,带双重检查锁定
class SinglePointer{
private:SinglePointer(){}SinglePointer(const SinglePointer &) = delete;SinglePointer &operator=(const SinglePointer &) = delete;public:static SinglePointer *GetInst(){// 第一次检查if (single != nullptr){return single;}s_mutex.lock();// 第二次检查if (single != nullptr){s_mutex.unlock();return single;}single = new SinglePointer();s_mutex.unlock();return single;}private:static SinglePointer *single;static mutex s_mutex;
};//在.cpp文件中定义
SinglePointer *SinglePointer::single = nullptr;
std::mutex SinglePointer::s_mutex;
调用代码:
void thread_func_lazy(int i){cout << "this is lazy thread " << i << endl;cout << "inst is " << SinglePointer::GetInst() << endl;
}void test_singlelazy(){for (int i = 0; i < 3; i++){thread tid(thread_func_lazy, i);tid.join();}
}
程序输出:
this is lazy thread 0
inst is 0x7f9e8a00bc00
this is lazy thread 1
inst is 0x7f9e8a00bc00
this is lazy thread 2
inst is 0x7f9e8a00bc00
该模式试图通过减少锁的持有时间来提升性能。然而,这种实现在C++中是存在严重缺陷的。new 操作并非原子性,它大致包含三个步骤:
- 1. 分配内存;
- 2. 调用构造函数;
- 3. 赋值给指针 。
- 2. 调用构造函数;
编译器和处理器出于优化目的,可能对指令进行重排,导致第3步先于第2步完成 。若此时另一线程访问,它会获取一个非空但指向未完全构造对象的指针,进而引发未定义行为 。
C++11的现代解决方案:once_flag 与智能指针
为了安全地实现懒汉式加载,C++11 提供了 std::once_flag 和 std::call_once。call_once 能确保一个函数(或 lambda 表达式)在多线程环境下只被成功调用一次 。
// Singleton.h
#include <mutex>
#include <iostream>class SingletonOnceFlag{
public:static SingletonOnceFlag* getInstance(){static std::once_flag flag;std::call_once(flag, []{_instance = new SingletonOnceFlag();});return _instance;}void PrintAddress() {std::cout << _instance << std::endl;}~SingletonOnceFlag() {std::cout << "this is singleton destruct" << std::endl;}private:SingletonOnceFlag() = default;SingletonOnceFlag(const SingletonOnceFlag&) = delete;SingletonOnceFlag& operator=(const SingletonOnceFlag& st) = delete;static SingletonOnceFlag* _instance;
};// Singleton.cpp#include "Singleton.h"SingletonOnceFlag *SingletonOnceFlag::_instance = nullptr;
这样就完美解决了线程安全问题,但内存管理的问题依然存在。此时,std::shared_ptr 智能指针成为了理想的解决方案,它能实现所有权的共享和内存的自动回收。
智能指针版本:
// Singleton.h (智能指针版)#include <memory>class SingletonOnceFlag{
public:static std::shared_ptr<SingletonOnceFlag> getInstance(){static std::once_flag flag;std::call_once(flag, []{// 注意这里不能用 make_shared,因为构造函数是私有的_instance = std::shared_ptr<SingletonOnceFlag>(new SingletonOnceFlag());});return _instance;}//... 其他部分相同private://...static std::shared_ptr<SingletonOnceFlag> _instance;
};// Singleton.cpp (智能指针版)#include "Singleton.h"std::shared_ptr<SingletonOnceFlag> SingletonOnceFlag::_instance = nullptr;
测试代码:
#include "Singleton.h"
#include <thread>
#include <mutex>int main() {std::mutex mtx;std::thread t1([&](){auto inst = SingletonOnceFlag::getInstance();std::lock_guard<std::mutex> lock(mtx);inst->PrintAddress();});std::thread t2([&](){auto inst = SingletonOnceFlag::getInstance();std::lock_guard<std::mutex> lock(mtx);inst->PrintAddress();});t1.join();t2.join();return 0;
}
程序输出 (析构函数被正确调用):
0x7fde7b408c20
0x7fde7b408c20
this is singleton destruct
进阶玩法:私有析构与自定义删除器
有些大佬追求极致的封装,他们会把析构函数也设为private,防止外部不小心 delete 掉单例实例 。但这样 shared_ptr 默认的删除器就无法调用析构了。解决办法:我们可以给 shared_ptr 指定一个自定义的删除器(Deleter),通常是一个函数对象(仿函数)。这个删除器类被声明为单例类的友元(friend),这样它就有了调用私有析构函数的权限。
// Singleton.h
class SingleAutoSafe; // 前置声明// 辅助删除器
class SafeDeletor{
public:void operator()(SingleAutoSafe *sf){std::cout << "this is safe deleter operator()" << std::endl;delete sf;}
};class SingleAutoSafe{
public:static std::shared_ptr<SingleAutoSafe> getInstance(){static std::once_flag flag;std::call_once(flag, []{_instance = std::shared_ptr<SingleAutoSafe>(new SingleAutoSafe(), SafeDeletor());});return _instance;}// 声明友元类,让 SafeDeletor 可以访问私有成员friend class SafeDeletor;
private:SingleAutoSafe() = default;// 析构函数现在是私有的了~SingleAutoSafe() {std::cout << "this is singleton destruct" << std::endl;}// ...static std::shared_ptr<SingleAutoSafe> _instance;};
程序输出:
0x7f8c0a509d30
0x7f8c0a509d30
this is safe deleter operator()
可以看到,程序结束时,shared_ptr 调用了我们的 SafeDeletor,从而安全地销毁了实例。这种方式提供了最强的封装性。
终极方案:基于CRTP的通用单例模板
在大型项目中,为每个需要单例的类重复编写样板代码是低效的。更优雅的方案是定义一个通用的单例模板基类。任何类只需继承该基类,便能自动获得单例特性。这通常通过奇异递归模板模式实现,即派生类将自身作为模板参数传递给基类。
单例基类实现:
// Singleton.h
#include <memory>
#include <mutex>template <typename T>
class Singleton {
protected:Singleton() = default;Singleton(const Singleton<T>&) = delete;Singleton& operator=(const Singleton<T>& st) = delete;virtual ~Singleton() {std::cout << "this is singleton destruct" << std::endl;}static std::shared_ptr<T> _instance;public:static std::shared_ptr<T> GetInstance() {static std::once_flag s_flag;std::call_once(s_flag, []() {// new T 这里能成功,因为子类将基类设为了友元_instance = std::shared_ptr<T>(new T);});return _instance;}void PrintAddress() {std::cout << _instance.get() << std::endl;}
};template <typename T>
std::shared_ptr<T> Singleton<T>::_instance = nullptr;
使用这个模板基类:
现在,如果我们想让一个网络管理类 SingleNet 成为单例,只需要这样做:
// SingleNet.h
#include "Singleton.h"// CRTP: SingleNet 继承了以自己为模板参数的 Singleton
class SingleNet : public Singleton<SingleNet>{// 将基类模板实例化后设为友元,这样基类的 GetInstance 才能 new 出 SingleNetfriend class Singleton<SingleNet>;private:SingleNet() = default;~SingleNet() {std::cout << "SingleNet destruct " << std::endl;}
};
测试代码:
// main.cpp
int main() {std::thread t1([&](){SingleNet::GetInstance()->PrintAddress();});std::thread t2([&](){SingleNet::GetInstance()->PrintAddress();});t1.join();t2.join();return 0;}
程序输出:
0x7f9a2d409f40
0x7f9a2d409f40
SingleNet destruct
this is singleton destruct
我们几乎没写任何单例相关的逻辑,只通过一次继承和一句友元声明,就让 SingleNet 变成了一个线程安全的、自动回收内存的单例类。这就是泛型编程的强大之处。
总结
本文介绍了单例模式从传统到现代的多种实现方式。可总结为:
- 日常开发:对于C++11及以上版本,局部静态变量法是实现单例的首选,它兼具代码简洁性与线程安全性。
- 深入理解:了解饿汉式、懒汉式及双重检查锁定的历史与缺陷,对于理解并发编程中的陷阱至关重要。
- 企业级实践:在大型项目中,基于智能指针和 CRTP 的通用单例模板是最佳实践,它能提供类型安全、自动内存管理和最高的代码复用性。