现代 C++ 设计模式(二)
六、适配器
我曾经经常旅行,一个旅行适配器可以让我将欧洲插头插入英国或美国插座 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 没有查询的概念,所以我们让组件的任何封装指令都称为命令。