单例模式 C++

6 种 单例 的手写,都是懒汉(饿汉代码在 “懒汉 / 饿汉的区别”)

目录

✊前言 

GPT解析

概念解析

RAII

懒汉 / 饿汉的区别

特点

举例

单例 -- 伪代码

适用场景

单例 -- 实现方式

优缺点

手写 6 种单例模式

(一)懒汉 -- 内存泄露

(二)懒汉 -- 解决内存泄漏

(三)懒汉 -- 双检锁

(四)原子操作

(五)C++11 -- magic static

(六)模板类


“单例模式”实质上就是创建全局对象(全局访问节点 + 一个类一个实例)。如同普通的使用全局变量,在绝大多数场景下这并不是一个好做法,应当先检查一下设计是否合理
当你确认要在C++11及以后的版本中使用单例模式时,最佳的实现只有一个:magic static

被很多博客津津乐道的双重检查锁定是过时、复杂且错误的,原因参见

C++和双重检查锁定模式(DCLP)的风险_双检测锁单例 volatail c++-CSDN博客
(什么?你问C++11之前?对不起,由于标准还没有规定内存模型,所以做不到严格的线程安全)
最后重复强调一次,单例模式在绝大多数情况下是糟糕的设计,对着单例模式的八百种实现方法雕花更是一件浪费生命的事情

✊前言 

TinyWebServer 涉及到单例模式,花个 5 小时学习一下

参考文章 + GPT

【C++】C++ 单例模式总结(5种单例实现方法)_单例模式c++实现-CSDN博客

InterviewGuide大厂面试真题

C++ 线程安全的单例模式总结 - 掘金 (juejin.cn)

C++ 单例模式总结与剖析 - 行者孙 - 博客园 (cnblogs.com)

