《C++20设计模式》学习笔记---单例模式

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

C++20设计模式

第 5 章 单例模式

单例模式的理念非常简单。即应用程序中智能有一个特定组件的实例。例如将数据库加载到内存中并提供只读接口的组件是单例模式的主要应用场景之一因为浪费内存存储多个相同的数据是没有意义的。事实上应用程序可能会有一些限制使得两个及两个以上的数据库实例无法装入内存或者会引起内存不足从而导致出现故障。

5.1 作为全局对象的单例模式

解决这个问题的一个比较朴素的做法是确保对数据库对象的实例化不超过一次

    struct Database
    {
    	// brief please do not create more than one instance
        Database(){}
    };

这种方法的问题在于对象可以以隐藏的方式创建即构造对象是不会明显地、直接地调用构造函数。这可以是任何方式—拷贝构造函数/拷贝赋值函数、make_unique()调用或者使用IoC容器。
我能够想象到的一个显而易见的办法是提供一个静态全局对象

static Database database;

但是静态全局对象存在的问题是在不同的编译单元中静态全局变量的初始化顺序是未定义的这可能会造成不愉快的影响例如某个地方引用到的全局变量甚至还没被初始化。静态全局对象的发现性同样是个问题客户如何知道某个静态全局变量是存在的发现类会比发现全局对象更加简单因为Go to Type会搜索出比在全局作用域 :: 后的自动补充方式更精简的可选集。
缓解这种情况的一种方法是提供一个全局函数或者成员函数让该函数对外暴露必要的对象。

Database& get_database() {
	static Database database;
	return database;
}

调用这个函数可以获得Database对象的引用。但是应注意只有在C++11及之后这段代码才是线程安全的。所以需要检查编译器是否需要插入锁机制以防止静态对象在初始化过程中被多个线程并发访问。懒汉式和饿汉式单例
当然 这个场景很容易出错如果在Database的析够函数中使用了某个其他单例对象程序很可能会崩溃。这引入了一个哲学问题单例模式可以引用其他单例模式吗


【注】

  • 在C++11之前静态局部变量的初始化并不是线程安全的。这意味着当多个线程同时尝试首次访问一个静态局部变量时可能会导致竞态条件race condition。竞态条件可能导致多个线程尝试同时初始化该静态局部变量从而导致未定义的行为。
  • 在C++11中引入了线程安全的静态初始化保证Guaranteed copy elision for thread-local variables这使得静态局部变量的初始化变得线程安全。根据C++11标准如果一个函数的线程安全性由标准库保证那就是指这个函数可以在多线程程序中使用而不需要额外的同步措施。
  • 懒汉式在第一次使用时才创建单例实例。
    优点节省内存只有在需要时才创建实例。
    缺点需要考虑线程安全性因为在多线程环境下可能会导致竞态条件。
  • 饿汉式在程序运行时或类加载时即创建单例实例。
    优点简单不需要考虑线程安全问题。
    缺点可能会浪费内存空间因为在程序运行的早期就已经创建了实例如果在后续程序中没有使用到就白白占用了内存。

C++11后两种方式实现一致但是在C++11之前如下实例供参考

  • 懒汉式在 C++11 之前实现线程安全的单例模式需要使用同步机制来确保只有一个实例被创建。下面是一个使用双重检查锁定Double-Checked Locking实现懒汉式单例模式的示例
#include <iostream>
#include <mutex>

class LazySingleton {
public:
    static LazySingleton& getInstance() {
        if (instance == nullptr) {
            std::lock_guard<std::mutex> lock(singletonMutex);
            if (instance == nullptr) {
                instance = new LazySingleton();
            }
        }
        return *instance;
    }

private:
    LazySingleton() {}

    LazySingleton(const LazySingleton&);
    LazySingleton& operator=(const LazySingleton&);

    static LazySingleton* instance;
    static std::mutex singletonMutex;
};

LazySingleton* LazySingleton::instance = nullptr;
std::mutex LazySingleton::singletonMutex;

int main() {
    LazySingleton& s = LazySingleton::getInstance();
    return 0;
}

