Skip to content

Latest commit

 

History

History
320 lines (236 loc) · 13.4 KB

File metadata and controls

320 lines (236 loc) · 13.4 KB

五、单例

在(相当有限的)设计模式历史中,单体模式是最令人讨厌的设计模式。然而,仅仅说明这一点并不意味着你不应该使用 singleton:马桶刷也不是最令人愉快的设备,但有时它只是必要的。

单例设计模式源于一个非常简单的想法,即应用程序中应该只有一个特定组件的实例。例如,将数据库加载到内存中并提供只读接口的组件是单例组件的主要候选对象,因为浪费内存来存储几个相同的数据集实在没有意义。事实上,您的应用程序可能有这样的约束,即两个或更多的数据库实例不适合内存,或者会导致内存不足,从而导致程序出现故障。

作为全局对象的单例

解决这个问题的天真方法是简单地同意我们永远不会实例化这个对象,例如:

1   struct Database
2   {
3     /**
4     * \brief Please do not create more than one instance.
5     */
6     Database() {}
7   };

现在,这种方法的问题是,除了你的开发人员同事可能会简单地忽略这个建议之外,对象可以以隐蔽的方式创建,其中对构造器的调用不是立即显而易见的。这可以是任何事情——复制构造器/赋值,一个make_unique()调用,或者使用一个控制反转(IoC)容器。

想到的最明显的想法是提供一个单一的静态全局对象:

1   static Database database{};

全局静态对象的问题在于它们在不同编译单元中的初始化顺序是不确定的。这可能导致令人讨厌的结果,比如一个全局对象引用另一个全局对象,而后者还没有初始化。还有可发现性的问题:客户端如何知道全局变量的存在?发现类稍微容易一些,因为在::之后,Go to Type 给出了比自动补全更精简的集合。

减轻这种情况的一种方法是提供一个全局(或者说成员)函数,该函数公开必要的对象:

1   Database& get_database()
2   {
3     static Database database;
4     return database;
5   }

可以调用这个函数来获取对数据库的引用。但是,您应该知道,只有从 C++11 开始,才能保证上述内容的线程安全性,并且您应该检查您的编译器是否真的准备好插入锁,以防止在静态对象初始化时出现并发访问。

当然,这种情况很容易变得不可收拾:如果Database决定在它的析构函数中使用其他类似的暴露的 singleton,程序很可能会崩溃。这引出了更多的哲学观点:单身者引用其他单身者可以吗?

经典实现

前述实现的一个完全被忽略的方面是防止构造额外的对象。拥有一个全局静态Database并不能真正阻止任何人创建另一个实例。

对于那些对创建一个对象的多个实例感兴趣的人来说,我们很容易让生活变得糟糕:只需在构造器中放一个静态计数器,如果值增加了,就放throw:

1   struct Database
2   {
3     Database()
4     {
5       static int instance_count{ 0 };
6       if (++instance_count > 1)
7         throw std::exception("Cannot make >1 database!");
8     }
9   };

这是一种特别不友好的解决问题的方法:尽管它通过抛出一个异常来防止创建多个实例,但它未能传达这样一个事实,即我们不希望任何人多次调用构造器。

防止显式构造Database的唯一方法是再次将其构造器设为私有,并引入上述函数作为成员函数来返回唯一的实例:

 1   struct Database
 2   {
 3   protected:
 4     Database() { /* do what you need to do */ }
 5   public:
 6     static Database& get()
 7     {
 8       // thread-safe in C++11
 9       static Database database;
10       return database;
11     }
12     Database(Database const&) = delete;
13     Database(Database&&) = delete;
14     Database& operator=(Database const&) = delete;
15     Database& operator=(Database &&) = delete;
16   };

请注意我们是如何通过隐藏构造器和删除复制/移动构造器/赋值操作符来完全消除创建Database实例的任何可能性的。

在 C++11 之前的日子里,你可以简单地使用复制构造器/赋值函数private来达到大致相同的目的。作为手动操作的替代方法,您可能想要检查一下boost::noncopyable,一个您可以继承的类,它在隐藏成员方面添加了大致相同的定义…除了它不影响移动构造器/赋值。

