精读C++设计模式20 —— 结构型设计模式:桥接模式

精读C++设计模式20 ------ 结构型设计模式:桥接模式

​ 这是我们的第二个设计模式------桥接模式!桥接模式更加直白了,我们之前的适配器更倾向于对接口本身的桥接,这里说的是系统协作的桥接。笔者认为他跟适配器区别谈不上很大。但是还是要仔细说一说这个桥接模式,以及我们下面要引出的,笔者最最常用的pImpl法,他就属于桥接模式的一个响当当的代表

我们在桥接什么?

一句话:我们在桥接具体的抽象和具体的实现。或者说------让本来放在一个类中的实现和接口放在两个类中。举个例子,在过去,我们的类A的接口和实现会写在同一对.cpp/.h中,都隶属于A这个模块的代码。但是现在我们会放到两个类中。接口的类AInterface和因为不同情况而拥有不同实现的AImpl类。

桥接模式的精彩之处在于:将抽象(Abstraction)与实现(Implementor)分离,使两者可以独立变化。换句话说,把「接口/抽象层」和「实现层」分成两个维度,通过在抽象中持有对实现的引用来"桥接"它们。适合那些抽象和实现都可能独立扩展的场景。

这种设计模式被广泛的用在了跨平台库中

​ 太干了,直接甩出来一个例子:

cpp 复制代码
// Implementor(实现接口)
struct DrawingAPI {
    virtual ~DrawingAPI() = default;
    // 这就是我们的接口
    virtual void drawCircle(double x, double y, double r) = 0;
};

// ConcreteImplementorA / B
struct OpenGL_API : DrawingAPI { void drawCircle(...) override { /* OpenGL */ } };
struct DirectX_API : DrawingAPI { void drawCircle(...) override { /* DirectX */ } };

// Abstraction
class Shape {
protected:
    std::unique_ptr<DrawingAPI> api_;
public:
    Shape(std::unique_ptr<DrawingAPI> api) : api_(std::move(api)) {}
    virtual ~Shape() = default;
    virtual void draw() = 0;
};

// RefinedAbstraction
class Circle : public Shape {
    double x_, y_, r_;
public:
    Circle(double x,double y,double r,std::unique_ptr<DrawingAPI> api)
     : Shape(std::move(api)), x_(x), y_(y), r_(r) {}
    void draw() override { api_->drawCircle(x_, y_, r_); }
};

​ 注意到了嘛?我们的Circle到底如何画的,被封在了DirectX_API或者是OpenGL_API,并且可以在不修改 Circle 的情况下新增新 DrawingAPI


PIMPL(Pointer to IMPLementation)

​ PIMPL法太出名了,我甚至想把今天的标题改成设计模式------PIMPL法详谈,但是这属于桥接模式,咱们就讲好了桥接模式,然后再仔细说一说pImpl法是如何工作和设计的。

​ pImpl法没啥稀奇的。当你觉得头文件太膨胀,将实现挪动一个C++编译单元隔离头文件的时候,你实际上就在不自觉的pImpl。但是没有太多,因为我们之间还是再用一个名称符号耦合。如果我们进一步弱化,将实现的细节不光挪到了一个文件中,而是挪到了一个承载了实现细节的IMPL类中,而我们的接口只用一个前置式的声明指针引用,就像这样一样:

cpp 复制代码
class Impl;

class Interface {
public:
	Interface() { ... }
	void work();
private:
	Impl*	impl;
};

​ 我们另开一个文件包含IMPL类的实现和声明,这样就完成了我们的工作。我们实际上以一个非常非常小的代价(一次指针解引用)换来了完全隔离的抽象。

​ 好像还是没啥感觉,那我们从头试试!

当我们不使用pIMPL法

直接在头里声明所有私有数据,编译依赖大,头变动导致大量重编译。

cpp 复制代码
// widget.h
class Widget {
public:
    void doWork();
private:
    std::vector<int> data_;
    std::string name_;
};

​ 现在你在头文件中添加和减少一点成员,你很快气恼的发现,只要你动了哪怕一点,所有牵扯到的编译单元居然全部都要跟着变一次。如果这个是一个无敌巨大的项目,你一次改动就要重新编译成千上万个源文件!


最简单的 pImpl(裸指针 + 手动析构,缺 copy 支持)

把实现类前向声明,头文件只含指针,定义在 cpp。

cpp 复制代码
// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    void doWork();
private:
    struct Impl; // forward
    Impl* pImpl; // raw pointer
};

// widget.cpp
struct Widget::Impl {
    std::vector<int> data;
    std::string name;
    void doWorkImpl() { /* ... */ }
};

Widget::Widget() : pImpl(new Impl{/*init*/}) {}
Widget::~Widget() { delete pImpl; }
void Widget::doWork() { pImpl->doWorkImpl(); }