在上述示例中使用了双重检查锁定来确保线程安全地延迟初始化单例实例。通过对 instance 和 singletonMutex 进行双重检查可以保证在多线程环境中仅有一个实例被创建并且能够提供懒加载的特性。
需要注意的是在 C++11 之前对于单例模式的实现需要特别小心地处理线程安全性因为这不是由语言本身提供的保证。

  • 饿汉式在 C++11 之前实现饿汉式的单例模式相对来说更加简单因为在静态初始化阶段就创建了实例。下面是一个使用饿汉式实现单例模式的示例
class EagerSingleton {
public:
    static EagerSingleton& getInstance() {
        return instance;
    }

private:
    EagerSingleton() {}

    EagerSingleton(const EagerSingleton&);
    EagerSingleton& operator=(const EagerSingleton&);

    static EagerSingleton instance;
};

EagerSingleton EagerSingleton::instance;

int main() {
    EagerSingleton& s = EagerSingleton::getInstance();
    return 0;
}

在这个示例中EagerSingleton 类的实例在静态初始化阶段就被创建因此可以保证在程序运行的早期就已经存在唯一的实例。这种方式不需要涉及到线程安全性的考虑因为在 C++11 之前静态初始化并不涉及线程安全问题。
需要注意的是在多线程环境下如果有可能在程序早期就会访问该单例实例为了避免静态初始化顺序问题可能需要考虑其他的初始化策略。

  • C++11后示例
#include <iostream>
#include <mutex>

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance; // 线程安全的延迟初始化
        return instance;
    }

    void doSomething() {
        std::cout << "Doing something in Singleton" << std::endl;
    }

private:
    Singleton() {} // 构造函数私有化防止外部实例化
    Singleton(const Singleton&) = delete; // 禁止复制构造
    Singleton& operator=(const Singleton&) = delete; // 禁止赋值操作
};

int main() {
    Singleton& s = Singleton::getInstance();
    s.doSomething();
    return 0;
}

在这个示例中Singleton 类的构造函数是私有的这意味着外部无法直接实例化该类。通过 getInstance 方法我们可以获得 Singleton 的唯一实例。在 getInstance 方法中使用了静态局部变量来确保线程安全的延迟初始化因为C++11规定静态局部变量的初始化是线程安全的。由于全局静态变量的初始化是在首次访问时进行的所以这种方式保证了线程安全的单例模式。
这种单例模式保证了在整个程序生命周期内只会有一个 Singleton 实例存在且可以通过 getInstance 方法全局访问。


言归正传让我们继续学习书中的章节。

5.2 单例模式的经典实现

提示看完上一节注释中内容的同伴们这一节大体看下就可以了。
之前的5.1节的实现方式中被完全忽略的一个方面是防止创建额外的对象。全局静态的Database并不能真正组织在其他地方创建另一个实例。
对于那些喜欢创建一个对象的多个实例的人来说我们很容易让他崩溃—只需要在构造函数中放置一个静态计数器然后在值增加时抛出异常

    struct Database{
        Database(){
            static int insance_count{0};
            if (++insance_count > 1){
                // throw std::exception("cannot make > 1 database"); // exception不能带参数,这里写的是错的
                throw MyException("Error: cannot make > 1 database"); // 这里我自己实现了一个异常类
            }
        }
    };

这是一种非常不友好的方式尽管它通过抛出异常阻止了创建多个实例但它无法传达我们不希望构造函数被多次调用的事实。即使使用大量的文档来说明它但仍然会有一些倒霉的家伙试图在某些不确定的环境甚至可能是在生产环境下中多次调用它。
防止Database被显示构建的唯一方法仍旧是将其构造函数声明为私有的然后将之前提到的函数作为成员函数并返回Database对象的唯一实例

	struct Database
    {
    protected:
        Database(){
            /* do what you need to do*/
        }
    public:
		// thread-safe since C++11
        static Database& get()
        {
            static Database database;
            return database;
        }
        Database(Database const &) = delete;
        Database(Database &&) = delete; // 移动构造函数
        Database &operator=(Database const &) = delete;
        Database &operator=(Database &&) = delete; // 移动复制函数
    };

