适配器模式详解:让不兼容的接口携手工作,C++完整实现
引言
你有没有遇到过这样的情况:买了一个新手机,充电器却是Type-C接口,而你的充电宝只有USB-A接口?这时候你需要一个"转接头"------也就是适配器,来让两个不兼容的设备一起工作。
在软件开发中,我们也经常会遇到类似的问题:我们有一个已经存在的类,它的功能完全符合我们的需求,但它的接口却和我们当前系统的接口不兼容。如果直接修改这个类的接口,可能会影响到其他使用它的代码;如果重新写一个功能相同的类,又会造成代码重复。
适配器模式(Adapter Pattern) 正是为了解决这个问题而生的。它是一种结构型设计模式,能够将一个类的接口转换成客户端所期望的另一个接口,使得原本由于接口不兼容而不能一起工作的类能够协同工作。
今天我们就用C++语言,从基础概念到具体实现,全面深入地理解适配器模式。
一、适配器模式的核心概念
1.1 什么是适配器模式
适配器模式的核心思想是:创建一个中间层类(适配器),作为两个不兼容接口之间的转换器。它包装了一个已有的对象,将其接口转换成客户端所需要的接口,从而让客户端可以透明地使用这个对象。
适配器模式就像一个翻译官,当两个说不同语言的人需要交流时,翻译官把一个人的语言翻译成另一个人能听懂的语言,让沟通得以顺利进行。
1.2 适配器模式的三个核心角色
适配器模式包含三个关键角色:
- 目标接口(Target):客户端期望使用的接口,定义了客户端可以调用的方法
- 适配者(Adaptee):已经存在的、需要被适配的类,它有我们需要的功能,但接口不兼容
- 适配器(Adapter):中间转换类,它实现了目标接口,同时包装了一个适配者对象,将客户端对目标接口的调用转换为对适配者接口的调用
1.3 适配器模式的两种主要类型
根据适配器与适配者的关系,适配器模式可以分为两种:
- 类适配器 :适配器通过继承适配者类来实现适配
- 对象适配器 :适配器通过组合适配者对象来实现适配
这两种方式各有优缺点,我们会在后面详细讲解。
二、类适配器模式
2.1 UML类图
+----------------+ +----------------+
| Client | ----> | Target | <-- 目标接口
+----------------+ +----------------+
^
|
+----------------+ +----------------+
| Adaptee | <---- | Adapter | <-- 适配器
+----------------+ +----------------+
类适配器通过多重继承同时继承目标接口和适配者类,然后在实现目标接口的方法时,调用继承自适配者类的方法。
2.2 C++实现
我们以"日志系统适配"为例来实现类适配器模式。假设我们有一个旧的日志类OldFileLogger,它提供了一个writeLog方法来写入日志。现在我们的新系统要求所有日志类都必须实现ILogger接口,该接口定义了一个log方法,支持不同的日志级别。
cpp
#include <iostream>
#include <string>
// 目标接口:新系统要求的日志接口
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const std::string& message, int level) const = 0;
};
// 适配者:旧的文件日志类,接口不兼容
class OldFileLogger {
public:
void writeLog(const std::string& msg) const {
std::cout << "[旧文件日志] " << msg << std::endl;
}
};
// 类适配器:继承目标接口和适配者类
class ClassAdapter : public ILogger, public OldFileLogger {
public:
// 实现目标接口的log方法
void log(const std::string& message, int level) const override {
// 将新接口的调用转换为旧接口的调用
std::string formattedMsg = "级别" + std::to_string(level) + ": " + message;
writeLog(formattedMsg); // 调用继承自适配者的方法
}
};
// 客户端代码
int main() {
// 客户端只知道目标接口ILogger
ILogger* logger = new ClassAdapter();
// 客户端按照新接口的方式调用
logger->log("系统启动成功", 1);
logger->log("检测到异常连接", 3);
delete logger;
return 0;
}
2.3 运行结果
[旧文件日志] 级别1: 系统启动成功
[旧文件日志] 级别3: 检测到异常连接
2.4 优缺点分析
优点:
- 可以重写适配者类的方法,灵活性更高
- 不需要额外的指针来引用适配者对象,代码更简洁
缺点:
- 耦合度高:适配器继承了适配者类,违反了"优先使用组合而非继承"的设计原则
- 只能适配一个适配者类:由于C++不支持多重继承多个普通类,所以类适配器只能继承一个适配者类
- 可能会暴露适配者的内部方法:继承会将适配者的公有方法都暴露给适配器,可能会造成不必要的接口污染
三、对象适配器模式
3.1 UML类图
+----------------+ +----------------+
| Client | ----> | Target | <-- 目标接口
+----------------+ +----------------+
^
|
+----------------+
| Adapter | <-- 适配器
+----------------+
|
v
+----------------+
| Adaptee | <-- 适配者
+----------------+
对象适配器不继承适配者类,而是持有一个适配者对象的引用,然后在实现目标接口的方法时,调用这个引用指向的适配者对象的方法。
3.2 C++实现
我们还是用上面的日志系统例子,这次用对象适配器来实现。
cpp
#include <iostream>
#include <string>
#include <memory>
// 目标接口:新系统要求的日志接口
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const std::string& message, int level) const = 0;
};
// 适配者:旧的文件日志类,接口不兼容
class OldFileLogger {
public:
void writeLog(const std::string& msg) const {
std::cout << "[旧文件日志] " << msg << std::endl;
}
};
// 对象适配器:只继承目标接口,持有适配者对象的引用
class ObjectAdapter : public ILogger {
private:
std::unique_ptr<OldFileLogger> adaptee_; // 组合适配者对象
public:
// 构造函数接收适配者对象
explicit ObjectAdapter(std::unique_ptr<OldFileLogger> adaptee)
: adaptee_(std::move(adaptee)) {}
// 实现目标接口的log方法
void log(const std::string& message, int level) const override {
// 将新接口的调用转换为旧接口的调用
std::string formattedMsg = "级别" + std::to_string(level) + ": " + message;
adaptee_->writeLog(formattedMsg); // 调用组合的适配者对象的方法
}
};
// 客户端代码
int main() {
// 创建适配者对象
auto oldLogger = std::make_unique<OldFileLogger>();
// 创建适配器,包装适配者对象
ILogger* logger = new ObjectAdapter(std::move(oldLogger));
// 客户端按照新接口的方式调用
logger->log("系统启动成功", 1);
logger->log("检测到异常连接", 3);
delete logger;
return 0;
}
3.3 运行结果
和类适配器的运行结果完全相同:
[旧文件日志] 级别1: 系统启动成功
[旧文件日志] 级别3: 检测到异常连接
3.4 优缺点分析
优点:
- 耦合度低:使用组合而非继承,符合设计原则
- 可以适配多个适配者:一个适配器可以持有多个适配者对象,或者在运行时动态更换适配者
- 不会暴露适配者的内部方法:只有适配器知道适配者的存在,客户端完全看不到适配者的接口
缺点:
- 不能重写适配者类的方法
- 需要额外的指针来引用适配者对象,代码稍微复杂一点
注意 :在实际开发中,对象适配器是更推荐使用的方式,因为它的耦合度更低,灵活性更高。
四、适配器模式的常见变种
4.1 双向适配器
双向适配器可以同时适配两个不同的接口,使得两个接口的客户端都可以通过适配器调用对方的方法。
简单来说,双向适配器既是Target的适配器,也是Adaptee的适配器。它同时实现了Target和Adaptee的接口,让两个方向的调用都能正常工作。
cpp
// 双向适配器
class BidirectionalAdapter : public ILogger, public OldFileLogger {
public:
// 实现ILogger接口
void log(const std::string& message, int level) const override {
std::string formattedMsg = "级别" + std::to_string(level) + ": " + message;
writeLog(formattedMsg);
}
// 也可以实现OldFileLogger的接口,让旧客户端调用新功能
void writeLog(const std::string& msg) const override {
// 这里可以调用新的日志功能
std::cout << "[双向适配] 旧接口调用: " << msg << std::endl;
}
};
4.2 缺省适配器(接口适配器)
当一个接口有很多方法,而我们只需要实现其中的一部分时,可以使用缺省适配器。
缺省适配器是一个抽象类,它为接口中的所有方法提供了一个空的默认实现。这样,我们只需要继承这个缺省适配器类,然后重写我们需要的方法即可,而不需要实现接口中的所有方法。
cpp
// 一个有很多方法的接口
class IComplexLogger {
public:
virtual ~IComplexLogger() = default;
virtual void debug(const std::string& msg) = 0;
virtual void info(const std::string& msg) = 0;
virtual void warning(const std::string& msg) = 0;
virtual void error(const std::string& msg) = 0;
virtual void fatal(const std::string& msg) = 0;
};
// 缺省适配器:为所有方法提供空实现
class DefaultLoggerAdapter : public IComplexLogger {
public:
void debug(const std::string& msg) override {}
void info(const std::string& msg) override {}
void warning(const std::string& msg) override {}
void error(const std::string& msg) override {}
void fatal(const std::string& msg) override {}
};
// 只需要实现我们关心的方法
class SimpleErrorLogger : public DefaultLoggerAdapter {
public:
void error(const std::string& msg) override {
std::cout << "[错误] " << msg << std::endl;
}
void fatal(const std::string& msg) override {
std::cout << "[致命错误] " << msg << std::endl;
exit(1);
}
};
五、适配器模式的优缺点与适用场景
5.1 优点
- 提高了代码的复用性:不需要修改已有的类,就可以让它在新的系统中使用
- 提高了系统的灵活性:可以在运行时动态更换适配器,从而改变系统的行为
- 解耦了客户端和适配者:客户端只依赖目标接口,不需要知道适配者的存在
- 符合开闭原则:添加新的适配器不需要修改现有代码
5.2 缺点
- 增加了系统的复杂度:引入了额外的适配器类,增加了代码的层数
- 过多的适配器会让系统变得混乱:如果滥用适配器模式,会导致系统中出现大量的小类,增加理解和维护的难度
5.3 适用场景
- 想使用一个已经存在的类,但它的接口不符合你的需求
- 想创建一个可以复用的类,该类可以与其他不相关的类或不可预见的类协同工作
- 需要使用多个已经存在的子类,但不可能对每一个都进行子类化以匹配它们的接口
- 需要统一多个不同接口的调用方式
六、与其他模式的对比
很多人容易把适配器模式和其他一些结构型模式混淆,这里我们来做一个清晰的对比:
| 模式 | 核心目的 | 与适配器的区别 |
|---|---|---|
| 适配器模式 | 转换接口,让不兼容的类能够一起工作 | 改变接口,不改变功能 |
| 装饰器模式 | 动态地给对象添加额外的功能 | 不改变接口,增强功能 |
| 代理模式 | 控制对对象的访问 | 不改变接口,控制访问 |
| 桥接模式 | 将抽象与实现分离,使它们可以独立变化 | 分离抽象和实现,而不是转换接口 |
| 外观模式 | 为复杂的子系统提供一个简单的统一接口 | 简化接口,而不是转换接口 |
一句话总结:
- 适配器:换个接口
- 装饰器:加个功能
- 代理:控个访问
- 桥接:分个层次
- 外观:简个调用
七、现代C++改进建议
在现代C++(C++11及以后)中,我们可以对适配器模式进行一些改进,让代码更加简洁和安全:
7.1 使用智能指针管理内存
如前面的对象适配器例子所示,使用std::unique_ptr或std::shared_ptr来管理适配者对象的生命周期,避免手动内存管理带来的内存泄漏问题。
7.2 使用函数对象/Lambda简化适配器
当需要适配的接口只有一个方法时,我们可以使用std::function和Lambda表达式来简化适配器的实现,不需要写一个完整的类:
cpp
#include <functional>
// 客户端只需要一个函数对象
using LogFunction = std::function<void(const std::string&, int)>;
// 直接用Lambda创建适配器
int main() {
OldFileLogger oldLogger;
// 创建适配器函数
LogFunction logger = [&oldLogger](const std::string& message, int level) {
std::string formattedMsg = "级别" + std::to_string(level) + ": " + message;
oldLogger.writeLog(formattedMsg);
};
// 客户端调用
logger("系统启动成功", 1);
logger("检测到异常连接", 3);
return 0;
}
这种方式非常简洁,适合简单的适配场景。
7.3 使用模板适配器
如果需要适配多个具有相同方法签名的类,可以使用模板适配器来避免重复代码:
cpp
template <typename Adaptee>
class TemplateAdapter : public ILogger {
private:
Adaptee adaptee_;
public:
TemplateAdapter(Adaptee adaptee) : adaptee_(std::move(adaptee)) {}
void log(const std::string& message, int level) const override {
std::string formattedMsg = "级别" + std::to_string(level) + ": " + message;
adaptee_.writeLog(formattedMsg);
}
};
// 使用
int main() {
ILogger* fileLogger = new TemplateAdapter<OldFileLogger>(OldFileLogger());
fileLogger->log("使用模板适配器", 2);
delete fileLogger;
return 0;
}
八、总结
适配器模式是一种非常实用的设计模式,它的核心思想是**"转换接口,兼容不兼容"**。通过引入一个中间适配器类,我们可以在不修改现有代码的情况下,让原本接口不兼容的类能够一起工作。
在实际开发中,我们应该优先使用对象适配器,因为它的耦合度更低,灵活性更高。只有在需要重写适配者方法的特殊情况下,才考虑使用类适配器。
记住,设计模式不是银弹。适配器模式虽然强大,但也不能滥用。只有当你确实遇到了接口不兼容的问题,并且修改原有接口会带来很大风险时,才应该使用适配器模式。