假设您正在使用您同事编写的一个类,并且您想要扩展该类的功能。在不修改原始代码的情况下,你会怎么做呢?嗯,一种方法是继承:你创建一个派生类,添加你需要的功能,甚至可能是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 对象在本书第章第 19 章中有描述。本质上,空对象是符合某个接口的对象,但是有空方法,也就是说,完全不做任何事情的方法。这解决了当你必须提供一个对象到一个 API 中,但是你不希望这个对象实际上做任何事情时的问题。