请注意我们是如何通过隐藏构造函数和删除拷贝构造函数 / 移动构造函数 / 拷贝赋值函数来完全消除创建数据库实例的可能性的。在C++11之前只需将拷贝构造函数 / 拷贝赋值函数设置为私有的即可达到同样的目的。作为一种可选的方法我们可能希望使用boost::noncopyable它是一个可以继承的类它在隐藏成员方面添加了类似的定义……但并不影响移动构造函数和拷贝赋值函数。
再次重申如果database依赖其他静态变量或者全局变量那么在析够函数中它们是不安全的因为这些对象的销毁顺序是不确定的正在被调用的对象实际上可能已经被销毁了。
最后介绍一个特别讨厌的技巧即我们可以将get()实现为堆分配这样只有指针而非整个对象是静态的。

        static Database &get()
        {
            static Database *database = new Database();
            return *database;
        }

这个实现依赖 “Database一直存在直到程序结束” 的假设。使用指针而不是引用可以确保析够函数永远不会被调用即使定义了析够函数如果这样作它必须声明为公共的。这段代码不会导致内存泄漏。

线程安全

正如前面提到的 从C++11开始采用我们之前展示的代码完成单例模式的初始化是线程安全的这意味着如果两个线程同时调用get()我们也永远不会遇到创建两次数据库的情况。

在C++11之前需要使用一种称为双重校验锁的方式来实现单例模式典型的实现如下也可以看上一小节我注释中的内容但是问题在于我们使用的锁是C++11提供的那既然已经使用C++11了 为什么还要多次一举呢简直是浪费资源。下面代码是依据boost中的锁实现的

struct Database {
	// same members as before, but then ...
	static Database& instance();
private:
	static boost::atomic<Database*> instance;
	static boost::mutex mtx;
};

Database& Database::instance() {
	Database* db = instance.load(boost::memory_order_consume);
	if (!dp) {
		boot::mutex::scoped_lock lock(mtx);
		db = instance.load(boost::memory_order_consume);
		if (!db) {
			db = new Database();
			instance.store(db, boost::memory_order_release);
		}
	}
}

因为本书是关于现代C++的因此这里不会深入讨论这个方法。


5.3 单例模式存在的问题

假设数据库存储着一个链表链表中包括各国首都及其人口信息
Tokyo
33200000
New York
17800000
… etc
数据库单例模式将要设计的接口为

class Database {
public:
	virtual int get_population(const std::string& name) = 0;
}

我们设计一个给定首都城市名称返回该城市人口的成员函数。现在假设该接口被一个名为SingletonDatabase的由Database派生的具体的类采用SingletonDatabase以同样的方式实现单例模式

class SingleDatabase : public Database {
        SingleDatabase(){
            /* read data from database*/
            // 为了测试 我这里先手动填几个测试数据
            capitals.emplace("Xian", 14);
            capitals.emplace("Seoul", 10);
            capitals.emplace("Mexico City", 1);
        }
        std::map<std::string, int> capitals;

    public:
        SingleDatabase(SingleDatabase const&) = delete;
        void operator=(SingleDatabase const&) = delete;

        static SingleDatabase& get() {
            static SingleDatabase db;
            return db;
        }

        int get_population(const std::string& name) override {
            return capitals[name];
        }
    };

SingletonDatabase的构造函数从文本文件中读取各个首都的名称和人口 并保存到一个map中。get_population()方法用于返回指定城市的人口数量。
如前所述在本例中 单例模式真正存在的问题是它们能否在别的组件使用。在前面的基础是那个我们构建一个组件来计算几个不同城市的总人口

    struct SingleetonRecordFinder
    {
        int total_population(std::vector<std::string> names) {
            int result = 0;
            for (auto& name: names) {
                result += SingleDatabase::get().get_population(name);
            }
            return result;
        }
    };

问题是SingletonRecordFinder现在完全依赖SingeltonDatabase。这给测试带来了问题如果想检查SingletonRecordFinder是否正常工作我们需要使用实际数据库中的数据即

    void testSingletonTotalPopulation() {
        SingleetonRecordFinder rf;
        std::vector<std::string> names{"Seoul", "Mexico City"};

        int tp = rf.total_population(names);
        std::cout << __FUNCTION__ << "() result = " << tp << "\n";
    }

