[设计模式]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 
#include 

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 

class SingletonOnceFlag{
public:
    static std::shared_ptr getInstance(){
        static std::once_flag flag;
        std::call_once(flag, []{
            // 注意这里不能用 make_shared,因为构造函数是私有的
            _instance = std::shared_ptr(new SingletonOnceFlag());
        });
        return _instance;
    }
    //... 其他部分相同

private:
    //...
    static std::shared_ptr _instance;
};


// Singleton.cpp (智能指针版)

#include "Singleton.h"

std::shared_ptr SingletonOnceFlag::_instance = nullptr;

测试代码:

#include "Singleton.h"
#include 
#include 

int main() {
    std::mutex mtx;
    std::thread t1([&](){
        auto inst = SingletonOnceFlag::getInstance();
        std::lock_guard lock(mtx);
        inst->PrintAddress();
    });

    std::thread t2([&](){
        auto inst = SingletonOnceFlag::getInstance();
        std::lock_guard 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 getInstance(){
        static std::once_flag flag;
        std::call_once(flag, []{
            _instance = std::shared_ptr(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 _instance;

};

程序输出:

0x7f8c0a509d30
0x7f8c0a509d30
this is safe deleter operator()

可以看到,程序结束时,shared_ptr 调用了我们的 SafeDeletor,从而安全地销毁了实例。这种方式提供了最强的封装性。


终极方案:基于CRTP的通用单例模板

在大型项目中,为每个需要单例的类重复编写样板代码是低效的。更优雅的方案是定义一个通用的单例模板基类。任何类只需继承该基类,便能自动获得单例特性。这通常通过奇异递归模板模式实现,即派生类将自身作为模板参数传递给基类。

单例基类实现:

// Singleton.h
#include 
#include 

template 
class Singleton {
protected:
    Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton& st) = delete;
    virtual ~Singleton() {
        std::cout << "this is singleton destruct" << std::endl;
    }
    static std::shared_ptr _instance;

public:
    static std::shared_ptr GetInstance() {
        static std::once_flag s_flag;
        std::call_once(s_flag, []() {
            // new T 这里能成功,因为子类将基类设为了友元
            _instance = std::shared_ptr(new T);
        });

        return _instance;
    }

    void PrintAddress() {
        std::cout << _instance.get() << std::endl;
    }
};

template 
std::shared_ptr Singleton::_instance = nullptr;

使用这个模板基类:

现在,如果我们想让一个网络管理类 SingleNet 成为单例,只需要这样做:

// SingleNet.h
#include "Singleton.h"

// CRTP: SingleNet 继承了以自己为模板参数的 Singleton
class SingleNet : public Singleton{

    // 将基类模板实例化后设为友元,这样基类的 GetInstance 才能 new 出 SingleNet
    friend class Singleton;

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 的通用单例模板是最佳实践,它能提供类型安全、自动内存管理和最高的代码复用性。

你可能感兴趣的:(C++,开发语言,c++,单例模式,个人开发)