C++ 适配器模式详解
1. 适配器模式概述
适配器模式的目的是在不重写对象本身的情况下,将对象的当前接口转换为特定应用所需的接口。不同的应用代码可能对接口有不同的偏好,因此适配器模式有多种设计方式。例如,在多线程环境中,
locking_queue
对象的
push()
和
pop()
成员函数由同一个互斥锁保护,使得多个线程可以同时执行这些操作,且行为是明确的,这表明
locking_queue
对象是线程安全的。
2. 函数适配器
-
柯里化(Currying)
:函数适配器的一个常见应用是柯里化函数的一个或多个参数。柯里化意味着我们有一个多参数函数,固定其中一个参数的值,这样在每次调用时就无需再指定该参数。例如,有函数
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;
};
这个比较对象可以与标准排序函数一起使用:
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 表达式不能以这种方式重载,它们实际上不是函数,而是对象。
3.
std::bind
适配器
C++ 标准库提供了一个标准的可定制适配器
std::bind
,用于固定函数的某些参数。以下是使用示例:
// Example 17
using namespace std::placeholders; // For _1, _2 etc
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); // Returns 50
f1(3); // Returns 15
std::bind
的第一个参数是要绑定的函数,其余参数按顺序排列。需要绑定的参数用指定的值替换,需要保持自由的参数用占位符
_1
,
_2
等替换。返回值类型未指定,需要用
auto
捕获。返回值可以像函数一样调用,参数数量与占位符数量相同,也可以在任何需要可调用对象的上下文中使用。
然而,
std::bind
也有局限性,它不能绑定模板函数。例如,以下代码无法编译:
auto sort_much_less = std::bind(std::sort, _1, _2, much_less()); // No!
4. 适配器与装饰器
适配器和装饰器的区别并不总是很清晰。装饰器模式用于增强现有接口,而适配器模式用于将接口转换为与期望不同接口的代码集成。例如,我们可以创建一个适配器,将系统调用
std::time
的结果转换为可打印的日期格式。
std::time
返回一个
std::time_t
类型的值,它是一个整数,表示自某个标准时刻(称为“纪元开始”)以来的秒数。另一个系统函数
localtime
将这个值转换为包含日期元素(年、月、日等)的结构体。
以下是实现美国日期格式适配器的示例:
// 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_;
};
为了避免编写三个几乎相同的类来处理不同的日期格式(美国格式、欧洲格式和 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<<()
装饰整数。选择哪种描述方式取决于具体问题。
5. 编译时适配器
C++ 不仅有运行时接口,还有编译时接口,例如基于策略的设计。这些接口并不总是完全符合我们的需求,因此需要编写编译时适配器。
- 策略式智能指针示例 :在基于策略的设计中,策略是类的构建块,允许程序员为特定行为定制实现。例如,我们可以实现一个基于策略的智能指针,它会自动删除所拥有的对象。策略是删除操作的具体实现:
// 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_(del_policy)
{}
~SmartPtr() {
deletion_policy_(p_);
}
// ... pointer interface ...
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
。我们可以将堆类型作为第二个模板参数,使策略更通用:
// 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
不兼容,因为
SmartPtr
模板期望第二个参数是一个单类型参数的模板,而
DeleteHeap
有两个类型参数。我们需要一个适配器将双参数模板转换为单参数模板,并固定第二个参数为特定的堆类型。可以使用继承创建适配器:
// 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>;
6. 模板别名的局限性
模板别名在模板参数类型推导中不可见。例如,我们实现了一个流插入运算符,用于打印任何 STL 兼容序列容器的元素:
// 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;
}
这个模板函数有两个类型参数:容器类型和元素类型。容器本身是一个单类型参数的模板。编译器从第二个函数参数推导出容器类型和元素类型。
我们可以在一个简单的容器上测试这个插入运算符:
// 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);
// ... fill the buffer ...
cout << buffer; // Prints all elements of the buffer
但我们真正想要的是打印像
std::vector
这样的真实容器的元素:
std::vector<int> v;
// ... add some values to v ...
cout << v;
这段代码无法编译,因为
std::vector
实际上是一个双参数模板(第二个参数是分配器类型),而我们的流插入运算符声明为接受单参数的容器模板。我们可以使用别名创建适配器:
template <typename T> using vector1 = std::vector<T>;
vector1<int> v;
// ...
cout << v; // Does not compile either
这个代码仍然无法编译,因为模板别名在模板参数类型推导中不可见。在这种情况下,我们必须使用派生类适配器:
// Example 22
template <typename T>
struct vector1 : public std::vector<T> {
using std::vector<T>::vector;
};
vector1<int> v;
// ...
cout << v;
总结
本文详细介绍了 C++ 中的适配器模式,包括函数适配器、
std::bind
适配器、适配器与装饰器的区别以及编译时适配器。通过多个示例展示了如何使用适配器模式解决不同的接口适配问题,同时也指出了模板别名在模板参数类型推导中的局限性。在实际编程中,根据具体需求选择合适的适配器方式可以提高代码的灵活性和可维护性。
C++ 适配器模式详解(续)
7. 适配器模式的应用场景总结
适配器模式在 C++ 编程中有广泛的应用场景,下面通过表格进行总结:
|应用场景|说明|示例|
| ---- | ---- | ---- |
|多线程环境接口适配|将对象接口适配为多线程安全的接口,保证多线程操作的正确性和一致性|
locking_queue
的
push()
和
pop()
函数由同一互斥锁保护|
|函数参数绑定|固定函数的某些参数,简化函数调用|使用
std::bind
固定
f3
函数的部分参数|
|接口转换|将对象的当前接口转换为特定应用所需的接口|将
std::time
的结果转换为不同日期格式|
|模板参数适配|解决模板参数不匹配的问题|将双参数模板
DeleteHeap
转换为单参数模板
DeleteMyHeap
|
下面是一个展示适配器模式应用流程的 mermaid 流程图:
graph TD
A[原始对象或接口] --> B{是否需要适配}
B -- 是 --> C[选择适配方式]
C --> D[函数适配器]
C --> E[std::bind 适配器]
C --> F[编译时适配器]
D --> G[修改函数参数或调用方式]
E --> H[固定函数部分参数]
F --> I[解决模板参数不匹配]
B -- 否 --> J[直接使用]
G --> K[适配后对象或接口]
H --> K
I --> K
K --> L[应用于特定场景]
8. 不同适配器方式的比较
不同的适配器方式有各自的优缺点,下面通过表格进行比较:
|适配器方式|优点|缺点|适用场景|
| ---- | ---- | ---- | ---- |
|函数适配器(自定义)|灵活定制,可根据具体需求修改函数调用逻辑|代码编写相对复杂,需要手动实现适配逻辑|需要对函数调用进行复杂定制的场景|
|
std::bind
|标准库提供,使用方便,可固定函数部分参数|不能绑定模板函数|需要固定函数部分参数的简单场景|
|模板别名|代码简洁,可快速定义新的模板类型|在模板参数类型推导中不可见|不需要进行模板参数类型推导的场景|
|派生类适配器|可解决模板参数类型推导问题,能适配复杂的模板参数情况|代码量相对较大,需要继承和使用基类构造函数|需要进行模板参数类型推导,且模板参数不匹配的场景|
9. 适配器模式的注意事项
- 命名冲突 :在使用适配器时,要注意避免命名冲突。例如,使用 lambda 表达式创建适配器时,不能有同名的 lambda 表达式,因为它们不能重载。
-
模板参数匹配
:在编写编译时适配器时,要确保模板参数匹配。如
SmartPtr模板期望的参数类型与DeleteHeap不匹配,需要进行适配。 - 模板别名的局限性 :使用模板别名时,要清楚其在模板参数类型推导中的局限性,必要时使用派生类适配器。
10. 适配器模式的扩展应用
适配器模式可以与其他设计模式结合使用,进一步扩展其功能。例如,与装饰器模式结合,既可以实现接口的转换,又可以增强现有接口的功能。以下是一个简单的示例,展示如何将适配器模式与装饰器模式结合:
// 原始接口
class OriginalInterface {
public:
virtual void operation() = 0;
};
// 具体实现类
class ConcreteImplementation : public OriginalInterface {
public:
void operation() override {
std::cout << "Original operation" << std::endl;
}
};
// 适配器类
class Adapter : public OriginalInterface {
private:
OriginalInterface* original;
public:
Adapter(OriginalInterface* original) : original(original) {}
void operation() override {
// 适配操作
std::cout << "Adapter operation: ";
original->operation();
}
};
// 装饰器类
class Decorator : public OriginalInterface {
private:
OriginalInterface* original;
public:
Decorator(OriginalInterface* original) : original(original) {}
void operation() override {
// 增强操作
std::cout << "Decorator operation: ";
original->operation();
}
};
int main() {
ConcreteImplementation concrete;
Adapter adapter(&concrete);
Decorator decorator(&adapter);
decorator.operation();
return 0;
}
在这个示例中,
Adapter
类将
ConcreteImplementation
的接口进行了适配,
Decorator
类对适配后的接口进行了增强。
总结
适配器模式是 C++ 中一种非常实用的设计模式,它可以帮助我们解决接口不兼容的问题,提高代码的灵活性和可维护性。通过本文的介绍,我们了解了适配器模式的多种应用方式,包括函数适配器、
std::bind
适配器、编译时适配器等,以及不同适配器方式的优缺点和适用场景。同时,我们还学习了适配器模式的注意事项和扩展应用。在实际编程中,我们应根据具体需求选择合适的适配器方式,并结合其他设计模式,以实现更高效、更灵活的代码。
C++ 适配器模式详解
超级会员免费看
891

被折叠的 条评论
为什么被折叠?