这是个很糟糕的单元测试。它尝试读取一个活动数据库这通常是我们不想频繁操作的同时它也非常脆弱因为它依赖数据库中的具体值。如果首尔的人口发生变化情况会怎么样测试将会结束当然 许多人在与实时数据库隔离的持续集成系统上运行测试这使得这种方法更加可疑。
从测试的角度来看这个单元测试同样存在问题。记住我们需要的单元测试要测试的单元是SingletonRecordFinder。然而我们编写的测试并不是单元测试。因为SingletonRecordFinder使用SingletonDatabase所以实际上我们在同时测试来两个系统。如果集成测试是我们想要的那么这不会有哦问题但我们更愿意单独测试SingletonRecordFinder。
因此我们知道其时我们并不希望在测试中使用实际的数据库。那我们可以用一些在测试中可控的虚拟组件来替换数据库吗在目前的设计中这是不可能的正是这种灵活性欠缺导致了单例模式的衰落。
那么我们能够做什么呢首先我们不能显示地依赖SingletonDatabase。由于我们需要实现数据库接口因此可以创建一个新的Config-urableRecordFinder以配置数据的来源

    struct ConfigurableRecordFilder
    {
        explicit ConfigurableRecordFilder(Database& db)
            :db(db) {
        }
        int total_population(std::vector<std::string> names) {
            int result = 0;
            for (auto& name: names) {
                result += db.get_population(name);
            }
            return result;
        }
        Database& db;
    };

现在我们不再显式地使用SingleonDatabase而是使用db的引用。于是我们创建一个专门用于测试记录查找器虚拟数据库

    class DummyDatabase: public Database {
        std::map<std::string, int> capitals;
    public:
        DummyDatabase() {
            capitals["alpha"] = 1;
            capitals["beta"] = 2;
            capitals["gamma"] = 3;
        }

        int get_population(const std::string& name) override {
            return capitals[name];
        }
    };

借助DummyDatabase我们可以重新编写单元测试

    void testSingletonTotalPopulation_new() {
        DummyDatabase db{};
        ConfigurableRecordFilder rf{db};
        std::vector<std::string> names{"alpha", "gamma"};

        int tp = rf.total_population(names);
        std::cout << __FUNCTION__ << "() result = " << tp << "\n";
    }

这个单元测试更加鲁棒因为即使实际数据库中的数据发生变化我们也不必调整单元测试的值—因为虚拟数据保持不变。此外它还提供了更多有趣的可能性。我们现在可以对空数据库运行测试还可以对大小超过可用RAM的数据库运行测试。

5.3.1 每线程单例

我们已经提到过与单例模式初始化构建过程相关的线程安全性但是单例自身操作的线程安全如何呢可能的情况是应用程序中的所有线程之间不需要共享一个单例而每个线程都需要一个单例。
每线程单例的构建过程与之前的单例模式一样只是我们现在要为静态函数中的变量加上thread_local声明

    class PerThreadSingleton {
        PerThreadSingleton() {
            id = std::this_thread::get_id();
        }
    public:
        std::thread::id id;
        static PerThreadSingleton& get() {
            thread_local PerThreadSingleton instance;
            return instance;
        }
    };

上面的代码保留了线程id以便于打印演示。这个成员并不是必须的如果不想要那么不必保留它。现在为了验证每个线程确实有一个实例我们可以运行如下代码

    void testPerThreadSingleton()
    {
        std::thread t1([]() {
            std::cout << "t1: " << PerThreadSingleton::get().id << "\n";
        });
        std::thread t2([]() {
            std::cout << "t2: " << PerThreadSingleton::get().id << "\n";
            std::cout << "t2 again: " << PerThreadSingleton::get().id << "\n";
        });

        t1.join();
        t2.join();
    }

上述代码的输出如下(输出的顺序和值可能同这是正常的)
t1: xxxx
t2: yyyy
t2 again: yyyy
线程局部单例解决了特殊的问题。例如假设有一个类似下面的依赖关系图