C++ 与 设计模式(比较重要

单例设计模式 (refactoringguru.cn)

GPT解析

概念

单例模式是一种创建型设计模式,确保类只有一个实例,并提供一个全局访问点来访问该实例

通过将类的构造函数私有化,限制外部代码无法直接实例化该类,然后通过静态方法或静态成员变量来返回类的 唯一 实例

优势

  1. 全局访问点:通过单例模式,可以在程序中任何地方访问该单例对象,方便统一管理
  2. 节约资源:由于单例模式只创建一个实例,可以节约资源,避免多次创建对象
  3. 避免竞争条件:单例模式可以避免多线程环境下的竞争条件,确保线程安全
  4. 延迟初始化:可以延迟创建实例,只有在需要时才进行实例化

劣势

  1. 隐藏依赖关系:单例模式会引入全局状态,增加模块之间的耦合,使得代码更难测试和维护
  2. 不适合动态配置:当需要变化的对象时,单例模式可能无法满足需求
  3. 可能导致性能问题:在高并发场景下,单例模式可能成为瓶颈,需要考虑并发访问时的性能问题

应用

  1. 日志系统:在Web服务器中,可以使用单例模式来实现日志系统,确保所有模块都可以共享同一个日志对象
  2. 数据库连接池数据库连接池是Web服务器中经常使用的组件,可以使用单例模式确保连接池的 唯一 性
  3. 配置管理器:使用单例模式来管理服务器的配置信息,保证配置信息的 一致性和全局可访问性

概念解析

RAII

RAII 是 Resource Acquisition Is Initialization 的缩写,指的是资源获取即初始化。它是一种 C++ 中的编程技术,用于管理资源的生命周期

RAII 的基本思想是将资源的获取和释放操作封装在对象的构造函数和析构函数中。通过在对象的构造函数中获取资源,在析构函数中释放资源,可以确保资源在对象生命周期内始终正确地被获取和释放

这种技术的好处是可以避免因为忘记手动释放资源而导致的资源泄露问题。当对象离开作用域时,其析构函数会自动调用,从而触发资源的释放操作。这种自动化的资源管理能够提高代码的可靠性和可维护性

在版本二的示例中就使用了 RAII 技术,通过在单例类的构造函数中获取实例,在析构函数中释放实例。这样可以确保单例对象在程序退出时被正确释放,避免内存泄漏问题

懒汉 / 饿汉的区别

  • 懒汉式 -- 时间换空间 -- 访问量较(推荐内部静态变量的懒汉单例,代码量少)
  • 饿汉式 -- 空间换时间 -- 访问量较OR 线程较多的的情况
  1. 懒汉模式(Lazy Initialization)

    • 在懒汉模式中,单例对象在第一次调用 GetInstance() 函数时才被创建。也就是说,单例对象的实例化被推迟到需要使用时才进行
    • 这种延迟加载的方式可以节省一些初始资源和提高程序的启动速度
    • 然而,懒汉模式需要在多线程环境下考虑线程安全性。例如,当多个线程同时调用 GetInstance() 函数时,可能会导致创建多个实例的问题。因此,需要额外的同步措施来保证线程安全
  2. 饿汉模式(Eager Initialization)

    • 在饿汉模式中,单例对象在程序启动时就被创建,并且在整个程序运行期间都存在
    • 这种方式确保了在任何时刻都能够访问到单例对象,而不需要考虑多线程环境下的同步问题
    • 然而,饿汉模式可能会带来一些性能开销,因为单例对象的初始化发生在程序启动时

懒汉模式

// Singleton类的定义
class Singleton {
public:
    // 获取单例对象的静态成员函数
    static Singleton* GetInstance() {
        // 如果instance为空,即还没有创建单例对象
        if (instance == nullptr) {
            // 创建一个新的Singleton对象
            instance = new Singleton();
        }
        // 返回单例对象的指针
        return instance;
    }

private:
    // 构造函数和析构函数,设置为默认实现
    Singleton() = default;
    ~Singleton() = default;
    // 禁用拷贝构造函数和赋值运算符重载,确保单例对象唯一性和不会被复制
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 静态成员变量,指向单例对象。初始值为nullptr,表示还未创建单例对象
    static Singleton *instance;
};

// 初始化静态成员变量
Singleton* Singleton::instance = nullptr;

懒汉 -- 解释

  1. Singleton::instance:这表示instanceSingleton类的静态成员变量。静态成员变量只有 一份内存,属于整个类的 共享资源

  2. Singleton*:这表示instance是一个指向Singleton对象的指针。因为instanceSingleton类的静态成员,所以它是一个类范围内的静态指针

  3. Singleton::GetInstance():这是一个静态成员函数,用于获取单例对象的指针。在函数内部,如果instance为空(即指向nullptr),则创建一个新的Singleton对象并将其赋值给instance,然后返回instance的值

  4. Singleton()~Singleton():这是构造函数和析构函数的定义,在这里被设置为默认实现

  5. Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;:这两行代码禁用了拷贝构造函数和赋值运算符重载,确保单例对象不会被复制或赋值

需要时才创建单例对象,同时通过禁用拷贝构造和赋值操作来确保单例的唯一性和不被复制

饿汉模式

class Singleton {
public:
    static Singleton* GetInstance() {
        return instance;
    }

private:
    Singleton() = default; // 默认构造函数,设为 private,防止外部实例化对象
    ~Singleton() = default; // 默认析构函数,设为 private,防止外部删除对象
    Singleton(const Singleton&) = delete; // 删除拷贝构造函数,防止对象被复制
    Singleton& operator=(const Singleton&) = delete; // 删除赋值运算符重载,防止对象被赋值

    static Singleton *instance; // 静态成员变量,指向单例对象

};

Singleton* Singleton::instance = new Singleton(); // 在程序启动时即创建单例对象

特点

  • 将默认构造函数设为私有, 防止其他对象使用单例类的 new运算符
  • 新建一个静态构建方法作为构造函数。 该函数会 “偷偷” 调用私有构造函数来创建对象, 并将其保存在一个静态成员变量中。 此后所有对于该函数的调用都将返回这一缓存对象

单例模式 C++_第1张图片

举例

政府是单例模式的一个很好的示例。 一个国家只有一个官方政府。 不管组成政府的每个人的身份是什么, ​ “某政府” 这一称谓总是鉴别那些掌权者的全局访问节点

单例 -- 伪代码

/* 
数据库类会对`getInstance() 获取实例`方法
进行定义,以便客户端在程序各处都能访问
相同的数据库连接实例
*/
class Database is
    // 保存单例实例的成员变量 -- 声明为静态
    private static field instance : Database

    // 单例的构造函数 -- 私有,防止`new`运算符
    // 直接调用构造方法
    private constructor Database() is
        // 初始化代码(eg:数据库服务器的实际连接)

    // 用于控制对单例实例的访问权限的静态方法
    public static method getInstance() is
        if (Database.instance == null) 
            acquireThreadLock() 
                // 确保在该线程等待解锁时
                // 其他线程没有初始化该实例
                if (Database.instance == null) 
                    Database.instance = new Database()
        return Database.instance

    // 最后,任何单例都必须定义一些可在其实例上执行的业务逻辑
    public method query(sql) is
        // eg: 所有数据库查询请求都需通过该方法进行
        // 因此,可以在这里添加限流或缓冲逻辑

class Application is
    method main() is
        Database foo = Database.getInstance()
        foo.query("SELECT ...")

        Database bar = Database.getInstance()
        bar.query("SELECT ...")
        // 变量 `bar` 和 `foo` 中包含同一个对象

适用场景

‍1)某个类,对于所有客户端,只有一个可用实例