​ 最简单的IMPL法!完全可以随意的更改我们的实现了!因为所有的实现和非公开接口的变动,我们的Widget都完全不知道!我们可以随意的迭代我们的Widget::Impl实现!


std::unique_ptr 管理生命周期(推荐起点)

​ 我们是使用现代C++的,用 unique_ptr<Impl> 代替裸指针,自动析构,移动语义更自然。

cpp 复制代码
// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    Widget(const Widget&);            // custom copy
    Widget& operator=(const Widget&); // custom copy
    Widget(Widget&&) noexcept = default;
    Widget& operator=(Widget&&) noexcept = default;
    void doWork();
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

// widget.cpp
struct Widget::Impl { /* same */ };

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;

Widget::Widget(const Widget& other) : pImpl(std::make_unique<Impl>(*other.pImpl)) {}
Widget& Widget::operator=(const Widget& other) {
    if (this != &other) pImpl = std::make_unique<Impl>(*other.pImpl);
    return *this;
}

引入 clone()(将拷贝逻辑封装到 Impl)

将拷贝逻辑放到 Impl,更灵活,Impl 可以是抽象基类以支持多态 Impl。

cpp 复制代码
struct Widget::Impl {
    virtual ~Impl() = default;
    virtual std::unique_ptr<Impl> clone() const = 0;
    virtual void doWorkImpl() = 0;
};

struct ConcreteImpl : Impl {
    std::vector<int> data;
    std::unique_ptr<Impl> clone() const override { return std::make_unique<ConcreteImpl>(*this); }
    void doWorkImpl() override { /*...*/ }
};

Widget::Widget(const Widget& other) : pImpl(other.pImpl ? other.pImpl->clone() : nullptr) {}
Widget& Widget::operator=(Widget other) { swap(*this, other); return *this; } // copy-and-swap

性能优化:避免不必要的拷贝 ------ 支持移动与 noexcept

默认允许移动构造/移动赋值 noexcept,这样容器(如 vector)在扩容时可以用移动而不是拷贝:

cpp 复制代码
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;

并为拷贝赋值实现 copy-and-swap,以保证强异常安全语义:

cpp 复制代码
friend void swap(Widget& a, Widget& b) noexcept {
    using std::swap;
    swap(a.pImpl, b.pImpl);
}
Widget& Widget::operator=(Widget other) { swap(*this, other); return *this; }
其他一些乱七八糟收集来的小建议
  1. 在头文件仅 forward declare struct Impl; 并持有 std::unique_ptr<Impl> pImpl;
  2. 在 cpp 中定义 Impl(不暴露在头)。
  3. Impl 提供 clone()(如果需要支持深拷贝)。
  4. 提供 copy ctor/assign 实现为深拷贝(使用 clone),并提供默认或 noexcept 的 move ctor/assign。使用 copy-and-swap 可保证异常安全。
  5. 对性能敏感且发生大量小对象分配时,考虑 SBO;但先用简单方案(unique_ptr + clone)做测量再优化。
  6. 在 API 设计上尽量把修改操作收窄(减少对 Impl 的频繁写操作),以减少开销影响。
  7. 文档标注:pImpl 会阻止部分函数内联(因为实现在 cpp),如需要强内联性能,考虑把该方法放在头部或使用 inline 或模板策略而非 pImpl。
  8. 对于公开类含虚函数的场景:pImpl 不会替代 vtable(虚函数表位于持有虚函数的对象本身),但可以把虚方法的实现委托给 Impl。如果希望把 vtable 也隔离出来,需要另行设计(比如桥接/策略模式)。

代码:一个较为完整的"最佳实践"范例

cpp 复制代码
// widget.h
#include <memory>

class Widget {
public:
    Widget();
    ~Widget();

    Widget(const Widget&);            // deep copy by clone()
    Widget& operator=(const Widget&); // copy-and-swap
    Widget(Widget&&) noexcept = default;
    Widget& operator=(Widget&&) noexcept = default;

    void doWork();