A—needs—>B—needs—>C
A—needs—>C

假设创建了20个线程每一个线程都创建了一个A的实例。组件A依赖C两次直接依赖以及间接通过B依赖。现在如果C是有状态的并且在每个线程中都发生了变化那么单例对象C不可能是全局的但是我们可以做的是创建每线程单例C对象。这样单个线程中A将使用同一个C实例即可以自己使用也可以通过B间接使用。
当然另一个好处是在线程局部单例中我们不必担心线程安全问题。因此可以使用map而不必使用concurrent_hash_map。


【注】

  • thread_local 关键字是在C++11标准中引入的。C++11标准引入了多项多线程支持的特性其中包括了std::thread、std::mutex、std::atomic等多线程相关的类和函数以及引入了thread_local 关键字。
  • 引入 thread_local 关键字的目的是为了支持线程局部存储TLS允许程序员声明一个变量在每个线程中都有其自己的独立实例。这种变量的生命周期与线程的生命周期相对应这在多线程编程中非常有用。
  • 通过引入 thread_local 关键字C++11标准扩展了C++语言的多线程支持使得多线程编程变得更加便捷、安全和灵活。

thread_local 关键字用于声明线程局部存储TLS变量这种变量的生命周期与所属线程的生命周期相对应。它能够起到以下几个作用

  • 线程安全性通过 thread_local 关键字声明的变量每个线程都拥有其自己的变量实例。这样可以避免多个线程之间共享数据而导致的竞态条件和数据竞争。
  • 线程相关数据thread_local 变量适合存储线程相关的数据例如线程特有的配置信息、线程本地缓存等。每个线程可以拥有自己的变量副本而无需进行显式的线程标识或管理。
  • 线程上下文管理thread_local 变量可以用于管理线程的上下文信息如日志记录器、线程特定的错误处理等。它们能够在每个线程中独立地存储相关信息从而提高线程的隔离性和灵活性。
  • 性能优化thread_local 变量的存在可以避免一些全局变量的频繁加锁和解锁操作。通过将一些线程特定的数据存储于 thread_local 变量中可以减少对全局资源的竞争从而提高并发程序的性能。

总的来说thread_local 变量提供了一种机制可以为每个线程保留自己的变量副本从而有效地实现线程间的数据隔离和线程上下文的管理。


  • thread_local 变量本身不会引入同步机制。每个线程拥有其自己的 thread_local 变量副本这些变量是相互隔离的因此不会出现多个线程同时访问同一个 thread_local 变量的情况。
  • 在多线程环境中每个线程的 thread_local 变量是独立的因此不需要同步机制来保护其访问。这意味着对 thread_local 变量的访问不会引入竞态条件或数据竞争因为每个线程都有其独立的实例。
  • 然而在某些情况下thread_local 变量内部可能会包含需要同步的数据结构比如 thread_local 变量内部使用了某种共享数据结构。在这种情况下需要注意确保 thread_local 变量内部的数据结构能够保证线程安全。

总之thread_local 变量本身并不引入同步机制但需要注意其内部可能存在的同步问题尤其是当 thread_local 变量内部包含需要被多个线程访问的共享数据结构时。


5.3.2 环境上下文

本节主要说的是单例模式下使用共享数据的多线程安全内容过去冗余。就不做详细介绍了。

5.3.3 单例模式与控制反转

显式地将某个组件变为单例的方式具有明显的侵入性而如果决定某一时刻不再将某个类作为单例最终又会付出高昂的代价。另一种办法是采用一种约定。在这种约定中负责组件的函数并不直接控制组件的生命周期而是外包给控制反转Inversion of Control, IoC容器。
当使用Boost.DI的依赖注入框架时定义单例组件的代码如下

auto injector = di::make_injector(
	di::bind<IFoo>.to<Foo>.in(di::singleton),
	// other configuation steps here
	);

