A.14. 单例设计模式

世界上存在许多设计模式。设计模式本身是一个独立的主题,代表了人们在抽象层面上总结出的解决问题的经典方法。这些方法可以命名、传授给他人,并根据所选编程语言的特性和习惯用法具体实现出来。

单例(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;
  // ...
}

以下两个小节展示了创建和访问该单例的两种不同技术。

A.14.1 程序启动时的实例化

实现单例的一种方法是在 main() 函数开始之前就创建,具体做法是将其定义为其类的一个静态数据成员。这要求在类中声明该单例,并在一个单独的源文件中对其进行定义,以避免违反 ODR 原则。

什么是 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() << ' ';

这将输出单调递增的整数值,可能连续(只要没有其他线程并发地调用该单例的服务)。

A.14.2 首次调用时的实例化

另一种实例化单例的方式是:在第一次请求其服务时创建,具体做法是将单例实现为一个提供对其访问的函数中的静态变量。这样一来,由于静态局部变量是在函数第一次调用时创建,并在其后保持其状态,因此只要不会形成循环依赖,该单例就可以为其他单例提供服务。

一个可能的实现如下所示:

#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() 的调用。