现代-C---设计模式-全-
现代 C++ 设计模式(全)
一、介绍
设计模式这个话题听起来很枯燥,在学术上很枯燥,老实说,几乎在所有可以想象的编程语言中都是如此——包括像 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)
扩展来实现。
六、适配器
我曾经经常旅行,一个旅行适配器可以让我将欧洲插头插入英国或美国插座 1 中,这是对适配器模式的一个很好的类比:我们有一个接口,但我们想要一个不同的接口,在接口上构建一个适配器可以让我们到达我们想要的地方。
方案
这里有一个简单的例子:假设您正在使用一个非常擅长绘制像素的库。另一方面,你处理的是几何对象——直线、矩形之类的东西。您希望继续处理这些对象,但也需要渲染,因此需要使几何体适应基于像素的表示。
让我们从定义示例中的(相当简单的)域对象开始:
1 struct Point
2 {
3 int x, y;
4 };
5
6 struct Line
7 {
8 Point start, end;
9 };
现在让我们从理论上研究矢量几何。典型的矢量对象很可能是由一组Line
对象定义的。我们可以只定义一对纯虚拟迭代器方法,而不是从vector<Line>
继承:
1 struct VectorObject
2 {
3 virtual std::vector<Line>::iterator begin() = 0;
4 virtual std::vector<Line>::iterator end() = 0;
5 };
这样,如果您想定义一个Rectangle
,您可以在一个vector<Line>
类型的字段中保存一串行,并简单地公开它的端点:
1 struct VectorRectangle : VectorObject
2 {
3 VectorRectangle(int x, int y, int width, int height)
4 {
5 lines.emplace_back(Line{ Point{x,y}, Point{x + width,y} });
6 lines.emplace_back(Line{ Point{x + width,y}, Point{x+width, y+height} });
7 lines.emplace_back(Line{ Point{x,y}, Point{x,y+height} });
8 lines.emplace_back(Line{ Point{ x,y + height },
9 Point{ x + width, y + height } });
10 }
11
12 std::vector<Line>::iterator begin() override {
13 return lines.begin();
14 }
15 std::vector<Line>::iterator end() override {
16 return lines.end();
17 }
18 private:
19 std::vector<Line> lines;
20 };
现在,这里是设置。假设我们想在屏幕上画线。长方形,甚至!不幸的是,我们不能,因为绘图的唯一界面实际上是这样的:
1 void DrawPoints(CPaintDC& dc, std::vector<Point>::iterator\
2 start, std::vector<Point>::iterator end)
3 {
4 for (auto i = start; i != end; ++i)
5 dc.SetPixel(i->x, i->y, 0);
6 }
我这里用的是 MFC(微软基础类)的 CPaintDC 类,但这是题外话。关键是我们需要像素。我们只有台词。我们需要一个适配器。
适配器
好吧,假设我们想画几个矩形:
1 vector<shared_ptr<VectorObject>> vectorObjects{
2 make_shared<VectorRectangle>(10,10,100,100),
3 make_shared<VectorRectangle>(30,30,60,60)
4 }
为了绘制这些对象,我们需要将它们中的每一个从一系列线转换成大量的点。为此,我们创建一个单独的类来存储这些点,并将它们作为一对迭代器公开:
1 struct LineToPointAdapter
2 {
3 typedef vector<Point> Points;
4
5 LineToPointAdapter(Line& line)
6 {
7 // TODO
8 }
9
10 virtual Points::iterator begin() { return points.begin(); }
11 virtual Points::iterator end() { return points.end(); }
12 private:
13 Points points;
14 };
从一条线到多个点的转换正好发生在构造器中,所以适配器非常渴望。 2 转换的实际代码也相当简单:
1 LineToPointAdapter(Line& line)
2 {
3 int left = min(line.start.x, line.end.x);
4 int right = max(line.start.x, line.end.x);
5 int top = min(line.start.y, line.end.y);
6 int bottom = max(line.start.y, line.end.y);
7 int dx = right - left;
8 int dy = line.end.y - line.start.y;
9
10 // only vertical or horizontal lines
11 if (dx == 0)
12 {
13 // vertical
14 for (int y = top; y <= bottom; ++y)
15 {
16 points.emplace_back(Point{ left,y });
17 }
18 }
19 else if (dy == 0)
20 {
21 for (int x = left; x <= right; ++x)
22 {
23 points.emplace_back(Point{ x, top });
24 }
25 }
26 }
上面的代码很简单:我们只处理完全垂直或水平的行,而忽略其他的。我们现在可以使用这个适配器来实际呈现一些对象。我们从示例中取出两个矩形,并简单地将它们渲染成这样:
1 for (auto& obj : vectorObjects)
2 {
3 for (auto& line : *obj)
4 {
5 LineToPointAdapter lpo{ line };
6 DrawPoints(dc, lpo.begin(), lpo.end());
7 }
8 }
太美了!我们所做的就是,对于每个 vector 对象,获取它的每条线,为那条线构造一个LineToPointAdapter
,然后迭代由适配器产生的点集,将它们提供给DrawPoints()
。而且很管用!(相信我,确实如此。)
临时适配器
不过,我们的代码有一个主要问题:DrawPoints()
在我们可能需要的每一次屏幕刷新时都会被调用,这意味着相同行对象的相同数据会被适配器重新生成无数次。我们能做些什么呢?
一方面,我们可以在应用程序启动时预定义所有点,例如:
1 vector<Point> points;
2 for (auto& o : vectorObjects)
3 {
4 for (auto& l : *o)
5 {
6 LineToPointAdapter lpo{ l };
7 for (auto& p : lpo)
8 points.push_back(p);
9 }
10 }
然后DrawPoints()
的实现简化为
1 DrawPoints(dc, points.begin(), points.end());
但是让我们假设一下,vectorObjects
的原始集合是可以改变的。缓存这些点没有意义,但是我们仍然希望避免不断地重新生成潜在的重复数据。我们该如何应对?当然是带缓存的!
首先,为了避免再生,我们需要独特的识别线的方法,这就意味着我们需要独特的识别点的方法。ReSharper 的 Generate | Hash 函数拯救了我们:
1 struct Point
2 {
3 int x, y;
4
5 friend std::size_t hash_value(const Point& obj)
6 {
7 std::size_t seed = 0x725C686F;
8 boost::hash_combine(seed, obj.x);
9 boost::hash_combine(seed, obj.y);
10 return seed;
11 }
12 };
13
14 struct Line
15 {
16 Point start, end;
17
18 friend std::size_t hash_value(const Line& obj)
19 {
20 std::size_t seed = 0x719E6B16;
21 boost::hash_combine(seed, obj.start);
22 boost::hash_combine(seed, obj.end);
23 return seed;
24 }
25 };
在前面的例子中,我选择了 Boost 的散列实现。现在,我们可以构建一个新的LineToPointCachingAdapter
来缓存这些点,并在必要时重新生成它们。除了以下细微差别之外,实现几乎是相同的。
首先,适配器现在有了一个缓存:
1 static map<size_t, Points> cache;
这里的类型size_t
正是 Boost 的哈希函数返回的类型。现在,当涉及到迭代生成的点时,我们产生它们如下:
1 virtual Points::iterator begin() { return cache[line_hash].begin(); }
2 virtual Points::iterator end() { return cache[line_hash].end(); }
这是算法有趣的部分:在生成点之前,我们检查它们是否已经生成。如果他们有,我们就退出;如果没有,我们会生成它们并将其添加到缓存中:
1 LineToPointCachingAdapter(Line& line)
2 {
3 static boost::hash<Line> hash;
4 line_hash = hash(line); // note: line_hash is a field!
5 if (cache.find(line_hash) != cache.end())
6 return; // we already have it
7
8 Points points;
9
10 // same code as before
11
12 cache[line_hash] = points;
13 }
耶!多亏了哈希函数和缓存,我们大大减少了转换的次数。剩下的唯一问题是在不再需要旧点后将其删除。这个具有挑战性的问题留给读者做练习。
摘要
适配器是一个非常简单的概念:它允许您将您拥有的接口适配到您需要的接口。适配器唯一真正的问题是,在适配过程中,有时您最终会生成临时数据,以满足一些其他的数据表示。当这种情况发生时,转向缓存:确保新数据只在必要时生成。哦,如果您想在缓存的对象发生变化时清理过时的数据,您还需要做更多的工作。
我们还没有真正解决的另一个问题是懒惰:当前的适配器实现在创建转换时就执行转换。如果您只想在实际使用适配器时完成工作,该怎么办?这很容易做到,留给读者作为练习。
Footnotes 1
以防你像我一样是欧洲人,想抱怨每个人都应该使用欧洲插座:不;英国的设计在技术上更好,也更安全,所以如果我们真的只想要一个标准,英国的将会是我们的首选。
2
我们能让适配器变得懒惰吗?当然,我们可以只在本地保存line
(因为它是一个引用,我们不希望它过时或改变),然后,每当有人调用begin()
时,如果还没有初始化,就执行初始化。然而,如果我们有几个适配器成员,这个初始化检查必须在每个成员中重复。
七、桥接
如果你一直在关注 C++ 编译器的最新进展(特别是 GCC、Clang 和 MSVC),你可能已经注意到编译速度在提高。特别是,编译器变得越来越增量,以至于编译器实际上只能重建已经改变的定义,并重用其余的,而不是重建整个翻译单元。
我提出 C++ 编译的原因是因为“一个奇怪的技巧”(又是那个短语!)一直被开发人员用来尝试和优化编译速度。
当然,我说的是…
通俗的习语
让我首先解释一下在 Pimpl 习语中发生的技术方面的事情。假设您决定创建一个Person
类,它存储一个人的名字并允许他们打印问候。不是像通常那样定义Person
的成员,而是像这样定义类:
1 struct Person
2 {
3 std::string name;
4 void greet();
5
6 Person();
7 ~Person();
8
9 class PersonImpl;
10 PersonImpl *impl; // good place for gsl::owner<T>
11 };
这太奇怪了。对于一个简单的类来说,似乎有很多工作要做。让我们看看…我们有了名字和greet()
函数,但是为什么还要麻烦构造器和析构函数呢?还有这个class PersonImpl
是什么?
您看到的是一个选择在另一个类中隐藏其实现的类,这个类被称为PersonImpl
。需要特别注意的是,这个类没有在头文件中定义,而是驻留在.cpp
文件中(Person.cpp
,所以Person
和PersonImpl
在同一位置)。它的定义非常简单
1 struct Person::PersonImpl
2 {
3 void greet(Person* p);
4 }
最初的Person
类向前声明PersonImpl
,并继续保存指向它的指针。正是这个指针在Person's
构造器中被初始化,在析构函数中被销毁;如果智能指针能让你感觉更好,请随意使用。
1 Person::Person()
2 : impl(new PersonImpl) {}
3
4 Person::~Person() { delete impl; }
现在,我们开始实现Person::
greet()
,你可能已经猜到了,它只是将控制权传递给了PersonImpl::greet()
:
1 void Person::greet()
2 {
3 impl->greet(this);
4 }
5
6 void Person::PersonImpl::greet(Person* p)
7 {
8 printf("hello %s", p->name.c_str());
9 }
所以…简而言之,这就是 Pimpl 的习惯用法,所以唯一的问题是为什么?!?为什么要麻烦地跳过所有的关卡,委派greet()
并传递一个this
指针呢?这种方法有三个优点:
- 更大比例的类实现实际上是隐藏的。如果您的
Person
类需要一个充满了private/protected
成员的丰富 API,那么您将向您的客户公开所有这些细节,即使由于private/protected
访问修饰符,他们永远无法访问这些成员。使用 Pimpl,它们只能被提供公共接口。 - 修改隐藏 Impl 类的数据成员不会影响二进制兼容性。
- 头文件只需要包含声明所需的头文件,而不需要包含实现。例如,如果
Person
需要一个类型为vector<string>
的私有成员,您将被迫在Person.h
头中对<vector>
和<string>
都进行#include
(这是可传递的,所以任何使用Person.h
的人也会包括它们)。使用 Pimpl 习惯用法,这可以在.cpp
文件中完成。
您会注意到,上述几点使我们能够保持一个干净的、不变的头文件。这样做的副作用是降低了编译速度。而且,对我们来说重要的是,Pimpl 实际上是桥模式的一个很好的例子:在我们的例子中,pimpl
opaque 指针(opaque 是 transparent 的反义词,也就是说,你不知道它后面是什么)作为一个桥,将公共接口的成员和隐藏在.cpp
文件中的底层实现粘合在一起。
桥
Pimpl 习惯用法是桥设计模式的一个非常具体的说明,所以让我们来看一些更一般的东西。假设我们有两类对象(在数学意义上):几何形状和可以在屏幕上绘制它们的渲染器。
就像我们对适配器模式的说明一样,我们将假设呈现可以以矢量和光栅的形式发生(尽管我们不会在这里编写任何实际的绘图代码),就形状而言,让我们只限于圆形。
首先,这里是Renderer
基类:
1 struct Renderer
2 {
3 virtual void render_circle(float x, float y, float radius) = 0;
4 };
我们可以很容易地构造矢量和光栅实现;我将使用一些代码模拟下面的实际渲染,以将内容写入控制台:
1 struct VectorRenderer : Renderer
2 {
3 void render_circle(float x, float y, float radius) override
4 {
5 cout << "Rasterizing circle of radius " << radius << endl;
6 }
7 };
8
9 struct RasterRenderer : Renderer
10 {
11 void render_circle(float x, float y, float radius) override
12 {
13 cout << "Drawing a vector circle of radius " << radius << endl;
14 }
15 };
基类Shape
将保持对渲染器的引用;该形状将支持使用draw()
成员函数的自呈现,还将支持resize()
操作:
1 struct Shape
2 {
3 protected:
4 Renderer& renderer;
5 Shape(Renderer& renderer) : renderer{ renderer } {}
6 public:
7 virtual void draw() = 0;
8 virtual void resize(float factor) = 0;
9 };
你会注意到Shape
类引用了一个Renderer
。这恰好是我们建造的桥梁。我们现在可以创建一个Shape
类的实现,提供额外的信息,比如圆心的位置和半径。
1 struct Circle : Shape
2 {
3 float x, y, radius;
4
5 void draw() override
6 {
7 renderer.render_circle(x, y, radius);
8 }
9
10 void resize(float factor) override
11 {
12 radius *= factor;
13 }
14
15 Circle(Renderer& renderer, float x, float y, float radius)
16 : Shape{renderer}, x{x}, y{y}, radius{radius} {}
17 };
好了,这个模式很快就暴露出来了,有趣的部分当然是在draw()
中:这是我们使用桥将Circle
(它有关于它的位置和大小的信息)连接到渲染过程的地方。而确切地说,这里的桥梁是一座Renderer
,例如:
1 RasterRenderer rr;
2 Circle raster_circle{ rr, 5,5,5 };
3 raster_circle.draw();
4 raster_circle.resize(2);
5 raster_circle.draw();
在前面的例子中,桥是RasterRenderer
:你创建它,将一个引用传入Circle
,从那时起,对draw()
的调用将使用这个RasterRenderer
作为桥,画圆。如果你需要微调圆圈,你可以resize()
它,渲染仍然会工作得很好,因为渲染器不知道也不关心Circle
,甚至不把它作为参考!
摘要
桥是一个相当简单的概念,作为一个连接器或胶水,将两个部分连接在一起。抽象(接口)的使用允许组件在没有真正意识到具体实现的情况下相互交互。
也就是说,桥模式的参与者确实需要意识到彼此的存在。具体来说,Circle
需要一个对Renderer
的引用,相反,Renderer
知道如何专门画圆(因此,draw_circle()
成员函数的名字)。这与中介模式形成对比,中介模式允许对象在不直接知道对方的情况下进行通信。
八、组合
现实生活中,对象通常由其他对象组成(或者,换句话说,它们聚合了其他对象)。请记住,在本书这一部分的开始,我们同意将聚合和合成等同起来。
对于一个对象来说,宣传它是由某些东西组成的方式非常少。字段本身并不构成接口,除非您创建虚的 getters 和 setters。您可以通过实现begin()/end()
成员来宣传自己是由一组对象组成的,但是请记住,这实际上并没有说明太多:毕竟,您可以在其中做任何您想做的事情。类似地,你可以通过做一个迭代器 typedef 来宣传你是一个特定类型的容器,但是真的有人会检查它吗?
宣传成为容器的另一个选择是从容器继承。这基本上没问题:即使 STL 容器没有虚拟析构函数,如果你的析构函数中也不需要任何东西,并且你不希望人们继承你的类型,那么一切都没问题——继续从std::vector
继承;应该不会有什么坏事发生。
那么,组合模式是什么呢?本质上,我们试图给单个对象和对象组一个相同的接口。当然,定义一个接口并在两个对象中实现它是很容易的。但是同样地,您可以在适当的时候尝试利用 duck 类型化机制,比如begin()/end()
。 1 Duck typing,总的来说是一个可怕的想法,因为它依赖于秘密知识,而不是在某个接口中明确定义。顺便说一下,没有什么可以阻止你用begin()
和end()
创建一个显式接口,但是迭代器类型是什么呢?
数组支持的属性
组合设计模式通常应用于整个类,但是在我们开始之前,我想向您展示如何在属性的规模上使用它。对于术语属性,我当然指的是类的字段以及这些字段暴露给 API 消费者的方式。
想象一个有不同数字特征的生物的电脑游戏。每个生物可以有一个strength
值,一个agility
值,以此类推。所以这很容易定义:
1 class Creature
2 {
3 int strength, agility, intelligence;
4 public:
5 int get_strength() const
6 {
7 return strength;
8 }
9
10 void set_strength(int strength)
11 {
12 Creature::strength = strength;
13 }
14 // other getter and setters here
15 };
目前为止,一切顺利。但是现在假设我们想要计算这个生物的一些综合统计数据。例如,我们想知道其统计数据的总和、所有统计数据的平均值以及最大值。由于我们的数据被分割成多个字段,因此我们最终实现了以下内容:
1 class Creature
2 {
3 // other members here
4 int sum() const {
5 return strength + agility + intelligence;
6 }
7
8 double average() const {
9 return sum() / 3.0;
10 }
11
12 int max() const {
13 return ::max(::max(strength, agility), intelligence);
14 }
15 };
这种实现令人不快,原因有很多:
- 当计算所有统计数据的总和时,我们很容易犯错误并忘记其中的一项。
- 当计算平均值时,我们使用一个真正的幻数 3.0,它对应于计算中使用的字段数。
- 在计算最大值时,我们必须构造成对的
std::max()
调用。
代码本身就很糟糕;现在,想象一下向组合中添加另一个属性。这将需要对sum()
、average()
、max()
以及任何其他聚合计算进行真正可怕的重构。这能避免吗?事实证明是可以的。
使用数组支持的属性的方法如下。首先,我们为所有必需的属性定义枚举成员,然后继续创建适当大小的数组:
1 class Creature
2 {
3 enum Abilities { str, agl, intl, count };
4 array<int, count> abilities;
5 };
上面的enum
定义有一个额外的值叫做count
,它告诉我们总共有多少个元素。注意,我们使用的是一个enum
,而不是一个enum class
,这使得那些成员的使用稍微容易一些。
我们现在可以为力量、敏捷等定义 getters 和 setters。投射到我们的后备阵列中,例如:
1 int get_strength() const { return abilities[str]; }
2 void set_strength(int value) { abilities[str] = value; }
3 // same for other properties
您的 IDE 不会为您生成这种代码,但这是为灵活性付出的小小代价。
现在,精彩的部分来了:我们对sum()
、average()
和max()
的计算变得非常琐碎,因为在所有这些情况下,我们所要做的就是迭代一个数组:
1 int sum() const {
2 return accumulate(abilities.begin(), abilities.end(), 0);
3 }
4
5 double average() const {
6 return sum() / (double)count;
7 }
8
9 int max() const {
10 return *max_element(abilities.begin(), abilities.end());
11 }
这不是很棒吗?不仅代码更容易编写和维护,而且向类中添加新属性就像添加一个新的enum
成员和一个 getter-setter 对一样简单;聚集根本不需要改变!
组合图形对象
想象一下像 PowerPoint 这样的应用程序,您可以选择几个不同的对象,然后将它们作为一个对象拖动。然而,如果你要选择一个单一的对象,你也可以抓住那个对象。渲染也是如此:你可以渲染一个单独的图形对象,或者你可以将几个图形组合在一起,然后它们作为一个组来绘制。
这种方法的实现相当容易,因为它只依赖于一个接口,如下所示:
1 struct GraphicObject
2 {
3 virtual void draw() = 0;
4 };
现在,从名字来看,你可能会认为GraphicObject
总是标量,也就是说,它总是代表一个单独的项目。但是,想想看:几个矩形和圆形组合在一起表示一个组合图形对象(因此得名组合设计模式)。所以正如我可以定义的,比方说,一个圆:
1 struct Circle : GraphicObject
2 {
3 void draw() override
4 {
5 std::cout << "Circle" << std::endl;
6 }
7 };
同样,我可以定义一个由几个其他图形对象组成的GraphicObject
。是的,这种关系可以无限递归:
1 struct Group : GraphicObject
2 {
3 std::string name;
4
5 explicit Group(const std::string& name)
6 : name{name} {}
7
8 void draw() override
9 {
10 std::cout << "Group " << name.c_str() << " contains:" << std::endl;
11 for (auto&& o : objects)
12 o->draw();
13 }
14
15 std::vector<GraphicObject*> objects;
16 };
标量Circle
和任何Group
都是可绘制的,因为它们都实现了draw()
函数。Group
保存一个指向其他图形对象的指针向量(这些也可以是Group
!)并使用该向量来呈现自身。
下面是这个 API 的使用方法:
1 Group root("root");
2 Circle c1, c2;
3 root.objects.push_back(&c1);
4
5 Group subgroup("sub");
6 subgroup.objects.push_back(&c2);
7
8 root.objects.push_back(&subgroup);
9
10 root.draw();
前面的代码生成以下输出:
1 Group root contains:
2 Circle
3 Group sub contains:
4 Circle
这是组合设计模式最简单的实现,尽管有我们自己定义的自定义接口。现在,如果我们尝试采用其他一些更标准化的方法来迭代对象,这个模式会是什么样子呢?
神经网络
机器学习是热门的新事物,我希望它保持这种状态,否则我将不得不更新这一段。机器学习的一部分是使用人工神经网络:试图模仿我们大脑中神经元工作方式的软件结构。
神经网络的中心概念当然是神经元。一个神经元可以产生一个(通常是数字的)输出,作为其输入的函数,我们可以将该值反馈给网络中的其他连接。我们将只关注连接,因此我们将对神经元建模如下:
1 struct Neuron
2 {
3 vector<Neuron*> in, out;
4 unsigned int id;
5
6 Neuron()
7 {
8 static int id = 1;
9 this->id = id++;
10 }
11 };
我在id
字段中添加了标识。现在,你可能想做的是将一个神经元和另一个神经元连接起来,这可以通过
1 template<> void connect_to<Neuron>(Neuron& other)
2 {
3 out.push_back(&other);
4 other.in.push_back(this);
5 }
这个函数做一些相当可预测的事情:它在当前(this
)神经元和其他某个神经元之间建立连接。目前为止,一切顺利。
现在,假设我们也想创建神经元层。一层相当简单,就是特定数量的神经元组合在一起。让我们再一次犯下继承std::vector
的大罪:
1 struct NeuronLayer : vector<Neuron>
2 {
3 NeuronLayer(int count)
4 {
5 while (count --> 0)
6 emplace_back(Neuron{});
7 }
8 };
看起来不错,对吧?我甚至还附送了运算符-->
供你欣赏。 2 但是现在,我们遇到了一点麻烦。
问题是这样的:我们希望神经元能够连接到神经元层。概括地说,我们希望这样做:
1 Neuron n1, n2;
2 NeuronLayer layer1, layer2;
3 n1.connect_to(n2);
4 n1.connect_to(layer1);
5 layer1.connect_to(n1);
6 layer1.connect_to(layer2);
如你所见,我们有四个不同的案例要处理:
- 神经元连接到另一个神经元
- 神经元连接到层
- 连接到神经元的层;和
- 连接到另一层的层
正如您可能已经猜到的,在 Baator 中,我们不可能对connect_to()
成员函数进行四次重载。如果有三个不同的类,你真的会考虑创建九个函数吗?我不这么认为。
相反,我们要做的是插入一个基类——由于多重继承,我们完全可以做到这一点。那么,下面呢?
1 template <typename Self>
2 struct SomeNeurons
3 {
4 template <typename T> void connect_to(T& other)
5 {
6 for (Neuron& from : *static_cast<Self*>(this))
7 {
8 for (Neuron& to : other)
9 {
10 from.out.push_back(&to);
11 to.in.push_back(&from);
12 }
13 }
14 }
15 };
connect_to()
的实现绝对值得讨论。正如你所看到的,这是一个模板成员函数,它接受T
,然后两两迭代*this
和T&
的神经元,互连每一对。但是有一个警告:我们不能只迭代*this
,因为这会给我们一个SomeNeurons&
,而我们要的是实际类型。
这就是为什么我们被迫让SomeNeurons&
成为一个模板类,其中模板参数Self
指的是 inheritor 类。然后,在取消引用和迭代内容之前,我们继续将this
指针转换为Self*
。这意味着Neuron
必须继承SomeNeurons<Neuron>
——这是为了方便而付出的小小代价。
剩下的工作就是在Neuron
和NeuronLayer
中实现SomeNeurons::begin()
和end()
,以使基于范围的for
循环实际工作。
因为NeuronLayer
继承自vector<Neuron>
,所以不需要明确实现begin()/end()
对——它已经自动存在了。但是Neuron
确实需要一种迭代的方式...基本上就是它本身。它需要让自己成为唯一可迭代的元素。这可以通过以下方式完成:
1 Neuron* begin() override { return this; }
2 Neuron* end() override { return this + 1; }
我会给你一点时间来欣赏这个设计的残忍之处。正是这一片神奇,让SomeNeurons::
connect_to()
成为可能。简而言之,我们让单个(标量)对象表现得像一个可迭代的对象集合。这允许以下所有用途:
1 Neuron neuron, neuron2;
2 NeuronLayer layer, layer2;
3
4 neuron.connect_to(neuron2);
5 neuron.connect_to(layer);
6 layer.connect_to(neuron);
7 layer.connect_to(layer2);
更不用说这样一个事实,如果你要引入一个新的容器(比如说某个NeuronRing
),你所要做的就是从SomeNeurons<NeuronRing>
继承,实现begin()/end()
,新的类将会立即连接到所有的Neuron
和NeuronLayer
摘要
组合设计模式允许我们为单个对象和对象集合提供相同的接口。这可以通过显式使用接口成员来实现,或者通过 duck 类型化来实现——例如,基于范围的for
循环不需要您继承任何东西,并且在具有合适的begin()/end()
成员的类型化的基础上工作。
正是这些begin()/end()
成员允许标量类型伪装成“集合”有趣的是,我们的connect_to()
函数的嵌套for
循环能够将这两个构造连接在一起,尽管它们具有不同的迭代器类型:Neuron
返回一个Neuron*
,而NeuronLayer
返回vector<Neuron>::iterator
——这两者并不完全相同。啊,模板的魔力!
最后,我必须承认,只有当你想要一个单一的成员函数时,所有这些跳跃才是必要的。如果你喜欢调用一个全局函数,或者你喜欢有多个connect_to()
实现,基类SomeNeurons
是不必要的。
Footnotes 1
公平地说,双头垄断是过度的;我们可以借鉴 Swift 的做法,定义一个包含单个成员的接口,比如:std::optional<T> next()
。这样,你可以直接调用next()
,直到它给你一个空值。写类似while (auto item = foo.next()) { ... }
的东西
2
当然,没有-->
运算符;很简单,后缀递减—
后跟大于号>
。然而,效果正如-->
箭头所示:在while (count --> 0)
中,我们迭代直到count
达到零。你可以用“操作符”做类似的事情,比如<--
、--->
等等。
九、装饰器
假设您正在使用您同事编写的一个类,并且您想要扩展该类的功能。在不修改原始代码的情况下,你会怎么做呢?嗯,一种方法是继承:你创建一个派生类,添加你需要的功能,甚至可能是override
什么的,然后你就可以开始了。
是的,除了这并不总是有效,原因有很多。例如,你通常不希望从std::
vector
继承,因为它缺少虚拟析构函数,或者从int
继承(那是不可能的)。但是,继承不起作用的最关键的原因是,在这种情况下,您需要几个增强,并且您希望保持这些增强是独立的,因为,您知道,单一责任原则。
装饰模式允许我们在不修改原始类型(开闭原则)或导致派生类型数量激增的情况下增强现有类型。
方案
让我解释一下多重增强的含义:假设你有一个名为Shape
的类,你有两个名为ColoredShape
和Transpar-entShape
的继承者——你还需要考虑到有人想要一个ColoredTransparentShape
的事实。所以我们生成了三个类来支持两个增强;如果我们有三个增强,我们将需要七个(7!)截然不同的阶层。让我们不要忘记,我们实际上想要不同的形状(Square
、Circle
等)。)—那些会从什么基类继承?有了三个增强和两个不同的形状,类的数量将跃升至 14。很明显,这是一种不可管理的情况——即使您正在使用代码生成工具!
让我们为此编写一些代码。假设我们定义了一个名为Shape
的抽象类:
1 struct Shape
2 {
3 virtual string str() const = 0;
4 };
在前面的类中,str()
是一个虚函数,我们将使用它来提供特定形状的文本表示。
我们现在可以用这个接口实现像Circle
或Square
这样的形状:
1 struct Circle : Shape
2 {
3 float radius;
4
5 explicit Circle(const float radius)
6 : radius{radius} {}
7
8 void resize(float factor) { radius *= factor; }
9
10 string str() const override
11 {
12 ostringstream oss;
13 oss << "A circle of radius " << radius;
14 return oss.str();
15 }
16 }; // Square implementation omitted
我们已经知道,普通的继承本身并不能为我们提供增强形状的有效方法,所以我们必须求助于组合——这是装饰模式用来增强对象的机制。实际上有两种截然不同的方法,以及其他几种我们需要讨论的模式:
- 动态组合允许你在运行时组合一些东西,通常是通过传递引用。它允许最大的灵活性,因为合成可以在运行时响应例如用户的输入而发生。
- 静态组合意味着对象及其增强是在编译时通过使用模板来组合的。这意味着在编译时需要知道对象的确切增强集,因为以后不能修改它。
如果前面的内容听起来有点神秘,不要担心——我们将以动态和静态的方式实现装饰器,所以很快一切就都清楚了。
动态装饰器
假设我们想用一点颜色来增强形状。我们使用组合而不是继承来实现一个ColoredShape
,它只是引用一个已经构造好的Shape
并增强它:
1 struct ColoredShape : Shape
2 {
3 Shape& shape;
4 string color;
5
6 ColoredShape(Shape& shape, const string& color)
7 : shape{shape}, color{color} {}
8
9 string str() const override
10 {
11 ostringstream oss;
12 oss << shape.str() << " has the color " << color;
13 return oss.str();
14 }
15 };
如你所见,ColoredShape
本身就是一个Shape
。您通常会这样使用它:
1 Circle circle{0.5f};
2 ColoredShape redCircle{circle, "red"};
3 cout << redCircle.str();
4 // prints "A circle of radius 0.5 has the color red"
如果我们现在想要另一个增强,增加形状的透明度,这也是微不足道的:
1 struct TransparentShape : Shape
2 {
3 Shape& shape;
4 uint8_t transparency;
5
6 TransparentShape(Shape& shape, const uint8_t transparency)
7 : shape{shape}, transparency{transparency} {}
8
9 string str() const override
10 {
11 ostringstream oss;
12 oss << shape.str() << " has "
13 << static_cast<float>(transparency) / 255.f*100.f
14 << "% transparency";
15 return oss.str();
16 }
17 };
我们现在有一个增强,透明度值为 0..255 范围,并将其报告为百分比值。我们不能单独使用增强功能:
1 Square square{3};
2 TransparentShape demiSquare{square, 85};
3 cout << demiSquare.str();
4 // A square with side 3 has 33.333% transparency
但是最棒的是我们可以把ColoredShape
和TransparentShape
组合在一起,做出一个既有颜色又有透明度的形状:
1 TransparentShape myCircle{
2 ColoredShape{
3 Circle{23}, "green"
4 }, 64
5 };
6 cout << myCircle.str();
7 // A circle of radius 23 has the color green has 25.098% transparency
看到我在那里做了什么吗?我只是把整个事情都安排妥当了。现在,公平地说,你也可以做一件没有多大意义的事情,就是重复同一个装饰器一次。例如,有一个Colored-Shape{ColoredShape{...}}
是没有意义的,但是它可以工作,给出了有些矛盾的结果。如果你决定用断言或一些 OOP 魔法来对抗它,你可以这么做,但是我想知道你将如何处理类似
1 ColoredShape{TransparentShape{ColoredShape{...}}}
这是更具挑战性的检测,即使这是可能的,我认为它根本不值得检查。我们需要假设程序员有一些理智。
静态装饰器
你注意到了吗,在设置场景时,我给了Circle
一个名为resize()
的函数,它不是Shape
接口的一部分。正如您可能已经猜到的,因为它不是Shape
的一部分,所以您真的不能从装饰器中调用它。我的意思是:
1 Circle circle{3};
2 ColoredShape redCircle{circle, "red"};
3 redCircle.resize(2); // won't compile!
假设你真的不在乎是否能在运行时组合对象,但是你真的在乎是否能访问一个修饰对象的所有字段和成员函数。有可能构造这样一个装饰器吗?
事实上,的确如此,它是通过模板和继承实现的——但不是那种导致状态空间爆炸的继承。相反,我们将应用一种叫做 Mixin 继承的东西,这是一种类从它自己的模板参数继承的方法。
所以这里有一个想法——我们将创建一个新的ColoredShape
,它继承自一个模板参数。我们无法将模板参数约束为任何特定的类型,所以我们将使用一个static_assert
来代替:
1 template <typename T> struct ColoredShape : T
2 {
3 static_assert(is_base_of<Shape, T>::value,
4 "Template argument must be a Shape");
5
6 string color;
7
8 string str() const override
9 {
10 ostringstream oss;
11 oss << T::str() << " has the color " << color;
12 return oss.str();
13 }
14 }; // implementation of TransparentShape<T> omitted
有了ColoredShape<T>
和TransparentShape<T>
的实现,我们现在可以将它们组合成一个彩色的透明形状:
1 ColoredShape<TransparentShape<Square>> square{"blue"};
2 square.size = 2;
3 square.transparency = 0.5;
4 cout << square.str();
5 // can call square's own members
6 square.resize(3);
这不是很棒吗?很好,但并不完美:我们似乎已经失去了对构造器的充分利用,所以即使我们能够初始化最外层的类,我们也无法在一行代码中完全构造出具有特定大小、颜色和透明度的形状。
来放糖衣(装饰品!)在我们的蛋糕上,给ColoredShape
和TransparentShape
转发构造器。这些构造器将接受两个参数:第一个是特定于当前模板类的参数,第二个是我们将转发给基类的通用参数包。我的意思是:
1 template <typename T> struct TransparentShape : T
2 {
3 uint8_t transparency;
4
5 template<typename...Args>
6 TransparentShape(const uint8_t transparency, Args ...args)
7 : T(std::forward<Args>(args)...)
8 , transparency{ transparency } {}
9 ...
10 }; // same for ColoredShape
只是重申一下,前面的构造器可以接受任意数量的参数,其中第一个参数用于初始化透明度值,其余的只是转发给基类的构造器,不管它是什么。
构造器的数量自然必须正确,如果它们的数量或值类型不正确,程序将无法编译。如果您开始向类型中添加默认构造器,那么整个参数集的使用会变得更加灵活,但也会带来歧义和混乱。
哦,确保永远不要使用这些构造器explicit
,否则在将装饰函数组合在一起时,会与 C++ 的复制列表初始化规则相冲突。现在,如何真正利用这些优点呢?
1 ColoredShape2<TransparentShape2<Square>> sq = { "red", 51, 5 };
2 cout << sq.str() << endl;
3 // A square with side 5 has 20% transparency has the color red
太美了!这正是我们想要的。这就完成了我们的静态装饰器的实现。同样,您可以增强它以避免重复类型,如ColoredShape<ColoredShape<…>>
或循环类型,如ColoredShape<TransparentShape<ColoredShape<...>>>
,但在静态环境中,这感觉像是浪费时间。不过,由于各种形式的模板魔术,这是完全可行的。
功能装饰
虽然装饰模式通常应用于类,但它同样可以应用于函数。例如,假设您的代码中有一个特殊的操作给您带来了麻烦:您希望在调用该操作时记录所有实例,并在 Excel 中分析统计数据。这当然可以通过在调用之前和之后添加一些代码来实现,也就是说:
1 cout << "Entering function\n";
2 // do the work
3 cout << "Exiting funcion\n";
这工作得很好,但是在关注点分离方面并不好:我们真的想将日志功能存储在某个地方,以便我们可以重用它,并在必要时增强它。
如何做到这一点有不同的方法。一种方法是简单地将整个工作单元作为 lambda 提供给某个日志组件,如下所示:
1 struct Logger
2 {
3 function<void()> func;
4 string name;
5
6 Logger(const function<void()>& func, const string& name)
7 : func{func},
8 name{name}
9 {
10 }
11
12 void operator()() const
13 {
14 cout << "Entering " << name << endl;
15 func();
16 cout << "Exiting " << name << endl;
17 }
18 };
使用这种方法,您可以编写以下内容:
1 Logger([]() {cout << "Hello" << endl; }, "HelloFunction")();
2 // output:
3 // Entering HelloFunction
4 // Hello
5 // Exiting HelloFunction
总是有一个选项,不是作为一个std::function
而是作为一个模板参数传入函数。这与前面的结果略有不同:
1 template <typename Func>
2 struct Logger2
3 {
4 Func func;
5 string name;
6
7 Logger2(const Func& func, const string& name)
8 : func{func}, name{name} {}
9
10 void operator()() const
11 {
12 cout << "Entering " << name << endl;
13 func();
14 cout << "Exiting " << name << endl;
15 }
16 };
前面实现的用法完全相同。我们可以创建一个实用函数来实际创建这样一个记录器:
1 template <typename Func> auto make_logger2(Func func,
2 const string& name)
3 {
4 return Logger2<Func>{ func, name }; // () = call now
5 }
然后像这样使用它:
1 auto call = make_logger2([]() {cout << "Hello!" << endl; }, "HelloFunction");
2 call();
“有什么意义?”你可能会问。嗯……我们现在有能力创建一个装饰器(里面有装饰函数),并在我们选择的时间调用它。
现在,给你一个挑战:如果你想记录函数add()
的调用,定义如下…
1 double add(double a, double b)
2 {
3 cout << a << "+" << b << "=" << (a + b) << endl;
4 return a + b;
5 }
但是你也想得到返回值?是的,从记录器返回一个返回值。没那么容易!但肯定不是不可能。让我们制作我们的记录器的另一个化身:
1 template <typename R, typename... Args>
2 struct Logger3<R(Args...)>
3 {
4 Logger3(function<R(Args...)> func, const string& name)
5 : func{func},
6 name{name}
7 {
8 }
9
10 R operator() (Args ...args)
11 {
12 cout << "Entering " << name << endl;
13 R result = func(args...);
14 cout << "Exiting " << name << endl;
15 return result;
16 }
17
18 function<R(Args ...)> func;
19 string name;
20 };
在前面的例子中,模板参数R
指的是返回值的类型,而Args
,你肯定已经猜到了。装饰器保留该函数,并在必要时调用它,唯一的区别是operator()
返回一个R
,因此您不会丢失返回值。
我们可以构造另一个效用make_
函数:
1 template <typename R, typename... Args>
2 auto make_logger3(R (*func)(Args...), const string& name)
3 {
4 return Logger3<R(Args...)>(
5 std::function<R(Args...)>(func),
6 name);
7 }
注意,我没有使用std::function
,而是将第一个参数定义为一个普通的函数指针。我们现在可以使用这个函数实例化记录的调用并使用它:
1 auto logged_add = make_logger3(add, "Add");
2 auto result = logged_add(2, 3);
当然,make_logger3
可以用依赖注入来代替。这种方法的好处是能够
- 通过提供一个空对象 1 而不是一个实际的日志记录器来动态地打开和关闭日志记录
- 禁用正在记录的代码的实际调用(同样,通过替换不同的记录器)
总而言之,这是开发人员工具箱上的另一个有用的工具。我将这种方法编织到依赖注入中作为读者的练习。
摘要
装饰器在遵循 OCP 的同时给了类额外的功能。它的关键方面是可组合性:几个装饰器可以以任何顺序应用于一个对象。我们已经了解了以下类型的装饰器:
- 动态装饰器可以存储引用(如果你愿意,甚至可以存储整个值!)并提供动态(运行时)可组合性,代价是不能访问底层对象自己的成员。
- 静态装饰器使用 mixin 继承(从模板参数继承)在编译时组成装饰器。这失去了任何类型的运行时灵活性(不能重新组合对象),但允许您访问底层对象的成员。这些对象也可以通过构造器转发完全初始化。
- 函数装饰器可以包装代码块或者特定的函数,以允许行为的组合。
值得一提的是,在不允许多重继承的语言中,decorators 也用于模拟多重继承,它聚合多个对象,然后提供一个接口,该接口是聚合对象接口的集合并集。
Footnotes 1
Null 对象在本书第章第十九章中有描述。本质上,空对象是符合某个接口的对象,但是有空方法,也就是说,完全不做任何事情的方法。这解决了当你必须提供一个对象到一个 API 中,但是你不希望这个对象实际上做任何事情时的问题。
十、外观
首先,让我们把语言学的问题放在一边:字母\\\\\\\\\\\'中的小曲线被称为 cedilla \\\\\\\\\\\\\\\\\'字母\\\\\\\\\\欢迎你们中特别有经验的人在代码中使用字母,,因为大多数编译器都会很好地处理它。
好的,现在,关于那个模式…
我花了很多时间在定量金融和算法交易领域工作。正如你可能猜到的,一个好的交易终端需要的是将信息快速传递到交易者的大脑中:你希望事情尽可能快地呈现出来,没有任何延迟。
大多数财务数据(除了图表)实际上都是纯文本呈现的:黑色屏幕上的白色字符。在某种程度上,这类似于终端/控制台/命令行界面在您自己的操作系统中的工作方式,但是有一个微妙的区别。
终端如何工作
终端窗口的第一部分是缓冲区。这是存储渲染角色的地方。缓冲区是内存的一个矩形区域,通常是一个 1D 1 或 2D char
或wchar_t
数组。一个缓冲区可以比终端窗口的可视区域大得多,所以它可以存储一些您可以回滚到的历史输出。
通常,缓冲器具有指定当前输入行的指针(例如,整数)。这样,一个满的缓冲区不会重新分配所有的行;它只是覆盖最老的一个。
然后是视口的概念。视口呈现特定缓冲区的一部分。缓冲区可能很大,因此视口只需从缓冲区中取出一个矩形区域并进行渲染。当然,视口的大小必须小于或等于缓冲区的大小。
最后,还有控制台(终端窗口)本身。控制台显示视口,允许上下滚动,甚至接受用户输入。控制台实际上是一个门面:一个相当复杂的幕后设置的简化表示。
通常,大多数用户与单个缓冲区和视口进行交互。但是,可以有一个控制台窗口,在其中,例如,在两个视口之间垂直分割区域,每个视口都有相应的缓冲区。这可以通过使用实用程序来完成,比如 Linux 命令screen
。
先进的终端
典型操作系统终端的一个问题是,如果你用管道把大量数据输入终端,它会非常慢。比如一个 Windows 终端窗口(cmd.exe
)使用 GDI 来渲染字符,完全没有必要。在一个快节奏的交易环境中,您希望渲染是硬件加速的:字符应该呈现为使用 API(如 OpenGL)放置在表面上的预渲染纹理。 2
交易终端由多个缓冲区和视窗组成。在典型的设置中,不同的缓冲区可能会同时更新来自不同交易所或交易机器人的数据,所有这些信息都需要显示在一个屏幕上。
缓冲区还提供了比 1D 或 2D 线性存储更令人兴奋的功能。例如,TableBuffer
可以定义为:
1 struct TableBuffer : IBuffer
2 {
3 TableBuffer(vector<TableColumnSpec> spec, int totalHeight) { ... }
4
5 struct TableColumnSpec
6 {
7 string header;
8 int width;
9 enum class TableColumnAlignment {
10 Left, Center, Right
11 } alignment;
12 }
13 };
换句话说,一个缓冲区可以接受一些规范并构建一个表(是的,一个很好的老式 ASCII 格式的表!)并呈现在屏幕上。
视口负责从缓冲区获取数据。它的一些特征包括:
- 对它所显示的缓冲区的引用
- 它的大小
- 如果视口小于缓冲区,它需要指定要显示缓冲区的哪一部分。这用绝对 x-y 坐标表示。
- 整个控制台窗口上视区的位置
- 光标的位置,假设该视口当前正在接受用户输入
门面在哪里?
在这个特殊的系统中,控制台本身就是门面。在内部,控制台必须管理大量不同的对象:
1 struct Console
2 {
3 vector<Viewport*> viewports;
4 Size charSize, gridSize;
5 ...
6 };
控制台的初始化通常也是一件非常讨厌的事情。然而,由于它是一个外观,它实际上试图给出一个真正可访问的 API。这可能需要一些合理的参数来初始化
1 Console::Console(bool fullscreen, int char_width, int char_height,
2 int width, int height, optional<Size> client_size)
3 {
4 // single buffer and viewport created here
5 // linked together and added to appropriate collections
6 // image textures generated
7 // grid size calculated depending on whether we want fullscreen mode
8 }
或者,可以将所有这些参数打包到一个对象中,这个对象也有一些合理的缺省值:
1 Console::Console(const ConsoleCreationParameters& ccp) { ... }
2
3 struct ConsoleCreationParameters
4 {
5 optional<Size> client_size;
6 int character_width{10};
7 int character_height{14};
8 int width{20};
9 int height{30};
10 bool fullscreen{false};
11 bool create_default_view_and_buffer{true};
12 };
摘要
外观设计模式是一种将简单界面放在一个或多个复杂子系统前面的方式。在我们的示例中,可以直接使用涉及许多缓冲区和视窗的复杂设置,或者,如果您只是想要一个具有单个缓冲区和相关视窗的简单控制台,您可以通过一个非常容易访问和直观的 API 来获得它。
Footnotes 1
大多数缓冲区通常是一维的。这样做的原因是,在某个地方传递单指针比双指针更容易,当结构的大小是确定的和不可变的时,使用array
或vector
没有多大意义。1D 方法的另一个优势是,当涉及到 GPU 处理时,CUDA 等系统会使用多达 6 个维度进行寻址,因此过一段时间后,从 N 维块/网格位置计算 1D 指数就成了第二天性。
2
我们也使用 ASCII,因为很少需要 Unicode。如果不需要支持额外的字符集,那么 1 char = 1 byte 是一个很好的做法。虽然与当前的讨论无关,但它也极大地简化了字符串处理算法在 GPU 和 CPU 上的实现。
十一、享元
Flyweight(有时也称为令牌或 cookie)是一个临时组件,充当对某个对象的“智能引用”。通常,flyweights 用于拥有大量非常相似的对象的情况,并且您希望最小化专用于存储所有这些值的内存量。
让我们看一些与这种模式相关的场景。
用户名
想象一个大型多人在线游戏。我跟你赌 20 美元,有不止一个用户叫约翰·史密斯——很简单,因为这是一个流行的名字。因此,如果我们要反复存储这个名字(用 ASCII 码),我们将为每个这样的用户花费 11 个字节。相反,我们可以将名称存储一次,然后存储一个指向具有该名称的每个用户的指针(只有 8 个字节)。那是相当节省的。
将名字分成名和姓可能更有意义:这样,Fitzgerald Smith 将由两个指针(16 个字节)表示,分别指向名和姓。事实上,如果我们使用索引而不是名称,我们可以大大减少使用的字节数。你不会期望有两个不同的名字吧?
我们实际上可以typedef
稍后再做调整:
1 typedef uint32_t key;
有了这个定义,我们可以使用户定义如下:
1 struct User
2 {
3 User(const string& first_name, const string& last_name)
4 : first_name{add(first_name)}, last_name{add(last_name)} {}
5 ...
6 protected:
7 key first_name, last_name;
8 static bimap<key, string> names;
9 static key seed;
10 static key add(const string& s) { ... }
11 };
如你所见,构造器用调用add()
函数的结果初始化成员first_name
和last_name
。该函数根据需要将键-值对(键由seed
生成)插入到names
结构中。这里我使用了一个boost::bimap
(双向映射),因为这样更容易搜索重复的名字——记住,如果名字或姓氏已经在 bimap 中,我们只需要返回一个索引。
所以这里是add():
的实现
1 static key add(const string& s)
2 {
3 auto it = names.right.find(s);
4 if (it == names.right.end())
5 {
6 // add it
7 names.insert({++seed, s});
8 return seed;
9 }
10 return it->second;
11 }
这是 get-or-add 机制的一个相当标准的实现。如果你以前没有遇到过它,你可能想查阅bimap
的文档以获得更多关于它如何工作的信息。
所以现在,如果我们想要实际公开名字和姓氏(字段是protected
,类型是key
,不是很有用!),我们可以提供适当的 getters 和 setters:
1 const string& get_first_name() const
2 {
3 return names.left.find(last_name)->second;
4 }
5
6 const string& get_last_name() const
7 {
8 return names.left.find(last_name)->second;
9 }
例如,要定义User
的流输出操作符,您可以简单地编写
1 friend ostream& operator<<(ostream& os, const User& obj)
2 {
3 return os
4 << "first_name: " << obj.get_first_name()
5 << " last_name: " << obj.get_last_name();
6 }
就这样。我不打算提供节省空间量的统计数据(这实际上取决于您的样本大小),但希望很明显,在大量重复用户名的情况下,节省是显著的——特别是如果您通过更改typedef
来进一步重写sizeof(key)
。
助推。享元
在前面的例子中,我手工制作了一个 Flyweight,尽管我可以重用一个 avaivalable 作为 Boost 库。boost::flyweight
类型确实如它在罐头上所说的那样:构造一个节省空间的飞锤。
这使得User
类的实现变得相当琐碎:
1 struct User2
2 {
3 flyweight<string> first_name, last_name;
4
5 User2(const string& first_name, const string& last_name)
6 : first_name{first_name},
7 last_name{last_name} {}
8 };
您可以通过运行以下代码来验证它实际上是一个 flyweight:
1 User2 john_doe{ "John", "Doe" };
2 User2 jane_doe{ "Jane", "Doe" };
3 cout << boolalpha <<
4 (&jane_doe.last_name.get() == &john_doe.last_name.get()); // true
字符串范围
如果你调用std::string::substring()
,应该会返回一个全新的字符串吗?现在还没有定论:如果您想操纵它,那么当然可以,但是如果您想改变子串来影响原来的对象呢?一些编程语言(例如 Swift、Rust)显式地将子串作为一个范围返回,这也是 Flyweight 模式的一种实现,除了允许我们通过该范围操作底层对象之外,还节省了所使用的内存量。
相当于字符串范围的 C++ 是一个string_view
,数组还有其他的变化——任何避免复制数据的东西!让我们试着构造我们自己的,非常简单的,字符串范围。
让我们假设我们在一个类中存储了一堆文本,我们想获取一系列文本并将其大写,有点像文字处理器或 IDE 可能做的事情。我们可以将每个字母都大写,这样就可以了,但是让我们假设我们希望将底层的纯文本保持在原始状态,并且只在使用流输出操作符时才大写。
天真的方法
一个非常愚蠢的方法是定义一个大小与纯文本字符串匹配的bool
ean 数组,其值指示我们是否将字符大写。我们可以这样实现它:
1 class FormattedText
2 {
3 string plainText;
4 bool *caps;
5 public:
6 explicit FormattedText(const string& plainText)
7 : plainText{plainText}
8 {
9 caps = new bool[plainText.length()];
10 }
11 ~FormattedText()
12 {
13 delete[] caps;
14 }
15 };
我们现在可以创建一个实用方法来资本化一个特定的范围:
1 void capitalize(int start, int end)
2 {
3 for (int i = start; i <= end; ++i)
4 caps[i] = true;
5 }
然后定义一个使用布尔掩码的流输出操作符:
1 friend std::ostream& operator<<(std::ostream& os, const FormattedText& obj)
2 {
3 string s;
4 for (int i = 0; i < obj.plainText.length(); ++i)
5 {
6 char c = obj.plainText[i];
7 s += (obj.caps[i] ? toupper(c) : c);
8 }
9 return os << s;
10 }
不要误解我,这种方法是有效的。这里:
1 FormattedText ft("This is a brave new world");
2 ft.capitalize(10, 15);
3 cout << ft << endl;
4 // prints "This is a BRAVE new world"
但是,同样,将每个字符定义为具有布尔标志是非常愚蠢的,因为只有开始和结束标记就可以了。让我们再次尝试使用 Flyweight 模式。
Flyweight 实现
让我们实现一个利用 Flyweight 设计模式的BetterFormattedText
。我们将从定义外部类和 Flyweight 开始,我已经实现了一个嵌套类(为什么不呢?):
1 class BetterFormattedText
2 {
3 public:
4 struct TextRange
5 {
6 int start, end;
7 bool capitalize;
8 // other options here, e.g. bold, italic, etc.
9
10 bool covers(int position) const
11 {
12 return position >= start && position <= end;
13 }
14 };
15 private:
16 string plain_text;
17 vector<TextRange> formatting;
18 };
如您所见,TextRange
只存储了它所应用的起点和终点,以及实际的格式信息——我们是否想要大写文本以及任何其他格式选项(粗体、斜体等)。).它只有一个成员函数covers()
,帮助我们确定这段格式是否需要应用于给定位置的字符。
BetterFormattedText
存储一个vector
的TextRange
飞锤,并能够根据需要构建新的飞锤:
1 TextRange& get_range(int start, int end)
2 {
3 formatting.emplace_back(TextRange{ start, end });
4 return *formatting.rbegin();
5 }
在前面的例子中发生了三件事:
- 一个新的
TextRange
被构建。 - 它会被移动到矢量中。
- 返回对最后一个元素的引用。
在前面的实现中,我们没有真正检查重复的范围——这也符合基于 Flyweight 的空间经济的精神。
我们现在可以为BetterFormattedText
实现operator<<
:
1 friend std::ostream& operator<<(std::ostream& os,
2 const BetterFormattedText& obj)
3 {
4 string s;
5 for (size_t i = 0; i < obj.plain_text.length(); i++)
6 {
7 auto c = obj.plain_text[i];
8 for (const auto& rng : obj.formatting)
9 {
10 if (rng.covers(i) && rng.capitalize)
11 c = toupper(c);
12 s += c;
13 }
14 }
15 return os << s;
16 }
同样,我们所做的就是遍历每个字符,并检查是否有覆盖它的范围。如果有,我们应用范围指定的任何内容,在我们的例子中,大写。请注意,这种设置允许范围自由重叠。
现在,我们可以像以前一样使用我们构建的所有内容来大写相同的单词,尽管使用了稍有不同、更灵活的 API:
1 BetterFormattedText bft("This is a brave new world");
2 bft.get_range(10, 15).capitalize = true;
3 cout << bft << endl;
4 // prints "This is a BRAVE new world"
摘要
Flyweight 模式基本上是一种节省空间的技术。它的具体体现是多种多样的:有时您将 Flyweight 作为 API 令牌返回,允许您对生成它的任何人进行修改,而在其他时候,Flyweight 是隐式的,隐藏在幕后——就像我们的User
的情况一样,客户端并不知道实际使用的 Flyweight。
十二、代理
当我们查看装饰设计模式时,我们看到了增强对象功能的不同方式。代理设计模式是类似的,但是它的目标通常是精确地(或者尽可能接近地)保留正在使用的 API,同时提供某些内部机制。
代理并不是一个真正的同质 API,因为人们构建的不同类型的代理非常多,并且服务于完全不同的目的。在这一章中,我们将看看不同的代理对象的选择,你可以在网上找到更多。
智能指针
代理模式最简单、最直接的例子是智能指针。智能指针是指针的包装器,它还保存引用计数,覆盖某些运算符,但总而言之,它为您提供了在普通指针中可以获得的接口:
1 struct BankAccount
2 {
3 void deposit(int amount) { ... }
4 };
5
6 BankAccount *ba = new BankAccount;
7 ba->deposit(123);
8 auto ba2 = make_shared<BankAccount>();
9 ba2->deposit(123); // same API!
因此,智能指针也可以用来替代某些需要普通指针的位置。例如,无论ba
是指针还是智能指针,if (ba) { ... }
都是有效的,在这两种情况下,*ba
都将得到底层对象。等等。
当然,差异是存在的。最明显的一点是你不必在智能指针上调用delete
。但除此之外,它真的尽可能地接近一个普通的指针。
财产代理
在其他编程语言中,术语“属性”用于表示一个字段以及该字段的一组 getter/setter 方法。在 C++ 1 中没有属性,但是如果我们想继续使用一个字段,同时赋予它特定的访问器/赋值器行为,我们可以构建一个属性代理。
本质上,属性代理是一个可以伪装成属性的类,所以我们可以这样定义它:
1 template <typename T> struct Property
2 {
3 T value;
4 Property(const T initial_value)
5 {
6 *this = initial_value;
7 }
8 operator T()
9 {
10 // perform some getter action
11 return value;
12 }
13 T operator =(T new_value)
14 {
15 // perform some setter action
16 return value = new_value;
17 }
18 };
在前面的实现中,我在您通常会定制(或直接替换)的地方添加了注释,如果您要走这条路,这些注释大致对应于 getter/setter 的位置。
因此,我们的类Property<T>
本质上是T
的替代,不管它是什么。它的工作原理是简单地允许与T
的相互转换,让双方都使用value
字段。现在你可以用它,比如说,作为一个字段:
1 struct Creature
2 {
3 Property<int> strength{ 10 };
4 Property<int> agility{ 5 };
5 };
对字段的典型操作也适用于属性代理类型的字段:
1 Creature creature;
2 creature.agility = 20;
3 auto x = creature.strength;
虚拟代理
如果你试图解引用一个nullptr
或者一个未初始化的指针,你就是在自找麻烦。但是,有些情况下,您只希望在对象被访问时构造它,而不希望过早地分配它。
这种方法被称为惰性实例化。如果你确切地知道哪里需要懒惰行为,你可以提前计划,为它做特别的准备。如果你不这样做,那么…你可以建立一个代理,接受一个现有的对象,并使其懒惰。我们称之为虚拟代理,因为底层对象可能根本不存在,所以我们不是访问具体的东西,而是访问虚拟的东西。
想象一个典型的Image
界面:
1 struct Image
2 {
3 virtual void draw() = 0;
4 };
一个Bitmap
的急切(与懒惰相反)实现将在构造时从文件中加载图像,即使该图像实际上并不需要。(是的,下面的代码是一个仿真。)
1 struct Bitmap : Image
2 {
3 Bitmap(const string& filename)
4 {
5 cout << "Loading image from " << filename << endl;
6 }
7
8 void draw() override
9 {
10 cout << "Drawing image " << filename << endl;
11 }
12 };
这个Bitmap
的构造动作将触发图像的加载:
1 Bitmap img{ "pokemon.png" }; // Loading image from pokemon.png
那不完全是我们想要的。我们想要的是那种只在使用draw()
方法时才加载自身的位图。现在,我想我们可以跳回Bitmap
,让它变得懒惰,但是假设它是固定不变的,不可修改的(或者说不可继承的)。
因此,我们可以构建一个虚拟代理,它将聚合原始的Bitmap
,提供一个相同的接口,并重用原始的Bitmap
的功能:
1 struct LazyBitmap : Image
2 {
3 LazyBitmap(const string& filename)
4 : filename(filename) {}
5 ~LazyBitmap() { delete bmp; }
6 void draw() override
7 {
8 if (!bmp)
9 bmp = new Bitmap(filename);
10 bmp->draw();
11 }
12
13 private:
14 Bitmap *bmp{nullptr};
15 string filename;
16 };
我们到了。正如您所看到的,这个LazyBitmap
的构造器要简单得多:它所做的只是存储要从中加载图像的文件名,仅此而已——图像实际上并没有被加载。
所有的神奇都发生在draw()
:
这是我们检查bmp
指针的地方,看看底层(急切!)位图已被构造。如果它没有,我们就构造它,然后调用它的draw()
函数来实际绘制图像。
现在假设您有一些使用Image
类型的 API:
1 void draw_image(Image& img)
2 {
3 cout << "About to draw the image" << endl;
4 img.draw();
5 cout << "Done drawing the image" << endl;
6 }
我们可以使用带有实例LazyBitmap
的 API 来代替Bitmap
(万岁,多态!)渲染图像,以惰性方式加载图像:
1 LazyBitmap img{ "pokemon.png" };
2 draw_image(img); // image loaded here
3
4 // About to draw the image
5 // Loading image from pokemon.png
6 // Drawing image pokemon.png
7 // Done drawing the image
通信代理
假设您在类型为Bar
的对象上调用成员函数foo()
。你的典型假设是Bar
已经被分配到运行你的代码的同一台机器上,你同样期望Bar::foo()
在同一个进程中执行。
现在想象一下,您做出一个设计决策,将Bar
及其所有成员转移到网络上的另一台机器上。但是你仍然希望旧代码工作!如果您想继续像以前一样,您将需要一个通信代理——一个“通过线路”代理调用的组件,当然,如果需要的话,还可以收集结果。
让我们实现一个简单的乒乓服务来说明这一点。首先,我们定义一个接口:
1 struct Pingable
2 {
3 virtual wstring ping(const wstring& message) = 0;
4 };
如果我们正在构建乒乓进程,我们可以如下实现Pong
:
1 struct Pong : Pingable
2 {
3 wstring ping(const wstring& message) override
4 {
5 return message + L" pong";
6 }
7 };
基本上,您 ping 一个Pong
,它将单词" pong"
附加到消息的末尾并返回该消息。请注意,我在这里没有使用ostringstream&
,而是在每次循环中创建一个新的字符串:这个 API 很容易复制为 web 服务。
我们现在可以尝试这种设置,看看它在流程中是如何工作的:
1 void tryit(Pingable& pp)
2 {
3 wcout << pp.ping(L"ping") << "\n";
4 }
5
6 Pong pp;
7 for (int i = 0; i < 3; ++i)
8 {
9 tryit(pp);
10 }
最终结果是我们打印了三次"ping pong"
,正如我们所希望的。
现在,假设您决定将Pingable
服务迁移到一个很远很远的 web 服务器上。也许你甚至决定使用其他平台,比如 ASP.NET,而不是 C++:
1 [Route("api/[controller]")]
2 public class PingPongController : Controller
3 {
4 [HttpGet("{msg}")]
5 public string Get(string msg)
6 {
7 return msg + " pong";
8 }
9 } // achievement unlocked: use C# in a C++ book
有了这个设置,我们将构建一个名为RemotePong
的通信代理来代替Pong
。微软的 REST SDK 在这里就派上用场了。 2
1 struct RemotePong : Pingable
2 {
3 wstring ping(const wstring& message) override
4 {
5 wstring result;
6 http_client client(U("http://localhost:9149/"));
7 uri_builder builder(U("/api/pingpong/"));
8 builder.append(message);
9 pplx::task<wstring> task = client.request(
10 methods::GET, builder.to_string())
11 .then(=
12 {
13 return r.extract_string();
14 });
15 task.wait();
16 return task.get();
17 }
18 };
如果您不习惯 REST SDK,前面的内容可能有点令人困惑;除了 REST 支持之外,SDK 还使用了并发运行时,这是一个微软的库,用于并发支持。
实施后,我们现在可以进行一项更改:
1 RemotePong pp; // was Pong
2 for (int i = 0; i < 3; ++i)
3 {
4 tryit(pp);
5 }
就是这样,您得到的是相同的输出,但是实际的实现可以在 Kestrel 上运行,在地球另一边的某个 Docker 容器中。
摘要
本章介绍了一些代理人。与装饰模式不同,代理不会试图通过添加新成员来扩展对象的功能(除非实在没办法)。它所做的只是增强现有成员的底层行为。
存在大量不同的代理:
- 属性代理是替代对象,可以在分配和/或访问期间替换字段并执行附加操作。
- 虚拟代理提供对底层对象的虚拟访问,并且可以实现诸如惰性对象加载之类的行为。您可能觉得自己正在处理一个真实的对象,但是底层的实现可能还没有创建,例如,可以按需加载。
- 通信代理允许我们改变对象的物理位置(例如,将它移动到云中),但允许我们使用几乎相同的 API。当然,在这种情况下,API 只是远程服务(如 REST API)的一个垫片。
- 除了调用底层函数之外,日志代理还允许您执行日志记录。
还有很多其他的代理,您自己构建的代理可能不会属于一个预先存在的类别,而是会执行一些特定于您的领域的操作。
Footnotes 1
如果你能接受非标准的 C++,那就看看__declspec(property)
,它在许多现代编译器中都有实现,包括 Clang、Intel,当然还有 MSVC。
2
Microsoft REST SDK 是一个用于 REST 服务的 C++ 库。它既是开源的,又是跨平台的。可以在 GitHub 上找到: https://github.com/Microsoft/cpprestsdk
十三、责任链
想想公司渎职的典型例子:内幕交易。假设某个交易员因内幕消息交易被当场抓获。这件事该怪谁?如果管理层不知道,那就是交易员。但也许交易员的同事也参与其中,在这种情况下,团队经理可能是负责人。或者这种做法是制度性的,在这种情况下,首席执行官应该承担责任。
这是一个责任链的例子:你有一个系统的几个不同的元素,他们都可以一个接一个地处理一个消息。作为一个概念,它很容易实现,因为它所隐含的就是使用某种列表。
方案
想象一个电脑游戏,其中每个生物都有一个名字和两个特征值——attack
和defense
:
1 struct Creature
2 {
3 string name;
4 int attack, defense;
5 // constructor and << here
6 };
现在,随着生物在游戏中的进展,它可能会遇到一个物品(例如,一把魔剑),或者它可能会被附魔。无论哪种情况,它的攻击和防御值都会被我们称为CreatureModifier
的东西修改。
此外,应用几个修改器的情况并不少见,所以我们需要能够在一个生物上堆叠修改器,允许它们按照附着的顺序被应用。
让我们看看如何实现这一点。
指针链
在传统的责任链(CoR)方式中,我们将如下实现CreatureModifier
:
1 class CreatureModifier
2 {
3 CreatureModifier* next{nullptr};
4 protected:
5 Creature& creature; // alternative: pointer or shared_ptr
6 public:
7 explicit CreatureModifier(Creature& creature)
8 : creature(creature) {}
9
10 void add(CreatureModifier* cm)
11 {
12 if (next) next->add(cm);
13 else next = cm;
14 }
15
16 virtual void handle()
17 {
18 if (next) next->handle(); // critical!
19 }
20 };
这里发生了很多事情,我们依次讨论:
- 该类获取并存储一个对它计划修改的
Creature
的引用。 - 这个类实际上并没有做很多事情,但是它不是抽象的:它的所有成员都有实现。
next
成员指向这个成员之后的一个可选的CreatureModifier
。言外之意当然是,它所指向的修饰语是CreatureModifier
的继承者。- 函数
add()
增加了另一个生物属性到属性链中。这是递归完成的:如果当前的修饰符是nullptr
,我们就把它设置成那个值,否则我们遍历整个链,把它放在最后。 - 函数
handle()
简单地处理链中的下一项,如果它存在的话;它没有自己的行为。它是virtual
的事实意味着它应该被覆盖。
到目前为止,我们所拥有的只是一个穷人的只加单链表的实现。但是当我们开始继承它的时候,事情将有希望变得更清楚。例如,下面是你如何制作一个可以让生物的attack
值翻倍的修改器:
1 class DoubleAttackModifier : public CreatureModifier
2 {
3 public:
4 explicit DoubleAttackModifier(Creature& creature)
5 : CreatureModifier(creature) {}
6
7 void handle() override
8 {
9 creature.attack *= 2;
10 CreatureModifier::handle();
11 }
12 };
好吧,我们终于有进展了。所以这个修饰符继承自CreatureModifier
,在它的handle()
方法中做了两件事:加倍攻击值和从基类调用handle()
。第二部分很关键:应用修饰符链的唯一方式是每个继承者不要忘记在自己的handle()
实现结束时调用基类。
这是另一个更复杂的修饰词。该调整值为attack
等于或小于 2 的生物增加 1 点防御:
1 class IncreaseDefenseModifier : public CreatureModifier
2 {
3 public:
4 explicit IncreaseDefenseModifier(Creature& creature)
5 : CreatureModifier(creature) {}
6
7 void handle() override
8 {
9 if (creature.attack <= 2)
10 creature.defense += 1;
11 CreatureModifier::handle();
12 }
13 };
最后我们再次调用基类。综上所述,我们现在可以创建一个生物,并对其应用修改器组合:
1 Creature goblin{ "Goblin", 1, 1 };
2 CreatureModifier root{ goblin };
3 DoubleAttackModifier r1{ goblin };
4 DoubleAttackModifier r1_2{ goblin };
5 IncreaseDefenseModifier r2{ goblin };
6
7 root.add(&r1);
8 root.add(&r1_2);
9 root.add(&r2);
10
11 root.handle();
12
13 cout << goblin << endl;
14 // name: Goblin attack: 4 defense: 1
如你所见,地精的赔率是 4/1,因为它的攻击加倍了两次,而且防御调整值虽然增加了,但并不影响它的防御分数。
这里还有一个奇怪的地方。假设你决定对一个生物施一个法术,这样它就不会有任何加值。容易做到吗?实际上很简单,因为你所要做的就是避免调用基类handle()
:这避免了执行整个链:
1 class NoBonusesModifier : public CreatureModifier
2 {
3 public:
4 explicit NoBonusesModifier(Creature& creature)
5 : CreatureModifier(creature) {}
6
7 void handle() override
8 {
9 // nothing here!
10 }
11 };
就是这样!现在,如果您将NoBonusesModifier
放在链的开头,将不会应用更多的元素。
经纪人链
指针链的例子是非常人为的。在现实世界中,你会希望生物能够任意接受和失去奖励,这是只附加链表所不支持的。此外,你不希望永久地修改基础生物属性(就像我们所做的),相反,你希望保持临时的修改。
实现 CoR 的一种方式是通过集中式组件。这个组件可以保存游戏中所有可用的修正值列表,并且可以通过确保所有相关的奖励都被应用来帮助查询特定生物的攻击或防御。
我们将要构建的组件称为事件代理。因为它连接到每个参与的组件,所以它代表了中介设计模式,而且,因为它通过事件响应查询,所以它利用了观察者设计模式。
让我们建造一个。首先,我们将定义一个名为Game
的结构,它将代表一个正在进行的游戏:
1 struct Game // mediator
2 {
3 signal<void(Query&)> queries;
4 };
我们正在使用助推器。Signals2 库用于保存一个名为queries
的信号。本质上,这让我们做的是激发这个信号,并让每个插槽(监听组件)处理它。但是事件与质疑生物的攻击或防御有什么关系呢?
好吧,假设你想查询一个生物的统计数据。您当然可以尝试读取一个字段,但是请记住:在知道最终值之前,我们需要应用所有的修饰符。因此,我们将把一个查询封装在一个单独的对象中(这是命令模式 1 ),定义如下:
1 struct Query
2 {
3 string creature_name;
4 enum Argument { attack, defense } argument;
5 int result;
6 };
我们在前面提到的类中所做的一切都包含了从生物那里查询特定值的概念。我们需要提供的只是生物的名字和我们感兴趣的统计数据。正是这个值(嗯,是对它的引用)将被构造并被Game::queries
用来应用修饰符并返回最终的value
。
现在,让我们继续讨论Creature
的定义。和我们之前的很像。就字段而言,唯一的区别是对Game
的引用:
1 class Creature
2 {
3 Game& game;
4 int attack, defense;
5 public:
6 string name;
7 Creature(Game& game, ...) : game{game}, ... { ... }
8 // other members here
9 };
现在,注意attack
和defense
是如何私有的。这意味着,要获得最终(修饰后)攻击值,您需要调用一个单独的 getter 函数,例如:
1 int Creature::get_attack() const
2 {
3 Query q{ name, Query::Argument::attack, attack };
4 game.queries(q);
5 return q.result;
6 }
这就是奇迹发生的地方!我们所做的不是返回一个值或者静态地应用一些基于指针的链,而是用正确的参数创建一个Query
,然后将查询发送给订阅了Game::queries
的任何人来处理。每个订阅的组件都有机会修改基线attack
值。
所以现在让我们实现修饰符。同样,我们将创建一个基类,但这一次它没有handle()
方法:
1 class CreatureModifier
2 {
3 Game& game;
4 Creature& creature;
5 public:
6 CreatureModifier(Game& game, Creature& creature)
7 : game(game), creature(creature) {}
8 };
所以修饰符基类并不特别有趣。事实上,您可以根本不使用它,因为它所做的只是确保用正确的参数调用构造器。但是既然我们已经采用了这种方法,现在让我们继承CreatureModifier
并看看如何执行实际的修改:
1 class DoubleAttackModifier : public CreatureModifier
2 {
3 connection conn;
4 public:
5 DoubleAttackModifier(Game& game, Creature& creature)
6 : CreatureModifier(game, creature)
7 {
8 conn = game.queries.connect(&
9 {
10 if (q.creature_name == creature.name &&
11 q.argument == Query::Argument::attack)
12 q.result *= 2;
13 });
14 }
15
16 ~DoubleAttackModifier() { conn.disconnect(); }
17 };
如你所见,所有有趣的事情都发生在构造器和析构函数中;不需要额外的方法。在构造器中,我们使用Game
引用来获取Game::queries
信号并连接到它,指定一个加倍攻击的 lambda。自然地,lambda 必须进行一些检查:我们需要确保我们增加了正确的生物(我们通过名称进行比较),并且我们所追求的统计数据实际上是attack
。两条信息都保存在Query
引用中,正如我们修改的初始result
值一样。
我们还注意保存信号连接,以便在对象被破坏时断开它。这样,我们可以暂时应用修改器,当修改器超出范围时,让它消失。
综上所述,我们得到以下结果:
1 Game game;
2 Creature goblin{ game, "Strong Goblin", 2, 2 };
3 cout << goblin << endl;
4 // name: Strong Goblin attack: 2 defense: 2
5 {
6 DoubleAttackModifier dam{ game, goblin };
7 cout << goblin << endl;
8 // name: Strong Goblin attack: 4 defense: 2
9 }
10 cout << goblin << endl;
11 // name: Strong Goblin attack: 2 defense: 2
12 }
这里发生了什么事?在被改造之前,地精是 2/2。然后,我们制造一个范围,在这个范围内地精受到一个DoubleAttack
Modifier
的影响,所以在范围内它是一个 4/2 生物。一旦我们退出这个作用域,修饰符的析构函数就会触发,它会断开自己与代理的连接,因此在查询这些值时不再影响它们。因此,地精本身再次回复为 2/2 生物。
摘要
责任链是一个非常简单的设计模式,它让组件依次处理一个命令(或一个查询)。CoR 最简单的实现是简单地创建一个指针链,理论上,你可以用一个普通的vector
来代替它,或者,如果你也想快速移除的话,用一个list
来代替它。
一个更复杂的代理链实现也利用了中介者和观察者模式,允许我们处理对事件(信号)的查询,让每个订阅者在最终值返回给客户端之前,对最初传递的对象(它是贯穿整个链的单个引用)进行修改。
Footnotes 1
实际上,这里有点混乱。命令查询分离(CQS)的概念建议将操作分离成命令(改变状态,不产生任何值)和查询(不改变任何东西,但产生一个值)。GoF 没有查询的概念,所以我们让组件的任何封装指令都称为命令。
十四、命令
想一个琐碎的变量赋值,比如meaning_of_life = 42
。变量被赋值了,但是没有任何记录表明赋值发生了。没有人能给我们以前的值。我们不能将赋值的事实序列化到某个地方。这是有问题的,因为没有变更的记录,我们就不能回滚到以前的值,执行审计,或者进行基于历史的调试。
命令设计模式提出,我们向对象发送命令:关于如何做某事的指令,而不是通过 API 操纵它们来直接处理对象。命令只不过是一个数据类,其成员描述做什么和如何做。让我们来看一个典型的场景。
方案
让我们试着为一个有余额和透支额度的典型银行账户建模。我们将在上面实现deposit()
和withdraw()
函数:
1 struct BankAccount
2 {
3 int balance = 0;
4 int overdraft_limit = -500;
5
6 void deposit(int amount)
7 {
8 balance += amount;
9 cout << "deposited " << amount << ", balance is now " <<
10 balance << "\n";
11 }
12
13 void withdraw(int amount)
14 {
15 if (balance - amount >= overdraft_limit)
16 {
17 balance -= amount;
18 cout << "withdrew " << amount << ", balance is now " <<
19 balance << "\n";
20 }
21 }
22 };
当然,现在我们可以直接调用成员函数,但是让我们假设,为了审计的目的,我们需要记录每一笔存款和取款,但是我们不能在BankAccount
中直接这样做,因为——你猜怎么着——我们已经设计、实现并测试了那个类。
实现命令模式
我们将从定义一个命令的接口开始:
1 struct Command
2 {
3 virtual void call() const = 0;
4 };
有了这个接口,我们现在可以用它来定义一个BankAccountCommand
,它将封装关于如何处理银行账户的信息:
1 struct BankAccountCommand : Command
2 {
3 BankAccount& account;
4 enum Action { deposit, withdraw } action;
5 int amount;
6
7 BankAccountCommand(BankAccount& account, const Action
8 action, const int amount)
9 : account(account), action(action), amount(amount) {}
该命令中包含的信息包括:
- 要操作的帐户
- 要采取的操作;选项集和存储这些选项的变量都在一个声明中定义
- 存款或取款的金额
一旦客户提供了这些信息,我们就可以利用这些信息进行存款或取款:
1 void call() const override
2 {
3 switch (action)
4 {
5 case deposit:
6 account.deposit(amount);
7 break;
8 case withdraw:
9 account.withdraw(amount);
10 break;
11 }
12 }
使用这种方法,我们可以创建命令,然后在命令上执行帐户权限的修改:
1 BankAccount ba;
2 Command cmd{ba, BankAccountCommand::deposit, 100};
3 cmd.call();
这会在我们的账户上存 100 美元。放轻松!如果您担心我们仍然向客户端公开原始的deposit()
和withdraw()
成员函数,您可以将它们设为private
,并简单地将BankAccountCommand
指定为友元类。
撤消操作
因为一个命令封装了关于对一个BankAccount
的修改的所有信息,它同样可以回滚这个修改,并将其目标对象返回到其先前的状态。
首先,我们需要决定是否将撤销相关的操作放入我们的Command
接口。出于简洁的目的,我将在这里这样做,但一般来说,这是一个需要尊重我们在本书开始时讨论过的接口分离原则的设计决策(第一章)。例如,如果您设想一些命令是最终的,并且不受撤销机制的影响,那么将Command
拆分成Callable
和Undoable
可能是有意义的。
无论如何,这里是更新的Command
;注意,我特意从函数中删除了const
:
1 struct Command
2 {
3 virtual void call() = 0;
4 virtual void undo() = 0;
5 };
这里是BankAccountCommand::undo()
的一个天真的实现,其动机是(不正确的)账户存款和取款是对称操作的假设:
1 void undo() override
2 {
3 switch (action)
4 {
5 case withdraw:
6 account.deposit(amount);
7 break;
8 case deposit:
9 account.withdraw(amount);
10 break;
11 }
12 }
为什么这个实现被打破了?因为如果你试图提取相当于一个发达国家国内生产总值的金额,你不会成功,但当回滚交易时,我们没有办法知道它失败了!
为了获得这个信息,我们修改withdraw()
来返回一个成功标志:
1 bool withdraw(int amount)
2 {
3 if (balance - amount >= overdraft_limit)
4 {
5 balance -= amount;
6 cout << "withdrew " << amount << ", balance now " <<
7 balance << "\n";
8 return true;
9 }
10 return false;
11 }
那就好多了!我们现在可以修改整个BankAccountCommand
来做两件事:
- 取款时,在内部存储一个
success
标志。 - 调用
undo()
时使用该标志。
我们开始吧:
1 struct BankAccountCommand : Command
2 {
3 ...
4 bool withdrawal_succeeded;
5
6 BankAccountCommand(BankAccount& account, const Action action,
7 const int amount)
8 : ..., withdrawal_succeeded{false} {}
9
10 void call() override
11 {
12 switch (action)
13 {
14 ...
15 case withdraw:
16 withdrawal_succeeded = account.withdraw(amount);
17 break;
18 }
19 }
你现在明白为什么我从Command?
的成员中移除了const
了吗?现在我们正在分配一个成员变量withdrawal_succeeded
,我们不能再声称call()
是const
。我想我本可以把它留在undo()
的,但这没什么好处。
好了,现在我们有了标志,我们可以改进我们的undo()
实现了:
1 void undo() override
2 {
3 switch (action)
4 {
5 case withdraw:
6 if (withdrawal_succeeded)
7 account.deposit(amount);
8 break;
9 ...
10 }
11 }
Tada!我们最终可以用一致的方式撤销撤销命令。
当然,这个练习的目的是为了说明,除了存储关于要执行的操作的信息之外,命令还可以存储一些中间信息,这些信息对于审计之类的事情也是有用的:如果您检测到一系列 100 次失败的撤销尝试,您就可以调查潜在的黑客攻击。
复合命令
可以用两个命令模拟从账户 A 到账户 B 的资金转移:
- 从 A 中提取 X 美元
- 将$X 存入 B
如果我们不创建和调用这两个命令,而是只创建和调用一个封装了这两个命令的命令,那就太好了。这是我们在第八章中讨论的复合设计模式的本质。
让我们定义一个框架复合命令。我将从vector <BankAccountCommand>
继承——这可能会有问题,因为std::vector
没有虚拟析构函数,但在我们的例子中这不是问题。这里有一个非常简单的定义:
1 struct CompositeBankAccountCommand : vector<BankAccountCommand>, Command
2 {
3 CompositeBankAccountCommand(const initializer_list<value_type>& items)
4 : vector<BankAccountCommand>(items) {}
5
6 void call() override
7 {
8 for (auto& cmd : *this)
9 cmd.call();
10 }
11
12 void undo() override
13 {
14 for (auto it = rbegin(); it != rend(); ++it)
15 it->undo();
16 }
17 };
如您所见,CompositeBankAccountCommand
既是一个向量,也是一个Command
,这符合复合设计模式的定义。我添加了一个接受初始化列表的构造器(非常有用!)并实施了undo()
和redo()
操作。注意,undo()
进程以相反的顺序执行命令;希望我不必解释为什么您希望这是默认行为。
那么现在,专门用于转账的复合命令怎么样?我将它定义如下:
1 struct MoneyTransferCommand : CompositeBankAccountCommand
2 {
3 MoneyTransferCommand(BankAccount& from,
4 BankAccount& to, int amount) :
5 CompositeBankAccountCommand
6 {
7 BankAccountCommand{from, BankAccountCommand::withdraw, amount},
8 BankAccountCommand{to, BankAccountCommand::deposit, amount}
9 } {}
10 };
如你所见,我们所做的就是重用基类构造器用两个命令初始化对象,然后重用基类的call()/undo()
实现。
但是等等,这不对吧?基类实现并不完全符合它,因为它们没有包含失败的思想。如果我不能从 A 处取钱,我就不应该把钱存到 B 处:整个链条会自动取消。
为了支持这一想法,需要进行更剧烈的变革。我们需要
- 给
Command
添加一个success
标志。 - 记录下每一次操作的成败。
- 确保该命令只有在最初成功时才能撤消。
- 引入一个名为
DependentCompositeCommand
的新中间类,它非常小心地回滚命令。
当调用每个命令时,我们只有在前一个命令成功的情况下才这样做;否则,我们简单地将success
标志设置为false
。
1 void call() override
2 {
3 bool ok = true;
4 for (auto& cmd : *this)
5 {
6 if (ok)
7 {
8 cmd.call();
9 ok = cmd.succeeded;
10 }
11 else
12 {
13 cmd.succeeded = false;
14 }
15 }
16 }
没有必要覆盖undo()
,因为我们的每个命令都检查它自己的success
标志,并且只有当它被设置为true
时才撤销操作。
人们可以想象出一种更强的形式,其中复合命令只有在其所有部分都成功的情况下才成功(想象一下取款成功但存款失败的转帐——您希望它通过吗?)—这有点难以实现,我再次将它作为一个练习留给读者。
本节的全部目的是说明当考虑到现实世界的业务需求时,一个简单的基于命令的方法是如何变得非常复杂的。你是否真的需要这种复杂性…嗯,这取决于你。
命令查询分离
命令查询分离(CQS)的概念是指系统中的操作大致分为以下两类:
- 命令,是系统执行某些操作的指令,这些操作涉及状态突变,但不产生任何值
- 查询是对产生值但不改变状态的信息的请求
任何当前直接公开其状态以进行读写的对象都可以隐藏其状态(使其成为private
),然后,不是提供 getter 和 setter 对,而是提供一个单一的接口。我的意思是:假设我们有一个Creature
,它有两个属性叫做strength
和agility
。我们可以这样定义这种生物:
1 class Creature
2 {
3 int strength, agility;
4 public:
5 Creature(int strength, int agility)
6 : strength{strength}, agility{agility} {}
7
8 void process_command(const CreatureCommand& cc);
9 int process_query(const CreatureQuery& q) const;
10 };
如您所见,没有 getters 和 setters,但是我们有两个(只有两个!)名为process_command()
和process_query()
的 API 成员,用于与Creature
对象的所有交互。这两个都是专用类,与CreatureAbility
枚举一起定义如下:
1 enum class CreatureAbility { strength, agility };
2
3 struct CreatureCommand
4 {
5 enum Action { set, increaseBy, decreaseBy } action;
6 CreatureAbility ability;
7 int amount;
8 };
9
10 struct CreatureQuery
11 {
12 CreatureAbility ability;
13 };
如您所见,该命令描述了您想要更改哪个成员、如何更改以及更改多少。query 对象只指定要查询的内容,我们假设查询的结果是从函数返回的,而不是在 query 对象本身中设置的(如果其他对象影响了这个对象,正如我们已经看到的,这就是您应该做的)。
下面是process_command()
的定义:
1 void Creature::process_command(const CreatureCommand &cc)
2 {
3 int* ability;
4 switch (cc.ability)
5 {
6 case CreatureAbility::strength:
7 ability = &strength;
8 break;
9 case CreatureAbility::agility:
10 ability = &agility;
11 break;
12 }
13 switch (cc.action)
14 {
15 case CreatureCommand::set:
16 *ability = cc.amount;
17 break;
18 case CreatureCommand::increaseBy:
19 *ability += cc.amount;
20 break;
21 case CreatureCommand::decreaseBy:
22 *ability -= cc.amount;
23 break;
24 }
25 }
下面是更简单的定义:
1 int Creature::process_query(const CreatureQuery &q) const
2 {
3 switch (q.ability)
4 {
5 case CreatureAbility::strength: return strength;
6 case CreatureAbility::agility: return agility;
7 }
8 return 0;
9 }
如果您想要记录或持久保存这些命令和查询,现在只有两个地方需要这样做。所有这些的唯一真正问题是,对于只想以熟悉的方式操作对象的人来说,使用 API 有多困难。
幸运的是,如果我们愿意,我们总是可以制造 getter/setter 对;这些只会调用带有适当参数的process_
方法:
1 void Creature::set_strength(int value)
2 {
3 process_command(CreatureCommand{
4 CreatureCommand::set, CreatureAbility::strength, value
5 });
6 }
7
8 int Creature::get_strength() const
9 {
10 return process_query(CreatureQuery{CreatureAbility::strength});
11 }
不可否认,前面是一个非常简单的例子,说明了 CQS 系统内部实际发生的事情,但是它有望提供一个思路,说明如何将所有的对象接口分成命令和查询部分。
摘要
命令设计模式很简单:它基本上意味着对象可以使用封装了指令的特殊对象相互通信,而不是将相同的指令指定为方法的参数。
有时候,你不希望这样的对象使目标发生变异,或者导致它做一些特定的事情;相反,您希望使用这样的对象从目标查询一个值,在这种情况下,我们通常将这样的对象称为查询。虽然在大多数情况下,查询是依赖于方法的返回类型的不可变对象,但是也有一些情况(例如,参见责任链代理链示例;当你希望返回的结果被其他组件修改时。但是组件本身仍然没有修改,只有结果。
UI 系统中大量使用命令来封装典型的动作(例如,复制或粘贴),然后允许通过几种不同的方式调用单个命令。例如,您可以使用顶级应用程序菜单、工具栏上的按钮或键盘快捷键进行复制。最后,这些动作可以被组合成宏——可以被记录然后随意重放的动作序列。
Footnotes 1
我们有专门的历史调试工具,如 Visual Studio 的 IntelliTrace 或 UndoDB。
十五、解释器
解释器设计模式的目标是,你猜对了,解释输入,尤其是文本输入,尽管公平地说这真的无关紧要。解释器的概念与大学教授的编译理论和类似课程有很大联系。因为我们在这里没有足够的空间来深入研究不同类型的解析器的复杂性,所以本章的目的是简单地展示一些你可能想要解释的事情的例子。
这里有几个相当明显的例子:
- 像
42
或1.234e12
这样的数字文字需要被解释为有效地存储在二进制中。在 C++ 中,这些操作通过 C APIs 如atof()
以及更复杂的库如 Boost.LexicalCast 来实现 - 正则表达式帮助我们找到文本中的模式,但你需要认识到的是,正则表达式本质上是一种独立的、嵌入式的特定领域语言(DSL)。当然,在使用它们之前,必须对它们进行正确的解释。
- 任何结构化数据,无论是 CSV、XML、JSON 还是更复杂的数据,在使用之前都需要解释。
- 在解释器应用的顶峰,我们有完全成熟的编程语言。毕竟,像 C 或 Python 这样的语言的编译器或解释器在使某些东西可执行之前必须真正理解这种语言。
鉴于与口译有关的挑战的扩散和多样性,我们将简单地看一些例子。这些用来说明如何构建一个解释器:要么从零开始,要么利用一个在工业规模上帮助做这些事情的库。
数值表达式计算器
假设我们决定解析非常简单的数学表达式,比如 3+(5–4),也就是说,我们将限制自己使用加法、减法和括号。我们想要一个程序,可以读取这样的表达式,当然,计算表达式的最终值。
我们将手工构建计算器,而不求助于任何解析框架。这应该有望突出解析文本输入所涉及的一些复杂性。
乐星
解释表达式的第一步称为词法分析,它涉及到将一系列字符转换成一系列标记。一个标记通常是一个基本的语法元素,我们应该以这样一个简单的序列结束。在我们的例子中,令牌可以是
- 整数
- 运算符(加号或减号)
- 左括号或右括号
因此,我们可以定义以下结构:
1 struct Token
2 {
3 enum Type { integer, plus, minus, lparen, rparen } type;
4 string text;
5
6 explicit Token(Type type, const string& text)
7 : type{type}, text{text} {}
8
9 friend ostream& operator<<(ostream& os, const Token& obj)
10 {
11 return os << "`" << obj.text << "`";
12 }
13 };
你会注意到Token
不是一个enum
,因为除了类型之外,我们还想存储与这个令牌相关的文本,因为它并不总是预定义的。
现在,给定一个包含表达式的std::string
,我们可以定义一个词法分析过程,将文本转换成vector<Token>
:
1 vector<Token> lex(const string& input)
2 {
3 vector<Token> result;
4
5 for (int i = 0; i < input.size(); ++i)
6 {
7 switch (input[i])
8 {
9 case '+':
10 result.push_back(Token{ Token::plus, "+" });
11 break;
12 case '-':
13 result.push_back(Token{ Token::minus, "-" });
14 break;
15 case '(':
16 result.push_back(Token{ Token::lparen, "(" });
17 break;
18 case ')':
19 result.push_back(Token{ Token::rparen, ")" });
20 break;
21 default:
22 // number ???
23 }
24 }
25 }
解析预定义的令牌很容易。事实上,我们本可以把它们作为map<BinaryOperation, char>
来添加,以简化事情。但是解析一个数字并不容易。如果我们击中了 1,我们应该等待,看看下一个字符是什么。为此,我们定义了一个单独的例程:
1 ostringstream buffer;
2 buffer << input[i];
3 for (int j = i + 1; j < input.size(); ++j)
4 {
5 if (isdigit(input[j]))
6 {
7 buffer << input[j];
8 ++i;
9 }
10 else
11 {
12 result.push_back(Token{ Token::integer, buffer.str() });
13 break;
14 }
15 }
本质上,当我们不断读取(抽取)数字时,我们将它们添加到缓冲区中。完成后,我们从整个缓冲区中创建一个Token
,并将其添加到结果vector
中。
从语法上分析
解析过程将一系列标记转换成有意义的、通常面向对象的结构。在顶部,拥有一个树的所有元素都实现的抽象父类型通常很有用:
1 struct Element
2 {
3 virtual int eval() const = 0;
4 };
类型的eval()
函数计算这个元素的数值。接下来,我们可以创建一个元素来存储整数值(如 1、5 或 42):
1 struct Integer : Element
2 {
3 int value;
4
5 explicit Integer(const int value)
6 : value(value) {}
7
8 int eval() const override { return value; }
9 };
如果我们没有一个Integer
,就必须有一个加法或者减法之类的运算。在我们的例子中,所有的操作都是二进制的,这意味着它们有两个部分。例如,我们模型中的2+3
可以用伪代码表示为BinaryOperation{Literal{2}, Literal{3}, addition}
:
1 struct BinaryOperation : Element
2 {
3 enum Type { addition, subtraction } type;
4 shared_ptr<Element> lhs, rhs;
5
6 int eval() const override
7 {
8 if (type == addition)
9 return lhs->eval() + rhs->eval();
10 return lhs->eval() - rhs->eval();
11 }
12 };
注意,在前面,我使用了一个enum
而不是一个enum class
,这样我可以在后面写BinaryOperation::addition
。
但是不管怎样,继续解析过程。我们需要做的就是将一系列的Token
转换成一棵Expression
的二叉树。从一开始,它看起来就像这样:
1 shared_ptr<Element> parse(const vector<Token>& tokens)
2 {
3 auto result = make_unique<BinaryOperation>();
4 bool have_lhs = false; // this will need some explaining :)
5 for (size_t i = 0; i < tokens.size(); i++)
6 {
7 auto token = tokens[i];
8 switch(token.type)
9 {
10 // process each of the tokens in turn
11 }
12 }
13 return result;
14 }
从前面的代码中我们唯一需要讨论的是have_lhs
变量。记住,你试图得到的是一棵树,在这棵树的根部,我们期待一个BinaryExpression
,根据定义,它有左右两边。但是当我们在一个数字上时,我们怎么知道它是表达式的左边还是右边呢?是的,我们不知道,这就是为什么我们要追踪这个。
现在让我们一个案例一个案例地检查一下。首先,整数——它们直接映射到我们的Integer
结构,所以我们所要做的就是将文本转换成数字。(顺便说一句,如果我们愿意,我们也可以在 lexing 阶段这样做。)
1 case Token::integer:
2 {
3 int value = boost::lexical_cast<int>(token.text);
4 auto integer = make_shared<Integer>(value);
5 if (!have_lhs) {
6 result->lhs = integer;
7 have_lhs = true;
8 }
9 else result->rhs = integer;
10 }
plus
和minus
标记简单地决定了我们当前正在处理的操作的类型,所以它们很简单:
1 case Token::plus:
2 result->type = BinaryOperation::addition;
3 break;
4 case Token::minus:
5 result->type = BinaryOperation::subtraction;
6 break;
然后是左括号。是的,只有左边,我们在这个层次上没有明确地检测到右边的。基本上,这里的想法很简单:找到右括号(我现在忽略嵌套的括号),取出整个子表达式,parse()
递归地将它设置为我们当前正在处理的表达式的左边或右边:
1 case Token::lparen:
2 {
3 int j = i;
4 for (; j < tokens.size(); ++j)
5 if (tokens[j].type == Token::rparen)
6 break; // found it!
7
8 vector<Token> subexpression(&tokens[i + 1], &tokens[j]);
9 auto element = parse(subexpression); // recursive call
10 if (!have_lhs)
11 {
12 result->lhs = element;
13 have_lhs = true;
14 }
15 else result->rhs = element;
16 i = j; // advance
17 }
在真实的场景中,您会希望这里有更多的安全特性:不仅处理嵌套括号(我认为这是必须的),还处理缺少右括号的不正确表达式。如果真的不见了,你会怎么处理?抛出异常?尝试解析剩下的内容,并假设结束在最后?还有别的吗?所有这些问题都留给读者作为练习。
根据 C++ 本身的经验,我们知道为解析错误生成有意义的错误消息是非常困难的。事实上,您会发现一种叫做“跳过”的现象,在这种情况下,如果有疑问,词法分析器或语法分析器将试图跳过不正确的代码,直到遇到有意义的东西:这种方法正是静态分析工具所采用的,当用户键入不完整的代码时,静态分析工具有望对其正确工作。
使用词法分析器和语法分析器
实现了lex()
和parse()
之后,我们最终可以解析表达式并计算其值:
1 string input{ "(13-4)-(12+1)" };
2 auto tokens = lex(input);
3 auto parsed = parse(tokens);
4 cout << input << " = " << parsed->eval() << endl;
5 // prints "(13-4)-(12+1) = -4"
用 Boost 解析。精神
在现实世界中,很少有人用手动解析器来处理复杂的事情。当然,如果您正在解析一种“琐碎的”数据存储格式,比如 XML 或 JSON,那么手工滚动解析器是很容易的。但是如果您正在实现自己的 DSL 或编程语言,这不是一个选项。
助推。Spirit 是一个库,它通过为解析器的构造提供 succint(虽然不是特别直观)API 来帮助创建解析器。该库并不试图明确地将词法分析和解析阶段分开(除非您真的想这样做),而是允许您定义如何将文本结构映射到您定义的类型的对象上。
让我给你看一些使用 Boost 的例子。tln 编程语言的精神。 1
抽象语法树
首先,您需要 AST(抽象语法树)。在这方面,我简单地创建了一个支持访问者设计模式的基类,因为遍历这些结构非常重要:
1 struct ast_element
2 {
3 virtual ~ast_element() = default;
4 virtual void accept(ast_element_visitor& visitor) = 0;
5 };
然后,该接口用于在我的语言中定义不同的代码结构,例如:
1 struct property : ast_element
2 {
3 vector<wstring> names;
4 type_specification type;
5 bool is_constant{ false };
6 wstring default_value;
7
8 void accept(ast_element_visitor& visitor) override
9 {
10 visitor.visit(*this);
11 }
12 };
前面的属性定义有四个不同的部分,每个部分都存储在一个公共可访问的字段中。注意,它使用了一个type_specification
,它本身就是另一个ast_element
。
AST 的每一个类都需要适应 Boost。fusion——另一个 Boost 库,支持编译时(元编程)和运行时算法的融合(因此得名)。改编代码非常简单:
1 BOOST_FUSION_ADAPT_STRUCT(
2 tlön::property,
3 (std::vector<std::wstring>, names),
4 (tlön::type_specification, type),
5 (bool, is_constant),
6 (std::wstring, default_value)
7 )
Spirit 解析成常见的数据类型没有问题,比如一个std::vector
或std::optional
。它在多态性方面确实有一些问题:Spirit 更喜欢使用variant
,而不是让您的 AST 类型相互继承,也就是说:
1 typedef variant<function_body, property, function_signature> class_member;
句法分析程序
助推。Spirit 让我们将解析器定义为一组规则。使用的语法非常类似于正则表达式或 BNF (Bachus-Naur 形式)符号,除了运算符放在符号之前,而不是之后。以下是一个规则示例:
1 class_declaration_rule %=
2 lit(L"class ") >> +(alnum) % '.'
3 >> -(lit(L"(") >> -parameter_declaration_rule % ',' >> lit(")"))
4 >> "{"
5 >> *(function_body_rule | property_rule | function_signature_rule)
6 >> "}";
前面期望类声明以单词class
开始。然后它期望一个或多个单词(每个单词是一个或多个字母数字字符,因此是+(alnum)
),用句点'.'
分隔——这就是%
操作符的用途。您可能已经猜到,结果会映射到一个vector
上。随后,在花括号之后,我们期望零个或多个函数、属性或函数签名的定义——使用variant
将这些字段映射到我们之前的定义。
自然,有些元素是 AST 元素整个层次结构的“根”。在我们的例子中,这个根叫做file
(惊喜!),这里有一个函数既解析文件又漂亮地打印它:
1 template<typename TLanguagePrinter, typename Iterator>
2 wstring parse(Iterator first, Iterator last)
3 {
4 using spirit::qi::phrase_parse;
5
6 file f;
7 file_parser<wstring::const_iterator> fp{};
8 auto b = phrase_parse(first, last, fp, space, f);
9 if (b)
10 {
11 return TLanguagePrinter{}.pretty_print(f);
12 }
13 return wstring(L"FAIL");
14 }
前面的类型TLanguagePrinter
本质上是一个访问者,它知道如何用不同的语言(比如 C++)来呈现我们的 AST。
打印机
在解析了语言之后,我们可能想要编译它,或者在我的例子中,将它转换成其他语言。考虑到我们之前已经在整个 AST 层次结构中实现了一个accept()
方法,这相当容易。
唯一的挑战是如何对待variant
类型的人,因为他们需要特殊的访客。在std::variant
的情况下,你要找的是std::visit()
,但是因为我们用的是boost::variant
,所以要调用的函数是boost::accept_visitor()
。这个函数要求你给它一个从static_visitor
继承的类的实例,并为每一个可能的类型提供函数调用重载。这里有一个例子:
1 struct default_value_visitor : static_visitor<>
2 {
3 cpp_printer& printer;
4
5 explicit default_value_visitor(cpp_printer& printer)
6 : printer{printer}
7 {
8 }
9
10 void operator()(const basic_type& bt) const
11 {
12 // for a scalar value, we just dump its default
13 printer.buffer << printer.default_value_for(bt.name);
14 }
15
16 void operator()(const tuple_signature& ts) const
17 {
18 for (auto& e : ts.elements)
19 {
20 this->operator()(e.type);
21 printer.buffer << ", ";
22 }
23 printer.backtrack(2);
24 }
25 };
然后调用accept_visitor(foo, default_value_visitor{...})
,正确的重载将被调用,这取决于实际存储在variant
中的对象的类型。
摘要
首先,需要说明的是,相对而言,解释器设计模式有点不常见——构建解析器的挑战现在被认为是无关紧要的,这就是为什么我看到它在许多英国大学(包括我自己的大学)的计算机科学课程中被删除。此外,除非你打算从事语言设计,或者制作静态代码分析工具,否则你不太可能找到需求量很大的构建解析器的技能。
也就是说,解释的挑战是计算机科学的一个完全独立的领域,一本设计模式书的一章无法合理地公正对待它。如果您对这个主题感兴趣,我建议您查看诸如 Lex/Yacc、ANTLR 等专门针对 lexer/parser 构造的框架。我还可以推荐为流行的 ide 编写静态分析插件——这是一个很好的方式来感受真实的 ASTs 是什么样子的,是如何遍历的,甚至是如何修改的。
Footnotes 1
tln 是一种玩具语言,我构建它是为了演示“如果你不喜欢现有的语言,就构建一种新的语言”的想法。它使用 Boost。Spirit 并交叉编译(transpiles)成 C++。它是开源的,可以在 https://github.com/nesteruk/tlon
找到
十六、迭代器
每当您开始处理复杂的数据结构时,都会遇到遍历的问题。这可以用不同的方式处理,但是最常见的遍历方式,比如说,一个vector
是使用一个叫做迭代器的东西。
简单地说,迭代器是一个对象,它可以指向集合中的一个元素,并且知道如何移动到集合中的下一个元素。因此,只需要实现++
操作符和!=
操作符(这样你就可以比较两个迭代器并检查它们是否指向同一个东西)。就这样。
C++ 标准库大量使用迭代器,所以我们将讨论迭代器的使用方式,然后我们将看一看如何创建我们自己的迭代器以及迭代器的局限性。
标准库中的迭代器
假设您有一个名称列表,例如:
1 vector<string> names{ "john", "jane", "jill", "jack" };
如果您想获得names
集合中的第一个名字,您可以调用一个名为begin()
的函数。这个函数不会通过值或引用给出名字;相反,它给你一个迭代器:
1 vector<string>::iterator it = names.begin(); // begin(names)
函数begin()
作为vector
的成员函数和全局函数存在。全局变量对数组(C 风格的数组,而不是 ??)特别有用,因为它们不能有成员函数。
所以begin()
返回一个迭代器,你可以把它看作一个指针:在vector
的情况下,它有相似的机制。例如,您可以解引用迭代器来打印实际名称:
1 cout << "first name is " << *it << "\n";
2 // first name is john
给我们的迭代器知道如何前进,也就是移动到指向下一个元素。重要的是要认识到++
指的是向前移动的概念,也就是说,它不同于在内存中向前移动(即增加内存地址)的指针的++
:
1 ++it; // now points to jane
我们也可以使用迭代器(和指针一样)来修改它所指向的元素:
1 it->append(" goodall"s);
2 cout << "full name is " << *it << "\n";
3 // full name is jane goodall
现在,begin()
的对应词当然是end()
,但是它并不指向最后一个元素,而是指向最后一个元素之后的元素。下面是一个笨拙的例子:
1 1 2 3 4
2 begin() ^ ^ end()
可以使用end()
作为终止条件。例如,让我们使用我们的it
迭代器变量打印其余的名字:
1 while (++it != names.end())
2 {
3 cout << "another name: " << *it << "\n";
4 }
5 // another name: jill
6 // another name: jack
除了begin()
和end()
,我们还有rbegin()
和rend()
,它们允许我们在集合中向后移动。在这种情况下,您可能已经猜到了,rbegin()
指向最后一个元素,rend()
指向第一个元素之前的一个元素:
1 for (auto ri = rbegin(names); ri != rend(names); ++ri)
2 {
3 cout << *ri;
4 if (ri + 1 != rend(names)) // iterator arithmetic
5 cout << ", ";
6 }
7 cout << endl;
8 // jack, jill, jane goodall, john
前面有两件事值得指出。首先,即使我们正在向后遍历向量,我们仍然在迭代器上使用了++
操作符。第二,我们被允许做算术:同样,当我写ri + 1
时,这指的是ri
之前的元素,而不是之后的元素。
我们也可以有不允许修改对象的常量迭代器:它们通过cbegin()/cend()
返回,当然,也有反向变量crbegin()/crend()
:
1 vector<string>::const_reverse_iterator jack = crbegin(names);
2 // won't work
3 *jack += "reacher";
最后,值得一提的是现代 C++ 结构,这是一个基于范围的 for 循环,它是从容器的begin()
一直迭代到到达容器的end()
的一种速记方式:
1 for (auto& name : names)
2 cout << "name = " << name << "\n";
注意迭代器在这里是自动解引用的:变量name
是一个引用,但是你同样可以通过值进行迭代。
遍历二叉树
让我们来完成遍历二叉树的经典“comp sci”练习。首先,我们将这个 tere 的一个节点定义如下:
1 template <typename T> struct Node
2 {
3 T value;
4 Node<T> *left = nullptr;
5 Node<T> *right = nullptr;
6 Node<T> *parent = nullptr;
7 BinaryTree<T>* tree = nullptr;
8 };
每个节点都有对其left
和right
分支、其父节点(如果有)以及整个树的引用。节点可以独立构建,也可以根据其子节点的规范来构建:
1 explicit Node(const T& value)
2 : value(value)
3 {
4 }
5
6 Node(const T& value, Node<T>* const left, Node<T>* const right)
7 : value(value),
8 left(left),
9 right(right)
10 {
11 this->left->tree = this->right->tree = tree;
12 this->left->parent = this->right->parent = this;
13 }
最后,我们引入一个实用的成员函数来设置tree
指针。这是在Node
的所有子节点上递归完成的:
1 void set_tree(BinaryTree<T>* t)
2 {
3 tree = t;
4 if (left) left->set_tree(t);
5 if (right) right->set_tree(t);
6 }
有了这个,我们现在可以定义一个叫做BinaryTree
的结构——正是这个结构允许迭代:
1 template <typename T> struct BinaryTree
2 {
3 Node<T>* root = nullptr;
4
5 explicit BinaryTree(Node<T>* const root)
6 : root{ root }
7 {
8 root->set_tree(this);
9 }
10 };
现在我们可以为树定义一个迭代器。迭代二叉树有三种常见的方法,我们首先实现的是 preorder:
- 我们一遇到这个元素就返回它。
- 我们递归地遍历左边的子树。
- 我们递归地遍历右边的子树。
所以让我们从构造器开始:
1 template <typename U>
2 struct PreOrderIterator
3 {
4 Node<U>* current;
5
6 explicit PreOrderIterator(Node<U>* current)
7 : current(current)
8 {
9 }
10
11 // other members here
12 };
我们需要定义operator !=
来和其他迭代器比较。因为我们的迭代器充当指针,所以这是微不足道的:
1 bool operator!=(const PreOrderIterator<U>& other)
2 {
3 return current != other.current;
4 }
我们还需要*
操作符来取消引用:
1 Node<U>& operator*() { return *current; }
现在,困难的部分来了:遍历树。这里的挑战是我们不能使算法递归——记住,遍历发生在++
操作符中,所以我们最终实现如下:
1 PreOrderIterator<U>& operator++()
2 {
3 if (current->right)
4 {
5 current = current->right;
6 while (current->left)
7 current = current->left;
8 }
9 else
10 {
11 Node<T>* p = current->parent;
12 while (p && current == p->right)
13 {
14 current = p;
15 p = p->parent;
16 }
17 current = p;
18 }
19 return *this;
20 }
这还挺乱的!此外,它看起来一点也不像树遍历的经典实现,因为我们没有递归。我们一会儿会回到这个话题。
现在,最后一个问题是我们如何从我们的BinaryTree
中暴露迭代器。如果我们将它定义为树的默认迭代器,我们可以如下填充它的成员:
1 typedef PreOrderIterator<T> iterator;
2
3 iterator begin()
4 {
5 Node<T>* n = root;
6
7 if (n)
8 while (n->left)
9 n = n->left;
10 return iterator{ n };
11 }
12
13 iterator end()
14 {
15 return iterator{ nullptr };
16 }
值得注意的是,在begin()
中迭代并不从整棵树的根开始;相反,它从最左边的可用节点开始。
现在所有的部分都就位了,下面是我们如何进行遍历:
1 BinaryTree<string> family{
2 new Node<string>{"me",
3 new Node<string>{"mother",
4 new Node<string>{"mother's mother"},
5 new Node<string>{"mother's father"}
6 },
7 new Node<string>{"father"}
8 }
9 };
10
11 for (auto it = family.begin(); it != family.end(); ++it)
12 {
13 cout << (*it).value << "\n";
14 }
您也可以将这种形式的遍历公开为一个单独的对象,即:
1 class pre_order_traversal
2 {
3 BinaryTree<T>& tree;
4 public:
5 pre_order_traversal(BinaryTree<T>& tree) : tree{tree} {}
6 iterator begin() { return tree.begin(); }
7 iterator end() { return tree.end(); }
8 } pre_order;
用作:
1 for (const auto& it: family.pre_order)
2 {
3 cout << it.value << "\n";
4 }
类似地,可以定义in_order
和post_order
遍历算法来公开适当的迭代器。
协程迭代
我们有一个严重的问题:在我们的遍历代码中,operator++
是一个不可读的混乱,与你在维基百科上读到的任何关于树遍历的内容都不匹配。这是可行的,但这仅仅是因为我们预先初始化了迭代器,从最左边的节点开始,而不是根节点,这也是有问题的和令人困惑的。
为什么我们会有这个问题?因为++
操作符的函数是不可恢复的:它不能在调用之间保持栈,因此递归是不可能的。现在,如果我们有一种机制可以鱼和熊掌兼得:可以执行适当递归的可恢复函数,会怎么样?这正是协程的作用。
使用协程,我们可以如下实现后序树遍历:
1 generator<Node<T>*> post_order_impl(Node<T>* node)
2 {
3 if (node)
4 {
5 for (auto x : post_order_impl(node->left))
6 co_yield x;
7 for (auto y : post_order_impl(node->right))
8 co_yield y;
9 co_yield node;
10 }
11 }
12
13 generator<Node<T>*> post_order()
14 {
15 return post_order_impl(root);
16 }
这不是很棒吗?算法终于又可读了!此外,看不到任何begin()/end()
:我们只是返回一个generator
,这是一个专门设计用于逐步返回通过co_yield
输入的值的类型。在产生每个值之后,我们可以暂停执行并做其他事情(比如打印值),然后在不丢失上下文的情况下继续迭代。这使得递归成为可能,并允许我们这样写:
1 for (auto it: family.post_order())
2 {
3 cout << it->value << endl;
4 }
协程是 C++ 的未来,它解决了许多传统迭代器不适合的问题。
摘要
迭代器设计模式在 C++ 中以显式和隐式(例如,基于范围的)形式无处不在。不同类型的迭代器用于迭代不同的对象:例如,反向迭代器可能适用于vector
,但不适用于单链表。
实现您自己的迭代器就像提供++
和!=
操作符一样简单。大多数迭代器只是简单的指针外观,用于在集合被丢弃之前遍历一次。
协程修复了迭代器中存在的一些问题:它们允许在调用之间保留状态,这是其他语言(例如 C#)很久以前就已经实现的。因此,协程允许我们编写递归算法。
十七、中介
我们编写的大部分代码都有不同的组件(类)通过直接引用或指针相互通信。但是,也有不希望对象一定意识到对方存在的情况。或者,也许你确实希望他们知道彼此,但你仍然不希望他们通过指针或引用来交流,因为,嗯,那些可能会变得陈旧,你也不想去引用一个nullptr
,不是吗?
因此,中介是一种促进组件间通信的机制。自然地,中介本身需要能够被参与的每个组件访问,这意味着它要么是一个全局static
变量,要么只是一个被注入每个组件的引用。
聊天室
典型的互联网聊天室是中介设计模式的经典例子,所以在进入更复杂的内容之前,让我们先实现它。
聊天室中最简单的参与者实现可以是:
1 struct Person
2 {
3 string name;
4 ChatRoom* room = nullptr;
5 vector<string> chat_log;
6
7 Person(const string& name);
8
9 void receive(const string& origin, const string& message);
10 void say(const string& message) const;
11 void pm(const string& who, const string& message) const;
12 };
所以我们有了一个拥有name
(用户 id)、聊天日志和指向实际ChatRoom
的指针的人。我们有一个构造器和三个成员函数:
- 允许我们接收信息。这个功能通常会在用户的屏幕上显示消息,并将其添加到聊天日志中。
say()
允许此人向房间里的每个人广播消息。pm()
是私人信息传递功能。您需要指定邮件收件人的姓名。
say()
和pm()
只是将操作转发到聊天室。说到这里,我们来实际实现一下ChatRoom
—并不是特别复杂:
1 struct ChatRoom
2 {
3 vector<Person*> people; // assume append-only
4
5 void join(Person* p);
6 void broadcast(const string& origin, const string& message);
7 void message(const string& origin, const string& who,
8 const string& message);
9 };
是否使用指针、引用或shared_ptr
来实际存储聊天室参与者的列表最终取决于您:唯一的限制是std::vector
不能存储引用。所以,我决定在这里用指针。ChatRoom
API 非常简单:
- 让一个人加入房间。我们不打算实现
leave()
,而是将这个想法推迟到本章的后续例子中。 - 将消息发送给每个人…嗯,不完全是每个人:我们不需要将消息发送回发送它的人。
message()
发送私人信息。
join()
的实现如下:
1 void ChatRoom::join(Person* p)
2 {
3 string join_msg = p->name + " joins the chat";
4 broadcast("room", join_msg);
5 p->room = this;
6 people.push_back(p);
7 }
就像经典的 IRC 聊天室一样,我们向房间里的每个人广播某人已经加入的消息。在这种情况下,origin
被指定为"room"
,而不是被加入的人。然后我们设置这个人的room
指针,并将他们添加到房间里的人的列表中。
现在,让我们看看broadcast()
:这是向每个房间参与者发送消息的地方。请记住,每个参与者都有自己的Person::receive()
函数来处理消息,因此实现有些琐碎:
1 void ChatRoom::broadcast(const string& origin, const string& message)
2 {
3 for (auto p : people)
4 if (p->name != origin)
5 p->receive(origin, message);
6 }
我们是否想要阻止广播信息被转发给我们自己是一个争论点,但我在这里积极地避免它。不过,其他人都明白这一点。
最后,这里是用message()
实现的私有消息:
1 void ChatRoom::message(const string& origin,
2 const string& who, const string& message)
3 {
4 auto target = find_if(begin(people), end(people),
5 & { return p->name == who; });
6 if (target != end(people))
7 {
8 (*target)->receive(origin, message);
9 }
10 }
这将在列表people
中搜索收件人,如果找到了收件人(因为谁知道呢,他们可能已经离开房间了),就将消息发送给那个人。
回到say()
和pm()
的Person's
实现,它们是:
1 void Person::say(const string& message) const
2 {
3 room->broadcast(name, message);
4 }
5
6 void Person::pm(const string& who, const string& message) const
7 {
8 room->message(name, who, message);
9 }
至于receive()
,嗯,这是一个在屏幕上显示消息并将其添加到聊天日志的好地方。
1 void Person::receive(const string& origin, const string& message)
2 {
3 string s{ origin + ": \"" + message + "\"" };
4 cout << "[" << name << "'s chat session] " << s << "\n";
5 chat_log.emplace_back(s);
6 }
我们在这里做了额外的工作,不仅显示消息来自谁,还显示我们当前在谁的聊天会话中——这将有助于诊断谁在何时说了什么。
这是我们将要经历的场景:
1 ChatRoom room;
2
3 Person john{ "john" };
4 Person jane{ "jane" };
5 room.join(&john);
6 room.join(&jane);
7 john.say("hi room");
8 jane.say("oh, hey john");
9
10 Person simon("simon");
11 room.join(&simon);
12 simon.say("hi everyone!");
13
14 jane.pm("simon", "glad you could join us, simon");
这是输出结果:
1 [john's chat session] room: "jane joins the chat"
2 [jane's chat session] john: "hi room"
3 [john's chat session] jane: "oh, hey john"
4 [john's chat session] room: "simon joins the chat"
5 [jane's chat session] room: "simon joins the chat"
6 [john's chat session] simon: "hi everyone!"
7 [jane's chat session] simon: "hi everyone!"
8 [simon's chat
session] jane: "glad you could join us, simon"
事件中介
在聊天室的例子中,我们遇到了一个一致的主题:每当有人发布消息时,参与者都需要通知。对于观察者模式来说,这似乎是一个完美的场景,这将在第二十章中讨论:中介者拥有一个所有参与者共享的事件的想法;然后,参与者可以订阅该事件以接收通知,他们还可以引发该事件,从而触发所述通知。
C++ 中没有内置事件(不像 C#),所以我们将使用一个库解决方案来演示。助推。Signals2 为我们提供了必要的功能,尽管术语略有不同:我们通常称之为信号(生成通知的对象)和槽(处理通知的函数)。
让我们来看一个更简单的例子,而不是再次重做聊天室:想象一场足球比赛,有球员和足球教练。教练看到自己的球队得分,自然要恭喜球员。当然,他们需要一些关于这个事件的信息,比如谁进了球,到目前为止他们进了多少球。
我们可以为任何类型的事件数据引入一个基类:
1 struct EventData
2 {
3 virtual ~EventData() = default;
4 virtual void print() const = 0;
5 };
我添加了print()
函数,这样每个事件都可以被打印到命令行,还添加了一个虚拟析构函数,让 ReSharper 闭嘴。现在,我们可以从这个类派生出一些与目标相关的数据:
1 struct PlayerScoredData : EventData
2 {
3 string player_name;
4 int goals_scored_so_far;
5
6 PlayerScoredData(const string& player_name, const int goals_scored_so_far)
7 : player_name(player_name),
8 goals_scored_so_far(goals_scored_so_far) {}
9
10 void print() const override
11 {
12 cout << player_name << " has scored! (their "
13 << goals_scored_so_far << " goal)" << "\n";
14 }
15 };
我们将再次构建一个中介器,但是它没有行为!说真的,有了事件驱动的基础设施,就不再需要它们了:
1 struct Game
2 {
3 signal<void(EventData*)> events; // observer
4 };
事实上,你可以只拥有一个全局signal
而不创建一个Game
类,但是我们在这里使用最小惊奇原则,如果一个Game&
被注入一个组件,我们知道那里有一个明显的依赖关系。
不管怎样,我们现在可以构造Player
类了。一名球员有一个名字,他们在比赛中的进球数,当然还有一个仲裁人Game
的参考:
1 struct Player
2 {
3 string name;
4 int goals_scored = 0;
5 Game& game;
6
7 Player(const string& name, Game& game)
8 : name(name), game(game) {}
9
10 void score()
11 {
12 goals_scored++;
13 PlayerScoredData ps{name, goals_scored};
14 game.events(&ps);
15 }
16 };
这里的Player::score()
是一个有趣的函数:它使用events
信号创建一个PlayerScoredData
,并将其发布给所有订阅者。谁得到这个事件?为什么,当然是一只Coach
:
1 struct Coach
2 {
3 Game& game;
4 explicit Coach(Game& game) : game(game)
5 {
6 // celebrate if player has scored <3 goals
7 game.events.connect([](EventData* e)
8 {
9 PlayerScoredData* ps = dynamic_cast<PlayerScoredData*>(e);
10 if (ps && ps->goals_scored_so_far < 3)
11 {
12 cout << "coach says: well done, " << ps->player_name << "\n";
13 }
14 });
15 }
16 };
Coach
类的实现很简单;我们的教练连名字都没有。但是我们确实给了他一个构造器,在那里创建了一个对game.events
的订阅,这样,无论什么时候发生了什么,教练都可以在提供的 lambda (slot)中处理事件数据。
注意,lambda 的参数类型是EventData*
——我们不知道一个球员是得分了还是被罚下了,所以我们需要dynamic_cast
(或类似的机制)来确定我们得到了正确的类型。
有趣的是,所有的魔法都发生在设置阶段:没有必要为特定的信号明确地登记插槽。客户端可以使用它们的构造器自由创建对象,然后,当玩家得分时,会发送通知:
1 Game game;
2 Player player{ "Sam", game };
3 Coach coach{ game };
4
5 player.score();
6 player.score();
7 player.score(); // ignored by coach
这会产生以下输出:
1 coach says: well done, Sam
2 coach says: well done, Sam
输出只有两行长,因为在第三个目标上,教练不再感兴趣了。
摘要
中介设计模式本质上提出了引入一个中间组件,系统中的每个人都可以引用该组件,并可以使用该组件相互通信。代替直接的内存地址,通信可以通过标识符(用户名、唯一的 id 等)进行。
中介器最简单的实现是一个成员列表和一个函数,它遍历列表并做它想要做的事情——无论是对列表的每个元素还是有选择地。
更复杂的 Mediator 实现可以使用事件来允许参与者订阅(和取消订阅)系统中发生的事情。这样,从一个组件发送到另一个组件的消息可以被视为事件。在这种设置中,如果参与者对某些事件不再感兴趣或者他们将要完全离开该系统,他们也很容易取消订阅这些事件。
十八、备忘录
当我们查看命令设计模式时,我们注意到理论上记录每一个单独的更改列表允许您将系统回滚到任何时间点——毕竟,您已经保留了所有修改的记录。
但是,有时您并不真正关心回放系统的状态,但是如果需要的话,您确实关心能够将系统回滚到特定的状态。
这正是 Memento 模式所做的:它存储系统的状态,并将其作为一个专用的、只读的对象返回,没有自己的行为。如果你愿意的话,这个“令牌”只能用于将它反馈到系统中,以将它恢复到它所代表的状态。
让我们看一个例子。
银行存款
让我们以之前创建的银行账户为例:
1 class BankAccount
2 {
3 int balance = 0;
4 public:
5 explicit BankAccount(const int balance)
6 : balance(balance) {}
但是现在我们决定做一个只有deposit()
功能的银行账户。与前面示例中的void
不同,deposit()
现在将返回一个Memento
:
1 Memento deposit(int amount)
2 {
3 balance += amount;
4 return { balance };
5 }
并且该备忘录然后将可用于将账户回滚到先前的状态:
1 void restore(const Memento& m)
2 {
3 balance = m.balance;
4 }
5 };
至于备忘录本身,我们可以做一个简单的实现:
1 class Memento
2 {
3 int balance;
4 public:
5 Memento(int balance)
6 : balance(balance)
7 {
8 }
9 friend class BankAccount;
10 };
这里有两点需要指出:
Memento
类是不可变的。想象一下,如果你真的可以改变平衡:你可以将账户回滚到一个从未有过的状态!- memento 将
BankAccount
声明为朋友类。这允许帐户实际使用balance
字段。另一个可行的方法是让Memento
成为BankAccount
的内部类。
下面是如何使用这样的设置:
1 void memento()
2 {
3 BankAccount ba{ 100 };
4 auto m1 = ba.deposit(50);
5 auto m2 = ba.deposit(25);
6 cout << ba << "\n"; // Balance: 175
7
8 // undo to m1
9 ba.restore(m1);
10 cout << ba << "\n"; // Balance: 150
11
12 // redo
13 ba.restore(m2);
14 cout << ba << "\n"; // Balance: 175
15 }
这个实现足够好了,尽管还缺少一些东西。例如,你永远不会得到代表期初余额的备忘录,因为构造器不能返回值。你可以在里面放一个指针,但是看起来有点难看。
撤消和重做
如果你要保存由BankAccount
生成的每一个备忘录会怎么样?在这种情况下,您会遇到类似于我们的Command
模式实现的情况,撤销和重做操作是这个记录的副产品。让我们看看如何用备忘录获得撤销/重做功能。
我们将引入一个新的银行账户类BankAccount2
,它将保存它所生成的每一个备忘录:
1 class BankAccount2 // supports undo/redo
2 {
3 int balance = 0;
4 vector<shared_ptr<Memento>> changes;
5 int current;
6 public:
7 explicit BankAccount2(const int balance) : balance(balance)
8 {
9 changes.emplace_back(make_shared<Memento>(balance));
10 current = 0;
11 }
我们现在已经解决了返回初始平衡的问题:初始变化的备忘录也被存储。当然,这个备忘录实际上并没有被返回,所以为了回滚到它,我想你可以实现一些reset()
函数之类的东西——这完全取决于你。
在前面的例子中,我们使用shared_ptr
来存储备忘录,我们也使用shared_ptr
来返回它们。此外,我们使用current
字段作为变更列表的“指针”,这样,如果我们决定撤销并后退一步,我们总是可以重做并恢复到我们刚刚做过的事情。
现在,这里是deposit()
函数的实现:
1 shared_ptr<Memento> deposit(int amount)
2 {
3 balance += amount;
4 auto m = make_shared<Memento>(balance);
5 changes.push_back(m);
6 ++current;
7 return m;
8 }
现在有趣的事情来了(顺便说一下,我们仍然在列出BankAccount2
的成员)。我们添加了一个基于备忘录恢复帐户状态的方法:
1 void restore(const shared_ptr<Memento>& m)
2 {
3 if (m)
4 {
5 balance = m->balance;
6 changes.push_back(m);
7 current = changes.size() - 1;
8 }
9 }
恢复过程与我们之前看到的过程有很大不同。首先,我们实际上检查了shared_ptr
是否被初始化——这是相关的,因为我们现在有了一种发送无操作信号的方式:只返回一个默认值。此外,当我们恢复一个备忘录时,我们实际上将该备忘录推入更改列表中,以便撤销操作可以正确地对其进行操作。
现在,这里是undo()
的(相当棘手的)实现:
1 shared_ptr<Memento> undo()
2 {
3 if (current > 0)
4 {
5 --current;
6 auto m = changes[current];
7 balance = m->balance;
8 return m;
9 }
10 return{};
11 }
如果current
指向大于零变化,我们只能undo()
。如果是这样的话,我们把指针移回来,在那个位置抓取变化,应用它,然后返回那个变化。如果我们不能回滚到前一个备忘录,我们返回一个默认构造的shared_ptr
,为此我们签入restore()
。
redo()
的实现非常相似:
1 shared_ptr<Memento> redo()
2 {
3 if (current + 1 < changes.size())
4 {
5 ++current;
6 auto m = changes[current];
7 balance = m->balance;
8 return m;
9 }
10 return{};
11 }
同样,我们需要能够重做一些事情:如果可以,我们安全地做,如果不行,我们什么也不做,返回一个空指针。综上所述,我们现在可以开始使用撤销/重做功能了:
1 BankAccount2 ba{ 100 };
2 ba.deposit(50);
3 ba.deposit(25); // 125
4 cout << ba << "\n";
5
6 ba.undo();
7 cout << "Undo 1: " << ba << "\n"; // Undo 1: 150
8 ba.undo();
9 cout << "Undo 2: " << ba << "\n"; // Undo 2: 100
10 ba.redo();
11 cout << "Redo 2: " << ba << "\n"; // Redo 2: 150
12 ba.undo(); // back to 100 again
摘要
Memento 模式就是分发令牌,这些令牌可以用来将系统恢复到以前的状态。通常,令牌包含将系统移动到特定状态所需的所有信息,如果它足够小,您还可以使用它来记录系统的所有状态,以便不仅允许将系统任意重置到先前的状态,还允许对系统所处的所有状态进行受控的向后(撤消)和向前(重做)导航。
十九、空对象
我们并不总是选择我们工作的界面。例如,我宁愿让我的车自己把我送到目的地,而不用我把 100%的注意力放在路上和旁边开车的危险的疯子身上。软件也是如此:有时你并不真的想要某项功能,但它已经内置在界面中了。那你是做什么的?你创建了一个空对象。
方案
假设您继承了一个使用以下接口的库:
1 struct Logger
2 {
3 virtual ~Logger() = default;
4 virtual void info(const string& s) = 0;
5 virtual void warn(const string& s) = 0;
6 };
本库使用该接口对银行账户进行操作,例如:
1 struct BankAccount
2 {
3 std::shared_ptr<Logger> log;
4 string name;
5 int balance = 0;
6
7 BankAccount(const std::shared_ptr<Logger>& logger,
8 const string& name, int balance)
9 : log{logger}, name{name}, balance{balance} { }
10
11 // more members here
12 };
事实上,BankAccount
可以有类似于:
1 void BankAccount::deposit(int amount)
2 {
3 balance += amount;
4 log->info("Deposited $" + lexical_cast<string>(amount)
5 + " to " + name + ", balance is now $" + lexical_cast<string>(balance));
6 }
那么,这里的问题是什么?嗯,如果你确实需要日志,没有问题,你只需要实现你自己的日志类…
1 struct ConsoleLogger : Logger
2 {
3 void info(const string& s) override
4 {
5 cout << "INFO: " << s << endl;
6 }
7
8 void warn(const string& s) override
9 {
10 cout << "WARNING!!! " << s << endl;
11 }
12 };
你可以直接使用它。但是如果您根本不想要日志呢?
空对象
再次查看BankAccount
的构造器:
1 BankAccount(const shared_ptr<Logger>& logger,
2 const string& name, int balance)
因为构造器接受一个日志记录器,所以假设你可以通过传递一个未初始化的shared_ptr<BankAccount>(). BankAccount
就可以得到它是不安全的。在对它进行调度之前,可以在内部检查指针,但是你不知道它会这样做,没有额外的文档也不可能知道。
因此,唯一合理的传递给BankAccount
的是null object
——一个符合接口但不包含任何功能的类:
1 struct NullLogger : Logger
2 {
3 void info(const string& s) override {}
4 void warn(const string& s) override {}
5 };
shared_ptr
不是空对象
需要注意的是shared_ptr
和其他智能指针类不是空对象。空对象是保持正确操作(不执行任何操作)的东西。但是对未初始化的智能指针的调用会崩溃和烧毁:
1 shared_ptr<int> n;
2 int x = *n + 1; // yikes!
有趣的是,从调用的角度来看,没有办法让智能指针变得“安全”。换句话说,你不能写这样一个智能指针,如果foo
未初始化,foo->bar()
会神奇地变成空操作。原因是前缀*
和后缀->
操作符只是代理底层(原始)指针。没有办法在指针上做无操作。
设计改进
停下来想一想:如果BankAccount
在你的控制之下,你能改进界面使它更容易使用吗?这里有一些想法:
- 到处放指针检查。这整理出了
BankAccount
端的正确性,但并没有停止让库用户感到困惑。记住,你仍然没有意识到指针可以为空。 - 添加一个默认参数值,类似于
const shared_ptr<Logger>& logger = no_logging
,其中no_logging
是BankAccount
类的某个成员。即使是这种情况,你仍然需要在每个你想使用对象的地方检查指针值。 - 使用
optional
类型。这在习惯用法上是正确的,并且传达了意图,但是导致了传入一个optional<shared_ptr<T>>
和随后检查一个可选的是否为空的恐惧。
隐式空对象
还有另一个激进的想法,它包括绕过Logger
弯道两次跳跃。它包括将日志记录过程细分为调用(我们想要一个漂亮的Logger
接口)和操作(日志记录器实际做的事情)。因此,请考虑以下情况:
1 struct OptionalLogger : Logger {
2 shared_ptr<Logger> impl;
3 static shared_ptr<Logger> no_logging;
4 Logger(const shared_ptr<Logger>& logger) : impl{logger} {}
5 virtual void info(const string& s) override {
6 if (impl) impl->info(s); // null check here
7 }
8 // and similar checks for other members
9 };
10
11 // a static instance of a null object
12 shared_ptr<Logger> BankAccount::no_logging{};
所以现在我们已经从实现中抽象出了调用。我们现在做的是如下重新定义BankAccount
构造器:
1 shared_ptr<OptionalLogger> logger;
2 BankAccount(const string& name, int balance,
3 const shared_ptr<Logger>& logger = no_logging)
4 : log{make_shared<OptionalLogger>(logger)},
5 name{name},
6 balance{balance} { }
如您所见,这里有一个巧妙的借口:我们接受一个Logger
,但是存储一个OptionalLogger
(这是代理设计模式)。然后,对这个可选记录器的所有调用都是安全的——它们只有在底层对象可用时才“发生”:
1 BankAccount account{ "primary account", 1000 };
2 account.deposit(2000); // no crash
在前面的例子中实现的代理对象本质上是 Pimpl 习惯用法的定制版本。
摘要
空对象模式提出了一个 API 设计的问题:我们可以对我们依赖的对象做什么样的假设?如果我们使用一个指针(原始的或智能的),那么我们有义务在每次使用时检查这个指针吗?
如果您觉得没有这样的义务,那么客户端实现空对象的唯一方法就是构造所需接口的无操作实现,并传入该实例。也就是说,这只适用于函数:例如,如果对象的字段也被使用,那么您就真的有麻烦了。
如果您想主动支持将空对象作为参数传递的想法,您需要明确这一点:要么将参数类型指定为std::optional
,给参数一个暗示内置空对象的缺省值(例如= no_logging
),要么只编写文档解释在这个位置应该有什么样的值。
二十、观察者
observer 模式是一种流行且必要的模式,因此令人惊讶的是,与其他语言(例如 C#)不同,C++ 和标准库都没有现成的实现。尽管如此,一个安全的、正确实现的观察器(如果有这种东西的话)在技术上是一个复杂的构造,所以在这一章中我们将研究它所有的血淋淋的细节。
财产观察员
人会变老。这是生活的现实。但是当一个人长大一岁时,我们可能会祝贺他的生日。但是怎么做呢?给定一个定义,例如:
1 struct Person
2 {
3 int age;
4 Person(int age) : age{age} {}
5 };
我们怎么知道一个人的age
什么时候变了?我们没有。要看到变化,我们可以尝试轮询:每 100 毫秒读取一个人的年龄,并将新值与以前的值进行比较。这种方法可以工作,但是很繁琐,而且不可扩展。我们需要在这方面变得更聪明。
我们知道,我们希望在对一个人的age
字段的每一次写操作中得到通知。好吧,抓住这一点的唯一办法就是做一个二传手,那就是:
1 struct Person
2 {
3 int get_age() const { return age; }
4 void set_age(const int value) { age = value; }
5 private:
6 int age;
7 };
设置器set_age()
是我们可以通知任何关心age
实际上已经改变的人的地方。但是怎么做呢?
观察者
好吧,一种方法是定义某种基类,它需要被任何对获得Person
的改变感兴趣的人继承:
1 struct PersonListener
2 {
3 virtual void person_changed(Person& p,
4 const string& property_name, const any new_value) = 0;
5 };
然而,这种方法相当令人窒息,因为属性的改变可能发生在除了Person
之外的类型上,我们不希望为这些类型产生额外的类。这里有一些更普通的东西:
1 template<typename T> struct Observer
2 {
3 virtual void field_changed(T& source, const string& field_name) = 0;
4 };
希望field_changed()
中的两个参数是不言自明的。第一个是对实际更改了字段的对象的引用,第二个是字段的名称。是的,名字是作为string
传递的,这确实伤害了我们代码的可重构性(如果字段名改变了呢?). 1
这个实现将允许我们观察对一个Person
类的更改,例如,将它们写到命令行:
1 struct ConsolePersonObserver : Observer<Person>
2 {
3 void field_changed(Person& source, const string& field_name) override
4 {
5 cout << "Person's " << field_name << " has changed to "
6 << source.get_age() << ".\n";
7 }
8 };
例如,我们在场景中引入的灵活性将允许我们观察多个类的属性变化。例如,如果我们将类Creature
添加到组合中,您现在可以观察到:
1 struct ConsolePersonObserver : Observer<Person>, Observer<Creature>
2 {
3 void field_changed(Person& source, ...) { ... }
4 void field_changed(Creature& source, ...) { ... }
5 };
另一种选择是使用std::any
并去掉一个通用的实现。试试看!
可观察的
不管怎样,让我们回到Person
吧。由于这将成为一个可观察的类别,它必须承担新的责任,即:
- 保留一份对
Person
的变化感兴趣的所有观察者的私人名单 - 让观察者
subscribe()/unsubscribe()
注意到Person
的变化 - 用
notify()
通知所有观察者实际发生了变化
所有这些功能都可以很愉快地转移到一个单独的基类中,以避免为每个潜在的可观察对象复制它:
1 template <typename T> struct Observable
2 {
3 void notify(T& source, const string& name) { ... }
4 void subscribe(Observer<T>* f) { observers.push_back(f); }
5 void unsubscribe(Observer<T>* f) { ... }
6 private:
7 vector<Observer<T>*> observers;
8 };
我们已经实现了subscribe()
——它只是在观察者的私有列表中添加了一个新的观察者。观察者列表对任何人都不可用——甚至对派生类也不可用。我们不希望人们随意操纵这些收藏品。
接下来,我们需要实现notify()
。这个想法很简单:遍历每个观察者并一个接一个地调用它的field_changed()
:
1 void notify(T& source, const string& name)
2 {
3 for (auto obs : observers)
4 obs->field_changed(source, name);
5 }
然而,仅仅从Observable<T>
继承是不够的:每当一个字段被改变时,我们的类还需要在调用notify()
中尽自己的一份力量。
例如,考虑设置器set_age()
。它现在有三项责任:
- 检查名称是否已经更改。如果
age
是 20,我们给它分配 20,那么执行任何分配或通知都没有意义。 - 为该字段分配适当的值。
- 使用正确的参数调用
notify()
。
因此,set_age()
的新实现如下所示:
1 struct Person : Observable<Person>
2 {
3 void set_age(const int age)
4 {
5 if (this->age == age) return;
6 this->age = age;
7 notify(*this, "age");
8 }
9 private:
10 int age;
11 };
连接观察者和可观察物
我们现在准备开始使用我们创建的基础设施,以便获得关于Person
的字段更改的通知(好吧,我们可以称它们为属性,真的)。这里提醒一下我们的观察者长什么样:
1 struct ConsolePersonObserver : Observer<Person>
2 {
3 void field_changed(Person& source,
4 const string& field_name) override
5 {
6 cout << "Person's " << field_name << " has changed to "
7 << source.get_age() << ".\n";
8 }
9 };
我们是这样使用它的:
1 Person p{ 20 };
2 ConsolePersonObserver cpo;
3 p.subscribe(&cpo);
4 p.set_age(21); // Person's age has changed to 21.
5 p.set_age(22); // Person's age has changed to 22.
只要您不关心属性依赖和线程安全/可重入性的问题,您就可以在这里停下来,采用这个实现,并开始使用它。如果你想看到更复杂方法的讨论,请继续阅读。
依赖性问题
16 岁或 16 岁以上的人(在你的国家可能不同)可以投票。因此,假设我们希望在一个人的投票权发生变化时得到通知。首先,让我们假设我们的Person
类型有以下 getter:
1 bool get_can_vote() const { return age >= 16; }
注意,get_can_vote()
没有支持字段和 setter(我们可以引入这样一个字段,但这显然是多余的),然而我们也觉得有必要对它使用notify()
。但是怎么做呢?嗯,我们可以试着找出是什么导致了can_vote
的改变……没错,set_age()
的确如此!因此,如果我们想要投票状态变化的通知,这些需要在set_age()
完成。准备好,你会大吃一惊的!
1 void set_age(int value) const
2 {
3 if (age == value) return;
4
5 auto old_can_vote = can_vote(); // store old value
6 age = value;
7 notify(*this, "age");
8
9 if (old_can_vote != can_vote()) // check value has changed
10 notify(*this, "can_vote");
11 }
前面的函数太多了。我们不仅检查age
是否已经改变,我们还检查can_vote
是否已经改变并通知它!您可能会猜测这种方法的伸缩性不好,对吗?想象一下can_vote
依赖于两个字段,比如说age
和citizenship
——这意味着它们的设置器都必须处理can_vote
通知。如果age
也以这种方式影响其他十个属性呢?这是一个不可行的解决方案,会导致脆弱的代码无法维护,因为变量之间的关系需要手动跟踪。
简单地说,在前面的场景中,can_vote
是age
的依赖属性。依赖属性的挑战本质上是 Excel 等工具的挑战:给定不同单元格之间的大量依赖关系,当其中一个单元格发生变化时,您如何知道要重新计算哪些单元格。
当然,属性依赖可以被形式化为某种类型的map<string, vector<string>>
,它将保存一个受属性影响的属性列表(或者,相反,所有影响属性的属性)。可悲的是,这个映射必须手工定义,并且保持它与实际代码同步是相当棘手的。
取消订阅和线程安全
我忽略了讨论的一件事是一个观察者如何从一个可观察的事物中。通常,您希望将自己从观察者列表中删除,这在单线程场景中非常简单:
1 void unsubscribe(Observer<T>* observer)
2 {
3 observers.erase(
4 remove(observers.begin(), observers.end(), observer),
5 observers.end());
6 };
虽然使用擦除-删除习惯用法在技术上是正确的,但它只在单线程场景中是正确的。std::vector
不是线程安全的,所以同时调用subscribe()
和unsubscribe()
可能会导致意想不到的后果,因为这两个函数都会修改向量。
这很容易解决:只需锁定 observable 的所有操作。这可能看起来很简单:
1 template <typename T>
2 struct Observable
3 {
4 void notify(T& source, const string& name)
5 {
6 scoped_lock<mutex> lock{ mtx };
7 ...
8 }
9 void subscribe(Observer<T>* f)
10 {
11 scoped_lock<mutex> lock{ mtx };
12 ...
13 }
14 void unsubscribe(Observer<T>* o)
15 {
16 scoped_lock<mutex> lock{ mtx };
17 ...
18 }
19 private:
20 vector<Observer<T>*> observers;
21 mutex mtx;
22 };
另一个非常可行的替代方法是使用类似 TPL/PPL 中的concurrent_vector
的东西。很自然地,您会失去排序保证(换句话说,一个接一个地添加两个对象并不能保证它们以那个顺序被通知),但是它确实让您不必自己管理锁。
再融合
最后一个实现通过在有人需要时锁定三个关键方法中的任何一个来提供一些线程安全。但是现在让我们想象一下下面的场景:您有一个TrafficAdministration
组件,它一直监视一个人,直到他到了可以开车的年龄。当他们 17 岁时,组件退订:
1 struct TrafficAdministration : Observer<Person>
2 {
3 void TrafficAdministration::field_changed(
4 Person& source, const string& field_name) override
5 {
6 if (field_name == "age")
7 {
8 if (source.get_age() < 17)
9 cout << "Whoa there, you are not old enough to drive!\n";
10 else
11 {
12 // oh, ok, they are old enough, let's not monitor them anymore
13 cout << "We no longer care!\n";
14 source.unsubscribe(this);
15 }
16 }
17 }
18 };
这是一个问题,因为当年龄达到 17 岁时,整个呼叫链将是:
1 notify() --> field_changed() --> unsubscribe()
这是一个问题,因为在unsubscribe()
中,我们最终试图获取一个已经被占用的锁。这是一个可重入的问题。有不同的方法来处理这个问题。
- 一种方法是简单地禁止这种情况。毕竟,至少在这个特殊的例子中,很明显这里发生了重入。
- 另一种方法是放弃从集合中删除元素的想法。相反,我们可以这样做:
1 void unsubscribe(Observer<T>* o)
2 {
3 auto it = find(observers.begin(), observers.end(), o);
4 if (it != observers.end())
5 *it = nullptr; // cannot do this for a set
6 }
随后,当您notify()
时,您只需要进行额外的检查:
1 void notify(T& source, const string& name)
2 {
3 for (auto obs : observers)
4 if (obs)
5 obs->field_changed(source, name);
6 }
当然,以上只是解决了notify()
和subscribe()
之间可能的竞争。例如,如果您同时对subscribe()
和unsubscribe()
进行修改,这仍然是对集合的并发修改——并且仍然可能失败。所以,至少,你可能想在那里留一把锁。
还有一种可能是在notify()
中复制整个集合。你仍然需要锁,你只是没有把它应用到任何东西上。我的意思是:
1 void notify(T& source, const string& name)
2 {
3 vector<Observer<T>*> observers_copy;
4 {
5 lock_guard<mutex_t> lock{ mtx };
6 observers_copy = observers;
7 }
8 for (auto obs : observers_copy)
9 if (obs)
10 obs->field_changed(source, name);
11 }
在前面的实现中,我们确实获得了一个锁,但是当我们调用field_changed
时,这个锁已经被释放了,因为它只是在用于复制向量的人工作用域中创建的。我不会担心这里的效率,因为指针向量不会占用太多内存。
最后,总是可以用一个recursive_mutex
替换一个mutex
。一般来说,大多数开发人员都讨厌递归互斥体(SO 上的证明),不仅仅是因为性能问题,更重要的是因为在大多数情况下(就像 Observer 示例一样),如果您的代码设计得好一点,就可以使用普通的非递归变体。
这里有一些有趣的实际问题我们还没有真正讨论。它们包括以下内容:
- 同一个观察者加两次会怎么样?
- 如果我允许重复的观察者,那么
ubsubscribe()
会删除每一个实例吗? - 如果我们使用不同的容器,行为会受到什么影响?例如,我们决定通过使用
std::set
或boost::unordered_set
来防止重复,这对普通操作意味着什么? - 如果我想要按优先级排序的观察者,该怎么办?
一旦你的基础稳固,这些和其他实际问题都是可以解决的。我们不会在这里花更多的时间讨论它们。
通过升压观察器。信号 2
观察者模式有许多预打包的实现,可能最广为人知的是 Boost。信号 2 库。本质上,这个库提供了一个名为signal
的类型,用 C++ 术语表示一个信号(在别处称为事件)。该信号可以通过提供函数或λ来订阅。它也可以被取消订阅,当你想就此发出通知时,它可以被触发。
使用 Boost。信号 2,我们可以定义Observer<T>
如下:
1 template <typename T>
2 struct Observable
3 {
4 signal<void(T&, const string&)> property_changed;
5 };
它的调用如下所示:
1 struct Person : Observable<Person>
2 {
3 ...
4 void set_age(const int age)
5 {
6 if (this->age == age) return;
7
8 this->age = age;
9 property_changed(*this, "age");
10 }
11 };
API 的实际使用将直接使用signal
,当然,除非您决定添加更多的 API 陷阱以使其更容易:
1 Person p{123};
2 auto conn = p.property_changed.connect([](Person&, const string& prop_name)
3 {
4 cout << prop_name << " has been changed" << endl;
5 });
6 p.set_age(20); // name has been changed
7
8 // later, optionally
9 conn.disconnect();
一个connect()
调用的结果是一个connection
对象,当您不再需要信号通知时,它也可以用来取消订阅。
摘要
毫无疑问,本章给出的代码是一个过度思考和过度设计问题的明显例子,远远超出了大多数人想要实现的目标。
让我们回顾一下实现 Observer 时的主要设计决策:
- 决定你希望你的观察对象传达什么信息。例如,如果您正在处理字段/属性更改,您可以包括属性的名称。您也可以指定旧的/新的值,但是传递类型可能会有问题。
- 您希望您的观察器是一个疲惫的类,还是仅仅拥有一个虚函数列表就可以了?
- 你想如何处理观察员退订?
- 如果您不打算支持取消订阅——恭喜您,因为在可重入场景中不存在移除问题,所以实现
Observer,
将会节省很多精力。 - 如果你计划支持一个显式的
unsubscribe()
函数,你可能不希望在函数中直接删除,而是将你的元素标记为删除,以后再删除。 - 如果您不喜欢在(可能为空)原始指针上调度的想法,可以考虑使用
weak_ptr
来代替。
- 如果您不打算支持取消订阅——恭喜您,因为在可重入场景中不存在移除问题,所以实现
- 有可能从几个不同的线程调用一个
Observer<T>
的函数吗?如果是,您需要保护您的订阅列表:- 您可以在所有相关功能上放置一个
scoped_lock
;或者 - 你可以使用线程安全的集合,比如 TBB/PPL
concurrent_vector
。你失去了订购保证。
- 您可以在所有相关功能上放置一个
- 是否允许来自同一来源的多个订阅?如果是,就不能使用
std::set
。
遗憾的是,没有一个理想的 Observer 实现可以满足所有的要求。无论您选择哪种实现,都会有一些妥协。
Footnotes 1
C#已经在后续版本中两次明确解决了这个问题。首先,它引入了一个名为[CallerMemberName]
的属性,该属性将调用函数/属性的名称作为参数的字符串值插入。第二个版本简单地引入了nameof(Foo)
,它将一个符号的名称转换成一个字符串。
二十一、状态
我必须承认:我的行为受我的状态支配。如果我睡眠不足,我会有点累。如果我喝了酒,我就不会开车了。所有这些都是状态,它们支配着我的行为:我的感觉,我能做什么和不能做什么。
当然,我可以从一种状态转换到另一种状态。我可以去喝杯咖啡,这会让我从困倦中清醒过来(我希望!).所以我们可以把咖啡看作是一个触发器,让你真正从困倦状态转变为清醒状态。在这里,让我笨拙地为你举例说明:
1 coffee
2 sleepy --------> alert
所以,状态设计模式是一个非常简单的想法:状态控制行为;状态可以改变;唯一没有定论的是谁触发了状态变化。
从根本上说,有两种方法:
- 状态是具有行为的实际类,这些行为将实际状态从一个状态切换到另一个状态。
- 状态和转换只是枚举。我们有一个称为状态机的特殊组件来执行实际的转换。
这两种方法都是可行的,但第二种方法才是最常见的。我们将看一看他们两个,但是我必须承认我将浏览第一个,因为这不是人们通常做事情的方式。
状态驱动的状态转换
我们从最简单的例子开始:电灯开关。它只能处于开和关状态。我们将构建一个模型,其中任何状态都能够切换到其他状态:虽然这反映了状态设计模式的“经典”实现(按照 GoF 的书),但我不推荐这样做。
首先,让我们为电灯开关建模:它只有一种状态和一些从一种状态切换到另一种状态的方法:
1 class LightSwitch
2 {
3 State *state;
4 public:
5 LightSwitch()
6 {
7 state = new OffState();
8 }
9 void set_state(State* state)
10 {
11 this->state = state;
12 }
13 };
这看起来完全合理。我们现在可以定义State,
,在这个特殊的例子中,它将是一个实际的类:
1 struct State
2 {
3 virtual void on(LightSwitch *ls)
4 {
5 cout << "Light is already on\n";
6 }
7 virtual void off(LightSwitch *ls)
8 {
9 cout << "Light is already off\n";
10 }
11 };
这个实现远非直观,以至于我们需要慢慢地、小心地讨论它,因为从一开始,关于State
类的任何东西都没有意义。
首先,State
不抽象!你会认为一个国家你没有办法(或理由!)将是抽象的。但事实并非如此。
其次,State
允许从一种状态切换到另一种状态。这……对一个通情达理的人来说,毫无意义。想象一下电灯开关:它是改变状态的开关。人们并不指望国家本身会改变自己,但它似乎确实在改变。
第三,也许最令人困惑的是,State::on/off
的默认行为声称我们已经处于这种状态!当我们实现示例的其余部分时,这将在某种程度上结合在一起。
我们现在实现开和关状态:
1 struct OnState : State
2 {
3 OnState() { cout << "Light turned on\n"; }
4 void off(LightSwitch* ls) override;
5 };
6
7 struct OffState : State
8 {
9 OffState() { cout << "Light turned off\n"; }
10 void on(LightSwitch* ls) override;
11 };
OnState::off
和OffState::on
的实现允许状态本身切换到另一个状态!它看起来是这样的:
1 void OnState::off(LightSwitch* ls)
2 {
3 cout << "Switching light off...\n";
4 ls->set_state(new OffState());
5 delete this;
6 } // same for OffState::on
这就是转换发生的地方。这个实现包含了奇怪的对delete this
的调用,这在现实世界的 C++ 中并不常见。delete this
对状态的初始分配做了一个非常危险的假设。这个例子可以用智能指针重写,但是使用指针和堆分配清楚地表明状态在这里被主动破坏了。如果状态有一个析构函数,它将被触发,你将在这里执行额外的清理。
当然,我们确实希望开关本身也能切换状态,如下所示:
1 class LightSwitch
2 {
3 ...
4 void on() { state->on(this); }
5 void off() { state->off(this); }
6 };
因此,将所有这些放在一起,我们可以运行以下场景:
1 LightSwitch ls; // Light turned off
2 ls.on(); // Switching light on...
3 // Light turned on
4 ls.off(); // Switching light off...
5 // Light turned off
6 ls.off(); // Light is already off
我必须承认:我不喜欢这种做法,因为它不直观。当然,状态可以被告知(观察者模式)我们正在进入它。但是状态切换到另一个状态的想法——按照 GoF 书的说法,这是状态模式的“经典”实现——似乎并不特别令人愉快。
如果我们要笨拙地说明从OffState
到OnState
的过渡,它需要被说明为:
1 LightSwitch::on() -> OffState::on()
2 OffState -------------------------------------> OnState
另一方面,从OnState
到OnState
的转换使用基本的State
类,它告诉你你已经处于那个状态了:
1 LightSwitch::on() -> State::on()
2 OnState ----------------------------------> OnState
这里给出的例子可能看起来特别人工,所以我们现在要看看另一个手工设置,其中状态和转换被简化为枚举成员。
手工状态机
让我们试着为一个典型的电话对话定义一个状态机。首先,我们将描述电话的状态:
1 enum class State
2 {
3 off_hook,
4 connecting,
5 connected,
6 on_hold,
7 on_hook
8 };
我们现在还可以定义状态之间的转换,也称为enum class
:
1 enum class Trigger
2 {
3 call_dialed,
4 hung_up,
5 call_connected,
6 placed_on_hold,
7 taken_off_hold,
8 left_message,
9 stop_using_phone
10 };
现在,这个状态机的确切规则,也就是哪些转换是可能的,需要存储在某个地方。
1 map<State, vector<pair<Trigger, State>>> rules;
所以这有点笨拙,但本质上映射的关键是我们正在移动的State
,值是一组Trigger-
State
对,代表在这个状态下可能的触发器和当你使用触发器时进入的状态。
让我们初始化这个数据结构:
1 rules[State::off_hook] = {
2 {Trigger::call_dialed, State::connecting},
3 {Trigger::stop_using_phone, State::on_hook}
4 };
5
6 rules[State::connecting] = {
7 {Trigger::hung_up, State::off_hook},
8 {Trigger::call_connected, State::connected}
9 };
10 // more rules here
我们还需要一个开始状态,如果我们希望状态机在到达该状态时停止执行,我们还可以添加一个退出(终端)状态:
1 State currentState{ State::off_hook },
2 exitState{ State::on_hook };
这样,我们就不必为实际运行(我们使用编排这个术语)状态机构建单独的组件。例如,如果我们想建立一个交互式的电话模型,我们可以这样做:
1 while (true)
2 {
3 cout << "The phone is currently " << currentState << endl;
4 select_trigger:
5 cout << "Select a trigger:" << "\n";
6
7 int i = 0;
8 for (auto item : rules[currentState])
9 {
10 cout << i++ << ". " << item.first << "\n";
11 }
12
13 int input;
14 cin >> input;
15 if (input < 0 || (input+1) > rules[currentState].size())
16 {
17 cout << "Incorrect option. Please try again." << "\n";
18 goto select_trigger;
19 }
20
21 currentState = rules[currentState][input].second;
22 if (currentState == exitState) break;
23 }
首先:是的,我确实使用了goto
,这很好地说明了它的恰当之处。至于算法本身,这是相当明显的:我们让用户在当前状态下选择一个可用的触发器(operator <<
已经在幕后为State
和Trigger
实现了),如果触发器有效,我们通过使用之前创建的rules
映射转换到它。
如果我们到达的状态是退出状态,我们就跳出循环。这是一个与程序交互的例子:
1 The phone is currently off the hook
2 Select a trigger:
3 0\. call dialed
4 1\. putting phone on hook
5 0
6 The phone is currently connecting
7 Select a trigger:
8 0\. hung up
9 1\. call connected
10 1
11 The phone is currently connected
12 Select a trigger:
13 0\. left message
14 1\. hung up
15 2\. placed on hold
16 2
17 The phone is currently on hold
18 Select a trigger:
19 0\. taken off hold
20 1\. hung up
21 1
22 The phone is currently off the hook
23 Select a trigger:
24 0\. call dialed
25 1\. putting phone on hook
26 1
27 We are done using the phone
这个手卷状态机的主要好处是非常容易理解:状态和变迁是普通的枚举,变迁的集合定义在一个简单的std::map
中,起始和结束状态是简单的变量。
带 Boost 的状态机。主流媒体
在现实世界中,状态机更加复杂。有时,您希望在达到某个状态时发生一些动作。在其他时候,您希望转换是有条件的,也就是说,您希望转换仅在某些条件成立时发生。
使用 Boost 时。MSM(元状态机),一个属于 Boost 的状态机库,你的状态机是一个通过 CRTP 从state_machine_def
继承的类:
1 struct PhoneStateMachine : state_machine_def<PhoneStateMachine>
2 {
3 bool angry{ false };
我添加了一个bool
来表示呼叫者是否生气(例如,被挂起);我们稍后会用到它。现在,每个状态也可以驻留在状态机中,并且应该从state
类继承:
1 struct OffHook : state<> {};
2 struct Connecting : state<>
3 {
4 template <class Event, class FSM>
5 void on_entry(Event const& evt, FSM&)
6 {
7 cout << "We are connecting..." << endl;
8 }
9 // also on_exit
10 };
11 // other states omitted
如您所见,状态还可以定义当您进入或退出特定状态时发生的行为。
你也可以定义在转换时(而不是当你到达一个状态时)执行的行为:这些也是类,但是它们不需要继承任何东西;相反,他们需要向operator()
提供一个特定的签名:
1 struct PhoneBeingDestroyed
2 {
3 template <class EVT, class FSM, class SourceState, class TargetState>
4 void operator()(EVT const&, FSM&, SourceState&, TargetState&)
5 {
6 cout << "Phone breaks into a million pieces" << endl;
7 }
8 };
正如您可能已经猜到的那样,参数为您提供了对状态机以及您要去的和要去的状态的引用。
最后,我们有保护条件:这些条件决定了我们是否可以首先使用一个转换。现在,我们的布尔变量angry
不是 MSM 可用的形式,所以我们需要包装它:
1 struct CanDestroyPhone
2 {
3 template <class EVT, class FSM, class SourceState, class TargetState>
4 bool operator()(EVT const&, FSM& fsm, SourceState&, TargetState&)
5 {
6 return fsm.angry;
7 }
8 };
前面的代码生成了一个名为CanDestroyPhone
的保护条件,我们可以在稍后定义状态机时使用它。
对于定义状态机规则,Boost。MSM 使用 MPL(元编程库)。具体来说,转换表被定义为一个mpl::vector
,每行依次包含:
- 源州
- 转变
- 目标州
- 要执行的可选操作
- 可选的保护条件
有了这些,我们可以定义一些电话呼叫规则如下:
1 struct transition_table : mpl::vector <
2 Row<OffHook, CallDialed, Connecting>,
3 Row<Connecting, CallConnected, Connected>,
4 Row<Connected, PlacedOnHold, OnHold>,
5 Row<OnHold, PhoneThrownIntoWall, PhoneDestroyed,
6 PhoneBeingDestroyed, CanDestroyPhone>
7 > {};
在前面,与状态不同,像CallDialed
这样的转换是可以在状态机类之外定义的类。它们不必从任何基类继承,并且可以很容易地为空,但是它们必须是类型。
我们的transition_
table
的最后一行是最有趣的:它规定我们只能在受到CanDestroyPhone
保护条件的情况下尝试销毁手机,当手机实际被销毁时,应该执行PhoneBeingDestroyed
动作。
现在,我们还可以添加一些东西。首先,我们添加起始条件:因为我们使用了 Boost。MSM,起始条件是一个typedef
,不是一个变量:
1 typedef OffHook initial_state;
最后,如果没有可能的转换,我们可以定义要发生的动作。这是有可能的。例如,在你打碎手机后,你就不能再用它了,对吗?
1 template <class FSM, class Event>
2 void no_transition(Event const& e, FSM&, int state)
3 {
4 cout << "No transition from state " << state_names[state]
5 << " on event " << typeid(e).name() << endl;
6 }
Boost MSM 将状态机分为前端(我们刚才写的就是这个)和后端(运行它的部分)。使用后端 API,我们可以从前面的状态机定义中构造状态机:
1 msm::back::state_machine<PhoneStateMachine> phone;
现在,假设存在只打印我们所处状态的info()
函数,我们可以尝试编排以下场景:
1 info(); // The phone is currently off hook
2 phone.process_event(CallDialed{}); // We are connecting...
3 info(); // The phone is currently connecting
4 phone.process_event(CallConnected{});
5 info(); // The phone is currently connected
6 phone.process_event(PlacedOnHold{});
7 info(); // The phone is currently on hold
8
9 phone.process_event(PhoneThrownIntoWall{});
10 // Phone breaks into a million pieces
11
12 info(); // The phone is currently destroyed
13
14 phone.process_event(CallDialed{});
15 // No transition from state destroyed on event struct CallDialed
这就是你如何定义一个更复杂的、行业优势的状态机。
摘要
首先,有必要强调一下这一点。MSM 是 Boost 中两个可选的状态机实现之一,另一个是 Boost.Statechart。我很确定还有很多其他的状态机实现。
第二,状态机远不止这些。例如,许多库支持分级状态机的思想:例如,Sick
状态可以包含许多不同的子状态,如Flu
或Chickenpox
。如果你在状态Flu
,你也被假定在状态Sick
。
最后,值得再次强调的是,现代状态机离最初形式的状态设计模式有多远。重复 API 的存在(例如LightSwitch::on/off
vs. State::on/off
)以及自删除的存在,在我的书中是明确的代码味道。不要误解我的意思——这种方法是可行的,但是不直观而且麻烦。
二十二、策略
假设您决定接受一个包含几个字符串的数组或向量,并将它们作为一个列表输出,
- 仅仅
- 喜欢
- 这
如果您考虑不同的输出格式,您可能知道您需要获取每个元素并使用一些附加标记输出它。但是对于 HTML 或 LaTeX 这样的语言,列表也需要开始和结束标签或标记。这两种格式的列表处理既相似(需要输出每一项),又不同(输出项的方式)。每一个都可以用单独的策略来处理。
我们可以制定一个呈现列表的策略:
- 呈现开始标记/元素。
- 对于每个列表项,呈现该项。
- 呈现结束标记/元素。
可以为不同的输出格式制定不同的策略,然后可以将这些策略输入到一个通用的、不变的算法中来生成文本。
这是另一种存在于动态(运行时可替换)和静态(模板合成、固定)实例中的模式。让我们来看看他们两个。
动态策略
因此,我们的目标是以下列格式打印一个简单的文本项列表:
1 enum class OutputFormat
2 {
3 markdown,
4 html
5 };
我们的策略框架将在下面的基类中定义:
1 struct ListStrategy
2 {
3 virtual void start(ostringstream& oss) {};
4 virtual void add_list_item(ostringstream& oss, const string& item) {};
5 virtual void end(ostringstream& oss) {};
6 };
现在让我们跳到我们的文本处理组件。这个组件有一个特定于列表的成员函数,比如说,append_list()
。
1 struct TextProcessor
2 {
3 void append_list(const vector<string> items)
4 {
5 list_strategy->start(oss);
6 for (auto& item : items)
7 list_strategy->add_list_item(oss, item);
8 list_strategy->end(oss);
9 }
10 private:
11 ostringstream oss;
12 unique_ptr<ListStrategy> list_strategy;
13 };
所以我们有一个名为oss
的缓冲区,所有的输出都放在这里,这是我们用来呈现列表的策略,当然还有append_list()
,它指定了使用给定策略来实际呈现一个列表所需要采取的一组步骤。
现在,注意这里。如前所述,组合是骨架算法的具体实现的两个可能选项之一。
相反,我们可以添加像add_list_item()
这样的函数作为虚拟成员,由派生类重写:这就是模板方法模式所做的。
不管怎样,回到我们的讨论。我们现在可以继续为列表实现不同的策略,比如一个HtmlListStrategy
:
1 struct HtmlListStrategy : ListStrategy
2 {
3 void start(ostringstream& oss) override
4 {
5 oss << "<ul>\n";
6 }
7 void end(ostringstream& oss) override
8 {
9 oss << "</ul>\n";
10 }
11 void add_list_item(ostringstream& oss, const string& item) override
12 {
13 oss << "<li>" << item << "</li>\n";
14 }
15 };
通过实现覆盖,我们填补了指定如何处理列表的空白。我们将以类似的方式实现一个MarkdownListStrategy
,但是因为 Markdown 不需要开始/结束标签,我们将只使用override``add_list_item()
函数:
1 struct MarkdownListStrategy : ListStrategy
2 {
3 void add_list_item(ostringstream& oss,
4 const string& item) override
5 {
6 oss << " * " << item << endl;
7 }
8 };
我们现在可以开始使用TextProcessor
,给它输入不同的策略,得到不同的结果。例如:
1 TextProcessor tp;
2 tp.set_output_format(OutputFormat::markdown);
3 tp.append_list({"foo", "bar", "baz"});
4 cout << tp.str() << endl;
5
6 // Output:
7 // * foo
8 // * bar
9 // * baz
我们可以规定策略在运行时是可切换的——这就是为什么我们称这个实现为动态策略。这是在set_output_format()
函数中完成的,它的实现很简单:
1 void set_output_format(const OutputFormat format)
2 {
3 switch(format)
4 {
5 case OutputFormat::markdown:
6 list_strategy = make_unique<MarkdownListStrategy>();
7 break;
8 case OutputFormat::html:
9 list_strategy = make_unique<HtmlListStrategy>();
10 break;
11 }
12 }
现在,从一种策略切换到另一种策略是很简单的,您可以立即看到结果:
1 tp.clear(); // clears the buffer
2 tp.set_output_format(OutputFormat::Html);
3 tp.append_list({"foo", "bar", "baz"});
4 cout << tp.str() << endl;
5
6 // Output:
7 // <ul>
8 // <li>foo</li>
9 // <li>bar</li>
10 // <li>baz</li>
11 // </ul>
静态策略
多亏了模板的魔力,你可以将任何策略嵌入到类型中。仅需要对TextStrategy
类进行最小的修改:
1 template <typename LS>
2 struct TextProcessor
3 {
4 void append_list(const vector<string> items)
5 {
6 list_strategy.start(oss);
7 for (auto& item : items)
8 list_strategy.add_list_item(oss, item);
9 list_strategy.end(oss);
10 }
11 // other functions unchanged
12 private:
13 ostringstream oss;
14 LS list_strategy;
15 };
前面所发生的一切就是我们添加了LS
模板参数,用这种类型创建了一个成员策略,并开始使用它来代替之前的指针。append_list()
的结果是相同的:
1 // markdown
2 TextProcessor<MarkdownListStrategy> tpm;
3 tpm.append_list({"foo", "bar", "baz"});
4 cout << tpm.str() << endl;
5
6 // html
7 TextProcessor<HtmlListStrategy> tph;
8 tph.append_list({"foo", "bar", "baz"});
9 cout << tph.str() << endl;
前面示例的输出与动态策略的输出相同。请注意,我们已经制作了两个TextProcessor
实例,每个都有不同的列表处理策略。
摘要
策略设计模式允许您定义算法的框架,然后使用组合来提供与特定策略相关的缺失的实现细节。这种方法有两种表现形式:
- 动态策略只是保存一个指向所用策略的指针/引用。想换个不同的策略吗?换个参照物就行了。放轻松!
- 静态策略要求您在编译时选择策略并坚持下去——以后没有改变主意的余地。
应该使用动态策略还是静态策略?嗯,动态的允许你在对象被构造后重新配置它们。想象一个控制文本输出形式的 UI 设置:你更愿意拥有一个可切换的TextProcessor
还是两个类型为TextProcessor<MarkdownStrategy>
和TextProcessor<HtmlStrategy>
的变量?这真的取决于你。
最后,您可以约束一个类型所采用的策略集:不允许使用一般的ListStrategy
参数,而是可以使用一个std::variant
来列出唯一允许传入的类型。
二十三、模板方法
策略和模板方法设计模式非常相似,以至于就像工厂一样,我很想将这些模式合并成一个单一的框架方法设计模式。我会忍住冲动。
策略和模板方法的区别在于,策略使用组合(不管是静态的还是动态的),而模板方法使用继承。但是,在一个地方定义算法的框架,在其他地方定义其实现细节的核心原则仍然存在,再次观察 OCP(我们只是扩展系统)。
游戏模拟
大多数棋盘游戏都非常相似:游戏开始(发生某种设置),玩家轮流玩,直到决定一个赢家,然后可以宣布赢家。不管是什么游戏——国际象棋、西洋跳棋还是其他游戏,我们都可以将算法定义如下:
1 class Game
2 {
3 void run()
4 {
5 start();
6 while (!have_winner())
7 take_turn();
8 cout << "Player " << get_winner() << " wins.\n";
9 }
如你所见,运行游戏的run()
方法简单地调用了一组其他方法。这些被定义为纯虚拟的,并且还具有protected
可见性,因此它们不会自己被调用:
1 protected:
2 virtual void start() = 0;
3 virtual bool have_winner() = 0;
4 virtual void take_turn() = 0;
5 virtual int get_winner() = 0;
平心而论,前面的一些方法,尤其是void
-return 的,不一定非得是纯虚拟的。例如,如果一些游戏没有明确的start()
程序,将start()
作为纯虚拟违反了 ISP,因为不需要它的成员仍然必须实现它。在策略一章中我们特意用虚拟无操作方法做了一个策略,但是用模板方法,情况就不那么明朗了。
现在,除了前面的例子,我们可以拥有与所有游戏相关的某些public
成员:玩家的数量和当前玩家的索引:
1 class Game
2 {
3 public:
4 explicit Game(int number_of_players)
5 : number_of_players(number_of_players){}
6 protected:
7 int current_player{ 0 };
8 int number_of_players;
9 }; // other members omitted
从现在开始,Game
类可以被扩展来实现一个国际象棋游戏:
1 class Chess : public Game
2 {
3 public:
4 explicit Chess() : Game{ 2 } {}
5 protected:
6 void start() override {}
7 bool have_winner() override { return turns == max_turns; }
8 void take_turn() override
9 {
10 turns++;
11 current_player = (current_player + 1) % number_of_players;
12 }
13 int get_winner() override { return current_player;}
14 private:
15 int turns{ 0 }, max_turns{ 10 };
16 };
一局国际象棋涉及两个玩家,所以它被输入到构造器中。然后,我们继续覆盖所有必要的功能,实现一些非常简单的模拟逻辑,以便在十回合后结束游戏。以下是输出:
1 Starting a game of chess with 2 players
2 Turn 0 taken by player 0
3 Turn 1 taken by player 1
4 ...
5 Turn 8 taken by player 0
6 Turn 9 taken by player 1
7 Player 0 wins.
这差不多就是全部了!
摘要
与使用组合并因此分为静态和动态变化的策略不同,模板方法使用继承,因此,它只能是静态的,因为一旦对象被构造,就没有办法篡改它的继承特征。
模板方法中唯一的设计决策是,您希望模板方法使用的方法是纯虚拟的,还是实际上有一个主体,即使这个主体是空的。如果你预见到一些方法对所有的继承者来说都是不必要的,那就让它们成为无操作的方法。
二十四、访问者
一旦你有了一个类型的层次结构,除非你有访问源代码的权限,否则不可能给这个层次结构的每个成员添加一个函数。这是一个需要预先计划的问题,并产生了访问者模式。
这里有一个简单的例子:假设您已经解析了一个数学表达式(当然,使用了解释器模式!)由double
值和加法运算符组成:
1 (1.0 + (2.0 + 3.0))
该表达式可以表示为类似如下的层次结构:
1 struct Expression
2 {
3 // nothing here (yet)
4 };
5
6 struct DoubleExpression : Expression
7 {
8 double value;
9 explicit DoubleExpression(const double value)
10 : value{value} {}
11 };
12
13 struct AdditionExpression : Expression
14 {
15 Expression *left, *right;
16
17 AdditionExpression(Expression* const left, Expression* const right)
18 : left{left}, right{right} {}
19
20 ~AdditionExpression()
21 {
22 delete left; delete right;
23 }
24 };
因此,给定对象的这种层次结构,假设您想要向各种Expression
继承者添加一些行为(嗯,我们现在只有两个,但是这个数字可以增加)。你会怎么做?
不速之客
我们将从最直接的方法开始,一种打破开闭原则的方法。本质上,我们将跳转到已经编写好的代码中,修改Expression
接口(通过关联,修改每个派生类):
1 struct Expression
2 {
3 virtual void print(ostringstream& oss) = 0;
4 };
除了破坏 OCP 之外,这种修改还依赖于这样一个假设,即您实际上可以访问该层次结构的源代码——这并不总是有保证的。但我们总得从某个地方开始,对吧?因此,随着这种变化,我们需要在DoubleExpression
中实现print()
(这很简单,所以我在这里省略了)以及在AdditionExpression
中实现print()
:
1 struct AdditionExpression : Expression
2 {
3 Expression *left, *right;
4 ...
5 void print(ostringstream& oss) override
6 {
7 oss << "(";
8 left->print(oss);
9 oss << "+";
10 right->print(oss);
11 oss << ")";
12 }
13 };
哦,这太有趣了!我们在子表达式上多态地递归调用print()
。精彩;让我们来测试一下:
1 auto e = new AdditionExpression{
2 new DoubleExpression{1},
3 new AdditionExpression{
4 new DoubleExpression{2},
5 new DoubleExpression{3}
6 }
7 };
8 ostringstream oss;
9 e->print(oss);
10 cout << oss.str() << endl; // prints (1+(2+3))
嗯,这很简单。但是现在假设您在层次结构中有 10 个继承者(顺便说一下,在现实世界的场景中并不少见),您需要添加一些新的eval()
操作。那是需要在十个不同的类中完成的十个修改。但是 OCP 不是真正的问题。
真正的问题是 SRP。你知道,像印刷这样的问题是需要特别关注的。与其说每个表达式都应该打印自己,为什么不引入一个知道如何打印表达式的ExpessionPrinter
?稍后,您可以引入一个知道如何执行实际计算的ExpressionEvaluator
,而不会以任何方式影响Expression
层次结构。
反射式打印机
既然我们已经决定制作一个单独的打印机组件,让我们去掉print()
成员函数(当然,要保留基类)。这里有一个警告:不能让Expression
类为空。为什么呢?因为只有当你真的有virtual
在里面的时候,你才会得到多态行为。所以,现在,让我们在里面放一个虚拟析构函数;那就行了!
1 struct Expression
2 {
3 virtual ~Expression() = default;
4 };
现在让我们试着实现一个ExpressionPrinter
。我的第一反应会是这样写:
1 struct ExpressionPrinter
2 {
3 void print(DoubleExpression *de, ostringstream& oss) const
4 {
5 oss << de->value;
6 }
7 void print(AdditionExpression *ae, ostringstream& oss) const
8 {
9 oss << "(";
10 print(ae->left, oss);
11 oss << "+";
12 print(ae->right, oss);
13 oss << ")";
14 }
15 };
前面代码编译的几率:零。C++ 知道,ae->left
是一个Expression
,但是由于它不在运行时检查类型(不像各种动态类型的语言),它不知道调用哪个重载。太糟糕了!
这里能做什么?嗯,只有一件事——移除重载并在运行时检查类型:
1 struct ExpressionPrinter
2 {
3 void print(Expression *e)
4 {
5 if (auto de = dynamic_cast<DoubleExpression*>(e))
6 {
7 oss << de->value;
8 }
9 else if (auto ae = dynamic_cast<AdditionExpression*>(e))
10 {
11 oss << "(";
12 print(ae->left, oss);
13 oss << "+";
14 print(ae->right, oss);
15 oss << ")";
16 }
17 }
18
19 string str() const { return oss.str(); }
20 private:
21 ostringstream oss;
22 };
上述内容实际上是一个可用的解决方案:
1 auto e = new AdditionExpression{
2 new DoubleExpression{ 1 },
3 new AdditionExpression{
4 new DoubleExpression{ 2 },
5 new DoubleExpression{ 3 }
6 }
7 };
8 ExpressionPrinter ep;
9 ep.print(e);
10 cout << ep.str() << endl; // prints "(1+(2+3))"
这种方法有一个相当大的缺点:没有编译器检查,事实上,您已经为层次结构中的每个元素实现了打印。
当添加新元素时,您可以继续使用ExpressionPrinter
而无需修改,它将跳过任何新类型的元素。
但这是一个可行的解决方案。说真的,很有可能就此打住,不再继续使用访问者模式:dynamic_cast
这并不昂贵,我认为许多开发人员会记得在if
语句中包含每一种类型的对象。
WTH 是调度吗?
每当人们谈到来访者,就会提到派遣这个词。这是什么?简而言之,“分派”是一个计算调用哪个函数的问题,具体来说,就是需要多少条信息才能进行调用。
这里有一个简单的例子:
1 struct Stuff {}
2 struct Foo : Stuff {}
3 struct Bar : Stuff {}
4
5 void func(Foo* foo) {}
6 void func(Bar* bar) {}
现在,如果我创建一个普通的Foo
对象,我可以用它调用func()
:
1 Foo *foo = new Foo;
2 func(foo); // ok
但是如果我决定将它转换为基类指针,编译器将不知道调用哪个重载:
1 Stuff *stuff = new Foo;
2 func(stuff); // oops!
现在,让我们从多方面来考虑这个问题:有没有什么方法可以强迫系统调用正确的重载,而不需要任何运行时(dynamic_cast
和类似的)检查?原来是有的。
看,当你在一个Stuff
上调用某个东西时,这个调用可以是多态的(多亏了一个 Vtable ),它可以被直接分派给必要的组件。这又可以调用必要的重载。这被称为双重分派,因为:
- 首先对实际对象进行多态调用。
- 在多态调用中,调用重载。因为在对象内部,
this
有一个精确的类型(例如,Foo*
或Bar*
),所以正确的重载被触发。
我的意思是:
1 struct Stuff {
2 virtual void call() = 0;
3 }
4 struct Foo : Stuff {
5 void call() override { func(this); }
6 }
7 struct Bar : Stuff {
8 void call() override { func(this); }
9 }
10
11 void func(Foo* foo) {}
12 void func(Bar* bar) {}
你能看到这里发生了什么吗?我们不能把一个通用的call()
实现粘在Stuff
中:不同的实现必须在它们各自的类中,这样this
指针才能被正确地类型化。
这个实现允许您编写以下内容:
1 Stuff *stuff = new Foo;
2 stuff->call(); // effectively calls func(stuff);
经典访客
访问者设计模式的“经典”实现使用双重分派。访问者成员函数的调用有一些约定:
- 访问者的成员函数通常被称为
visit()
。 - 在整个层次结构中实现的成员函数通常被称为
accept()
。
我们现在可以从我们的Expression
基类中扔掉那个虚拟析构函数,因为我们实际上已经有东西放在那里了:函数accept()
:
1 struct Expression
2 {
3 virtual void accept(ExpressionVisitor *visitor) = 0;
4 };
正如你所看到的,前面提到了一个名为ExpressionVisitor
的(抽象)类,它可以作为各种访问者的基类,比如ExpressionPrinter
、ExpressionEvaluator
等等。我在这里选择了一个指针,但是你可以用一个引用来代替。
现在,Expression
的每一个继承者都需要以相同的方式实现accept()
,即:
1 void accept(ExpressionVisitor* visitor) override
2 {
3 visitor->visit(this);
4 }
另一方面,我们可以将ExpressionVisitor
定义如下:
1 struct ExpressionVisitor
2 {
3 virtual void visit(DoubleExpression* de) = 0;
4 virtual void visit(AdditionExpression* ae) = 0;
5 };
注意,我们绝对必须为所有对象定义重载;否则,在实现相应的accept()
时,我们会得到一个编译错误。我们现在可以继承这个类来定义我们的ExpressionPrinter
:
1 struct ExpressionPrinter : ExpressionVisitor
2 {
3 ostringstream oss;
4 string str() const { return oss.str(); }
5 void visit(DoubleExpression* de) override;
6 void visit(AdditionExpression* ae) override;
7 };
visit()
函数的实现应该是相当明显的,因为我们已经不止一次地看到过它,但是我将再次展示它:
1 void ExpressionPrinter::visit(AdditionExpression* ae)
2 {
3 oss << "(";
4 ae->left->accept(this);
5 oss << "+";
6 ae->right->accept(this);
7 oss << ")";
8 }
注意现在调用是如何发生在子表达式本身上的,再次利用了双重分派。至于新的双派遣访问者的用法,这里是:
1 void main()
2 {
3 auto e = new AdditionExpression{
4 // as before
5 };
6 ostringstream oss;
7 ExpressionPrinter ep;
8 ep.visit(e);
9 cout << ep.str() << endl; // (1+(2+3))
10 }
实现附加访问者
那么,这种方式的优势是什么呢?这样做的好处是您只需通过层次结构实现一次accept()
成员。你再也不用碰任何一个成员了。例如:假设你现在想有一种方法来评估表达式的结果?这很简单:
1 struct ExpressionEvaluator : ExpressionVisitor
2 {
3 double result;
4 void visit(DoubleExpression* de) override;
5 void visit(AdditionExpression* ae) override;
6 };
但是需要记住的是,visit()
目前被声明为一个void
方法,所以实现可能看起来有点奇怪:
1 void ExpressionEvaluator::visit(DoubleExpression* de)
2 {
3 result = de->value;
4 }
5
6 void ExpressionEvaluator::visit(AdditionExpression* ae)
7 {
8 ae->left->accept(this);
9 auto temp = result;
10 ae->right->accept(this);
11 result += temp;
12 }
前面是无法从accept()
到return
的副产品,有点棘手。本质上,我们评估左边的部分并缓存值。然后我们评估正确的部分(因此result
被设置),然后用我们缓存的值增加它,从而产生总和。不完全是直观的代码!
尽管如此,它工作得很好:
1 auto e = new AdditionExpression{ /* as before */ };
2 ExpressionPrinter printer;
3 ExpressionEvaluator evaluator;
4 printer.visit(e);
5 evaluator.visit(e);
6 cout << printer.str() << " = " << evaluator.result << endl;
7 // prints "(1+(2+3)) = 6"
同样,你可以添加许多其他不同的游客,向 OCP 致敬,并在这个过程中享受乐趣。
非循环访问者
现在是一个很好的时机来提及访问者设计模式实际上有两种类型。他们是
- 循环访问者,这是基于函数重载。由于层次结构(必须知道访问者的类型)和访问者(必须知道层次结构中的每个类)之间的循环依赖,该方法的使用仅限于不经常更新的稳定层次结构。
- 非循环访问者,这是基于 RTTI。这里的优点是对被访问的层次结构没有限制,但是,正如您可能已经猜到的,存在性能问题。
非循环访问者实现的第一步是实际的访问者接口。我们没有为层次结构中的每一个类型定义一个visit()
重载,而是尽可能地使事情通用化:
1 template <typename Visitable>
2 struct Visitor
3 {
4 virtual void visit(Visitable& obj) = 0;
5 };
我们需要我们的域模型中的每个元素都能够接受这样的访问者,但是由于每个专门化都是唯一的,我们所做的就是引入一个标记接口——一个空类,除了一个虚拟析构函数之外什么也不是:
1 struct VisitorBase // marker interface
2 {
3 virtual ~VisitorBase() = default;
4 };
前面的类没有行为,但是我们将把它作为一个accept()
方法的参数,用于我们实际想要访问的任何对象。现在,我们能做的是重新定义我们之前的Expression
类,如下所示:
1 struct Expression
2 {
3 virtual ~Expression() = default;
4
5 virtual void accept(VisitorBase& obj)
6 {
7 using EV = Visitor<Expression>;
8 if (auto ev = dynamic_cast<EV*>(&obj))
9 ev->visit(*this);
10 }
11 };
下面是新的accept()
方法的工作原理:我们获取一个VisitorBase
,然后尝试将其转换为一个Visitor<T>
,其中T
是我们当前所在的类型。如果转换成功,这个访问者知道如何访问我们的类型,所以我们调用它的visit()
方法。如果它失败了,那就没用了。理解为什么obj
本身没有我们可以调用的visit()
是很重要的。如果是这样的话,就需要为每一个有兴趣调用它的类型重载,这正是引入循环依赖的原因。
在我们模型的其他部分实现了accept()
之后,我们可以通过再次定义一个ExpressionPrinter
来把所有的东西放在一起,但是这一次,它看起来如下:
1 struct ExpressionPrinter : VisitorBase,
2 Visitor<DoubleExpression>,
3 Visitor<AdditionExpression>
4 {
5 void visit(DoubleExpression &obj) override;
6 void visit(AdditionExpression &obj) override;
7
8 string str() const { return oss.str(); }
9 private:
10 ostringstream oss;
11 };
如你所见,我们实现了VisitorBase
标记接口以及一个Visitor<T>
用于我们想要访问的每个T
。如果我们省略了一个特定的类型T
(例如,假设我注释掉了Visitor<DoubleExpression>
),程序仍然会编译,并且相应的accept()
调用,如果它来了,将简单地作为空操作执行
在前面的内容中,visit()
方法的实现实际上与我们在传统的 visitor 实现中所拥有的完全相同,结果也是如此。
std::
和visit
的变体
虽然与传统的访问者模式没有直接关系,但是值得一提的是std::visit
,仅仅是因为它的名字暗示了与访问者模式有关。本质上,std::visit
是一种访问变体类型的正确部分的方法。
这里有一个例子:假设您有一个地址,该地址的一部分是一个house
字段。现在,一所房子可以只是一个数字(如“伦敦路 123 号”),也可以有一个名字,如“蒙特菲奥里城堡”因此,您可以按如下方式定义变量:
1 variant<string, int> house;
2 // house = "Montefiore Castle";
3 house = 221;
这两个赋值都有效。现在,假设您决定打印房屋名称或门牌号。为此,您可以首先定义一个类型,该类型为变体中不同类型的成员提供函数调用重载:
1 struct AddressPrinter
2 {
3 void operator()(const string& house_name) const {
4 cout << "A house called " << house_name << "\n";
5 }
6
7 void operator()(const int house_number) const {
8 cout << "House number " << house_number << "\n";
9 }
10 };
现在,这种类型可以与std::visit()
结合使用,这是一个将访问者应用到 variant 类型的库函数:
1 AddressPrinter ap;
2 std::visit(ap, house); // House number 221
由于一些现代 C++ 的魔力,也可以适当地定义一组访问者函数。我们需要做的是构造一个类型为auto&
的 lambda,获取底层类型,使用if constexpr
进行比较,并进行相应的处理:
1 std::visit([](auto& arg) {
2 using T = decay_t<decltype(arg)>;
3
4 if constexpr (is_same_v<T, string>)
5 {
6 cout << "A house called " << arg.c_str() << "\n";
7 }
8 else
9 {
10 cout << "House number " << arg << "\n";
11 }
12 }, house);
摘要
访问者设计模式允许我们向对象层次结构中的每个元素添加一些行为。我们看到的方法包括
- 介入式:向层次结构中的每个对象添加一个虚方法。有可能(假设你有源代码),但打破 OCP。
- 反射式:添加一个不需要改变对象的独立访问者;每当需要运行时调度时使用
dynamic_cast
。 - 经典(双重分派):整个层次结构确实被修改了,但只是一次,而且是以一种非常普通的方式。这个层级的每一个成员都学会了如何接待访客。然后,我们对 visitor 进行子类化,以在各个方向增强层次结构的功能。
访问者经常与解释器模式一起出现:在解释了一些文本输入并将其转换成面向对象的结构之后,我们需要,例如,以特定的方式呈现抽象语法树。Visitor 帮助在整个层次结构中传播一个ostringstream
(或类似的对象)并将数据整理在一起。
二十五、也许是单子
在 C++ 中,像在许多其他语言中一样,我们有不同的方式来表达一个值的存在或不存在。特别是在 C++ 中,我们可以使用以下任何一种:
- 使用
nullptr
对缺勤进行编码。 - 使用智能指针(例如,
shared_ptr
),同样可以测试其是否存在。 std::optional<T>
是库解决方案;如果缺少值,它可以存储类型为T
或std::nullopt
的值。
假设我们决定采用nullptr
方法。在这种情况下,让我们假设我们的域模型定义了一个Person
,它可能有也可能没有一个Address
,反过来,它可以有一个可选的house_name
1 :
1 struct Address {
2 string* house_name = nullptr;
3 };
4
5 struct Person {
6 Address* address = nullptr;
7 };
我们感兴趣的是写一个函数,给定一个人,安全地打印这个人的房屋名称,当然如果它存在的话。在“传统的”C++ 中,我们会这样实现它:
1 void print_house_name(Person* p)
2 {
3 if (p != nullptr &&
4 p->address != nullptr &&
5 p->address->house_name != nullptr) // ugh!
6 cout << *p->address->house_name << endl;
7 }
前面的代码代表了深入对象结构的过程,注意不要访问nullptr
值。相反,这种向下钻取的过程可以通过使用可能单子以函数的方式来表示。
为了构造单子,我们将定义一个新的类型Maybe<T>
。此类型将用作参与下钻过程的临时对象:
1 template <typename T> struct Maybe {
2 T* context;
3 Maybe(T *context) : context(context) { }
4 };
到目前为止,Maybe
看起来像一个指针容器,没什么令人兴奋的。它也不是很有用,因为给定一个Person* p
,我们不能产生一个Maybe(p)
,因为我们不能从构造器中传递的参数推导出类模板参数。在这种情况下,我们还创建了一个 helper 全局函数,因为函数实际上可以推导出模板参数:
1 template <typename T> Maybe<T> maybe(T* context)
2 {
3 return Maybe<T>(context);
4 }
现在,我们想要做的是给Maybe
一个成员函数
- 如果
context != nullptr
,则更深地钻入对象;或者 - 如果上下文实际上是
nullptr
,则什么也不做
“向下钻取”的过程被封装到模板参数Func
中,如下所示:
1 template <typename Func>
2 auto With(Func evaluator)
3 {
4 return context != nullptr ? maybe(evaluator(context)) : nullptr;
5 }
前面是高阶函数的一个例子,也就是取函数的函数。 2 我们创建的这个函数采用了另一个名为evaluator
的函数,假设当前上下文为非空,可以在上下文中调用这个函数,并返回一个可以包装在另一个Maybe
中的指针。这个技巧允许链接With()
呼叫。
现在,以类似的方式,我们可以创建另一个成员函数,这次只需调用context
上的给定函数,而不改变上下文本身:
1 template <typename TFunc>
2 auto Do(TFunc action)
3 {
4 if (context != nullptr) action(context);
5 return *this;
6 }
我们完事了。我们现在可以做的是重新定义我们的print_house_name()
函数如下:
1 void print_house_name(Person* p)
2 {
3 auto z = maybe(p)
4 .With([](auto x) { return x->address; })
5 .With([](auto x) { return x->house_name; })
6 .Do([](auto x) { cout << *x << endl; });
7 }
这里有几点需要注意。首先,我们设法创建了一个流畅的接口,即一个可以将函数调用一个接一个链接起来的设置。这种说法是有道理的,因为每个操作符(With
、Do
等)。)返回*this
或者一个新的Maybe<T>
。同样值得注意的是,下钻过程是如何在每一个转折点被 lambda 函数封装的。
正如您可能猜到的,前面的方法确实有性能成本,尽管这些成本很难预测,并且取决于编译器优化代码的能力。它也远非完美,因为我很乐意省略[](auto x)
部分,以支持一些速记符号。理想情况下,类似于maybe(p).With{it->address}
的东西会很好。
Footnotes 1
房子的名字是真实存在的(至少在英国是这样的):当你买了一座城堡,它的地址不是“伦敦路 123 号”,而只是“蒙特菲奥里城堡”,这就是它的地址。你可以猜到,并不是所有的房子都有名字,这就解释了为什么这个字段是可选的。
2
严格地说,高阶函数要么接受一个函数作为一个或多个参数,要么返回一个函数(或两者都有)。
3
例如,Kotlin 和 Swift 编程语言支持这种方法。如果没有必要,这两种语言都允许程序员避免额外的 lambda 函数仪式。这包括省略参数、捕获列表和返回值,以及使用花括号,而不是圆括号,这让您可以简单地打开一个事实上的作用域,并放置所有要由 lambda 执行的语句。
第一部分:创建模式
即使没有创造模式,用 C++ 创造一个对象的行为也充满了危险。应该在栈上创建还是在堆上创建?那应该是一个原始指针,一个唯一的或共享的指针,还是其他什么?最后,手动创建对象是否仍然合适,或者我们是否应该将基础设施的所有关键方面的创建推迟到专门的构造,如工厂(稍后将详细介绍它们!)还是控制容器的倒置?
无论您选择哪一个选项,创建对象仍然是一件苦差事,尤其是如果构建过程很复杂或者需要遵守特殊的规则。这就是创造模式的来源:它们是与创建对象相关的常见方法。
如果你对基本的 C++ 或者智能指针不太熟悉,这里有一个简单的 C++ 对象创建方法的回顾:
- 栈分配创建一个将在栈上分配的对象。该对象将在作用域结束时被自动清理(您可以用一对花括号在任何地方创建一个人工作用域)。如果你把这个对象赋给一个变量,这个对象将在作用域的最末端调用析构函数;如果不这样做,析构函数将被立即调用。(这可能会破坏 Memento 设计模式的一些实现,我们稍后会发现。)
- 使用原始指针的堆分配将对象放在堆上(也称为自由存储)。
Foo* foo = new Foo;
创建了一个Foo
的新实例,并留下了谁负责清理对象的问题。GSL 1owner<T>
试图引入一些原始指针“所有权”的概念,但不涉及任何清理代码——你仍然必须自己编写。 - 一个唯一的指针(
unique_ptr
)可以获取一个堆分配的指针并管理它,这样当不再有对它的引用时,它会被自动清除。唯一指针确实是唯一的:你不能复制它,也不能把它传递给另一个函数而不失去对原指针的控制。 - 共享指针(
shared_ptr
)接受一个堆分配的指针并管理它,但是允许在代码中共享这个指针。只有当指针上没有组件时,拥有的指针才会被清除。 - 弱指针(
weak_ptr
)是一个智能但无所有权的指针,它保存对由shared_ptr
管理的对象的弱引用。您需要将它转换成一个shared_ptr
,以便能够实际访问被引用的对象。它的用途之一是打破shared_ptr
s 的循环引用。
从函数返回对象
如果你要返回大于一个字大小的值,有几种方法可以从函数中返回一些东西。首先,也是最明显的是:
1 Foo make_foo(int n)
2 {
3 return Foo{n};
4 }
您可能会觉得,使用前面的方法,正在制作Foo
的完整副本,从而浪费了宝贵的资源。但并不总是如此。假设您将Foo
定义为:
1 struct Foo
2 {
3 Foo(int n) {}
4 Foo(const Foo&) { cout << "COPY CONSTRUCTOR!!!\n"; }
5 };
您会发现复制构造器可能被调用零到两次:调用的确切次数取决于编译器。返回值优化(RVO)是一个编译器特性,它专门防止产生额外的副本(因为它们不会真正影响代码的行为)。然而,在复杂的场景中,你真的不能指望 RVO 会发生,但是在选择是否优化返回值的时候,我更倾向于选择 Knuth。 2
当然,另一种方法是简单地返回一个智能指针,比如一个unique_ptr
:
1 unique_ptr<Foo> make_foo(int n)
2 {
3 return make_unique<Foo>(n);
4 }
这是非常安全的,但也是固执己见的:你已经为用户选择了智能指针。他们不喜欢智能指针怎么办?如果他们更喜欢shared_ptr
呢?
第三个也是最后一个选择是使用原始指针,可能与 GSL 的owner<T>
一起使用。这样,您不是在强制清理分配的对象,而是在传递一个非常明确的信息,即这是调用者的责任:
1 owner<Foo*> make_foo(int n)
2 {
3 return new Foo(n);
4 }
你可以把这种方法看作是给用户一个提示:我正在返回一个指针,从现在开始由你来负责这个指针。当然,现在make_foo()
的调用者需要处理指针:要么正确调用delete
,要么将其包装在unique_ptr
或shared_ptr
中。请记住,owner<T>
没有提到复制。
所有这些选项都同样有效,很难说哪个选项更好。
Footnotes 1
指南支持库( https://github.com/Microsoft/GSL
)是 C++ 核心指南建议的一组函数和类型。这个库包括许多类型,其中的owner<T>
类型用于指示指针的所有权。
2
以《计算机编程的艺术》系列丛书而闻名的唐纳德·克努特(Donald Knuth)曾写过一篇论文,声称“过早优化是万恶之源”。C++ 让过早的优化变得非常诱人,但是你应该抵制这种诱惑,直到 A)你完全明白你在做什么;B)您实际体验到需要优化的性能效果。
第二部分:结构模式
顾名思义,结构模式就是建立应用程序的结构,以提高代码的一致性、可用性和可重构性。
当涉及到确定一个物体的结构时,我们可以采用两种相当众所周知的方法:
- 继承:对象自动获取其基类的非私有字段和函数。为了允许实例化,对象必须实现来自其父对象的每个纯虚拟成员;如果没有,那它就是抽象的,不能被创造(但是你可以从中继承)。
- 复合:通常意味着孩子不能离开父母而存在。想象一个拥有
owner<T>
类型成员的对象:当对象被销毁时,它们也随之被销毁。 - 聚合:一个对象可以包含另一个对象,但是那个对象也可以独立存在。想象一个拥有类型为
T*
或shared_ptr<T>
的成员的对象。
如今,组合和聚合都以相同的方式处理。如果你有一个字段类型为Address
的Person
类,你可以选择Address
是外部类型还是嵌套类型。在这两种情况下,只要它是public
,就可以将其实例化为Address
或Person::Address
。
我认为,当我们真正指的是聚合时,使用组合这个词已经变得如此普遍,以至于我们也可以以可互换的方式使用它们。这里有一些证据:当我们谈到 IoC 容器时,我们说的是复合根。但是等等,IoC 容器不是单独控制每个对象的生存期吗?确实如此,所以我们在这里使用“组合”这个词,实际上是指“聚合”。
第三部分:行为模式
当大多数人听说行为模式时,主要是关于动物以及如何让它们做你想做的事情。嗯,在某种程度上,所有的编码都是关于程序做你想做的事情,所以行为软件设计模式涵盖了非常广泛的行为,尽管如此,这些行为在编程中还是很常见的。
作为一个例子,考虑软件工程领域。我们有经过编译的语言,其中包括词法分析、语法分析和无数其他事情(解释器模式),并且,在为程序构建了抽象语法树(AST)之后,您可能想要分析程序中可能存在的错误(访问者模式)。所有这些行为都很常见,可以用模式来表达,这就是我们今天在这里的原因。
与创造模式(专门关注对象的创建)或结构模式(关注对象的组合/聚合/继承)不同,行为设计模式不遵循一个中心主题。虽然不同的模式之间有某些相似之处(例如,策略和模板方法以不同的方式做同样的事情),但是大多数模式都提供了解决特定问题的独特方法。
第四部分:附录 A:函数式设计模式
Appendix A: Functional Design Patterns
虽然 C++ 主要是一种面向对象的编程语言,但是对函数对象(例如,std::function
)和 lambda 函数的支持使得它对单子的支持有限,单子是函数编程世界的设计模式。不过,不得不说,由于对函数对象以及有用的辅助结构(例如,代数数据类型、模式匹配等)的更好处理,单子在函数式编程语言中更有用。).
我不打算在本书中展示单子的目录,但是我想展示至少一个单子的例子,它可以被 C++ 开发人员使用。