在上面的代码中我们使用字母 “ I ” 来表示接口类型。本质上 di::bind这一行代码的意思是 每当需要具有IFoo类型成员的组件时我们就使用Foo的但李示例来初始化组件。
许多开发人员认为在DI容器中使用单例是唯一可以接受的使用单例的方式。至少如果需要用其他东西替换单例使用这种方法就可以在一个中心位置配置容器的代码处执行这个操作。另外一个好处是我们不必自己实现任何单例的逻辑这可以防止出现潜在的错误。此外是否提到过Boost.DI是线程安全的

5.4.4 单态模式

单态模式(Monostate)是单例模式模式的一种变体。单态模式行为上类似于单例模式但看起来像一个普通的类。

class Printer {
	static int id;
public:
	int get_id() const { return id; }
	void set_id(int val) { id = val;}
};

能看出这里发生了什么吗这个类看起来只是一个普通的带有getter和setter方法的类不过它们操作的都是静态static数据

这似乎是一个非常巧妙的技巧允许用户实例化Printer但它们都引用相同的数据。但是用户怎么知道这一点呢使用时用户只是很自然地实例化两个Printer对象并不为它们分配不同的id当发现两个Printer对象的id相同时用户一定会感到非常惊讶

从某种程度上说 单态模式是有效的而且单态模式有一些优点。例如单态模式允许继承和多态开发者可以更容易地定义和控制其声明周期当然 你可能并不希望总是如此。单态模式最大的优势是它允许我们使用并修改在当前系统中已经使用的对象使其以单态模式的方式在系统中运行如果系统能够很好地处理单态模式的多个对象实例那么我们无需编写额外的代码就得到了一个类似于单例模式的实现。

单态模式的缺点也同样明显它是一个侵入性方法将普通对象转换为单态状态并不容易并且静态成员的使用意味着它总是会占据内存空间即使我们不需要单态模式。单态模式最大的缺点在于它做了过于乐观的假设即外界总是会通过getter和setter方法来访问单态类的成员。如果直接访问它们重构实现几乎注定要失败。

5.4 总结

单例模式并不完全令人厌恶但是如果不小心使用它们会破坏应用程序的可测试性可可重构性。如果必须使用单例模式请尝试避免直接使用它如编写SomeComponent.get().foo()将其指定为依赖项例如作为构造函数的参数并保证所有依赖项都是从应用程序的某个唯一的位置例如控制反转容器获取或初始化的。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

“《C++20设计模式》学习笔记---单例模式” 的相关文章

Java中StringRedisTemplate和RedisTemplate怎么使用 - 开发技术

这篇文章主要介绍“Java中StringRedisTemplate和RedisTemplate怎么使用”的相关知识,小编通过实际案例向大家展示操作过程,操作方法简单快捷,实用性强,希望这篇“Java中StringRedisTemplate和RedisTemplate怎...

创建线程

pthread_create:创建新的控制流 pthread_exit:从现有的控制流中退出 pthread_join:从控制流中得到退出状态 pthread_cleanup_push:注册在退出控制流时调用的函数 pthread_self:获取控制流的ID...

Python中的生成器原理是什么 - 开发技术

这篇文章主要介绍“Python中的生成器原理是什么”的相关知识,小编通过实际案例向大家展示操作过程,操作方法简单快捷,实用性强,希望这篇“Python中的生成器原理是什么”文章能帮助大家解决问题。什么是python生成器生成器是一种特殊的迭代器,它内部也有__iter...

Codeforces Round #152 (Div. 2) / 248B Chilly Willy (数论)

B. Chilly Willy http://codeforces.com/problemset/problem/248/B time limit per test memory limit per test input...

计算机入门基础知识大全

♥️作者:小刘在C站 ♥️个人主页:小刘主页 ♥️每天分享云计算网络运维课堂笔记,努力不一定有收获,但一定会有收获加油!一起努力,共赴美好人生! ♥️夕阳下,是最美的,绽放,愿所有的美好,再疫情结束后如约而至。 目录 一.计算机发展史: 二.计算机的组成:...

ubuntu

.tar解包:tar xvf FileName.tar打包:tar cvf FileName.tar DirName(注:tar是打包,不是压缩!) --------------------------------------------- .gz解压1:gunzip FileName.gz解压2:...