我再次重申,如果database依赖于其他静态或全局变量,在它的析构函数中使用它们是不安全的,因为这些对象的析构顺序是不确定的,你可能实际上调用的是已经被析构的对象。

最后,在一个特别糟糕的技巧中,您可以将get()实现为堆分配(这样只有指针,而不是整个对象是静态的)。

1   static Database& get() {
2     static Database* database = new Database();
3     return *database;
4   }

前面的实现依赖于这样一个假设,即Database一直存在到程序结束,并且使用指针而不是引用来确保析构函数永远不会被调用,即使你创建了一个析构函数(如果你创建了一个析构函数,它必须是public)。不,前面的代码不会导致内存泄漏。

线程安全

正如我已经提到的,从 C++11 开始,以前面列出的方式初始化单例是线程安全的,这意味着如果两个线程同时调用get(),我们不会遇到数据库被创建两次的情况。

在 C++11 之前,您将使用一种称为双重检查锁定的方法来构造 singleton。典型的实现如下所示:

 1   struct Database
 2   {
 3     // same members as before, but then...
 4     static Database& instance();
 5   private:
 6     static boost::atomic<Database*> instance;
 7     static boost::mutex mtx;
 8   };
 9
10   Database& Database::instance()
11   {
12     Database* db = instance.load(boost::memory_order_consume);
13     if (!db)
14     {
15       boost::mutex::scoped_lock lock(mtx);
16       db = instance.load(boost::memory_order_consume);
17       if (!db)
18       {
19         db = new Database();
20         instance.store(db, boost::memory_order_release);
21       }
22     }
23   }

由于这本书关注的是现代 C++,我们就不再赘述这种方法了。

Singleton 的问题是

假设我们的数据库包含一个首都城市及其人口的列表。我们的单例数据库将遵循的接口是:

1   class Database

2   {
3   public:
4     virtual int get_population(const std::string& name) = 0;
5   };

我们有一个单一的成员函数,它获取给定城市的人口。现在,让我们假设这个接口被一个名为SingletonDatabase的具体实现所采用,这个实现以和我们之前所做的一样的方式来实现 singleton:

 1   class SingletonDatabase : public Database
 2   {
 3     SingletonDatabase() { /* read data from database */ }
 4     std::map<std::string, int> capitals;
 5   public:
 6     SingletonDatabase(SingletonDatabase const&) = delete;
 7     void operator=(SingletonDatabase const&) = delete;
 8
 9     static SingletonDatabase& get()
10     {
11       static SingletonDatabase db;
12       return db;
13     }
14
15     int get_population(const std::string& name) override

16     {
17       return capitals[name];
18     }
19   };

正如我们注意到的,像前面这样的单例的真正问题是它们在其他组件中的使用。我的意思是:假设在前面例子的基础上,我们构建一个组件来计算几个不同城市的总人口:

 1   struct SingletonRecordFinder
 2   {
 3     int total_population(std::vector<std::string> names)
 4     {
 5       int result = 0;
 6       for (auto& name : names)
 7         result += SingletonDatabase::get().get_population(name);
 8       return result;
 9     }
10   };

麻烦的是SingletonRecordFinder现在牢牢依赖SingletonDatabase。这给测试带来了一个问题:如果我们想检查SingletonRecordFinder是否正常工作,我们需要使用实际数据库中的数据,也就是说:

1   TEST(RecordFinderTests, SingletonTotalPopulationTest)
2   {
3     SingletonRecordFinder rf;
4     std::vector<std::string> names{ "Seoul", "Mexico City" };
5     int tp = rf.total_population(names);
6     EXPECT_EQ(17500000 + 17400000, tp);
7   }

但是如果我们不想使用实际的数据库进行测试呢?如果我们想使用其他虚拟元件呢?在我们目前的设计中,这是不可能的,而正是这种不灵活导致了 Singeton 的垮台。

那么,我们能做什么呢?首先,我们需要停止对Singleton-Database的依赖。因为我们需要的只是实现Database接口的东西,所以我们可以创建一个新的ConfigurableRecordFinder,让我们配置数据来自哪里:

 1   struct ConfigurableRecordFinder
 2   {
 3     explicit ConfigurableRecordFinder(Database& db)
 4       : db{db} {}
 5
 6     int total_population(std::vector<std::string> names)
 7     {
 8       int result = 0;
 9       for (auto& name : names)
10         result += db.get_population(name);
11       return result;
12     }
13
14     Database& db;
15   };

