一、介绍
设计模式这个话题听起来很枯燥,在学术上很枯燥,老实说,几乎在所有可以想象的编程语言中都是如此——包括像 JavaScript 这样甚至不是真正面向对象的编程语言!那么,为什么又有一本关于它的书呢?
我猜这本书存在的主要原因是 C++ 又伟大了。经过长时间的停滞后,它现在正在发展、成长,尽管事实上它不得不与向后的 C 兼容作斗争,但好的事情正在发生,尽管不是以我们都希望的速度。(我在看模块,还有其他东西。)
现在,关于设计模式——我们不应该忘记最初的设计模式书 1 是用 C++ 和 Smalltalk 出版的,并附有示例。从那以后,大量的编程语言将设计模式直接整合到语言中:例如,C#直接将 Observer 模式与其对事件的内置支持(以及相应的event
关键字)整合在一起。C++ 没有做到这一点,至少在语法层面上没有。也就是说,对许多编程场景来说,像std::function
这样的类型的引入确实使事情变得简单了许多。
设计模式也是一项有趣的研究,研究如何用许多不同的方式解决问题,有不同程度的技术复杂性和不同种类的权衡。有些模式或多或少是必不可少的,不可避免的,而另一些模式更多的是一种科学好奇心(但是仍然会在本书中讨论,因为我是一个完美主义者)。
读者应该意识到,对某些问题的综合解决方案(例如,观察者模式)通常会导致过度工程化,也就是说,创建的结构比大多数典型场景所必需的要复杂得多。虽然过度工程很有趣(嘿,你可以真正解决问题并给你的同事留下深刻印象),但它通常是不可行的。
预赛
这本书是给谁的
这本书是对经典 GoF 书的现代更新,专门针对 C++ 编程语言。我是说,你们中有多少人在写闲聊?不多;这是我的猜测。
本书的目标是研究我们如何将现代 C++(目前可用的 C++ 最新版本)应用到经典设计模式的实现中。同时,这也是一次充实任何对 C++ 开发人员有用的新模式和方法的尝试。
最后,在某些地方,这本书只是现代 C++ 的一个技术演示,展示了它的一些最新特性(例如,协程)如何使困难的问题变得更容易解决。
关于代码示例
本书中的示例都适合投入生产,但是为了提高可读性,做了一些简化:
- 很多时候,你会发现我用
struct
而不是class
来避免在太多地方写public
关键字。 - 我将避免使用
std::
前缀,因为它会损害可读性,尤其是在代码密度高的地方。如果我用的是string
,你可以打赌我指的是std::string
。 - 我将避免添加
virtual
析构函数,然而在现实生活中,添加它们可能是有意义的。 - 在极少数情况下,我会通过值来创建和传递参数,以避免
shared_ptr
/make_shared
/等的扩散。智能指针增加了另一个层次的复杂性,它们与本书中介绍的设计模式的集成留给读者作为练习。 - 我有时会省略代码元素,否则这些元素对于完成一个类型的功能是必要的(例如,移动构造器),因为它们会占用太多的空间。
- 在很多情况下,我会省略
const
,而在正常情况下,它实际上是有意义的。const-correction 经常导致 API 表面的分裂和重叠,这在 book 格式中不太好。
您应该知道,大多数示例利用了现代 C++ (C++11、14、17 及更高版本),并且通常使用开发人员可以使用的最新 C++ 语言特性。例如,当 C++14 让我们自动推断返回类型时,你不会找到很多以-> decltype(...)
结尾的函数签名。没有一个例子是针对某个特定的编译器,但是如果你选择的编译器不能正常工作, 2 你需要找到解决方法。
在某些时候,我会引用其他编程语言,比如 C#或 Kotlin。有时注意到其他语言的设计者是如何实现一个特定的特性是很有趣的。C++ 对于从其他语言中借鉴普遍可用的思想并不陌生:例如,在变量声明和返回类型上引入auto
和类型推断在许多其他语言中都存在。
在开发人员工具上
本书中的代码样本是为现代 C++ 编译器而编写的,无论是 Clang、GCC 还是 MSVC。我一般假设您使用的是最新的编译器版本,因此,您将使用我可以使用的最新、最好的语言特性。在某些情况下,高级语言的例子将需要为早期的编译器降级;在其他情况下,这可能行不通。
就开发人员工具而言,这本书并没有特别提到它们,所以如果你有一个最新的编译器,你应该很好地遵循这些例子:它们大多数都是自包含的.cpp
文件。不管怎样,我想借此机会提醒您,像 CLion 或 ReSharper C++ 这样的高质量开发工具极大地改善了开发体验。只需投入很少的资金,您就可以获得大量额外的功能,这些功能可以直接转化为编码速度和代码质量的提高。
海盗行为
数字盗版是无法回避的现实。全新的一代正在成长,他们从未买过一部电影或一本书——甚至是这本书。对此我们无能为力。我唯一能说的是,如果你盗版了这本书,你可能读的不是最新版本。
在线数字出版的乐趣在于,随着新版本 C++ 的出现,我可以更新这本书,并做更多的研究。因此,如果你购买了这本书,随着新版本的 C++ 语言和标准库的发布,你将获得免费的更新。如果没有…哦,好吧。
重要概念
在我们开始之前,我想简单提一下 C++ 世界的一些关键概念,它们将在本书中被引用。
奇怪的重复模板模式
嘿,这显然是一种模式!我不知道它是否有资格被列为一个独立的设计模式,但它肯定是 C++ 世界中的一种模式。本质上,这个想法很简单:继承者将自己作为模板参数传递给它的基类:
1 struct Foo : SomeBase<Foo>
2 {
3 ...
4 }
现在,你可能想知道为什么有人会这么做?一个原因是能够访问基类实现中的类型化this
指针。
例如,假设每一个SomeBase
的继承者都实现了一个迭代所需的begin()/end()
对。如何在SomeBase
的成员中迭代对象?直觉告诉你不能,因为SomeBase
本身没有提供begin()/end()
接口。但是如果您使用 CRTP,您实际上可以将this
转换为一个派生类类型:
1 template <typename Derived>
2 struct SomeBase
3 {
4 void foo()
5 {
6 for (auto& item : *static_cast<Derived*>(this))
7 {
8 ...
9 }
10 }
11 }
有关这种方法的具体示例,请查看第九章。
混合遗传
在 C++ 中,可以将类定义为从它自己的模板参数继承,例如:
1 template <typename T> struct Mixin : T
2 {
3 ...
4 }
这种方法被称为 mixin 继承,允许类型的分层组合。例如,您可以允许Foo<Bar<Baz>> x;
声明一个实现所有三个类特征的类型变量,而不必实际构建一个全新的FooBarBaz
类型。
有关这种方法的具体示例,请查看第九章。
性能
属性只不过是一个(通常是私有的)字段和一个 getter 和 setter 的组合。在标准 C++ 中,属性如下所示:
1 class Person
2 {
3 int age;
4 public:
5 int get_age() const { return age; }
6 void set_age(int value) { age = value; }
7 };
许多语言(例如 C#、Kotlin)通过将属性直接烘焙到编程语言中来内在化属性的概念。虽然 C++ 还没有做到这一点(将来也不太可能做到),但有一个名为property
的非标准声明说明符,您可以在大多数编译器(MSVC、Clang、英特尔)中使用:
1 class Person
2 {
3 int age_;
4 public:
5 int get_age() const { return age_; }
6 void set_age(int value) { age_ = value; }
7 __declspec(property(get=get_age, put=set_age)) int age;
8 };
这可以按如下方式使用:
1 Person person;
2 p.age = 20; // calls p.set_age(20)
坚实的设计原则
SOLID 是一个缩写词,代表以下设计原则(及其缩写):
- 单一责任原则
- 开闭原则(OCP)
- 利斯科夫替代原理
- 接口隔离原则(ISP)
- 从属倒置原则
这些原则是罗伯特·c·马丁在 21 世纪初提出的——事实上,它们只是从罗伯特的书和博客中表达的几十条原则中挑选出来的五条。这五个特殊的主题贯穿了对模式和软件设计的讨论,所以在我们深入设计模式之前(我知道你们都很渴望),我们将简要回顾一下坚实的原则是什么。
单一责任原则
假设你决定记下你最私密的想法。这本杂志有一个标题和许多条目。您可以如下建模:
1 struct Journal
2 {
3 string title;
4 vector<string> entries;
5
6 explicit Journal(const string& title) : title{title} {}
7 };
现在,您可以添加向日志添加条目的功能,以条目在日志中的序号为前缀。这很简单:
1 void Journal::add(const string& entry)
2 {
3 static int count = 1;
4 entries.push_back(boost::lexical_cast<string>(count++)
5 + ": " + entry);
6 }
该日志现在可用作:
1 Journal j{"Dear Diary"};
2 j.add("I cried today");
3 j.add("I ate a bug");
将这个函数作为Journal
类的一部分是有意义的,因为添加日志条目是日志实际需要做的事情。杂志的责任是记录条目,所以任何与之相关的事情都是公平的。
现在假设您决定通过将日志保存在文件中来使其持久化。您将这段代码添加到Journal
类中:
1 void Journal::save(const string& filename)
2 {
3 ofstream ofs(filename);
4 for (auto& s : entries)
5 ofs << s << endl;
6 }
这种方法是有问题的。日志的职责是保存日志条目,而不是将它们写入磁盘。如果您将磁盘写入功能添加到Journal
和类似的类中,持久性方法的任何改变(比如,您决定写入云而不是磁盘)都需要在每个受影响的类中进行大量微小的改变。
我想在这里暂停一下,提出一个观点:一个让你不得不在大量的类中做许多微小的改变的架构,无论是否相关(如在层次结构中),都是典型的代码味道——表明有些事情不太对劲。现在,它实际上取决于具体情况:如果你正在重命名一个在上百个地方使用的符号,我认为这通常是可以的,因为 ReSharper、CLion 或任何你使用的 IDE 实际上会让你执行一次重构,并让变化传播到任何地方。但是当你需要完全重做一个界面的时候…嗯,那会是一个非常痛苦的过程!
因此,我声明持久性是一个单独的问题,最好在一个单独的类中表达,例如:
1 struct PersistenceManager
2 {
3 static void save(const Journal& j, const string& filename)
4 {
5 ofstream ofs(filename);
6 for (auto& s : j.entries)
7 ofs << s << endl;
8 }
9 };
这正是单一责任的含义:每个类只有一个责任,因此也只有一个改变的理由。只有在条目的存储方面还需要做更多的事情时,Journal
才需要更改——例如,您可能希望每个条目都有一个时间戳作为前缀,因此您需要更改add()
函数来实现这一点。另一方面,如果你想改变持久性机制,这将在PersistenceManager
中改变。
违反 SRP 的反模式的一个极端例子叫做 God 对象。一个 God 对象是一个巨大的类,它试图处理尽可能多的问题,成为一个很难处理的巨大怪物。
对我们来说幸运的是,上帝对象很容易识别,而且由于源代码控制系统(只需计算成员函数的数量),负责任的开发人员可以很快被识别出来并受到适当的惩罚。
开闭原理
假设我们在数据库中有一系列(完全假设的)产品。每个产品都有颜色和尺寸,定义如下:
1 enum class Color { Red, Green, Blue };
2 enum class Size { Small, Medium, Large };
3
4 struct Product
5 {
6 string name;
7 Color color;
8 Size size;
9 };
现在,我们希望为一组给定的产品提供一定的过滤功能。我们制作一个类似如下的过滤器:
1 struct ProductFilter
2 {
3 typedef vector<Product*> Items;
4 };
现在,为了支持按颜色过滤产品,我们定义了一个成员函数来实现这一点:
1 ProductFilter::Items ProductFilter::by_color(Items items, Color color)
2 {
3 Items result;
4 for (auto& i : items)
5 if (i->color == color)
6 result.push_back(i);
7 return result;
8 }
我们目前通过颜色过滤项目的方法非常好。我们的代码投入生产,但不幸的是,过了一段时间,老板来了,要求我们也实现按大小过滤。所以我们跳回ProductFilter.cpp
,添加以下代码并重新编译:
1 ProductFilter::Items ProductFilter::by_color(Items items, Color color)
2 {
3 Items result;
4 for (auto& i : items)
5 if (i->color == color)
6 result.push_back(i);
7 return result;
8 }
这感觉像是完全的复制,不是吗?为什么不直接写一个带谓词(some function
)的通用方法呢?嗯,一个原因可能是不同形式的过滤可以以不同的方式完成:例如,一些记录类型可能被索引,需要以特定的方式进行搜索;有些数据类型适合在 GPU 上搜索,而有些则不适合。
我们的代码投入生产,但是老板又一次回来告诉我们,现在需要通过颜色和大小进行搜索。那么,除了增加另一个功能,我们还能做什么呢?
1 ProductFilter::Items ProductFilter::by_color_and_size(Items
2 items, Size size, Color color)
3 {
4 Items result;
5 for (auto& i : items)
6 if (i->size == size && i->color == color)
7 result.push_back(i);
8 return result;
9 }
从前面的场景来看,我们想要的是贯彻开闭原则,即一个类型对扩展是开放的,但对修改是封闭的。换句话说,我们希望过滤是可扩展的(可能在不同的编译单元中),而不必修改它(并重新编译已经工作并可能已经提供给客户机的东西)。
如何才能实现?嗯,首先我们概念上分开(SRP!)我们的过滤过程分为两个部分:一个过滤器(一个获取所有项目并只返回一些项目的过程)和一个规范(应用于数据元素的谓词的定义)。
我们可以对规范接口做一个非常简单的定义:
1 template <typename T> struct Specification
2 {
3 virtual bool is_satisfied(T* item) = 0;
4 };
在前面的例子中,类型T
是我们选择的任何类型:它当然可以是Product
,但也可以是其他类型。这使得整个方法可以重用。
接下来,我们需要一种基于Specification<T>
的过滤方法:这是通过定义一个Filter<T>
来完成的,你猜对了:
1 template <typename T> struct Filter
2 {
3 virtual vector<T*> filter(
4 vector<T*> items,
5 Specification<T>& spec) = 0;
6 };
同样,我们所做的只是为一个名为filter
的函数指定签名,该函数接受所有的项目和一个规范,并返回所有符合规范的项目。假设条目被存储为一个vector<T*>
,但是实际上你可以传递给filter()
一对迭代器或者一些专门为遍历集合而设计的定制接口。遗憾的是,C++ 语言未能标准化枚举或集合的概念,这在其他编程语言中也存在(例如。网的IEnumerable
)。
综上所述,改进滤波器的实现非常简单:
1 struct BetterFilter : Filter<Product>
2 {
3 vector<Product*> filter(
4 vector<Product*> items,
5 Specification<Product>& spec) override
6 {
7 vector<Product*> result;
8 for (auto& p : items)
9 if (spec.is_satisfied(p))
10 result.push_back(p);
11 return result;
12 }
13 };
同样,您可以将传入的Specification<T>
看作是仅受限于一定数量的可能筛选器规范的std::function
的强类型等价物。
现在,这是最简单的部分。要制作滤色器,您需要制作一个ColorSpecification
:
1 struct ColorSpecification : Specification<Product>
2 {
3 Color color;
4
5 explicit ColorSpecification(const Color color) : color{color} {}
6
7 bool is_satisfied(Product* item) override {
8 return item->color == color;
9 }
10 };
有了这个规范,有了一个产品列表,我们现在可以对它们进行如下筛选:
1 Product apple{ "Apple", Color::Green, Size::Small };
2 Product tree{ "Tree", Color::Green, Size::Large };
3 Product house{ "House", Color::Blue, Size::Large };
4
5 vector<Product*> all{ &apple, &tree, &house };
6
7 BetterFilter bf;
8 ColorSpecification green(Color::Green);
9
10 auto green_things = bf.filter(all, green);
11 for (auto& x : green_things)
12 cout << x->name << " is green" << endl;
前面得到了“苹果”和“树”,因为它们都是绿色的。现在,到目前为止,我们唯一没有实现的是搜索尺寸和颜色(或者,实际上,解释了如何搜索尺寸或颜色,或者混合不同的标准)。答案是你简单地做一个复合规范。例如,对于逻辑 AND,您可以使其如下所示:
1 template <typename T> struct AndSpecification : Specification<T>
2 {
3 Specification<T>& first;
4 Specification<T>& second;
5
6 AndSpecification(Specification<T>& first, Specification<T>& second)
7 : first{first}, second{second} {}
8
9 bool is_satisfied(T* item) override
10 {
11 return first.is_satisfied(item) && second.is_satisfied(item);
12 }
13 };
现在,您可以在更简单的Specification
的基础上自由创建复合条件。重用我们之前制定的green
规范,找到绿色的大东西现在就像:
1 SizeSpecification large(Size::Large);
2 ColorSpecification green(Color::Green);
3 AndSpecification<Product> green_and_large{ large, green };
4
5 auto big_green_things = bf.filter(all, green_and_big);
6 for (auto& x : big_green_things)
7 cout << x->name << " is large and green" << endl;
8
9 // Tree is large and green
这是很多代码!但是请记住,由于 C++ 的强大功能,您可以简单地为两个Specification<T>
对象引入一个operator &&
,从而使过滤过程由两个(或更多!)标准极其简单:
1 template <typename T> struct Specification
2 {
3 virtual bool is_satisfied(T* item) = 0;
4
5 AndSpecification<T> operator &&(Specification&& other)
6 {
7 return AndSpecification<T>(*this, other);
8 }
9 };
如果您现在避免为尺寸/颜色规格制造额外的变量,复合规格可以减少到一行:
1 auto green_and_big =
2 ColorSpecification(Color::Green)
3 && SizeSpecification(Size::Large);
让我们回顾一下什么是 OCP 原理,以及前面的例子是如何实施它的。基本上,OCP 指出,你不应该需要回到你已经编写和测试的代码,并改变它。这正是这里正在发生的事情!我们创建了Specification<T>
和Filter<T>
,从那时起,我们所要做的就是实现其中一个接口(不需要修改接口本身)来实现新的过滤机制。这就是“开放供扩展,封闭供修改”的含义
利斯科夫替代原理
以 Barbara Liskov 命名的 Liskov 替换原则指出,如果一个接口接受一个类型为Parent
的对象,那么它应该同样接受一个类型为Child
的对象,而不破坏任何东西。我们来看一个 LSP 坏掉的情况。
这是一个长方形。它有宽度和高度,还有一堆计算面积的 getters 和 setters:
1 class Rectangle
2 {
3 protected:
4 int width, height;
5 public:
6 Rectangle(const int width, const int height)
7 : width{width}, height{height} { }
8
9 int get_width() const { return width; }
10 virtual void set_width(const int width) { this->width = width; }
11 int get_height() const { return height; }
12 virtual void set_height(const int height) { this->height = height; }
13
14 int area() const { return width * height; }
15 };
现在让我们假设我们制作一种特殊的Rectangle
称为Square
。该对象覆盖设置器来设置宽度和高度:
1 class Square : public Rectangle
2 {
3 public:
4 Square(int size): Rectangle(size,size) {}
5 void set_width(const int width) override {
6 this->width = height = width;
7 }
8 void set_height(const int height) override {
9 this->height = width = height;
10 }
11 };
这种方法是邪恶的。你还看不到它,因为它看起来确实很无辜:设置者只是简单地设置了两个维度,会有什么问题呢?好吧,如果我们采用前面的方法,我们可以很容易地构造一个采用Rectangle
的函数,这个函数在采用正方形时会爆炸:
1 void process(Rectangle& r)
2 {
3 int w = r.get_width();
4 r.set_height(10);
5
6 cout << "expected area = " << (w * 10)
7 << ", got " << r.area() << endl;
8 }
前面的函数将公式Area = Width × Height
作为不变量。它获取宽度,设置高度,并正确地期望乘积等于计算的面积。但是用Square
调用前面的函数会产生不匹配:
1 Square s{5};
2 process(s); // expected area = 50, got 25
从这个例子(我承认有点做作)可以看出,process()
由于完全不能接受派生类型Square
而不是基本类型Rectangle
而破坏了 LSP。如果你给它一个Rectangle
,一切都很好,所以可能需要一段时间问题才会在你的测试中出现(或者在生产中——希望不是!).
解决办法是什么?嗯,有很多。就我个人而言,我认为Square
类型甚至不应该存在:相反,我们可以创建一个工厂(见第三章)来创建矩形和正方形:
1 struct RectangleFactory
2 {
3 static Rectangle create_rectangle(int w, int h);
4 static Rectangle create_square(int size);
5 };
您可能还需要一种方法来检测Rectangle
实际上是一个正方形:
1 bool Rectangle::is_square() const
2 {
3 return width == height;
4 }
在这种情况下,核心选项是在Square
的set_width()/set_height()
中抛出一个异常,声明这些操作不受支持,您应该使用set_size()
来代替。然而,这违反了最小惊讶原则,因为你期望调用set_width()
来做出有意义的改变……我说的对吗?
界面分离原理
好吧,这里有另一个人为的例子,但仍然适合说明这个问题。假设您决定定义一台多功能打印机:一台可以打印、扫描和传真文档的设备。所以你这样定义它:
1 struct MyFavouritePrinter /* : IMachine */
2 {
3 void print(vector<Document*> docs) override;
4 void fax(vector<Document*> docs) override;
5 void scan(vector<Document*> docs) override;
6 };
这很好。现在,假设您决定定义一个接口,需要由计划制造多功能打印机的每个人来实现。因此,您可以使用您最喜欢的 IDE 中的提取接口函数,您将得到如下内容:
1 struct IMachine
2 {
3 virtual void print(vector<Document*> docs) = 0;
4 virtual void fax(vector<Document*> docs) = 0;
5 virtual void scan(vector<Document*> docs) = 0;
6 };
这是一个问题。问题的原因是这个接口的一些实现者可能不需要扫描或传真,只需要打印。然而,您是在强迫他们实现那些额外的特性:当然,它们都可以是不可操作的,但是为什么要这么麻烦呢?
因此,ISP 建议您拆分接口,以便实现者可以根据他们的需求进行挑选。由于打印和扫描是不同的操作(例如,扫描仪不能打印),我们为它们定义了单独的接口:
1 struct IPrinter
2 {
3 virtual void print(vector<Document*> docs) = 0;
4 };
5
6 struct IScanner
7 {
8 virtual void scan(vector<Document*> docs) = 0;
9 };
然后,打印机或扫描仪可以实现所需的功能:
1 struct Printer : IPrinter
2 {
3 void print(vector<Document*> docs) override;
4 };
5
6 struct Scanner : IScanner
7 {
8 void scan(vector<Document*> docs) override;
9 };
现在,如果我们真的想要一个IMachine
接口,我们可以将其定义为上述接口的组合:
1 struct IMachine: IPrinter, IScanner /* IFax and so on */
2 {
3 };
当您开始在具体的多功能设备中实现该接口时,这就是要使用的接口。例如,您可以使用简单的委托来确保Machine
重用由特定的IPrinter
和IScanner
提供的功能:
1 struct Machine : IMachine
2 {
3 IPrinter& printer;
4 IScanner& scanner;
5
6 Machine(IPrinter& printer, IScanner& scanner)
7 : printer{printer},
8 scanner{scanner}
9 {
10 }
11
12 void print(vector<Document*> docs) override {
13 printer.print(docs);
14 }
15
16 void scan(vector<Document*> docs) override
17 {
18 scanner.scan(docs);
19 }
20 };
因此,简单重述一下,这里的想法是将复杂接口的各个部分分离成单独的接口,以避免强迫实现者实现他们并不真正需要的功能。每当你为某个复杂的应用程序编写插件时,如果你得到一个有 20 个令人困惑的函数的接口,要用各种 no-ops 和return nullptr
来实现,API 作者很可能违反了 ISP。
从属倒置原则
倾角的原始定义陈述如下 3 :
A.高层模块不应该依赖低层模块。两者都应该依赖于抽象。
这句话的基本意思是,如果您对日志感兴趣,您的报告组件不应该依赖于具体的ConsoleLogger
,而是可以依赖于ILogger
接口。在这种情况下,我们认为报告组件是高级的(更接近于业务领域),而日志记录作为一个基本问题(有点像文件 I/O 或线程,但不完全是)被认为是低级模块。
B.抽象不应该依赖于细节。细节应该依赖于抽象。
这再次重申了对接口或基类的依赖比对具体类型的依赖更好。希望这句话的真实性是显而易见的,因为这种方法支持更好的可配置性和可测试性——前提是您使用一个好的框架来处理这些依赖性。
所以现在的主要问题是:你如何实际实现所有上述内容?这无疑需要做更多的工作,因为现在你需要明确地声明,例如,Reporting
依赖于一个ILogger
。你可能会这样表达:
1 class Reporting
2 {
3 ILogger& logger;
4 public:
5 Reporting(const ILogger& logger) : logger{logger} {}
6 void prepare_report()
7 {
8 logger.log_info("Preparing the report");
9 ...
10 }
11 };
12 }
现在的问题是,要初始化前面的类,您需要显式调用Reporting{ConsoleLogger{}}
或类似的东西。如果Reporting
依赖于五个不同的接口呢?如果ConsoleLogger
有自己的附属国怎么办?您可以通过编写大量代码来管理这一点,但有一种更好的方法。
现代的、时髦的、流行的方法是使用依赖注入:这实质上意味着你使用一个库,比如 Boost。DI 4 自动满足特定组件的依赖需求。
让我们考虑一个例子,一辆汽车有一个引擎,但也需要写入日志。就目前的情况来看,我们可以说汽车依赖于这两个因素。首先,我们可以把发动机定义为:
1 struct Engine
2 {
3 float volume = 5;
4 int horse_power = 400;
5
6 friend ostream& operator<< (ostream& os, const Engine& obj)
7 {
8 return os
9 << "volume: " << obj.volume
10 << " horse_power: " << obj.horse_power;
11 } // thanks, ReSharper!
12 };
现在,由我们来决定是否要提取一个IEngine
接口并将其提供给汽车。也许我们会,也许不会,这通常是一个设计决策。如果你设想有一个引擎层次结构,或者你预见需要一个NullEngine
(见第十九章)用于测试目的,那么是的,你确实需要抽象出接口。
无论如何,我们也想记录日志,因为这可以通过多种方式实现(控制台、电子邮件、SMS、鸽子邮件等等),我们可能希望有一个ILogger
接口:
1 struct ILogger
2 {
3 virtual ~ILogger() {}
4 virtual void Log(const string& s) = 0;
5 };
以及某种具体的实现:
1 struct ConsoleLogger : ILogger
2 {
3 ConsoleLogger() {}
4
5 void Log(const string& s) override
6 {
7 cout << "LOG: " << s.c_str() << endl;
8 }
9 };
现在,我们将要定义的汽车取决于引擎和日志组件。我们两者都需要,但如何存储它们真的取决于我们:我们可以使用指针、引用、unique_ptr/shared_ptr
或其他东西。我们将把这两个依赖组件定义为构造器参数:
1 struct Car
2 {
3 unique_ptr<Engine> engine;
4 shared_ptr<ILogger> logger;
5
6 Car(unique_ptr<Engine> engine,
7 const shared_ptr<ILogger>& logger)
8 : engine{move(engine)},
9 logger{logger}
10 {
11 logger->Log("making a car");
12 }
13
14 friend ostream& operator<<(ostream& os, const Car& obj)
15 {
16 return os << "car with engine: " << *obj.engine;
17 }
18 };
现在,当我们初始化Car
时,您可能希望看到make_unique/make_shared
调用。但我们不会那样做。而是用 Boost.DI,首先定义一个绑定,将ILogger
绑定到ConsoleLogger
;这意味着,基本上,“任何时候有人要求一个ILogger
,就给他们一个ConsoleLogger
”:
1 auto injector = di::make_injector(
2 di::bind<ILogger>().to<ConsoleLogger>()
3 );
现在我们已经配置了注射器,我们可以用它来创建一辆汽车:
1 auto car = injector.create<shared_ptr<Car>>();
前面的代码创建了一个指向完全初始化的Car
对象的shared_ptr<Car>
,这正是我们想要的。这种方法的伟大之处在于,要改变正在使用的日志记录程序的类型,我们可以在一个地方改变它(bind
调用),每个出现ILogger
的地方现在都可以使用我们提供的其他日志记录组件。这种方法还有助于我们进行单元测试,并允许我们使用存根(或空对象模式)来代替模拟。
模式时间到了!
理解了坚实的设计原则之后,我们就可以开始研究设计模式本身了。系好安全带;这将是一次漫长(但希望不会无聊)的旅程!
Footnotes 1
Erich Gamma 等人,设计模式:可重用面向对象软件的元素(波士顿,MA: Addison Wesley,1994)。
2
英特尔,我正看着你呢!
3
罗伯特·c·马丁,《敏捷软件开发,原则,模式和实践》(纽约:普伦蒂斯霍尔出版社,2003 年),第 127-131 页。
4
此刻,助推。DI 还不是 Boost 的一部分,它是 GitHub 库的一部分。
二、构建器
构建器模式与复杂对象的创建有关,也就是说,不能在一行构造器调用中构建的对象。这些类型的对象本身可能由其他对象组成,并且可能包含不太明显的逻辑,因此需要一个专门用于对象构造的单独组件。
我想值得预先注意的是,虽然我说过构建器关注复杂的对象,但我们将看一个相当小的例子。这样做纯粹是为了优化空间,因此领域逻辑的复杂性不会影响读者理解模式的实际实现。
方案
假设我们正在构建一个呈现网页的组件。首先,我们将输出一个简单的无序列表,其中有两项包含单词 hello 和 world。一个非常简单的实现可能如下所示:
1 string words[] = { "hello", "world" };
2 ostringstream oss;
3 oss << "<ul>";
4 for (auto w : words)
5 oss << " <li>" << w << "</li>";
6 oss << "</ul>";
7 printf(oss.str().c_str());
这实际上给了我们想要的东西,但是这种方法不太灵活。我们如何将这个列表从项目符号列表变成编号列表呢?列表创建后,我们如何添加另一个项目?显然,在我们这个僵化的计划中,这是不可能的。
因此,我们可以走 OOP 路线,定义一个HtmlElement
类来存储关于每个标签的信息:
1 struct HtmlElement
2 {
3 string name;
4 string text;
5 vector<HtmlElement> elements;
6
7 HtmlElement() {}
8 HtmlElement(const string& name, const string& text)
9 : name(name), text(text) { }
10
11 string str(int indent = 0) const
12 {
13 // pretty-print the contents
14 }
15 }
有了这种方法,我们现在可以以更明智的方式创建我们的列表:
1 string words[] = { "hello", "world" };
2 HtmlElement list{"ul", ""};
3 for (auto w : words)
4 list.elements.emplace_back{HtmlElement{"li", w}};
5 printf(list.str().c_str());
这工作得很好,给了我们一个更可控的、OOP 驱动的项目列表的表示。但是构建每个HtmlElement
的过程不是很方便,我们可以通过实现构建器模式来改进它。
简单生成器
Builder 模式只是试图将对象的分段构造外包给一个单独的类。我们的第一次尝试可能会产生这样的结果:
1 struct HtmlBuilder
2 {
3 HtmlElement root;
4
5 HtmlBuilder(string root_name) { root.name = root_name; }
6
7 void add_child(string child_name, string child_text)
8 {
9 HtmlElement e{ child_name, child_text };
10 root.elements.emplace_back(e);
11 }
12
13 string str() { return root.str(); }
14 };
这是一个构建 HTML 元素的专用组件。add_child()
方法是用于向当前元素添加更多子元素的方法,每个子元素是一个名称-文本对。它可以按如下方式使用:
1 HtmlBuilder builder{ "ul" };
2 builder.add_child("li", "hello");
3 builder.add_child("li", "world");
4 cout << builder.str() << endl;
你会注意到,此时,add_child()
函数正在返回void
。我们可以使用返回值做很多事情,但返回值最常见的用途之一是帮助我们构建一个流畅的界面。
流畅的构建器
让我们将add_child()
的定义更改如下:
1 HtmlBuilder& add_child(string child_name, string child_text)
2 {
3 HtmlElement e{ child_name, child_text };
4 root.elements.emplace_back(e);
5 return *this;
6 }
通过返回对构建器本身的引用,现在可以链接构建器调用。这就是所谓的流畅界面:
1 HtmlBuilder builder{ "ul" };
2 builder.add_child("li", "hello").add_child("li", "world");
3 cout << builder.str() << endl;
引用或指针的选择完全取决于您。如果您想用->
操作符来链接调用,您可以这样定义add_child()
:
1 HtmlBuilder* add_child(string child_name, string child_text)
2 {
3 HtmlElement e{ child_name, child_text };
4 root.elements.emplace_back(e);
5 return this;
6 }
然后像这样使用它:
1 HtmlBuilder builder{"ul"};
2 builder->add_child("li", "hello")->add_child("li", "world");
3 cout << builder << endl;
传达意图
我们为 HTML 元素实现了一个专用的构建器,但是我们类的用户如何知道如何使用它呢?一个想法是,只要他们构造一个对象,就简单地强迫他们使用生成器。你需要做的是:
1 struct HtmlElement
2 {
3 string name;
4 string text;
5 vector<HtmlElement> elements;
6 const size_t indent_size = 2;
7
8 static unique_ptr<HtmlBuilder> build(const string& root_name)
9 {
10 return make_unique<HtmlBuilder>(root_name);
11 }
12
13 protected: // hide all constructors
14 HtmlElement() {}
15 HtmlElement(const string& name, const string& text)
16 : name{name}, text{text}
17 {
18 }
19 };
我们的方法是双管齐下的。首先,我们隐藏了所有的构造器,所以它们不再可用。然而,我们已经创建了一个工厂方法(这是我们将在后面讨论的设计模式),用于从HtmlElement
中创建一个构建器。这也是一个静态方法。下面是如何使用它的方法:
1 auto builder = HtmlElement::build("ul");
2 builder.add_child("li", "hello").add_child("li", "world");
3 cout << builder.str() << endl;
但是我们不要忘记,我们的最终目标是建造一个HtmlElement
,而不仅仅是它的建造者!因此,锦上添花可以是构建器上的operator HtmlElement
的实现,以产生最终值:
1 struct HtmlBuilder
2 {
3 operator HtmlElement() const { return root; }
4 HtmlElement root;
5 // other operations omitted
6 };
前面的一个变化是返回std::move(root)
,但是你是否想这样做完全取决于你自己。
无论如何,操作符的添加允许我们编写以下内容:
1 HtmlElement e = HtmlElement::build("ul")
2 .add_child("li", "hello")
3 .add_child("li", "world");
4 cout << e.str() << endl;
遗憾的是,没有办法明确地告诉其他用户以这种方式使用 API。希望对构造器的限制和静态build()
函数的存在能让用户使用构造器,但是,除了操作符之外,给HtmlBuilder
本身添加一个相应的build()
函数也是有意义的:
1 HtmlElement HtmlBuilder::build() const
2 {
3 return root; // again, std::move possible here
4 }
Groovy 风格的生成器
这个例子稍微偏离了专门的构建器,因为确实看不到构建器。它只是对象构造的一种替代手段。
Groovy、Kotlin 等编程语言都试图通过支持使过程更好的语法结构来展示他们在构建 DSL 方面有多棒。但是 C++ 有什么不同呢?感谢初始化列表,我们可以使用普通的类有效地构建一个 HTML 兼容的 DSL。
首先,我们将定义一个 HTML 标签:
1 struct Tag
2 {
3 std::string name;
4 std::string text;
5 std::vector<Tag> children;
6 std::vector<std::pair<std::string, std::string>> attributes;
7
8 friend std::ostream& operator<<(std::ostream& os, const Tag& tag)
9 {
10 // implementation omitted
11 }
12 };
到目前为止,我们有一个Tag
可以存储它的名称、文本、子元素(内部标签),甚至 HTML 属性。我们也有一些漂亮的打印代码,但太无聊了,不能在这里展示。
现在我们可以给它几个protected
构造器(因为我们不希望任何人直接实例化它)。我们以前的实验告诉我们,我们至少有两种情况:
- 由名称和文本初始化的标签(例如,列表项)
- 由名称和子元素集合初始化的标记
第二种情况更有趣。我们将使用类型为std::vector
的参数:
1 struct Tag
2 {
3 ...
4 protected:
5 Tag(const std::string& name, const std::string& text)
6 : name{name}, text{text} {}
7
8
9 Tag(const std::string& name, const std::vector<Tag>& children)
10 : name{name}, children{children} {}
11 };
现在我们可以从这个Tag
类继承,但是只针对有效的 HTML 标签(从而约束了我们的 DSL)。让我们定义两个标签:一个用于段落,另一个用于图像:
1 struct P : Tag
2 {
3 explicit P(const std::string& text)
4 : Tag{"p", text} {}
5
6 P(std::initializer_list<Tag> children)
7 : Tag("p", children) {}
8
9 };
10
11 struct IMG : Tag
12 {
13 explicit IMG(const std::string& url)
14 : Tag{"img", ""}
15 {
16 attributes.emplace_back({"src", url});
17 }
18 };
前面的构造器进一步约束了我们的 API。根据前面的构造器,一个段落只能包含文本或一组子元素。另一方面,图像可以不包含其他标签,但是必须有一个名为img
的属性,并提供地址。
现在,这个魔术的声望…由于统一初始化和我们产生的所有构造器,我们可以编写以下代码:
1 std::cout <<
2
3 P {
4 IMG { "http://pokemon.com/pikachu.png" }
5 }
6
7 << std::endl;
这不是很棒吗?我们已经为段落和图像构建了一个迷你 DSL,这个模型可以很容易地扩展到支持其他标签。而且看不到任何电话!
复合助洗剂
我们将用一个例子来结束对构建器的讨论,在这个例子中,多个构建器被用来构建一个单独的对象。假设我们决定记录一个人的一些信息:
1 class Person
2 {
3 // address
4 std::string street_address, post_code, city;
5
6 // employment
7 std::string company_name, position;
8 int annual_income = 0;
9
10 Person() {}
11 };
Person
有两个方面:他们的地址和就业信息。如果我们想为每一个都有单独的构建器,那该怎么办呢——我们怎样才能提供最方便的 API 呢?为此,我们将构建一个复合构建器。这种构造并不简单,所以要注意——尽管我们希望工作和地址信息有不同的构造器,但我们将产生不少于四个不同的类。
我选择在本书中完全避免 UML,但是这是类图有意义的一种情况,所以下面是我们实际要构建的:
我们称第一节课为PersonBuilderBase
:
1 class PersonBuilderBase
2 {
3 protected:
4 Person& person;
5 explicit PersonBuilderBase(Person& person)
6 : person{ person }
7 {
8 }
9 public:
10 operator Person()
11 {
12 return std::move(person);
13 }
14
15 // builder facets
16
17 PersonAddressBuilder lives() const;
18 PersonJobBuilder works() const;
19 };
这比我们前面简单的构建器要复杂得多,所以让我们依次讨论每个成员:
- 引用
person
是对正在构建的对象的引用。这可能看起来非常奇怪,但这是为子构建者特意做的。注意,Person
的物理存储在这个类中不存在。这很关键!根类只保存引用,不保存构造的对象。 - 分配引用的构造器是
protected
,因此只有继承者(PersonAddressBuilder
和PersonJobBuilder
)可以使用它。 - 是我们以前用过的一个把戏。我假设
Person
有一个正确定义的 move 构造器——ReSharper 可以轻松地生成一个。 lives()
和works()
是返回构建器方面的函数:分别初始化地址和雇佣信息的子构建器。
现在,前面的基类中唯一缺少的是正在构造的实际对象。它在哪里?嗯,它实际上存储在一个我们称之为,咳咳,PersonBuilder
的继承者中。这是我们希望人们实际使用的类:
1 class PersonBuilder : public PersonBuilderBase
2 {
3 Person p; // object being built
4 public:
5 PersonBuilder() : PersonBuilderBase{p} {}
6 };
所以这是建造物体的地方。这个类并不意味着被继承:它只是一个实用工具,让我们启动建立一个构建器的过程。 1
为了找出我们最终得到不同的公共和受保护构造器的确切原因,让我们看一下其中一个子构建器的实现:
1 class PersonAddressBuilder : public PersonBuilderBase
2 {
3 typedef PersonAddressBuilder self;
4 public:
5 explicit PersonAddressBuilder(Person& person)
6 : PersonBuilderBase{ person } {}
7
8 self& at(std::string street_address)
9 {
10 person.street_address = street_address;
11 return *this;
12 }
13
14 self& with_postcode(std::string post_code) { ... }
15
16 self& in(std::string city) { ... }
17 };
如你所见,PersonAddressBuilder
为建立一个人的地址提供了一个流畅的界面。注意,它实际上继承自PersonBuilderBase
(意味着它已经获得了lives()
和works()
成员函数)并调用基本构造器,传递一个引用。尽管它没有从PersonBuilder
继承——如果继承了,我们会创建太多的Person
实例,说实话,我们只需要一个。
正如您所猜测的,PersonJobBuilder
是以相同的方式实现的。这两个类以及PersonBuilder
都被声明为Person
中的friend
类,以便能够访问它的私有成员。
现在,你期待已久的时刻到了:这些建筑商的一个实例:
1 Person p = Person::create()
2 .lives().at("123 London Road")
3 .with_postcode("SW1 1GB")
4 .in("London")
5 .works().at("PragmaSoft")
6 .as_a("Consultant")
7 .earning(10e6);
你能看到这里发生了什么吗?我们使用create()
函数得到一个构建器,使用lives()
函数得到一个PersonAddressBuilder
,但是一旦我们完成了地址信息的初始化,我们只需调用works()
并切换到使用一个PersonJobBuilder
来代替。
当我们完成构建过程时,我们使用与之前相同的技巧将正在构建的对象作为Person
。注意,一旦这样做了,构建器就不可用了,因为我们用std::move()
移动了Person
。
摘要
构建器模式的目标是定义一个完全致力于复杂对象或对象集的分段构建的组件。我们已经观察到建造者的以下关键特征:
- 构建者可以拥有一个流畅的接口,该接口可用于使用单个调用链的复杂构建。为了支持这一点,构建器函数应该返回
this
或*this
。 - 为了强制 API 的用户使用构建器,我们可以使目标对象的构造器不可访问,然后定义一个返回构建器的静态函数
create()
。 - 通过定义适当的运算符,可以将生成器强制转换为对象本身。
- 由于统一的初始化器语法,Groovy 风格的构建器在 C++ 中是可能的。这种方法非常通用,允许创建不同的 DSL。
- 单个构建器接口可以公开多个子构建器。通过巧妙地使用继承和流畅的接口,人们可以轻松地从一个构建器跳到另一个构建器。
只是为了重申我已经提到的一些东西,当对象的构造是一个重要的过程时,使用 Builder 模式是有意义的。由有限数量的合理命名的构造器参数明确构造的简单对象可能应该使用构造器(或依赖注入),而不需要这样的构造器。
Footnotes 1
GitHub 上的@CodedByATool 建议将层次结构分成两个独立的基类,以避免重复的Person
实例——感谢这个想法!
三、工厂
我遇到了一个问题,试图使用 Java,现在我遇到了一个问题工厂。古老的爪哇笑话。
本章同时介绍了两种 GoF 模式:工厂方法和抽象工厂。这些模式密切相关,因此我们将一起讨论它们。
方案
让我们从一个激励人心的例子开始。支持你想在笛卡尔空间存储关于一个Point
的信息。因此,您继续执行类似这样的操作:
1 struct Point
2 {
3 Point(const float x, const float y)
4 : x{x}, y{y} {}
5 float x, y; // strictly Cartesian
6 };
目前为止,一切顺利。但是现在,你也想用极坐标来初始化这个点。您需要另一个带有签名的构造器:
1 Point(const float r, const float theta)
2 {
3 x = r * cos(theta);
4 y = r * sin(theta);
5 }
但是不幸的是,你已经有了一个带有两个float
的构造器,所以你不能有另一个。 1 你是做什么的?一种方法是引入枚举:
1 enum class PointType
2 {
3 cartesian,
4 polar
5 };
然后向点构造器添加另一个参数:
1 Point(float a, float b, PointType type = PointType::cartesian)
2 {
3 if (type == PointType::cartesian)
4 {
5 x = a;
6 y = b;
7 }
8 else
9 {
10 x = a * cos(b);
11 y = a * sin(b);
12 }
13 }
请注意前两个参数的名称是如何更改为a
和b
的:我们再也不能告诉用户这些值应该来自哪个坐标系。与使用x
、y
、rho
和theta
来传达意图相比,这是一种明显的表现力的丧失。
总的来说,我们的构造器设计是可用的,但是很难看。看看能不能改进。
工厂方法
构造器的问题在于它的名字总是与类型相匹配。这意味着我们不能在其中传递任何额外的信息,不像在普通函数中那样。此外,由于名字总是相同的,我们不能有两个重载,一个采用x,y
,另一个采用r,theta
。
那么我们能做什么呢?那么,制作构造器protected
然后公开一些静态函数来创建新点怎么样?
1 struct Point
2 {
3 protected:
4 Point(const float x, const float y)
5 : x{x}, y{y} {}
6 public:
7 static Point NewCartesian(float x, float y)
8 {
9 return { x,y };
10 }
11 static Point NewPolar(float r, float theta)
12 {
13 return { r * cos(theta), r * sin(theta) };
14 }
15 // other members here
16 };
上述每个静态函数都被称为工厂方法。它所做的只是创建一个Point
并返回它,这样做的好处是方法名和参数名清楚地传达了需要哪种坐标。
现在,要创建一个点,你只需写
1 auto p = Point::NewPolar(5, M_PI_4);
从前面的内容中,我们可以清楚地推测,我们正在创建一个极坐标为 r = 5 和θ=π/4 的新点。
工厂
就像使用 Builder 一样,我们可以将所有的Point
-创建函数从Point
中取出,放入一个单独的类,即所谓的工厂。首先,我们重新定义了Point
类:
1 struct Point
2 {
3 float x, y;
4 friend class PointFactory;
5 private:
6 Point(float x, float y) : x(x), y(y){}
7 };
两件事在这里一文不值:
Point
的构造器是private
,因为我们不希望任何人直接调用它。这不是一个严格的要求,但是将其公开会产生一点模糊,因为它向用户呈现了两种不同的构造对象的方式。Point
声明PointFactory
为friend
类。这样做是故意的,以便Point
的私有构造器对 factor 可用——否则,工厂将无法实例化该对象!这里的含义是,这两种类型是同时创建的,而不是很久以后才创建的工厂。
现在,我们简单地在一个名为PointFactory
的单独类中定义我们的NewXxx()
函数:
1 struct PointFactory
2 {
3 static Point NewCartesian(float x, float y)
4 {
5 return Point{ x,y };
6 }
7 static Point NewPolar(float r, float theta)
8 {
9 return Point{ r*cos(theta), r*sin(theta) };
10 }
11 };
就这样——我们现在有了一个专门为创建Point
实例而设计的专用类,使用如下:
1 auto my_point = PointFactory::NewCartesian(3, 4);
内部工厂
内部工厂就是它所创建的类型中的内部类的工厂。公平地说,内部工厂是 C#、Java 和其他缺少friend
关键字的语言的典型工件,但是没有理由在 C++ 中不能有它。
内部工厂存在的原因是因为内部类可以访问外部类的成员,反过来,外部类也可以访问内部类的私有成员。这意味着我们的Point
类也可以定义如下:
1 struct Point
2 {
3 private:
4 Point(float x, float y) : x(x), y(y) {}
5
6 struct PointFactory
7 {
8 private:
9 PointFactory() {}
10 public:
11 static Point NewCartesian(float x, float y)
12 {
13 return { x,y };
14 }
15 static Point NewPolar(float r, float theta)
16 {
17 return{ r*cos(theta), r*sin(theta) };
18 }
19 };
20 public:
21 float x, y;
22 static PointFactory Factory;
23 };
好吧,这是怎么回事?嗯,我们已经将工厂嵌入到工厂创建的类中。如果一个工厂只使用一种类型,这是很方便的,如果一个工厂依赖于几种类型,这就不那么方便了(如果它还需要它们的private
成员,这几乎是不可能的)。
你会注意到我在这里很狡猾:整个工厂都在一个private
块中,此外,它的构造器也被标记为private
。本质上,即使我们可以把这个工厂曝光为Point::PointFactory
,那也太拗口了。相反,我定义了一个名为Factory
的静态成员。这允许我们将工厂用作
1 auto pp = Point::Factory.NewCartesian(2, 3);
如果出于某种原因,你不喜欢混合使用::
和.
,你当然可以修改代码,这样你就可以在任何地方都使用::
。做到这一点的两个说法是:
-
将工厂公开,这样您就可以编写
-
如果你不喜欢
Point
这个词在前面出现两次,你可以把typedef PointFactory Factory
写成Point::Factory::NewXxx(...)
。这可能是人们能想到的最明智的语法。或者干脆叫内厂Factory
,这种一劳永逸的解决问题…除非你决定以后再考虑。
1 Point::PointFactory::NewXxx(...)`
是否拥有内部工厂的决定很大程度上取决于您喜欢如何组织代码。然而,从原始对象公开工厂极大地提高了 API 的可用性。如果我找到一个名为Point
的类型,它有一个private
构造器,我怎样才能知道这个类应该被使用呢?嗯,我不会,除非Person::
在代码完成清单中给我一些有意义的东西。
抽象工厂
到目前为止,我们一直在看单个对象的构造。有时,您可能会参与创建对象族。这实际上是一种非常罕见的情况,所以与工厂方法和简单的旧工厂模式不同,抽象工厂是一种只在复杂系统中出现的模式。不管怎样,我们需要谈论它,主要是出于历史原因。
这里有一个简单的场景:假设你在一家提供茶和咖啡的咖啡馆工作。这两种热饮是通过完全不同的设备生产的,我们都可以把它们做成工厂的模型。茶和咖啡实际上可以同时提供热或举行,但让我们把重点放在热点品种。首先,我们可以定义一个HotDrink
是什么:
1 struct HotDrink
2 {
3 virtual void prepare(int volume) = 0;
4 };
功能prepare
就是我们所说的准备特定体积的热饮。例如,对于类型Tea
,它将被实现为
1 struct Tea : HotDrink
2 {
3
4 void prepare(int volume) override
5 {
6 cout << "Take tea bag, boil water, pour " << volume <<
7 "ml, add some lemon" << endl;
8 }
9 };
类似的还有Coffee
型。在这一点上,我们可以编写一个假设的make_drink()
函数,它将获取一种饮料的名称并制作这种饮料。给定一组离散的案例,它看起来可能相当乏味:
1 unique_ptr<HotDrink> make_drink(string type)
2 {
3 unique_ptr<HotDrink> drink;
4 if (type == "tea")
5 {
6 drink = make_unique<Tea>();
7 drink->prepare(200);
8 }
9 else
10 {
11 drink = make_unique<Coffee>();
12 drink->prepare(50);
13 }
14 return drink;
15 }
记住,不同的饮料是由不同的机器制造的。在我们的例子中,我们对热饮感兴趣,我们将通过恰当命名的Hot-DrinkFactory
对热饮建模:
1 struct HotDrinkFactory
2 {
3 virtual unique_ptr<HotDrink> make() const = 0;
4 };
这种类型恰好是一个抽象工厂:它是一个具有特定接口的工厂,但它是抽象的,这意味着即使它可以作为函数参数,例如,我们也需要具体的实现来实际制作饮料。例如,在制作Coffee
的情况下,我们可以写
1 struct CoffeeFactory : HotDrinkFactory
2 {
3 unique_ptr<HotDrink> make() const override
4 {
5 return make_unique<Coffee>();
6 }
7 }
和以前一样,TeaFactory
也是如此。现在,假设我们想要定义一个更高级的接口来制作不同的饮料,热饮或冷饮。我们可以创建一个名为DrinkFactory
的类型,它本身包含对各种可用工厂的引用:
1 class DrinkFactory
2 {
3 map<string, unique_ptr<HotDrinkFactory>> hot_factories;
4 public:
5 DrinkFactory()
6 {
7 hot_factories["coffee"] = make_unique<CoffeeFactory>();
8 hot_factories["tea"] = make_unique<TeaFactory>();
9 }
10
11 unique_ptr<HotDrink> make_drink(const string& name)
12 {
13 auto drink = hot_factories[name]->make();
14 drink->prepare(200); // oops!
15 return drink;
16 }
17 };
这里我做了一个假设,我们希望根据饮料的名字而不是某个整数或成员来分配饮料。我们简单地制作一个字符串和关联工厂的映射:实际的工厂类型是HotDrinkFactory
(我们的抽象工厂),我们通过智能指针而不是直接存储它们(有意义,因为我们想防止对象切片)。
现在,当有人想要一杯饮料时,我们找到相关的工厂(想象一个咖啡店店员走到正确的机器前),制作饮料,准确地准备所需的体积(我在前面已经将其设置为常量;随意将其提升为一个参数)然后返回相关的饮料。这就是全部了。
功能工厂
我想提到的最后一件事是:当我们通常使用工厂这个术语时,我们通常指两种情况之一:
- 知道如何创建对象的类
- 一个函数,当被调用时,创建一个对象
第二种选择不仅仅是传统意义上的工厂方法。如果有人将返回类型T
的std::function
传入某个函数,这通常被称为工厂,而不是工厂方法。这可能看起来有点奇怪,但是当你考虑到方法是成员函数的同义词时,这就更有意义了。
对我们来说幸运的是,函数可以存储在变量中,这意味着我们可以将精确制备 200 毫升液体的过程内部化,而不是仅仅存储一个指向工厂的指针(正如我们在前面的DrinkFactory
中所做的)。这可以通过从工厂切换到简单使用功能块来实现,例如:
1 class DrinkWithVolumeFactory
2 {
3 map<string, function<unique_ptr<HotDrink>()>> factories;
4 public:
5 DrinkWithVolumeFactory()
6 {
7 factories["tea"] = [] {
8 auto tea = make_unique<Tea>();
9 tea->prepare(200);
10 return tea;
11 }; // similar for Coffee
12 }
13 };
当然,采用这种方法后,我们现在只能直接调用存储的工厂,即:
1 inline unique_ptr<HotDrink>
2 DrinkWithVolumeFactory::make_drink(const string& name)
3 {
4 return factories[name]();
5 }
这可以像以前一样使用。
摘要
让我们回顾一下术语:
- 工厂方法是作为创建对象的一种方式的类成员。它通常替换构造器。
- 工厂通常是一个知道如何构造对象的单独的类,尽管如果你传递一个构造对象的函数(如
std::function
或类似的),这个参数也称为工厂。 - 顾名思义,抽象工厂是一个抽象类,可以被提供一系列类型的具体类继承。抽象工厂在野外很少见。
与构造器调用相比,工厂有几个关键优势,即:
- 工厂可以说 no,这意味着它可以返回一个对象,而不是实际返回一个对象,例如一个
nullptr
。 - 命名更好,不受约束,不像构造器名。
- 一个工厂可以制造许多不同类型的物品。
- 工厂可以展示多态行为,实例化一个类并通过其基类的引用或指针返回它。
- 工厂可以实现缓存和其他存储优化;这也是诸如池或单例模式等方法的自然选择(在第五章中有更多关于这方面的内容)。
工厂与构建器的不同之处在于,对于工厂,您通常一次创建一个对象,而对于构建器,您通过提供部分信息来分段构建对象。
Footnotes 1
一些编程语言,最著名的是 Objective-C 和 Swift,确实允许函数重载,只是参数名不同。不幸的是,这种想法导致了所有调用中参数名称的病毒式传播。大多数时候,我还是更喜欢位置参数。
四、原型
想想你每天使用的东西,比如汽车或手机。很有可能,它不是从零开始设计的;相反,制造商选择了一个现有的设计,进行了一些改进,使其在视觉上与旧设计有所区别(这样人们就可以炫耀),并开始销售它,淘汰旧产品。这是一种自然状态,在软件世界中,我们会遇到类似的情况:有时,不是从头开始创建一个完整的对象(工厂和构建器模式在这里会有所帮助),而是希望获得一个预构造的对象,或者使用它的副本(这很容易),或者对它进行一点定制。
这让我们想到了拥有一个原型的想法:一个模型对象,我们可以制作副本,定制这些副本,然后使用它们。原型模式的挑战实际上是复制部分;其他的都好办。
客体结构
大多数对象构造都使用构造器。但是,如果您已经配置了一个对象,为什么不简单地复制该对象,而不是创建一个相同的对象呢?如果您必须应用 Builder 模式来简化分段对象构造,这一点尤其重要。
让我们考虑一个简单的例子,但它清楚地显示了重复:
1 Contact john{ "John Doe", Address{"123 East Dr", "London", 10 } };
2 Contact jane{ "Jane Doe", Address{"123 East Dr", "London", 11 } };
你可以看到这里发生了什么。john
和jane
都在同一栋大楼工作,但在不同的办公室。许多其他人可能在London
的123 East Dr
工作,那么如果我们想避免地址的重复初始化呢?我们怎么做?
事实是,原型模式完全是关于对象复制的。当然,我们没有一个统一的方法来复制一个对象,但是有很多选择,我们会选择其中的一些。
普通复制
如果你复制的是一个值,并且你复制的对象通过值存储所有的东西,那就没有问题。例如,如果您将前面示例中的Contact
和Address
定义为
1 struct Address
2 {
3 string street, city;
4 int suite;
5 }
6 struct Contact
7 {
8 string name;
9 Address address;
10 }
写这样的东西绝对没有问题
1 // here is the prototype:
2 Contact worker{"", Address{"123 East Dr", "London", 0}};
3
4 // make a copy of prototype and customize it
5 Contact john = worker;
6 john.name = "John Doe";
7 john.address.suite = 10;
实际上,这种情况很少发生。例如,在许多情况下,内部的Address
对象是一个指针:
1 struct Contact
2 {
3 string name;
4 Address *address; // pointer (or e.g., shared_ptr)
5 }
这在工作中抛出了一个难题,因为现在行Contact john = prototype
复制了指针,现在john
和prototype
以及原型的每个其他副本共享相同的地址。这绝对不是我们想要的。
通过复制结构复制
避免重复的最简单方法是确保在组成对象的所有组成部分(在本例中是Contact
和Address
)上定义复制构造器。例如,如果我们采用通过自有指针存储地址的想法,即:
1 struct Contact
2 {
3 string name;
4 Address* address;
5 }
然后我们需要创建一个复制构造器。在我们的例子中,实际上有两种方法可以做到这一点。正面方法看起来应该是这样的:
1 Contact(const Contact& other)
2 : name{other.name}
3 //, address{ new Address{*other.address} }
4 {
5 address = new Address(
6 other.address->street,
7 other.address->city,
8 other.address->suite
9 );
10 }
不幸的是,前面的方法不够通用。在这种情况下它肯定会工作(假设Address
有一个初始化其所有成员的构造器),但是如果 Address 决定将它的street
部分分割成一个由街道名、门牌号和附加信息组成的对象,该怎么办呢?那么我们会再次遇到同样的复制问题。
这里一个明智的做法是在Address
上定义一个复制构造器。在我们的例子中,这很简单:
1 Address(const string& street, const string& city, const int suite)
2 : street{street},
3 city{city},
4 suite{suite} {}
现在我们可以重写Contact
构造器来重用这个复制构造器:
1 Contact(const Contact& other)
2 : name{other.name}
3 , address{ new Address{*other.address} }
4 {}
请注意,如果您使用 ReSharper 的生成器进行复制和移动操作,它还会给出operator=
,在我们的例子中,它将被定义为
1 Contact& operator=(const Contact& other)
2 {
3 if (this == &other)
4 return *this;
5 name = other.name;
6 address = other.address;
7 return *this;
8 }
那好多了。现在,我们可以像以前一样构造一个原型,然后重用它:
1 Contact worker{"", new Address{"123 East Dr", "London", 0}};
2 Contact john{worker}; // or: Contact john = worker;
3 john.name = "John";
4 john.suite = 10;
这种方法是可行的,而且效果很好。这里唯一真正的问题,也是一个不容易解决的问题,是实现所有这些复制构造器所需的额外工作量。诚然,像 ReSharper 这样的工具可以在大多数情况下快速完成工作,但是也有很多需要注意的地方。例如,你认为如果我写了会发生什么
1 Contact john = worker;
并且忘记为Address
(而不是Contact
)实现复制赋值?没错,程序仍然可以编译。使用复制构造器稍微好一点,因为如果你试图调用一个,但它丢失了,你会得到一个错误,而operator =
是无处不在的,即使你没有指定正确的操作。
这是另一个问题:假设你开始使用类似双指针的东西(例如,void**
)?还是一个unique_ptr
?即使像 ReSharper 和 CLion 这样的工具很神奇,也不太可能在这一点上生成正确的代码,所以在这些类型上快速生成代码可能并不总是最好的主意。
通过坚持使用复制构造器而不生成复制赋值操作符,可以在一定程度上减少熵。另一种选择是抛弃复制构造器,转而使用类似
1 template <typename T> struct Cloneable
2 {
3 virtual T clone() const = 0;
4 }
然后继续实现这个接口,并在需要实际副本时调用prototype.clone()
。这实际上比复制构造器/赋值更好地传达了意图。
不管你选择哪一个,这里的要点是这种方法是可行的,但是如果你的对象图非常复杂,就会变得有点乏味。
序列化
其他编程语言的设计者也遇到了同样的问题,即必须在整个对象图上显式定义复制操作,并很快意识到一个类需要“平凡地可序列化”——例如,默认情况下,您应该能够获取一个类并将其写入一个文件,而不必给该类赋予任何特征(嗯,最多一两个属性)。
为什么这与手头的问题相关?因为如果您可以将某些东西序列化到文件或内存中,那么您可以反序列化它,保留所有信息,包括所有依赖对象。这不是很方便吗?嗯…
不幸的是,与其他编程语言不同,C++ 在序列化方面没有给我们提供任何免费的午餐。例如,我们不能将一个复杂的对象图序列化为一个文件。为什么不呢?嗯,在其他编程语言中,编译后的二进制文件不仅包括可执行代码,还包括大量元数据,序列化可以通过一种叫做反射的特性来实现——到目前为止 C++ 中还没有这种特性。
如果我们想要序列化,就像显式复制操作一样,我们需要自己实现它。幸运的是,我们可以使用名为 Boost 的现成库,而不是修改位并想出序列化std::string
的方法。序列化来为我们解决一些问题。下面是一个我们如何给Address
类型添加序列化支持的例子:
1 struct Address
2 {
3 string street;
4 string city;
5 int suite;
6 private:
7 friend class boost::serialization::access;
8 template<class Ar> void serialize(Ar& ar, const unsigned int version)
9 {
10 ar & street;
11 ar & city;
12 ar & suite;
13 }
14 }
老实说,这看起来有点落后,但是最终结果是我们已经使用&
操作符指定了Address
的所有部分,我们需要将这些部分写入保存对象的位置。请注意,前面的代码是一个用于保存和加载数据的成员函数。可以告诉 Boost 在保存和加载时执行不同的操作,但这与我们的原型设计需求并不特别相关。
现在,我们还需要对Contact
类型执行相同的操作。开始了。
1 struct Contact
2 {
3 string name;
4 Address* address = nullptr;
5 private:
6 friend class boost::serialization::access;
7 template<class Ar> void serialize(Ar& ar, const unsigned int version)
8 {
9 ar & name;
10 ar & address; // no *
11 }
12 };
前面的serialize()
函数的结构或多或少是相同的,但是请注意一件有趣的事情:我们没有将地址作为ar & *address
来访问,而是将其序列化为ar & address
,而没有取消对指针的引用。Boost 足够聪明,可以判断出发生了什么,即使address
被设置为nullptr
,它也能很好地序列化/反序列化。
因此,如果您想以这种方式实现原型模式,您需要在对象图中出现的每一个可能的类型上实现serialize()
。但是如果您这样做了,那么您现在可以做的就是定义一种通过序列化/反序列化来克隆对象的方法:
1 auto clone = [](const Contact& c)
2 {
3 // 1\. Serialize the contact
4 ostringstream oss;
5 boost::archive::text_oarchive oa(oss);
6 oa << c;
7 string s = oss.str();
8
9 // 2\. Deserialize the contact
10 istringstream iss(oss.str());
11 boost::archive::text_iarchive ia(iss);
12 Contact result;
13 ia >> result;
14 return result;
15 };
现在,有了一个名为john
的联系人,你可以简单地写
1 Contact jane = clone(john);
2 jane.name = "Jane"; // and so on
然后随心所欲地定制jane
。
原型工厂
如果您有想要复制的预定义对象,那么您实际上在哪里存储它们呢?一个全局变量?也许吧。实际上,假设我们公司既有主办公室又有辅助办公室。我们可以像这样声明全局变量:
1 Contact main{ "", new Address{ "123 East Dr", "London", 0 } };
2 Contact aux{ "", new Address{ "123B East Dr", "London", 0 } };
例如,我们可以将这些定义粘贴到Contact.h
中,这样任何使用Contact
类的人都可以获得这些全局变量中的一个并复制它们。但是更明智的方法是使用某种专用类来存储原型,并在需要时分发所述原型的定制副本。这将给我们带来额外的灵活性:例如,我们可以创建效用函数,并分发正确初始化的unique_ptr
s:
1 struct EmployeeFactory
2 {
3 static Contact main;
4 static Contact aux;
5
6 static unique_ptr<Contact> NewMainOfficeEmployee(string name, int suite)
7 {
8 return NewEmployee(name, suite, main);
9 }
10
11 static unique_ptr<Contact> NewAuxOfficeEmployee(string name, int suite)
12 {
13 return NewEmployee(name, suite, aux);
14 }
15
16 private:
17 static unique_ptr<Contact> NewEmployee(
18 string name, int suite, Contact& proto)
19 {
20 auto result = make_unique<Contact>(proto);
21 result->name = name;
22 result->address->suite = suite;
23 return result;
24 }
25 };
前述内容现在可以如下使用:
1 auto john = EmployeeFactory::NewAuxOfficeEmployee("John Doe", 123);
2 auto jane = EmployeeFactory::NewMainOfficeEmployee("Jane Doe", 125);
为什么要用工厂?嗯,考虑一下我们复制一个prototype
然后忘记定制它的情况。它将在实际数据应该在的地方有一些空白字符串和零。使用我们讨论工厂时的方法,我们可以,例如,创建所有非完全初始化的构造器private
,将EmployeeFactory
声明为friend class
,就这样——现在客户端没有办法获得部分构造的Contact
。
摘要
原型设计模式体现了对象深度复制的概念,这样,您就可以获得一个预制的对象,复制它,稍加修改,然后独立于原始对象使用它,而不是每次都进行完全初始化。
在 C++ 中只有两种实现原型模式的方法,而且都需要手工操作。它们是:
- 编写正确复制对象的代码,即执行深度复制。这可以在复制构造器/复制赋值操作符或单独的成员函数中完成。
- 编写支持序列化/反序列化的代码,然后使用这种机制将克隆实现为序列化紧接着反序列化。这带来了额外的计算成本;它的重要性取决于你需要多长时间复制一次。与使用复制构造器相比,这种方法的唯一优点是可以免费获得序列化。
无论您选择哪种方法,都需要做一些工作。如果您决定选择这两种方法中的任何一种,代码生成工具(例如 ReSharper、CLion)可以提供帮助。最后,不要忘记,如果你按值存储数据,你并没有真正的问题。
五、单例
在(相当有限的)设计模式历史中,单体模式是最令人讨厌的设计模式。然而,仅仅说明这一点并不意味着你不应该使用 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)
扩展来实现。