《C++20设计模式》学习笔记——第5章单例模式


在设计模式的有限历史中,单例(Singleton)模式也许是最令人讨厌的设计模式了。不过这并不意味着我们不应该使用单例模式:马桶刷也不是让人喜欢的设备,但它是生活中必需的(-_-)。

单例模式的理念非常简单:应用程序中只能有一个特定组件的实例。

例如,将数据库加载到内存中并提供只读接口的组件是单例模式的主要应用场景之一,因为浪费内存存储多个相同的数据集是没有意义的,甚至可能会引起内存不足。

1. 作为全局对象的单例模式

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

struct Database
{
    /**
        * \brief Please do not create more than one instance.
    */
    Database() {}
};

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

static Database database{};

但它存在的问题是:

  1. 在不同的编译单元中,静态全局对象的初始化顺序是未定义的:例如某个地方引用的全局变量甚至还没被初始化;
  2. 静态全局对象的可发现性同样是个问题:客户如何知道某个全局变量是存在的?发现类会比发现全局对象更加简单,因为Go to Type会搜索出比在全局作用域运算符::后的自动补全方式更精简的可选集。

缓解这种情况的一种方法是提供一个全局函数(或者说成员函数),让该函数对外暴露必要的对象:

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

注意:只有在C++11之后,这段代码才是线程安全的,所以需要检查编译器是否需要插入锁机制,以防止静态对象在初始化过程中被多个线程并发访问。
在C++11之前,需要使用一种称为双重校验锁的方式来实现单例模式(具体实现见原书,此处略)。

当然,这个场景很容易出错:如果在Database的析构函数中使用了某个其他单例对象,程序很可能会崩溃:因为这些对象的销毁顺序是不确定的,正在被调用的对象实际上可能已经被销毁了。

2. 单例模式的经典实现

之前的实现方式中,被完全忽略的一个方面是“防止创建额外的对象”。全局静态的Database并不能真正阻止在其他地方创建另一个实例。

防止Database被显式构建的唯一方式仍旧是将其构造函数声明为私有的,然后将之前提到的函数作为成员函数,并返回Database对象的唯一实例:

struct Databse
{
protected:
    Database() {/* do what you need to do */} 
public:
    static Database& get()
    {
        //thread-safe since C++11
        static Database database;
        return database;
    }
    Database(Database const&) = delete;
    Database(Database&&) = delete;
    Database& operator=(Database const&) = delete;
    Database& operator=(Database&&) = delete;
};

以上代码中:通过1.隐藏构造函数;2.删除拷贝构造函数/移动构造函数/拷贝赋值运算符/移动赋值运算符,来完全消除创建数据库实例的可能性。

最后,介绍一个特别“讨厌”的技巧,即我们可以将get()函数实现为堆分配(这样只有指针而非整个对象是静态的);使用指针而不是引用可以确保析构函数永远不会被调用,即使定义了析构函数,这段代码不会导致内存泄漏。

static Database& get()
{
    static Database* database = new Database();
    return *database;
}
3. 单例模式存在的问题
  • 单例模式在单元测试中欠缺灵活性!正是这种灵活性欠缺导致了单例模式的衰落(具体内容见原书)。

以下内容个人认为不属于单例模式存在的问题,而是单例模式的一些具体应用场景:

3.1 每线程单例

一种可能的情况是,应用程序的所有线程之间不需要共享一个单例,而是每个线程都需要一个单例(即每线程单例)。

每线程单例的构建过程与之前的单例模式一样,只是我们现在要为静态函数中的变量加上thread_local声明:

class PerThreadSingleton
{
    PerThreadSingleton()
    {
        id_ = this_thread::get_id();
    }
public:
    thread::id id_;
    static PerthreadSingleton& get()
    {
        thread_local PerThreadSingleton instance;
        return instance;
    }
};
//上面的代码保留了线程id以便于打印演示,这个成员并不是必需的。

线程局部单例解决了多线程中组件间的特殊依赖问题(具体看原书例子)。

另一个好处是,不必担心线程安全问题,因此可以使用map而不必使用concurrent_hash_map

3.2 环境上下文

本节首先引入一个概念——环境上下文:
上下文(Context)可以理解为程序执行的背景环境,包含了在特定时刻程序所需的所有信息。这些信息可以包括变量的值、函数的调用情况、执行的位置等。

大概意思应该可以理解吧,但是和单例模式有什么关系呢?我也有些懵,还是看实际例子吧:

仍然以我们的老伙计砌墙为例,目前需要在房屋地基上砌几堵墙,尽管这些墙位于房屋的不同位置,不过它们相对于房屋地面的高度是大致相同的。