单例模式禁止通过,特殊构建方法以外的方式,创建自身类的对象

特殊构建方法可以创建一个新对象,如果该对象已被创建,则返回已有对象

2)需要严格控制全局变量

单例模式保证类只存在一个实例

除了单例类自身,无法通过任何方式,替换缓存的实例

PS:通过修改 getInstance() 获取实例的代码,可以设置生成单例的数量

单例 -- 实现方式

1,私有静态成员变量 -- 保存实例

2,公有静态构建方法 -- 获取实例

3,静态方法中,实现延迟初始化 -- 首次被调用创建新对象,存储在静态成员变量中,此后每次被调用都返回该实例

4,私有类的构造函数 -- 只有类的静态方法,可以调用该构造函数

5,检查客户端代码,构造函数的调用 -- 替换 为,静态构建方法的调用

优缺点

单例拥有与全局变量相同的优缺点。 尽管它们非常有用, 但却会破坏代码的模块化特性 

优点 

1)一个类一个实例

2)指向该实例的全局访问节点

3)仅在首次请求单例对象时初始化

缺点

1)违反 “单一职责原则”,单例模式同时解决了 2 个问题

2)可能掩盖不良设计,eg:各组件间了解过多

3)多线程下,需特殊处理,避免多个线程多次创建单例对象

4)单例客户端代码的单元测试,较为困难 -- 许多测试框架,基于继承的方式创建模拟对象

但是单例模式的构造函数 -- 私有 && 大部分语言无法重写静态方法

--> 所以,单例模式与测试代码不要共存最好

手写 6 种单例模式

大厂面试真题

(一)懒汉 -- 内存泄露

// 版本一:简单懒汉模式,存在内存泄漏和线程安全问题

class Singleton1 {
public:
    // 获取单例实例的静态方法
    // 用于获取 Singleton1 类的唯一实例
    static Singleton1* GetInstance() { 
        if (instance == nullptr)
            instance = new Singleton1(); // 创建新实例
        return instance; // 返回 Singleton1 实例
    }

private:
    // 防止外界 构造/删除 对象
    Singleton1() = default; // 私有构造
    ~Singleton1() = default; // 私有析构函数
    Singleton1(const Singleton1&) = default; // 禁止拷贝构造
    // 禁止赋值
    Singleton1& operator = (const Singleton1&) = default;

    // 静态成员指针,指向唯一实例
    static Singleton1 *instance; 
};

Singleton1* Singleton1::instance = nullptr;
/* 
存在内存泄漏问题,instance 在堆上,需要手动释放
但是外界无法调用delete释放对象(私有静态成员指针)
&& 
线程安全问题 -- 多个线程同时分配内存
*/
  • 这是一个懒汉模式,即在需要时才创建实例
  • 存在内存泄漏问题,因为instance是在堆上分配的,需要手动释放,但外部无法调用delete来释放对象

(二)懒汉 -- 解决内存泄漏

补充知识

1)

std::atexit - cppreference.com

