C++ 3个常用API包装器模式:代理模式、适配器模式、外观模式
API包装器模式
通常,需要编写基于另一组类的包装器接口,用一个新的、更简洁的API,来隐藏所有底层遗留代码;或者,已经编写了C++ API,但后来需要给特定客户提供纯C接口,但又不想改变原来的代码封装;或者,你的API用的了一个第三方依赖库,你想让客户直接使用此库,但不想将此库直接暴露给客户。
包装器API的潜在副作用是影响性能,因为会增加一级额外的函数调用,以及存储保证层次状态带来的开销。但好处也是明显的,可以创建质量更高、更适配用户需求的API接口。
C++中,有3个常用的API包装器模式:代理模式,适配器模式,外观模式。它们都属于结构型模式,按包装器层和原始接口的差异递增。
代理模式
在GoF中,代理模式的意图定义为:为其他对象提供一种代理以控制对这个对象的访问。
代理模式提供一对一的转发接口,代理类和原始类应该具有相同接口。实现方式,可以是代理类存储原始类对象(即被代理类,也叫真实对象)的副本,或者指向原始类对象的指针(是不是很像Pimpl惯用法?),然后代理类的方法将重定向到原始类对象中的同名方法。
缺点:1)将原始类接口暴露给用户;2)在改变原始类接口时,需要维护代理接口的完整性。
代理模式 vs Impl惯用法
代理类形式上有点像Pimpl惯用法(见C++ Pimpl惯用法(桥接模式特例)),区别在哪?
-
Pimpl惯用法属于桥接模式特例,主要目的是通过接口类实现对用户屏蔽实现细节,重在隐藏实现细节。私有实现类(Impl class)和接口类的API接口没必要保持一致,而且私有实现类往往与接口类是同一个实现者。
-
代理模式主要目的是为用户提供控制对原始类对象的访问,要求接口必须保持与原始类接口一致,重在控制,即不能改变功能。被代理类经常是第三方库,或者已经设计好的部分。
代理模式的简单实现
代理类
class Proxy
{
public:
Proxy() : original_(new Original())
{}
~Proxy()
{
delete original_;
}
private:
Proxy(const Proxy&);
const Proxy &operator=(const Proxy&);
Original *original_;
};
将代理类设计为禁止copy,因为原始类不仅仅是一个类,背后往往设计到其他资源,仅仅拷贝对象并没有实际意义;当然,如果有copy对象需求,可以自行实现copy函数。
另一种方案,是在此方案基础上增加代理和原始API共享的virtual接口,目的在于通过C++语法来保持2个API同步。这样做前提是你能修改原始API。
// 通过公有接口IOriginal, 从语法层面确保代理类和被代理类接口一致
class IOriginal // 原始类接口
{
public:
virtual bool DoSomething(int value) = 0;
};
class Original : public IOriginal // 原始类
{
public:
bool DoSomething(int value);
};
class Proxy : public IOriginal // 代理类
{
public:
Proxy() : original_(new Original())
{}
~Proxy()
{
delete original_;
}
bool DoSomething(int value)
{
return original_->DoSomething(value);
}
private:
Proxy(const Proxy &);
const Proxy &operator=(const Proxy&);
Original *original_;
};
代理模式应用场景
1)实现原始对象的惰性实例化。直到特定方法被调用时,即对象真正被需要时,Original对象才真正实例化。
2)实现对Original对象的访问控制。如要在Proxy和Original对象之间插入权限层,确保当用户获得适当的授权后,只能调用Original对象上的特定方法。
3)支持调试或“演习”模式。支持在Proxy方法中插入调试语句,记录所有对Original对象的调用,或者使用一个标志以“演习”(dry run)模式调用Proxy,以禁止调用特定的Original方法;例如,禁止将对象状态写入磁盘。
4)保证Original类线程安全。通过给非线程安全地方法添加互斥锁实现线程安全。虽然不是确保线程安全的最佳实践,但如果不能修改Original,却也是一个权宜之计。比如为glibc库函数加锁。
5)支持资源共享。当多个Proxy对象共享相同的Original基础类。例如,可用于实现引用计数或写时复制(copy-on-write)语义。这种用法实际上是享元模式(Flyweight)。
6)应对Original类将来被修改的情况。如果预期依赖库可能会改变,可以为其API创建一个代理包装器模拟当前的行为。当库改变时,通过代理对象预留老的接口,改变代理类的底层实现,就可以使用新的库方法。准确来说,这是适配器对象,而非代理对象。
适配器模式
适配器模式意图:将一个类的接口转换成客户希望的另外一个接口。
也就是说,适配器提供接口转换功能。真实对象接口与客户希望的接口并不兼容,可能由于参数顺序或类型不同,使用习惯不同,命名约定不同等等原因,导致无法直接使用。此时,可以用适配器模式对真实对象接口进行转换。
适配器模式 vs 代理模式
两种相同点在于,可用来对真实对象进行API包装,被包装的对象经常是第三方依赖库,无法改变;另外,两者都不会改变真实对象的基本功能。
不同点:
-
代理模式 要求不改变真实对象的接口,代理模式重在提供控制对象的访问。
-
适配器模式 通常需要改变真实对象的接口(名字,参数个数、类型、顺序,返回值类型),适配器模式重在适配接口。
适配器模式简单实现
例如,现有真实对象类Rectangle 提供接口setDimension,是以圆心、半径、矩形宽和高方的式定义矩形,而客户需要通过矩形左下角、右上角坐标来定义矩形。可以通过适配器类RectangleAdapter,来对接口进行转换。
class RectangleAdapter
{
public:
RectangleAdapter() :
rect_(new Rectangle())
{}
~RectangleAdapter()
{
delete rect_;
}
void Set(float x1, float y1, float x2, float y2)
{
float w = x2 - x1;
float h = y2 - y1;
float cx = w / 2.0f + x1;
float cy = h / 2.0f + y1;
rect_->setDimension(cx, cy, w, h);
}
private:
// 禁止copy
RectangleAdapter(const RectangleAdapter&);
const RectangleAdapter& operator=(const RectangleAdapter&);
Rectangle *rect_;
};
适配器可以通过“组合”或“继承”来实现。前者称为对象适配器,后者称为类适配器。上面示例,显然是对象适配器。
适配器模式优点
1)强制API始终保持一致性。使用适配器模式能整合接口风格不同的类,为它们提供一致的接口供客户使用。
2)包装API的依赖库。可以不暴露依赖库及其接口给用户。
3)转换数据类型。例如,将极坐标转换为直角坐标。
4)为API暴露一个不同的调用约定。例如,为纯C API提供面向对象版本。另一个常用的场景,就是将系统调用封装成RAII管理方式,同时伴随着C++接口包装C接口。
外观模式
外观模式对意图:为子系统中的一组接口提供一个一致的界面,Facade模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
一个子系统可能包含多个对象,它们的API可能各不相同,如果让用户针对每个对象都定制一个调用,可能会有多种风格调用,而且相互之间很难转换。这将势必导致用户不得不了解个对象接口细节。而外观模式,为这个子系统内的所有API提供一个统一的接口,简化其使用,用户只需要对接外观模式即可。
一个典型的外观模式,就是Linux的系统调用open/close/read/write/fcntl等,用户只需要针对文件描述符调用这些函数即可,而不必关系操作的具体是什么设备。
外观模式 vs 适配器模式
外观模式与适配器模式都是改变被包装类的接口,它们有什么区别?
外观模式简化了类的结构,而适配器模式仍保持类的结构(并未修改类的结构关系)。外观模式通常是为一组对象提供统一接口,目的在于提供统一风格的接口;而一个适配器模式,往往针对一个真实对象(类)提供适配接口。
外观模式的简单实现
假设你在度假并入住了一家酒店。你计划先用晚餐,然后去看演出。如果不用外观模式,你需要先给餐厅打电话预订晚餐,接着打电话给剧院预订座位,可能还需要叫出租车来接你。在C++中,可将这3件事表示为3个独立的对象,并逐一处理每个对象。
class Taxi
{
public:
bool BookTaxi(int npeople, time_t pickup_time); // 预订出租车
};
class Restaurant
{
public:
bool ReserveTable(int npeople, time_t arrival_time); // 订晚餐
};
class Theater
{
public:
time_t GetShowTime();
bool ReserveSeats(int npeople, int tier); // 预订座位
};
假设你入住的是一家高档酒店,酒店的礼宾部能帮你完成所有事情。礼宾部首先查演出时间,然后根据掌握的当地情况,计算出合适的晚餐时间,并在最佳时间为你预订出租车。转换成C++术语,你只需要对接礼宾部对象即可,而且该对象的接口比使用上面3个对象接口更简单。
class ConciergeFacde
{
public:
enum ERestaurant {
RESTAURANT_YES,
RESTAURANT_NO
};
enum ETaxi {
TAXI_YES,
TAXI_NO
};
// 现在, 你只需要关心这一个对象的接口即可
time_t BookShow(int npeople, ERestaurant addRestaurant, ETaxi addTaxi);
};
外观模式优点
1)隐藏遗留代码。原有系统可能较为陈旧、脆弱,不提供一致的对象模型。而外观模式能有效基于原有代码创建一组设计良好的API,用户只需要使用新API即可。
2)创建便捷API。例如,OpenGL的GL库提供底层的基础例程,功能强大,但使用繁琐;GLU库基于GL库,提供高层次易于使用的接口。
3)支持简化功能或替代功能的API。抽象出对底层的子系统的访问后,就能替换某个子系统,且捕获影响客户代码。
参考
[1]Martin Reddy, 刘晓娜, 臧秀涛,等. C++ API设计[M]. 人民邮电出版社, 2013.