我们在上一节末尾提出了这样一个观点:装饰器模式因其保留装饰接口而具有特定的优势,但这些优势有时也会转化为局限性。适配器模式是一种更通用的模式,可以在这种情况下使用。
适配器模式的定义非常宽泛 —— 是一种结构型模式,允许将一个类的接口转换为另一个不同的接口。使得现有的类能够在期望不同接口的代码中使用,而无需修改原始类本身。这类适配器有时称为类包装器,它们包裹在一个类周围并呈现一个不同的接口。装饰器有时也称为类包装器,原因大致相同。
然而,适配器模式是一种非常通用、广泛的模式,可以用来实现其他几种更具体、定义更窄的模式 —— 特别是装饰器模式。由于装饰器模式更容易理解和遵循,所以先讨论了它。现在,我们将转向更一般的情况。
延续上一节的最后一个例子 —— 锁定装饰器。在锁的保护下调用函数,因此在同一时间,其他线程都无法调用由同一互斥锁保护的其他函数。在某些情况下,这足以使整个代码实现线程安全,但这还不够。
为了说明,将实现一个线程安全的队列对象。队列是一个中等复杂度的数据结构,即使不考虑线程安全也是如此。但不需要从零开始 —— C++ 标准库中提供了 std::queue。可以将对象压入队列,并以先进先出的顺序从中取出,但只能在单个线程上操作 —— 同时从两个不同的线程向同一个队列压入两个对象不安全。但我们有解决方案 —— 可以将带锁的队列实现为基本队列的装饰器。
由于在此我们不关心空基类优化(std::queue 不是一个空类),并且需要转发每一个成员函数调用,因此不需要继承,而可以使用组合,装饰器将包含队列和锁。
包装 push() 方法很简单。std::queue 中有两个版本的 push() —— 一个移动对象,另一个复制对象,应该用锁来保护:
// Example 11
template <typename T> class locking_queue {
using mutex = std::mutex;
using lock_guard = std::lock_guard<mutex>;
using value_type = typename std::queue<T>::value_type;
void push(const value_type& value) {
lock_guard l(m_);
q_.push(value);
}
void push(value_type&& value) {
lock_guard l(m_);
q_.push(value);
}
private:
std::queue<T> q_;
mutex m_;
};
现在,将注意力转向如何从队列中获取元素。标准队列有三个相关的成员函数 —— 首先,front() 允许访问队列的前端元素,但不会将其从队列中移除。然后,pop() 会移除前端元素,但不返回值(不提供对前端元素的访问 —— 只是将其移除)。如果队列为空,这两个函数都不应调用 —— 没有错误检查,但结果未定义。最后,还有第三个函数 empty();如果队列不为空,会返回 false,然后就可以调用 front() 和 pop()。
如果用锁来装饰这些函数:
locking_queue<int> q;
q.push(5);
... 程序运行一段时间后 ...
if (!q.empty()) {
int i = q.front();
q.pop();
}
每个函数本身都是线程安全的,但组合整体上却不是。
首先,调用 q.empty()。假设返回 false,因此知道队列中至少有一个元素。接着在下一行通过调用 q.front() 来访问,该调用返回 5。但这是程序中众多线程之一。另一个线程也正在同时执行相同的代码(实现这种行为正是我们进行此练习的目的)。该线程也调用 q.empty(),同样得到 false 的结果 —— 队列中确实有一个元素,而且尚未采取操作将其移除。第二个线程也调用 q.front(),同样得到了 5。这已经是一个问题了 —— 两个线程都试图从队列中取出一个元素,却得到了同一个元素。情况变得更糟 —— 第一个线程现在调用 q.pop() 并将 5 从队列中移除。此时队列已为空,但第二个线程对此并不知情 —— 之前已经调用了 q.empty()。因此,第二个线程现在也调用 q.pop(),这次是在一个空队列上调用。最理想的情况是程序会立即崩溃。
我们刚刚看到了一个普遍问题的具体案例 —— 一系列操作中的每一个单独来看都是线程安全的,但作为一个整体却不是线程安全的。事实上,这种带锁的队列完全无用,无法用它编写出线程安全的代码。需要的是一个单一的、线程安全的函数,能够在一把锁的保护下完成整个事务,作为一个不可中断的单一操作(此类事务称为原子操作)。例子中,这个事务就是:如果队列前端存在元素,则将其移除;如果不存在,则提供某种错误诊断信息,但 std::queue 的接口并未提供这样的事务性 API。
因此,现在需要一种新的模式 —— 一种能够将现有类的接口转换为我们所需的不同接口的模式。装饰器模式无法完成这项任务,而这正是适配器模式所要解决的问题。既然已经确定需要一个不同的接口,接下来只需决定这个新接口应该是什么样子。新的 pop() 成员函数应该完成所有这些工作:如果队列不为空,应从队列中移除第一个元素,并通过复制或移动的方式将其返回给调用者;如果队列为空,不应改变队列的状态,而应以某种方式通知调用者队列为空。一种实现方法是返回两个值 —— 元素本身(如果存在)和一个布尔值,用于告知队列是否为空。
以下是现在作为适配器(而非装饰器)的带锁队列的 pop() 部分实现:
// Example 11
template <typename T> class locking_queue {
... push() 实现没有变化 ...
bool pop(value_type& value) {
lock_guard l(m_);
if (q_.empty()) return false;
value = std::move(q_.front());
q_.pop();
return true;
}
private:
std::queue<T> q_;
mutex m_;
};
不需要修改 push() —— 单个函数调用已经完成了所有工作,因此适配器只需一对一地转发该部分接口即可。这个版本的 pop() 在成功从队列中移除元素时返回 true,否则返回 false。如果返回 true,则元素会被保存到提供的参数中;如果返回 false,则该参数保持不变。如果元素类型 T 支持移动赋值,则会使用移动操作而非复制。
当然,这并非此类原子 pop() 操作唯一可能的接口设计。另一种方法是将元素和布尔值作为一个 std::pair 返回。一个显著的区别是,此时无法保持元素不变 —— 它是返回值,必须包含某个内容。自然的做法是,如果队列中没有元素,则对元素进行默认构造(所以对元素类型 T 有了一个限制 —— 必须可默认构造)。
在 C++17 及更高版本中,更好的选择是返回一个 std::optional<T>:
// Example 12
template <typename T> class locking_queue {
... push() 实现没有变化 ...
std::optional<value_type> pop() {
lock_guard l(m_);
if (q_.empty()) return std::nullopt;
value_type value = std::move(q_.front());
q_.pop();
return { value };
}
};
根据需要使用此队列的应用代码,上述接口中的一种可能更为合适,也存在其他设计方式。无论采用哪种方式,最终都会得到两个由同一互斥锁保护的成员函数 push() 和 pop()。现在,任意数量的线程,可以同时执行这些操作的任意组合,其行为都明确定义,所以 locking_queue 对象是线程安全的。
不重写对象本身的情况下,将其从当前接口转换为特定应用程序所需的接口,这正是适配器模式的目的和用途。各种类型的接口都可能需要进行转换,因此也存在许多不同类型的适配器。我们将在下一节中了解这其中的一些类型。
我们刚刚看到了一种类适配器,它改变了类的接口。另一种接口是函数(成员函数或非成员函数)。一个函数有特定的参数,但我们可能希望用一组不同的参数来调用它。这就需要一个适配器。这类适配器的一个常见应用称为柯里化,即固定函数的一个(或多个)参数的值,这样在每次调用时就无需再指定该值。例如,我们有一个函数 f(int i, int j),但我们想要一个 g(i),它的效果等同于 f(i, 5),只是无需每次都输入 5。
我们将逐步实现一个适配器,下面来看一个更有趣的例子。std::sort 函数接受一个迭代器范围(要排序的序列),也可以用三个参数调用 —— 第三个参数是比较对象(默认使用 std::less,会调用排序对象的 operator<())。
现在我们想要实现不同的功能 —— 希望对浮点数进行模糊比较,即带有容差的比较:如果两个数 x 和 y 彼此足够接近,就认为它们不满足一个比另一个小的关系。只有当 x 明显小于 y 时,才将 x 排在 y 之前。
以下是比较函数对象(可调用对象):
// Example 13
struct much_less {
template <typename T>
bool operator()(T x, T y) {
return x < y && std::abs(x - y) > tolerance;
}
static constexpr double tolerance = 0.2;
}
比较对象可以与标准的 sort 函数一起使用:
std::vector<double> v;
std::sort(v.begin(), v.end(), much_less());
如果经常需要这种排序方式,可能希望对最后一个参数进行柯里化,创建一个适配器,该适配器只需要两个参数(迭代器),并隐含地使用指定的排序函数。以下就是这样一个简单的适配器:
// Example 13
template<typename RandomIt>
void sort_much_less(RandomIt first, RandomIt last) {
std::sort(first, last, much_less());
}
现在,可以用两个参数调用排序函数:
// Example 13
std::vector<double> v;
sort_much_less(v.begin(), v.end());
如果经常以这种方式对整个容器进行排序,可能希望再次改变接口,创建另一个适配器:
// Example 14
template<typename Container> void sort_much_less(Container& c) {
std::sort(c.begin(), c.end(), much_less());
}
C++20 中,std::sort 和其他 STL 函数提供了接受范围的重载版本,可以看作是上面容器适配器的泛化版本。现在,代码看起来更加简洁了:
// Example 14
std::vector<double> v;
sort_much_less(v);
C++14 提供了一种编写此类简单适配器的替代方法,通常应优先使用这种方法:可以使用 Lambda 表达式:
// Example 15
auto sort_much_less = [](auto first, auto last) {
return std::sort(first, last, much_less());
};
当然,比较函数 much_less() 本身也是一个可调用对象,也可以是一个 Lambda:
// Example 15a
auto sort_much_less = [](auto first, auto last) {
return std::sort(first, last,
[](auto x, auto y) {
static constexpr double tolerance = 0.2;
return x < y && std::abs(x - y) > tolerance;
});
};
这个容器适配器的视线也同样简单:
// Example 16
auto sort_much_less = [](auto& container) {
return std::sort(container.begin(), container.end(), much_less());
};
不能在同一个程序中,使用相同的名称同时定义这两个(重载的 Lambda) —— Lambda 表达式无法以这种方式重载。Lambda实际上根本不是函数,而是对象(不过,可以像第2章中所展示的那样,使用多个 Lambda 创建一个重载集)。
回到将某些参数固定或绑定为常量值来调用函数的问题,这种需求非常普遍,C++ 标准库为此提供了一个标准的可定制适配器,即 std::bind。以下示例展示了它的用法:
// Example 17
using namespace std::placeholders; // 使用方法为 _1, _2 等
int f3(int i, int j, int k) { return i + j + k; }
auto f2 = std::bind(f3, _1, _2, 42);
auto f1 = std::bind(f3, 5, _1, 7);
f2(2, 6); // 返回 50
f1(3); // 返回 15
这个标准适配器拥有自己的小型语言 —— std::bind 第一个参数是要绑定的函数,其余参数则按顺序是该函数的参数。需要绑定的参数需要用指定值替换,而需要保持自由的参数则用占位符 _1、_2 等替换(不一定按此顺序,也可以改变参数的顺序)。返回值的类型未指定,必须使用 auto 进行捕获。我们对返回值唯一了解的是,可以像函数一样调用,调用时的参数数量等于占位符的数量。需要可调用对象的上下文中,也可以作为函数使用,例如在另一个 std::bind 中:
// Example 17
...
auto f1 = std::bind(f3, 5, _1, 7);
auto f0 = std::bind(f1, 3);
f1(3); // 返回 15
f0(); // 还是返回 15
然而,这些对象是可调用的,而不是函数。如果尝试将其中一个赋值给函数指针,就会发现:
// Example 17
int (*p3)(int, int, int) = f3; // 没问题
int (*p1)(int) = f1; // 无法编译
相比之下,如果没有捕获,Lambda 可以转换为函数指针:
auto l1 = [](int i) { return f3(5, i, 7); }
int (*p1)(int) = l1; // 没问题
尽管 std::bind 非常有用,但它并不能免除编写自己的函数适配器的需要 ——它最大的限制是 std::bind 无法绑定模板函数。不能编写以下代码:
auto sort_much_less = std::bind(std::sort, _1, _2, much_less()); // 不行哦
这段代码无法编译。在模板内部,可以绑定它的特定实例化,但在排序示例中,这实际上并没有带来什么好处:
template<typename RandomIt>
void sort_much_less(RandomIt first, RandomIt last) {
auto f = std::bind(std::sort<RandomIt, much_less>,
_1, _2, much_less());
f(first, last, much_less());
}
正如在本节开头提到的,装饰器可以视为适配器模式的一种特殊情况。有时,区别并不在于模式的具体应用,而在于开发者如何看待它。
我们将装饰器(Decorator)模式描述为用于扩展现有接口的模式,而适配器(Adapter)模式则用于转换(适配)一个接口,以便与期望不同接口的代码集成。但这两者之间的区别并不总是清晰明确的。
例如,一个简单的类,将系统调用 std::time 的结果适配为可打印的日期格式(std::chrono 已经提供了此功能,但这里作为一个易于理解的示例)。函数 std::time 返回一个 std::time_t 类型的值,该值是一个整数,表示自过去某个标准时间点(称为“纪元开始”)以来经过的秒数。另一个系统函数 localtime 将此值转换为一个结构体,其中包含日期元素:年、月、日(以及小时、分钟等)。一般来说,日历计算相当复杂(这也是为什么 std::chrono 并不简单的原因),但我们假设系统库能正确处理这些计算,只需以正确的格式打印日期。例如,以下是如何以美国格式打印当前日期:
const std::time_t now = std::time(nullptr);
const tm local_tm = *localtime(&now);
cout << local_tm.tm_mon + 1 << "/" <<
local_tm.tm_mday << "/" <<
local_tm.tm_year + 1900;
我们想要创建一个适配器,将秒数表示的时间转换为特定格式的日期,并将其打印出来;我们需要分别为美国格式(月在前)、欧洲格式(日在前)和 ISO 格式(年在前)创建不同的适配器。
该适配器的实现相当直接:
// Example 18
class USA_Date {
public:
explicit USA_Date(std::time_t t) : t_(t) {}
friend std::ostream& operator<<(std::ostream& out,
const USA_Date& d) {
const tm local_tm = *localtime(&d.t_);
out << local_tm.tm_mon + 1 << "/" <<
local_tm.tm_mday << "/" <<
local_tm.tm_year + 1900;
return out;
}
private:
const std::time_t t_;
};
另外两种日期格式的区别仅在于打印字段的顺序,它们非常相似,以至于可以重构代码,以避免编写三个几乎完全相同的类。最简单的方法是使用模板,并将字段顺序编码为一个“格式代码”,该代码指定了打印日(字段0)、月(字段1)和年(字段2)的顺序。例如,“格式”210表示年、月、日 —— 即ISO日期格式。该格式代码可以作为整型模板参数:
// Example 19
template <size_t F> class Date {
public:
explicit Date(std::time_t t) : t_(t) {}
friend std::ostream& operator<<(std::ostream& out,
const Date& d) {
const tm local_tm = *localtime(&d.t_);
const int t[3] = { local_tm.tm_mday,
local_tm.tm_mon + 1,
local_tm.tm_year + 1900 };
constexpr size_t i1 = F/100;
constexpr size_t i2 = (F - i1*100)/10;
constexpr size_t i3 = F - i1*100 - i2*10;
static_assert(i1 >= 0 && i1 <= 2 && ..., "Bad format");
out << t[i1] << "/" << t[i2] << "/" << t[i3];
return out;
}
private:
const std::time_t t_;
};
using USA_Date = Date<102>;
using European_Date = Date<12>;
using ISO_Date = Date<210>;
这个小包装器将一种类型(一个整数)适配为可用于期望特定格式日期的代码中。或者,否是为整数“装饰”了 operator<<()? 最佳答案是…… 哪一个对思考特定问题更有帮助,就采用哪一个。重要的是要记住,最初使用模式语言的目的:是为了拥有一种简洁且普遍理解的方式来描述软件问题,以及选择的解决方案。当多种模式似乎能产生类似的结果时,选择的描述方式可以聚焦于你感兴趣的地方。
目前,我们只考虑了转换运行时接口的适配器,即程序执行时我们调用的接口。然而,C++ 也拥有编译时接口 —— 上一章中考虑的一个典型例子就是基于策略的设计。这些接口并不总是完全符合我们的需求,因此接下来需要了解如何编写编译时适配器。
在第16章中,我们了解了策略,其是类的构建模块 —— 允许开发者为特定行为定制实现。例如,可以实现这种基于策略的智能指针,会自动删除其拥有的对象。这里的策略就是删除操作的具体实现:
// Chapter 15, Example 08
template <typename T,
template <typename> class DeletionPolicy =
DeleteByOperator>
class SmartPtr {
public:
explicit SmartPtr(T* p = nullptr,
const DeletionPolicy<T>& del_policy =
DeletionPolicy<T>())
: p_(p), deletion_policy_(deletion_policy)
{}
~SmartPtr() {
deletion_policy_(p_);
}
... 指针接口 ...
private:
T* p_;
DeletionPolicy<T> deletion_policy_;
};
删除策略本身就是,一个模板参数。默认的删除策略是使用 operator delete:
template <typename T> struct DeleteByOperator {
void operator()(T* p) const {
delete p;
}
};
然而,对于在用户指定的堆上分配的对象,需要一个不同的删除策略,该策略能将内存归还给该堆:
template <typename T> struct DeleteHeap {
explicit DeleteHeap(MyHeap& heap) : heap_(heap) {}
void operator()(T* p) const {
p->~T();
heap_.deallocate(p);
}
private:
MyHeap& heap_;
};
然后,必须创建一个策略对象以供指针使用:
MyHeap H;
SmartPtr<int, DeleteHeap<int>> p(new int, H);
然而,这个策略并不太灵活 —— 只能处理一种类型的堆 —— MyHeap。如果将堆的类型设为第二个模板参数,就可以使该策略更具通用性。只要堆具有名为 deallocate() 的成员函数来回收内存,就可以将堆类与该策略一起使用:
// Example 20
template <typename T, typename Heap> struct DeleteHeap {
explicit DeleteHeap(Heap& heap) : heap_(heap) {}
void operator()(T* p) const {
p->~T();
heap_.deallocate(p);
}
private:
Heap& heap_;
};
当然,如果某个堆类使用其他名称来命名该成员函数,也可以使用类适配器使该类能够与策略配合使用。但当前面临一个更大的问题 —— 策略无法与智能指针一起工作。以下代码无法编译:
SmartPtr<int, DeletelHeap> p; // 无法编译
原因是再次出现了接口不匹配,只是这次是另一种类型的接口 —— 模板 template <typename T, template <typename> class DeletionPolicy> class SmartPtr {}; 期望第二个参数是一个只带一个类型参数的模板,而我们提供的 DeleteHeap 模板却有两个类型参数,这就像试图调用一个只有一个参数的函数却传入了两个参数。我们需要一个适配器,将双参数模板转换为单参数模板,并将第二个参数固定为特定的堆类型(但如果存在多种堆类型,无需重写策略,只需编写多个适配器即可)。可以使用继承来创建这个适配器 DeleteMyHeap(并记得将基类的构造函数,引入派生适配器类的作用域):
// Example 20
template <typename T>
struct DeleteMyHeap : public DeleteHeap<T, MyHeap> {
using DeleteHeap<T, MyHeap>::DeleteHeap;
};
也可以使用模板别名来实现同样的功能:
// Example 21
template <typename T>
using DeleteMyHeap = DeleteHeap<T, MyHeap>;
显然,第一种版本要冗长得多。但两种方式都必须了解,因为模板别名有一个限制。为了说明,来看一下另一个需要适配器的例子。我们将首先为 STL 兼容的序列容器实现流插入运算符(operator<<),前提是该容器的元素已定义了这样的运算符。这是一个简单的函数模板:
// Example 22
template <template <typename> class Container, typename T>
std::ostream& operator<<(std::ostream& out,
const Container<T>& c) {
bool first = true;
for (auto x : c) {
if (!first) out << ", ";
first = false;
out << x;
}
return out;
}
这个模板函数有两个类型参数:容器类型和元素类型。容器本身是一个带有一个类型参数的模板。编译器会从第二个函数参数中推导出容器类型和元素类型( operator<< 的第一个参数始终是流对象)。可以用一个简单的容器来测试插入运算符:
// Example 22
template <typename T> class Buffer {
public:
explicit Buffer(size_t N) : N_(N), buffer_(new T[N_]) {}
~Buffer() { delete [] buffer_; }
T* begin() const { return buffer_; }
T* end() const { return buffer_ + N_; }
...
private:
const size_t N_;
T* const buffer_;
};
Buffer<int> buffer(10);
... 填充buffer ...
cout << buffer; // 打印出buffer中的所有元素
但这是一个简单的容器,用处不大。我们真正想要的是打印容器(例如 std::vector)的元素:
std::vector<int> v;
... 向v添加一些元素 ...
cout << v;
但这段代码无法编译,因为 std::vector 实际上并不是一个只有一个类型参数的模板。它有两个参数 —— 第二个是分配器类型。这个分配器有一个默认值,这就是为什么 std::vector<int> 能成功编译的原因。但即使有这个默认参数,仍然是一个具有两个参数的模板,而流插入运算符声明为只接受只有一个参数的容器模板。
同样,可以通过编写一个适配器来解决这个问题(毕竟大多数 STL 容器都是使用默认分配器的)。编写此适配器最简单的方法是使用别名:
template <typename T> using vector1 = std::vector<T>;
vector1<int> v;
...
cout << v; // 还是无法编译
这也无法编译,现在可以展示前面提到的模板别名的限制 —— 模板别名不能用于模板参数推导。当编译器试图根据 cout 和 v 这两个参数来确定 operator<<() 调用的模板参数类型时,模板别名 vector1 不可见,必须使用派生类适配器:
// Example 22
template <typename T>
struct vector1 : public std::vector<T> {
using std::vector<T>::vector;
};
vector1<int> v;
...
cout << v;
顺便提一下,如果仔细阅读了上一章,可能会意识到之前已经遇到过双重模板参数存在多参问题,并通过将这些参数声明为可变参数模板来解决:
// Example 23
template <typename T,
template <typename, typename...> class Container,
typename... Args>
std::ostream& operator<<(std::ostream& out,
const Container<T, Args...>& c) {
...
}
现在 operator<<() 可以打印容器了,所以不再需要担心适配器了,对吗?还不完全对:仍然无法打印的一种容器是 std::array,它是一个带有一个类型参数和一个非类型参数的类模板。可以声明一个重载来处理这种情况:
// Example 23
template <typename T,
template <typename, size_t> class Container, size_t N>
std::ostream& operator<<(std::ostream& out,
const Container<T, N>& c) {
...
}
可能还会遇到另一种不符合这两种模板的容器(无论是因为必须如此,还是仅仅因为是以不同方式编写的遗留代码),则必须再次使用适配器。
现在,我们已经了解了如何实现装饰器,以期望的行为来增强类和函数接口,以及如何在现有接口不适合特定应用时创建适配器。装饰器模式,尤其是适配器模式,是非常通用和多用途的模式,可以用来解决许多问题。通常一个问题可以用多种方式解决,可以选择使用哪种模式,这也就不足为奇了。