2) C/C++程序终止时执行的函数——atexit()函数详解-腾讯云开发者社区-腾讯云 (tencent.com)

3)

atexit()函数的参数是一个函数指针,函数指针指向一个没有参数没有返回值的 函数

-- 成功时返回零

4)

单例模式 C++_第2张图片

先返回 #2,再返回 #1,是C++标准库的规定

代码 

// 版本二:解决内存泄漏,但仍存在线程安全问题
class Singleton2 {
public:
    // 获取单例实例的静态方法
    static Singleton2* GetInstance() {
        if (instance == nullptr) {
            instance = new Singleton2();
            atexit(Destructor); // 程序退出时释放
        }
        return instance;
    }

private:
    // 防止外界 构造/删除 对象
    Singleton2() = default; // 私有构造
    ~Singleton2() = default; // 私有析构
    Singleton2(const Singleton2&) = default; // 禁止拷贝构造
    // 禁止赋值 -- 通过定义为私有,避免外界调用,达到了禁止的目的
    Singleton2& operator=(const Singleton2&) = default;

    // 释放实例资源的静态方法
    static void Destructor() {
        if (instance != nullptr) {
            delete instance;
            instance = nullptr;
        }
    }

    // 静态成员指针,指向唯一实例
    static Singleton2 *instance; 
};

Singleton2* Singleton2::instance = nullptr;
/*
解决了内存泄漏,程序退出时释放对象
但仍存在多个线程同时分配内存的问题
*/
  • 在程序退出时通过atexit注册析构函数,解决了内存泄漏问题

(三)懒汉 -- 双检锁

需要注意的是,双检锁 -- Java下正确,Cpp下错误。具体参考

Ubuntu Pastebin

补充解释

是C++标准库中用于多线程编程的头文件,提供了互斥量(mutex)和其他与线程同步相关的类和函数

std::lock_guard 是一个RAII风格的互斥量封装类,它可以确保在其作用域结束时释放互斥量

代码中,lock_guard 用于对互斥量进行加锁操作,并且在作用域结束时会自动释放该锁,从而避免了忘记解锁的问题

std::mutex 是C++11引入的互斥量类,用于实现线程之间的互斥访问

mutex_ 是一个全局的 std::mutex 对象,用于保护 instance 的访问,确保在多线程环境下对 instance 进行安全的初始化

lock_guard lock(mutex_) 

创建了一个 lock_guard 对象,并在构造函数中对 mutex_ 进行加锁操作

表示使用 std::mutex 作为互斥量类型

这样可以确保在同一时间只有一个线程能够进入临界区,避免了多个线程同时对 instance 进行初始化的问题

关于 双检锁 两个 if 的解释

第一个 if 语句会检查指针 instance 是否为空,如果为空,则表示还没有创建单例实例

进入第一个 if 语句块后,会获取互斥锁 lock,确保只有一个线程可以进入临界区

第二个 if 语句再次检查指针 instance 是否为空

防止多个线程,通过第一个 if 语句的互斥锁后,同时创建实例

第二个 if 的 instance 仍然为空,表示通过的是第一个线程,单例实例可以安全的创建

双检锁 -- 最终效果:只有第一个线程加锁,并创建单例实例

代码

/*
版本三
双重检查锁(double-checked-locking)实现线程安全
*/

class Singleton3 {
public:
    // 获取单例实例的静态方法
    // 静态成员函数 返回 指向 Singleton3 类型对象的指针
    static Singleton3* GetInstance() {
        // 双检锁 - 两个 if,只有指针为空才加锁,避免每次调用
        // GetInstance() 都加锁,降低加锁的开销
        if (instance == nullptr) {
            lock_guard lock(mutex_); // 第一个线程加锁
            if (instance == nullptr) {
                instance = new Singleton3(); // 第一个线程创建实例
                atexit(Destructor);
            }
        }
        return instance;
    }

private:
    // 防止外界 构造/删除 对象
    Singleton3() = default; // 私有构造
    ~Singleton3() = default; // 私有析构
    Singleton3(const Singleton3&) = default; // 禁止拷贝
    Singleton3& operator=(const Singleton3&) = default; // 禁止赋值

