单例模式
今天读书中的第四种, 同时也是比较常见(面试大热门说是)的设计模式: 单例模式
单例的基本思想是: 应用程序中应该只有一个特定组件的实例.
比如将数据库加载到内存并且提供只读接口时, 要采用单例模式. 究其原因则是重复储存相同的数据集只有浪费内存而没有积极影响. 不过应用程序一般都约束不能将多个数据库实例放入内存.
全局单例对象
算是经典实现了, 一个简单的单例就是采用静态全局对象的方式:
struct Database {
public:
// TODO...
};
static Database database{};
采用这种方式的问题在于: 全局静态对象在不同编译单元中的初始化顺序是不确定的.
这可能会导致问题, 比如另一个全局对象想要引用我这个静态数据库对象, 而我的静态数据库独享还没被初始化, 那错误就来了.
对此的一个解决办法是用函数来返回这个静态对象:
struct Database {
public:
// TODO...
};
Database& get_database() {
static Database database;
return database;
}
通过函数调用的方式才能获得这个全局静态对象, 这样一来就将创建的顺序控制在自己手里了.
不过对于 c++ 来说, 在 c++11 及之后的标准, 对于上边代码才能保证线程安全, 我们应该检查编译器是否确实打算加入锁以防止静态对象初始化时的并发访问.
经典实现
上面的这些代码是不能保证只有一个对象被创建的, 为此我们可以加入一个计数器来防止多个对象被创建.
#include <stdexcept>
struct Database {
public:
Database() {
static int instance_count{0};
if (++instance_count > 1)
throw std::runtime_error("Cannot make >1 database!");
}
};
Database& get_database() {
static Database database;
return database;
}
这样一来效果就达到了, 不过对于使用他的人来说不太友好, 这里只是抛出异常指出不让创建多个实例, 但是我们的意思实际上是不希望多次调用构造函数. 为此我们可以将构造函数设置为私有的.
#include <stdexcept>
struct Database {
protected:
Database() {
static int instance_count{0};
if (++instance_count > 1)
throw std::runtime_error("Cannot make >1 database!");
}
public:
static Database& get() {
static Database* database = new Database();
return *database;
}
Database(Database const&) = delete;
Database(Database&&) = delete;
Database& operator=(Database const&) = delete;
Database& operator=(Database&&) = delete;
};
同时我们禁用拷贝构造, 拷贝赋值, 移动构造, 移动赋值, 毕竟我们不希望存在多个数据库, 同时对数据库的移动操作也没什么实际意义.
然后我们用 get 方法的时候, 让他从堆上开辟内存, 这样比较符合我们使用数据库的习惯.
线程安全
上面有提到对于 c++11及之后的标准来说直接按照上面来写就没问题(两个线程同时调用get也不会遇到创建两次的情况), 不过对于这之前的标准, 这要加入一些限制. 为此引入了双重检查锁定来构造单例:
#include <stdexcept>
struct Database {
private:
static boost::atomic<Database*> instance;
static boost::mutex mtx;
protected:
Database() {
static int instance_count{0};
if (++instance_count > 1)
throw std::runtime_error("Cannot make >1 database!");
}
public:
static Database& get_instance();
Database(Database const&) = delete;
Database(Database&&) = delete;
Database& operator=(Database const&) = delete;
Database& operator=(Database&&) = delete;
};
Database& Database::get_instance() {
Database* db = instance.load(std::memory_order_consume);
if (!db) {
boost::lock_guard<boost::mutex> lock(mtx);
db = instance.load(boost::memory_order_consume);
if (!db) {
db = new Database();
instance.store(db, boost::memory_order_release);
}
}
return *db;
}
这里要用 boost 的线程支持, 不过作者说书主要是讲现代c++的, 所以就不多讨论了.
单例模式遇到的麻烦
假定数据库包含了首都城市及其人口列表, 为此我们有一个获取给定城市人口的成员函数, 那么我们的单例数据库要遵循以下接口:
class Database {
public:
virtual int get_population(const std::string& name) = 0;
};
现在假定该接口被 SingletonDatabase 这一具体实现采用, 那么我们先按照之前那样实现这个单例:
#include <stdexcept>
#include <map>
using namespace std;
class Database {
public:
virtual ~Database() = default;
virtual int get_population(const std::string& name) = 0;
};
class SingletonDatabase : public Database {
private:
SingletonDatabase() {
static int instance_count{0};
if (++instance_count > 1)
throw std::runtime_error("Cannot make >1 database!");
}
map<string, int> capitals;
public:
SingletonDatabase(SingletonDatabase const&) = delete;
SingletonDatabase(SingletonDatabase&&) = delete;
SingletonDatabase& operator=(SingletonDatabase const&) = delete;
SingletonDatabase& operator=(SingletonDatabase&&) = delete;
static SingletonDatabase& get() {
static auto database = new SingletonDatabase();
return *database;
}
int get_population(const std::string& name) override {
return capitals[name];
}
};
正如开头提到的, 单例对象的真正问题在于它在其他组件中的使用, 为了说明问题, 构建一个计算几个不同城市总人口的函数:
// 这部分插入和上面的代码块一致的代码
struct SingletonRecordFinder {
public:
static int total_population(const vector<string>& names) {
int result = 0;
for (auto& name : names)
result += SingletonDatabase::get().get_population(name);
return result;
}
};
然后我们再写一个测试函数:
TEST(RecordFinderTests, SingletonTotalPopulationTest) {
SingletonRecordFinder rf;
std::vector<std::string> names{"Seoul", "Mexico City"};
int tp = rf.total_population(names);
EXPECT_EQ(17500000 + 17400000, tp);
}
现在就能看出问题了, 由于我们的类 SingletonRecordFinder 是完全依赖于 SingletonDatabase 而实现的, 所以我们要检查 SingletonRecordFinder 能否工作的话就得和上面的测试一样用实际数据库中的数据.
如果我们要选择用虚拟数据的话是不可能的(没有对应的实现), 这也是单例模式的限制所在.
为了有更大的灵活性, 要做一些修改, 首先不能再显示地依赖SingletonDatabase. 由于我们的目的只是实现数据库的接口, 所以说可以创建一个新的类来配置数据来源:
#include <stdexcept>
#include <map>
#include <vector>
using namespace std;
class Database {
public:
virtual ~Database() = default;
virtual int get_population(const std::string& name) = 0;
};
struct ConfigurableRecordFinder {
public:
// 这里使用引用而不是显式地使用单例, 这样一来就可以创建一个虚拟数据库了
Database& db;
explicit ConfigurableRecordFinder(Database& db) : db{db} {}
[[nodiscard]] int total_population(const std::vector<std::string>& names) const {
int result = 0;
for (auto& name : names)
result += db.get_population(name);
return result;
}
};
// 一个虚拟数据库
class DummyDatabase final : public Database {
private:
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];
}
};
TEST(RecordFinderTests, DummyTotalPopulationTest) {
DummyDatabase db{};
ConfigurableRecordFinder rf{db};
EXPECT_EQ(4, rf.total_population(std::vector<std::string>{"alpha", "gamma"}));
}
这里就做了很好的解耦(用了依赖注入), 虚基类数据库负责给出要实现的接口, 我们的虚拟数据库存储有数据, 并且为接口做出实现, 给出获得人数的方法, 最后 ConfigurableRecordFinder 负责将传入的任意数据库的总人数做出统计, 这样一来就不用依赖于单例,虚拟数据也可以实现测试统计了.
单例和控制反转
首先是控制反转和依赖注入的含义:
控制反转(IoC): 是一种设计原则,它通过将对象的创建和管理交给外部容器或框架来实现解耦.
而 IoC 容器是一个专门用于管理和配置依赖关系的工具或框架,它负责创建对象、管理依赖关系和生命周期. 反转容器通过外部容器或框架来管理依赖关系,从而提高代码的可维护性和可扩展性.
依赖注入(DI) 是控制反转的一种具体实现方式,通过构造函数、setter 方法或接口将依赖项传递给类或函数而不是在类内部创建这些依赖项。这使得代码更加灵活、可测试和易于维护.
boost 没用过导致看不懂, 这里贴原文吧:
明确地使组件成为单例的方法明显是侵入性的,并且决定停止将类视为单例最终的代价会特别昂贵。另一种解决方案是采用约定,而不是直接强制执行类的生命周期,而是将此功能外包给 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 是线程安全的?
单态
最后是单态, 单态(Monostate)实际上是单例模式的变体.
单态是一个与单例有类似行为但是看起来和普通类一致的类.
class Printer {
private:
static int id;
public:
int get_id() const { return id; }
void set_id(int value) { id = value; }
};
这里的 Printer 类只是一个带有 getter 和 setter 的普通类而已, 不过它实际上在处理静态数据, 所以他和单例的行为是一样的. 尽管我们可以创建两个 Printer 的实例然后给他们分配不同 id, 但是这两个是完全一样的.
单态某些情况下有效, 他有着几个优势:
- 很容易继承
- 可以利用多态性
- 生命周期被合理定义
还有最大的优点是: 对于一个已经在系统中使用了的现有对象, 可以修改它然后让他按照单态的方式运行. 这样一来就不用额外写代码获得一个类似单例的对象.
其缺点在于:
-
这是一个侵入性方法, 将普通对象转换为单态需要做出修改操作.
-
由于内部是静态成员, 所以即使暂时不用他, 也会占用内存.
-
单态预设对于数据成员的访问是通过
geteer和setter的, 如果重构有一项必然需求是一定要直接访问数据成员的话, 那就几乎不可能重构成功了.
总结
单例也不一定是总会带来巨大消极影响的,但如果使用不慎,单例会破坏应用程序的可测试性和可重构性。
如果确实必须使用单例,要尝试避免直接使用, 例如,编写 SomeComponent.getInstance().foo() 这样一长串的链式调用耦合性过高, 一旦要修改代码则代价过大.
可以用上控制反转(IoC), 将对象的创建和管理交给外部容器或框架来实现解耦, 在控制反转的一些方法中可以用依赖注入(DI), 将依赖项作为参数传递给类或函数,而不是在类内部创建这些依赖项.

被折叠的 条评论
为什么被折叠?