如果采用最原始的方法,我们可以在多个砌墙的方法调用中输入相同的高度值;但我们其实并不想这样做,我们也不想声明一个变量来记录墙的高度并用它传递高度值,我们更希望对墙的高度进行某种全局的设置,以满足如下要求:

  1. 墙的高度可以设置,设置后,这个高度将作为墙的默认值;
  2. 也可以修改墙的高度以建造一些不同高度的墙,然后再将墙的高度恢复为默认值;
  3. 可以通过API设定墙的具体高度值;

在这个例子中,墙的高度其实是环境上下文的一部分:在具体的某个时间点,墙的高度值在一系列具体的操作下有不同的状态和作用。

具体解决办法是:创建一个静态的、整个应用程序都可以访问的对象。

class BuildingContext final
{
    int height_{0};
    BuildingContext() = default;
public:
    int get_height()
    {
        return height_;
    }
    
    static std::stack<BuildingContext> stk;
    
    class Token
    {
    public:
        ~Token()
        {
            if(stk.size()>1)
                stk.pop();
        }
    };
    
    static BuildingContext current()
    {
        return stk.top();
    }
    
    static Token with_height(int h)
    {
        auto copy = current();
        copy.height_ = h;
        stk.push(copy);
        return Token{}; 
    }
};

可以看到,我们定义的环境上下文类具有如下特点:

  1. 添加final关键字:通常,支持对环境上下文类的继承意义不大;
  2. 构造函数声明为私有的,所以它不能被直接初始化;
  3. 定义了一个属性height_,代表墙的高度。它是只读的,提供了一个get_height()方法来获取值,但不可以从类的外部修改。

接下来,我们看到一些有趣的成员:

  1. stk:静态地将几个实例存储在栈中。这样当需要创建不同高度的墙时,通过新建一个上下文对象,然后将旧的上下文对象压入栈中;当完成任务后,再从栈中弹出原来的环境上下文对象,以恢复默认的墙高度值;
  2. 提供一个修改高度的辅助函数with_height(),该方法会创建一个备忘录对象(Token),提供一个局部作用域,在其中保存上下文对象;并且在Token的析构函数中将环境上下文对象从栈中弹出。

最后,为了满足第3条要求——在需要的时候覆写环境上下文的值,我们可以定义一个带有可选参数(高度)的类Wall:

Wall::Wall(const Point2D& s, const Point2D& e, std::optional<int> height = nullopt)
    : start_{s}, end_{e}
{
    this->height_ = height.value_or(BuildingContext::current().get_height());
}

上面代码的std::optional是C++17的新特性,不熟悉的朋友也可以直接定义两个构造函数来实现相同的功能。
这部分具体的使用示例见配套代码。

3.3 单例模式与控制反转

显式地将某个组件变为单例的方式具有明显的侵入性,而如果决定在某一时刻不再将某个类作为单例,最终又会付出高昂的代价。

另一种解决方案是采用一种约定,在这种约定中,负责组件的函数并不直接控制组件的生命周期,而是外包给控制反转容器(Inversion of Control, IoC)。可以使用Boost.DI的依赖注入框架:

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

上面的代码中,我们使用字母“I”来表示接口类型;di::bind这一行代码的意思是,每当需要IFoo类型成员的组件时,我们使用Foo的单例实例来初始化该组件。

许多开发人员认为,在DI容器中使用单例是唯一可以接受的使用单例的方式。至少,如果需要用其他东西替换单例,使用这种方法就可以在一个中心位置(配置容器的代码处)执行这个操作。另外一个好处是,我们不必自己实现任何单例的逻辑,这可以防止出现潜在的错误。此外,Boost.DI是线程安全的。

3.4 单态模式

单态模式行为上类似于单例模式,但看起来像一个普通的类。只不过是普通接口操作static数据。

class Printer
{
    static int s_id;
public:
    int get_id() {return s_id;}
    void set_id(int value) {s_id = value;}
};

单态模式允许继承和多态,开发者可以更容易地定义和控制其生命周期。其最大的优势在于,它允许我们使用并修改在当前系统中已经使用地对象,使其以单态模式的方式在系统中运行。

单态模式的缺点是:它是一种侵入性方法,并且静态成员的使用意味着它总是会占据内存空间。最大的缺点在于,它做了过于乐观的假设,即外界总是会通过getter和setter方法来访问单态类的成员。如果直接访问它们,重构实现几乎注定要失败。

4. 总结
  • 单例模式并不完全令人厌恶,但是如果不小心使用,它们会破坏应用程序的可测试性和可重构性。
  • 如果必须使用单例模式,请尝试避免直接使用它,将其指定为依赖项,并保证所有依赖项都是从应用程序的某个唯一的位置(如控制反转容器)获取或初始化的。
  • 23
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值