    // 释放实例资源的静态方法
    static void Destructor() {
        if (instance != nullptr) {
            delete instance;
            instance = nullptr;
        }
    }

    static Singleton3 *instance; // 静态成员指针,指向唯一实例
    static mutex mutex_; // 互斥锁,用于线程安全
}

// 定义静态指针成员 instance,用于保存 Singleton3 类的唯一实例
Singleton3* Singleton3::instance = nullptr;
// nullptr 表示未创建实例
  • 添加互斥锁确保多线程情况下只有一个线程能够创建实例
  • 通过双重检查锁定(double-checked locking)避免不必要的锁开销
  • new的时候的操作分为三步:分配内存、调用构造函数、返回指针

    有可能先返回指针当还没有调用构造函数导致对象没有初始化,其他线程获取到没有初始化的对象

(四)原子操作

补充解释

1)memory_order - cppreference.com

2)std::lock_guard - cppreference.com

3)atomic_thread_fence - cppreference.com

4)

instance.load(std::memory_order_relaxed) 方法来加载 Singleton4 类的静态原子指针 instance

std::memory_order_relaxed,表示对内存访问的轻量级约束,不会引入额外的内存屏障或同步操作

5)

atomic_thread_fence(std::memory_order_acquire): 这是一个原子操作,会创建一个内存屏障(memory fence)以确保前面的读取操作 instance.load 在内存中完成后,后续的读取操作不会受到影响

std::memory_order_acquire 表示获取操作的内存序,用于确保前面的加载操作不会被重排序

6)

lock_guard lock(mutex_),创建了一个 lock_guard 对象 lock,它使用了 mutex_ 互斥锁进行初始化

保证在当前作用域中,只有一个线程能够获得 mutex_ 锁的所有权,避免多个线程同时修改共享资源

表示使用 std::mutex 作为互斥量类型

代码 

// 版本四:原子操作 + 内存屏障 保证线程安全

class Singleton4 {
public:
    // 获取实例的静态方法
    // 静态成员函数 返回 指向 Singleton4 类型对象的指针
    static Singleton4* GetInstance() {
        // 加载单例实例指针
        Singleton4* tmp = instance.load(std::memory_order_relaxed);
        // 获取内存屏障 - 确保前面操作完成
        atomic_thread_fence(std::memory_order_acquire);
        if (tmp == nullptr) {
            // 加锁保证线程安全
            std::lock_guard lock(mutex_); // 加锁
            // 重新加载单例实例指针
            tmp = instance.load(std::memory_order_relaxed);
            if (tmp == nullptr) {
                tmp = new Singleton4; // 创建实例
                // 释放内存屏障 - 确保存储操作不被重排序
                atomic_thread_fence(std::memory_order_release);
                // 存储新创建的实例指针
                instance.store(tmp, std::memory_order_relaxed);
                atexit(Destructor); // 注册析构函数
            }
        }
        return tmp;
    }

private:
    // 防止外界 构造/删除 对象
    Singleton4() = default; // 私有构造
    ~Singleton4() = default; // 私有析构
    Singleton4(const Singleton4&) = default; // 拷贝构造
    Singleton4& operator=(const Singleton4&) = default; // 赋值

    // 释放实例资源的静态方法
    static void Destructor() {
        if (instance != nullptr) {
            delete instance; // 删除实例
            instance = nullptr; // 重置指针
        }
    }

    static std::atomic instance; // 原子指针 - 线程安全
    static std::mutex mutex_; // 互斥锁 - 用于双检锁
};

std::atomic Singleton4::instance; 
std::mutex Singleton4::mutex_;
  • 使用原子操作保证在多线程环境下的正确性
  • 通过原子操作和锁来保证线程安全
  • 使用原子操作代替互斥锁,提高了并发性能

(五)C++11 -- magic static

补充解释

关于 new 操作带来的 cpu reorder 操作 

简而言之 -- new 和 多线程 可能会产生冲突(需要采取其他措施来规避 BUG)

当程序执行到包含 new 操作符的语句时,需要进行动态内存分配。动态内存分配可能涉及到内存管理的复杂操作,因此编译器和处理器在执行这些操作时会进行一些优化,其中就包括CPU重新排序(CPU reordering)

