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.

posted @ 2022-06-28 00:35  明明1109  阅读(1100)  评论(0编辑  收藏  举报