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

2023-12-14 13:41:33

第 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()),将其指定为依赖项(例如,作为构造函数的参数),并保证所有依赖项都是从应用程序的某个唯一的位置(例如,控制反转容器)获取或初始化的。

文章来源:https://blog.csdn.net/BirdsLife/article/details/134862776
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。