    friend void inline swap(Widget& a, Widget& b) noexcept {
        std::swap;(a.pImpl, b.pImpl);
    }

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// widget.cpp
#include "widget.h"
#include <vector>
#include <string>

struct Widget::Impl {
    std::vector<int> data;
    std::string name;
    void doWorkImpl() { /* heavy work */ }
    std::unique_ptr<Impl> clone() const { return std::make_unique<Impl>(*this); }
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;

Widget::Widget(const Widget& other) : pImpl(other.pImpl ? other.pImpl->clone() : nullptr) {}
Widget& Widget::operator=(const Widget& other) {
    Widget tmp(other);
    swap(*this, tmp);
    return *this;
}

void Widget::doWork() { pImpl->doWorkImpl(); }

桥接(Bridge) vs 适配器(Adapter)------详细对比(含直观判断准则)

​ 说了这么多,我们回来,对比一下桥接(Bridge)和适配器(Adapter)。

​ 桥接模式,如你看到pIMPL法那样,它的核心工作更集中在:分离抽象与实现,让两边独立扩展。通常是"正面设计"------从一开始就按两个维度设计(例如:抽象类型×实现平台)。适合长期演化、多实现的系统。

​ 适配器更加像是一种补救了,他会把把一个已有接口转换成另一个期望接口,主要用于兼容性或重用。通常是"补救/集成"------把现成类"粘合"到新的接口上------啊哈,这不是补救史山嘛!

总结

我们在解决什么问题

桥接(Bridge)要解决的问题 :把"抽象"(接口/上层逻辑)与"实现"(底层细节/平台/策略)分离 ,让两者能独立演进,避免在单一类层次里把所有组合穷尽(类爆炸)。常见场景:图形库(shape × drawing backend)、跨平台 API、不同策略组合等。

pImpl(作为桥接的代表)要解决的问题 :头文件暴露实现细节导致的巨量编译依赖和 ABI 不稳定。目标是把实现移动到编译单元或 Impl 类里,减少头文件变化带来的连锁重编译,同时隐藏第三方/重依赖,提供更好的封装和二进制兼容性。


我们怎么解决的(方法与演进步骤 / 核心模式)
  • 抽象层(Abstraction)持有一个实现接口(Implementor)的指针/引用。
  • 抽象层定义高层 API;实现接口定义底层能力;具体实现(ConcreteImplementor)实现底层细节。
  • 抽象与实现各自独立扩展,运行时可组合(例如 Circle + OpenGL_APISquare + DirectX_API)。

方案对比和收获(优缺点、何时用、实践建议)
  • 裸指针 + 手写析构
    • 优:实现简单、隐藏实现。
    • 缺:内存管理繁琐,拷贝/异常容易出问题。
  • unique_ptr<Impl> + 自定义拷贝(clone)
    • 优:安全、移动高效、拷贝语义明确(深拷贝),推荐默认选项。
    • 缺:每次深拷贝可能有分配开销;阻止部分函数内联。
  • shared_ptr<Impl>(共享) / COW
    • 优:拷贝廉价;读多写少场景有效(COW 可节省复制)。
    • 缺:引用计数开销、多线程下复杂、写时语义增加复杂度。
  • SBO / placement-new
    • 优:减少小对象频繁堆分配开销、提升性能。
    • 缺:实现复杂,调试与维护成本高。只在有测量证据时使用。
干嘛pImpl?
  • 降低编译耦合:头变动不会触发大量重编译,适合库界面/ABI 稳定需求。
  • 隐藏第三方依赖:头文件不用包含 heavy headers(vector/string 等),只在 cpp 引入。
  • ABI 稳定:Impl 可随意演化而不破坏使用方二进制。
  • 缺点/权衡:额外一层指针间接与(可能)堆分配;失去部分内联优化;实现更复杂(拷贝/异常/并发需考虑)。
Bridge vs Adapter
  • 意图不同
    • Bridge:从设计一开始就想把抽象和实现分成两个独立维度,面向长期演化。
    • Adapter:事后为兼容或复用而把现有类包一层,转换接口------更多是补救或集成。
  • 实践判别
    • 如果你是为了"允许抽象与实现独立扩展"用 Bridge。
    • 如果你是为了"把已有类适配到新接口"用 Adapter。
  • 外观相似 :两者都可能包含委托指针,但关键看设计时机与意图
相关推荐
heyCHEEMS2 小时前
最长连续序列 Java
java·开发语言·算法
da_vinci_x3 小时前
设计稿秒出“热力图”:AI预测式可用性测试工作流,上线前洞察用户行为
前端·人工智能·ui·设计模式·可用性测试·ux·设计师
BS_Li3 小时前
用哈希表封装unordered_set和unordered_map
数据结构·c++·哈希算法·散列表
waves浪游3 小时前
C++多态
开发语言·c++
A9better4 小时前
嵌入式开发学习日志32——stm32之PWM
stm32·单片机·嵌入式硬件·学习
一只乔哇噻4 小时前
java后端工程师进修ing(研一版‖day50)
java·开发语言
快码加编~4 小时前
无法解析插件 org.apache.maven.plugins:maven-site-plugin:3.12.1
java·学习·maven·intellij-idea
aramae4 小时前
快速排序的深入优化探讨
c语言·开发语言·c++·算法·排序算法
托比-马奎尔4 小时前
Maven学习
java·学习·maven