CPU重新排序是指处理器在执行指令时,可能会对指令的执行顺序进行优化和重新排列,以提高指令执行的效率。这种优化可以通过并行执行指令、延迟加载数据等方式来实现,从而减少处理器的空闲时间,提高整体的执行效率

然而,对于涉及到内存操作的指令(比如动态内存分配和释放),处理器的重新排序可能会导致意想不到的结果。例如,在多线程环境下,如果一个线程执行了内存分配操作,而另一个线程同时进行了访问该内存的操作,由于处理器的重新排序,可能导致访问到未初始化的内存,或者看到不一致的数据,从而引发程序的错误行为

因此,针对使用 new 操作符进行动态内存分配的代码,需要特别注意处理器的重新排序可能带来的影响,确保在多线程环境下能够正确地进行内存访问和操作。而利用静态局部变量实现单例模式可以避免使用 new 操作符,从而避免了因为 new 操作带来的 CPU 重新排序可能带来的潜在问题

代码

有了 C++11 magic static 后,就不用在 懒汉模式 下加锁了,因为静态成员只初始化 1 次

// 版本五:C++11 magic static 特性实现线程安全

class Singleton5 {
public:
    // 获取单例实例
    static Singleton5& GetInstance() {
        /*
        (1)静态局部变量初始化时,并发线程同时进入声明语句,
        并发线程会阻塞等待其初始化结束,保证线程安全
        (2)静态局部变量首次经过它的声明才会被初始化,在其后所有调用中,
        声明都会被跳过
        (3)So, 定义在栈上的局部静态变量保存单例对象的
        优势:延迟加载;系统自动调用析构函数;回收内存;
        没有 new 操作 带来的 cpu reorder 操作;线程安全
        */
        static Singleton5 instance; // magic static 特性
        return instance;
    }
private:
    // 防止外界 构造/删除 对象
    Singleton5() = default; // 私有构造
    ~Singleton5() = default; // 私有析构
    Singleton5(const Singleton5&) = default; // 拷贝构造
    Singleton5& operator=(const Singleton5&) = default; // 赋值
};
  • 利用C++11的magic static特性,确保并发线程等待初始化结束
  • 不需要显式加锁来保证线程安全
  • c++11 magic static 特性:如果当变量在初始化的时候,并发同时进⼊声明语句,并发线程将会阻塞等待初始化结束

(六)模板类

补充解释

Singleton6 是将 DesignPattern 类作为模板参数传递给 Singleton6 类,表示 DesignPattern 是 Singleton6 的一个实例化对象。通过这种方式,可以在 Singleton6 类中定义静态的 GetInstance() 方法来获取 DesignPattern 类的唯一实例

 模板复习

(B站黑马C++)模板_c++模板-CSDN博客

挑重点敲一遍

代码

// 版本六:模板实现单例模式

template
class Singleton6 {
public:
    // 获取单例实例
    static T& GetInstance() {
        static T instance;
        return instance;
    }

protected: // 为了基类可以 构造/析构(仅限类内部和基类使用)
    // 析构虚函数, 支持多态,确保子类析构
    virtual ~Singleton6() = default; 
    Singleton6() = default; // 私有构造
    Singleton6(const Singleton6&) = default; // 禁止拷贝构造
    Singleton6& operator=(const Singlton6&) = default; // 禁止赋值
};

// Singleton6 指定模板类型为 DesingnPattern
class DesignPattern : public Singleton6 {
    // 友元:基类访问父类私有不受限制
    friend class Singleton6;

public:
    ~DesignPattern() = default; // 析构在public, 便于手动删除对象

private:
    DesignPattern() = default; // 私有构造
    DesignPattern(const DesignPattern&) = default; // 禁止拷贝
    // 禁止赋值
    DesignPattern& operator=(const Designpattern&) = default;
};

int main()
{
    // 获取单例实例
    DesignPattern &d1 = DesignPattern::Getinstance();
}
  • 利用模板实现单例模式,避免了重复编写单例模式的代码,提高代码复用性

你可能感兴趣的:(#,C++,设计模式,单例模式,c++)