世界上存在许多设计模式。设计模式本身是一个独立的主题,代表了人们在抽象层面上总结出的解决问题的经典方法。这些方法可以命名、传授给他人,并根据所选编程语言的特性和习惯用法具体实现出来。
单例(Singleton)设计模式描述了我们如何编写一个类,使其在整个程序中只实例化一次。
单例并不是一个广受喜爱的模式:使测试变得困难,引入了对全局状态的依赖,在程序中形成了一个单点故障和潜在的全局瓶颈;如果单例是可变的,还会使多线程编程变得更加复杂(因为其状态需要同步)等。但尽管如此,它仍然有其用途,在实际中广泛使用,本书中也偶尔会用到它。
在 C++ 中,有多种方式可以实现一个在整个程序中只被实例化一次的类。它们都具有以下几个关键特征:
类型的复制操作必须删除。如果允许复制单例对象,就会出现多个该类型的实例,这与单例的本质矛盾。
不应有公有构造函数。如果存在公有构造函数,使用端代码就可以调用它来创建多个实例。
不应有受保护的成员。从概念上讲,派生类的对象也是基类的对象,这同样会导致出现多个单例实例的矛盾(实际上就会存在多个实例!)。
由于没有公有构造函数,因此应该有一个私有构造函数(通常是一个默认构造函数),并且只能被类自身或它的友元(如果有的话)访问。为了简化问题,假设访问单例的方式通过该单例类的一个静态(显然是静态的)成员函数来完成。
我们将探讨在 C++ 中实现一个过于简单的单例(singleton)的方法。为了说明这个例子,该单例将按需提供递增的整数值。该类的基本思路如下:
#include <atomic>
class SequentialIdProvider {
// ...
std::atomic<long long> cur; // 状态(已同步)
// 默认构造函数(私有)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// 单例提供的服务(已同步)
auto next() { return cur++; }
// 删除复制操作
SequentialIdProvider(const SequentialIdProvider&) = delete;
SequentialIdProvider& operator=(const SequentialIdProvider&) = delete;
// ...
}
以下两个小节展示了创建和访问该单例的两种不同技术。
实现单例的一种方法是在 main() 函数开始之前就创建,具体做法是将其定义为其类的一个静态数据成员。这要求在类中声明该单例,并在一个单独的源文件中对其进行定义,以避免违反 ODR 原则。
ODR(One Definition Rule,单一定义规则)及相关问题在本书第 2 章中有描述,其核心思想是:在 C++ 中,每个对象可以有多个声明,但只能有一个定义。
一个可能的实现如下所示:
#include <atomic>
class SequentialIdProvider {
// 声明(私有)
static SequentialIdProvider singleton;
std::atomic<long long> cur; // 状态(已同步)
// 默认构造函数(私有)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// 静态成员函数,提供对对象的访问
static auto & get() { return singleton; }
// 单例提供的服务(已同步)
auto next() { return cur++; }
// 删除复制操作
SequentialIdProvider(const SequentialIdProvider&) = delete;
SequentialIdProvider& operator=(const SequentialIdProvider&) = delete;
// ...
};
// 在某个源文件中,例如 SequentialIdProvider.cpp
#include "SequentialIdProvider.h"
// 定义(调用默认构造函数)
SequentialIdProvider SequentialIdProvider::singleton;
只要各个全局对象之间没有依赖关系,这种实现方式就能很好地工作且效率较高。如果程序中的另一个单例需要访问 SequentialIdProvider 的服务,就可能会出现问题,因为 C++ 并不保证来自多个文件的全局对象的实例化顺序。
该实现的可能使用端代码如下所示:
auto & provider = SequentialIdProvider::get();
for(int i = 0; i != 5; ++i)
cout << provider.next() << ' ';
这将输出单调递增的整数值,可能连续(只要没有其他线程并发地调用该单例的服务)。
另一种实例化单例的方式是:在第一次请求其服务时创建,具体做法是将单例实现为一个提供对其访问的函数中的静态变量。这样一来,由于静态局部变量是在函数第一次调用时创建,并在其后保持其状态,因此只要不会形成循环依赖,该单例就可以为其他单例提供服务。
一个可能的实现如下所示:
#include <atomic>
class SequentialIdProvider {
std::atomic<long long> cur; // 状态(已同步)
// 默认构造函数(私有)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// 静态成员函数,提供对对象的访问
static auto & get() {
static SequentialIdProvider singleton; // 定义(静态局部变量,保证线程安全的初始化)
return singleton;
}
// 单例提供的服务(已同步)
auto next() { return cur++; }
// 删除复制操作
SequentialIdProvider(const SequentialIdProvider&) = delete;
SequentialIdProvider& operator=(const SequentialIdProvider&) = delete;
// ...
}
该实现的可能使用端代码如下所示:
auto & provider = SequentialIdProvider::get();
for(int i = 0; i != 5; ++i)
cout << provider.next() << ' ';
这将输出单调递增的整数值,可能连续(只要没有其他线程并发地调用该单例的服务)。
这个版本有一个隐藏的开销:函数内部的静态变量在 C++ 中被称为“魔法静态变量”(magic statics),因为语言保证它们只会构造一次,即使有多个线程同时调用该函数。这一特性说明对该静态变量的访问会涉及一些同步机制,并且这种同步开销会在每次调用该函数时都发生。
前面的使用端代码通过只调用一次 SequentialIdProvider::get(),之后重复使用该调用返回的引用,从而减轻了这一开销;真正引入同步开销的,是对 get() 的调用。