当前位置: 首页 > news >正文

[设计模式]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. 赋值给指针 。

编译器和处理器出于优化目的,可能对指令进行重排,导致第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 的通用单例模板是最佳实践,它能提供类型安全、自动内存管理和最高的代码复用性。
http://www.xdnf.cn/news/1126657.html

相关文章:

  • Ubuntu18.04 系统重装记录
  • 【高并发服务器】多路复用的总结 eventfd timerfd
  • 复习笔记 39
  • (李宏毅)deep learning(五)--learning rate
  • 单臂路由实现VLAN互通实验
  • 编译原理第一到三章(知识点学习/期末复习/笔试/面试)
  • HashMap详解
  • 优学教育官网搭建01首页
  • 多模态大语言模型arxiv论文略读(157)
  • Node.js 中http 和 http/2 是两个不同模块对比
  • React源码4 三大核心模块之一:Schedule,scheduleUpdateOnFiber函数
  • GBase 8a 与 Spring Boot + MyBatis 整合实战:从环境搭建到CRUD操作
  • Springboot集成SpringSecurity的介绍及使用
  • 【实时Linux实战系列】使用系统调用实现实时同步
  • 【PTA数据结构 | C语言版】前序遍历二叉树
  • 2025国自然青基、面上资助率,或创新低!
  • 板凳-------Mysql cookbook学习 (十一--------11)
  • C#,List<T> 与 Vector<T>
  • 焊接机器人智能节气阀
  • 关于list
  • 微信小程序入门实例_____从零开始 开发一个每天记账的微信小程序
  • 【GPIO】从STM32F103入门GPIO寄存器
  • 153.在 Vue 3 中使用 OpenLayers + Cesium 实现 2D/3D 地图切换效果
  • 淘宝扭蛋机小程序开发:重构电商娱乐化体验的新范式
  • Kruskal重构树
  • Linux操作系统从入门到实战(九)Linux开发工具(中)自动化构建-make/Makefile知识讲解
  • 12.6 Google黑科技GShard:6000亿参数MoE模型如何突破显存限制?
  • 导出内存溢出案例分析
  • 学习秒杀系统-实现秒杀功能(商品列表,商品详情,基本秒杀功能实现,订单详情)
  • JavaScript认识+JQuery的依赖引用