我们现在使用db引用,而不是显式地使用 singleton。这让我们可以专门为测试记录查找器创建一个虚拟数据库:

 1   class DummyDatabase : public Database
 2   {
 3     std::map<std::string, int> capitals;
 4   public:
 5     DummyDatabase()
 6     {
 7       capitals["alpha"] = 1;
 8       capitals["beta"] = 2;
 9       capitals["gamma"] = 3;
10     }
11
12     int get_population(const std::string& name) override {
13       return capitals[name];
14     }
15   };

现在,我们可以重写我们的单元测试来利用这个DummyDatabase:

1   TEST(RecordFinderTests, DummyTotalPopulationTest)
2   {
3     DummyDatabase db{};
4     ConfigurableRecordFinder rf{ db };
5     EXPECT_EQ(4, rf.total_population(
6       std::vector<std::string>{"alpha", "gamma"}));
7   }

这个测试更加健壮,因为如果实际数据库中的数据发生变化,我们不必调整我们的单元测试值——虚拟数据保持不变。

单线态和控制反转

显式地使一个组件成为单例的方法显然是侵入性的,并且决定停止将该类作为单例来处理将会导致特别高的代价。另一种解决方案是采用一种约定,不直接强制类的生存期,而是将此功能外包给 IoC 容器。

下面是使用 Boost 时定义单例组件的样子。依赖注入框架:

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

在前面,我在类型名中使用第一个字母I来表示接口类型。本质上,di::bind行说的是,每当我们需要一个有类型IFoo成员的组件时,我们用一个单独的实例Foo初始化那个组件。

许多人认为,在阿迪容器中使用单例是社会上唯一可以接受的单例用法。至少使用这种方法,如果您需要用其他东西替换单例对象,您可以在一个中心位置完成:容器配置代码。一个额外的好处是,您不必自己实现任何单例逻辑,这可以防止可能的错误。哦,我有没有提到那次提升。DI 是线程安全的吗?

单稳态

单态是单态模式的变体。它是一个表现得像单例的类,但看起来像一个普通的类。

1   class Printer

2   {
3     static int id;
4   public:
5     int get_id() const { return id; }
6     void set_id(int value) { id = value; }
7   };

你能看到这里发生了什么吗?这个类看起来像一个普通的类,有 getters 和 setters,但是它们实际上是在处理static数据!

这似乎是一个非常巧妙的技巧:你让人们实例化Printer,但是他们都引用相同的数据。然而,用户应该如何知道这些呢?用户会很高兴地实例化两台打印机,给它们分配不同的id,当它们完全相同时,他会非常惊讶!

单稳态方法在某种程度上是可行的,并且有几个优点。例如,它很容易继承,可以利用多态性,并且它的生命周期被合理地定义(但是话说回来,您可能并不总是希望如此)。它最大的优点是,您可以获取一个已经在整个系统中使用的现有对象,对其进行修补,使其以单稳态方式运行,如果您的系统在非大量对象实例的情况下运行良好,您就可以获得一个类似单例的实现,而无需重写额外的代码。

缺点也是显而易见的:这是一种侵入式的方法(将普通对象转换为单稳态并不容易),并且它使用静态成员意味着它总是会占用空间,即使在不需要的时候也是如此。最终,Monostate 最大的缺点是它做了一个非常乐观的假设,即类字段总是通过 getters 和 setters 公开。如果它们被直接访问,你的重构几乎注定要失败。 1

摘要

单例并不完全是邪恶的,但是如果不小心使用的话,它们会破坏应用程序的可测试性和可重构性。如果你真的必须使用单例,试着避免直接使用它(就像在,写SomeComponent.getInstance().foo()),而是继续把它指定为一个依赖项(例如,一个构造器参数),所有的依赖项都从你的应用程序中的一个位置得到满足(例如,一个控制容器的反转)。

Footnotes 1

公平地说,你可以鱼与熊掌兼得,但是你需要使用非标准的__declspec(property)扩展来实现。