从"类和类纠缠"到"接口和实现各过各的"
做C++开发这么多年,我一直觉得设计模式这事儿最怕"背名字"。桥接模式(Bridge Pattern)就是个典型——很多人能把定义背出来:"将抽象部分与实现部分分离,使它们都可以独立地变化",但真到了工程里,要么不自觉地把它用成了适配器,要么被虚函数和继承树缠得没法下手。
我最早对桥接模式产生强烈兴趣,是因为维护一个跨平台的渲染模块。业务层有窗口、画布、资源这些抽象,底层要对接Windows的GDI、Linux的X11,后面还要加一个EGL后端。最开始图省事,直接按平台写子类,结果每个抽象类下面都挂着三个平台实现,再加一个功能就要动四五个类的继承结构,compile time越来越长,review的时候光看类图就头大。这时候我才真正意识到:抽象和实现是两个独立的维度,硬塞进一棵继承树里,只会长出"类爆炸"的恶果。
这篇文章我打算换一个角度来讲桥接模式——不摆GoF的经典类图和术语表,而是直接从C++工程的角度,拆解桥接模式的几种"变体":经典指针分发、模板静态桥接、std::function回调注入,以及C++17之后的std::variant访问者形式。每种变体我都给出可编译的代码示例、适用场景和取舍点,最后聊聊我在实际项目中踩过的桥接模式相关的坑。不管你是刚学C++的实习生,还是写了五年以上C++的一线开发,这篇内容应该都能给你一些新的操作思路。
1. 为什么需要桥接模式:从"抽象和实现纠缠"说起
1.1 问题场景:多维继承导致的类爆炸
假设我们要写一个绘图程序,图形对象有圆形、矩形、三角形,渲染方式有矢量渲染和光栅渲染。按照新手最容易想到的写法,画一个类图:
- 抽象类
Shape,基类 CircleShape、RectShape、TriangleShape继承Shape- 每种图形还需要区分渲染方式:
VectorCircle、RasterCircle、VectorRect……
写到这里你就会发现,每加一种图形就要写两个渲染子类,每加一种渲染方式就要给所有图形各写一个子类。图形数量 N 乘以渲染方式 M,类数量是 N×M,这就是经典的"类爆炸"问题。
这个问题的本质是什么呢?抽象维度和实现维度被绑定在了同一个继承体系里。图形的几何属性(圆形、矩形)是"是什么"的问题,渲染方式(矢量、光栅)是"怎么画"的问题。前者是抽象的、稳定的概念,后者是具体的、容易变化的实现细节。把它们耦合在一棵继承树上,等于逼着两个维度的变化互相牵连。
1.2 桥接模式的解耦逻辑
桥接模式的做法是:把这两个维度拆开,分别建立独立的继承结构,然后在抽象层持有实现层的接口引用。抽象类负责业务逻辑和上层交互,具体的实现类负责平台相关或算法相关的细节。两者之间通过一个稳定的接口进行通信,这个接口就是"桥"。
打个比方:你用一个遥控器(抽象)控制一台电视(实现)。遥控器上的按键逻辑是稳定的——音量加减、频道切换,而电视的电路实现可能五花八门,有的是液晶屏,有的是OLED,有的还接了外接音响。遥控器不需要知道电视内部怎么工作,它只依赖一个"红外/蓝牙控制协议"(也就是桥接的接口)。你可以换一台电视,遥控器不用改;你也可以换个遥控器,电视不用改。这就是桥接。
在C++里,这个"桥"通常是抽象类里的一个指向实现对象指针或引用的成员变量。抽象层的方法调用实现层对应的方法,把核心逻辑和底层细节彻底隔开。
1.3 桥接、适配器、策略这三个模式的区别
聊桥接之前得先分清几个近亲模式,我见过太多人在这一点上栽跟头:
- **适配器(Adapter)**解决的是"接口不匹配"问题。比如要把第三方库的
drawDirectX()接口适配到你自己的paint()接口,你写个包装类做翻译,接口变了,但职责没变。桥接则是让抽象和实现可以各自独立进化,职责本身被拆分。 - 策略(Strategy)解决的是"算法的可替换性"问题。
Context类直接持有一个策略对象,运行时切换算法。桥接更侧重结构解耦,策略更侧重行为替换,但实现手法上有很多相似之处,后面讲std::function变体时会再提。 - 装饰器(Decorator)是"在接口不变的前提下给对象增加职责",强调层层包装;桥接则强调拆分,而不是叠加。
一句话总结:适配器是"让旧接口派上用场",策略是"换算法",桥接是"把抽象和实现绑定关系从继承改为组合"。组合优于继承,这不仅是设计原则,在C++里也直接关系到编译依赖和运行时开销,后面每个变体里你都会看到这句话的影子。
2. 经典GoF桥接:指针分发的标准形态
2.1 拆解经典结构
先看一段最标准的GoF桥接实现,场景还是图形绘制,但我会把代码写成C++风格。完整代码如下:
#include <iostream> #include <memory> // 实现层接口:画图API class DrawingAPI { public: virtual ~DrawingAPI() = default; virtual void drawCircle(double x, double y, double radius) = 0; }; // 具体实现A:矢量渲染 class VectorAPI : public DrawingAPI { public: void drawCircle(double x, double y, double radius) override { std::cout << "Vector: circle at (" << x << ", " << y << ") radius " << radius << std::endl; } }; // 具体实现B:光栅渲染 class RasterAPI : public DrawingAPI { public: void drawCircle(double x, double y, double radius) override { std::cout << "Raster: circle at (" << x << ", " << y << ") radius " << radius << std::endl; } }; // 抽象层:图形 class Shape { protected: std::shared_ptr<DrawingAPI> api_; // 桥:指向实现接口 public: explicit Shape(std::shared_ptr<DrawingAPI> api) : api_(std::move(api)) {} virtual ~Shape() = default; virtual void draw() = 0; virtual void resizeBy(double factor) = 0; }; // 扩展抽象:圆形 class Circle : public Shape { private: double x_, y_, radius_; public: Circle(double x, double y, double radius, std::shared_ptr<DrawingAPI> api) : Shape(std::move(api)), x_(x), y_(y), radius_(radius) {} void draw() override { api_->drawCircle(x_, y_, radius_); } void resizeBy(double factor) override { radius_ *= factor; } }; int main() { auto vectorApi = std::make_shared<VectorAPI>(); auto rasterApi = std::make_shared<RasterAPI>(); Circle c1(1.0, 2.0, 3.0, vectorApi); Circle c2(4.0, 5.0, 6.0, rasterApi); c1.draw(); // Vector: circle at (1, 2) radius 3 c2.draw(); // Raster: circle at (4, 5) radius 6 return 0; }这个示例里,Shape是抽象层,DrawingAPI是实现层接口,VectorAPI和RasterAPI是具体实现。Circle继承自Shape,但它的绘制行为完全委托给api_指向的实现对象。注意看,Circle本身只关心几何数据(圆心坐标、半径)和resizeBy这类业务操作,至于画出来是矢量还是光栅,它完全不知道——这层信息被桥挡住了。
2.2 生命周期管理怎么选:shared_ptr还是unique_ptr
代码里我用了shared_ptr,这是有讲究的。在真正的工程里,实现对象经常需要在多个抽象对象之间共享。比如一个渲染上下文可以被多个图形共用,底层有自己的资源状态,如果每个图形都持有一个独立拷贝,那状态就没法同步了。这时候用shared_ptr就顺理成章。
如果是"一个抽象对象独自占有一个实现对象"这种一对一关系,比如每个窗口持有一个专属的平台绘制器,那unique_ptr更合适,语义清晰、零额外开销:
class Window { std::unique_ptr<WindowImpl> impl_; public: explicit Window(std::unique_ptr<WindowImpl> impl) : impl_(std::move(impl)) {} void show() { impl_->show(); } };生命周期这块有个常见的坑:如果实现对象持有回调或者被异步任务引用,裸指针容易悬垂,shared_ptr又容易循环引用。我的做法是——接口设计上尽量不让实现对象反向引用抽象对象;如果不可避免,用weak_ptr打破循环。别小看这个细节,在我做插件系统时,一个循环引用导致整个模块无法卸载,查了一个下午。
2.3 虚函数指针分发的代价与接口设计要点
经典桥接靠虚函数实现运行时多态,每次调用api_->drawCircle(...)都涉及一次虚表查表。在现代CPU上虚函数开销大约几纳秒,如果你是在绘图函数里一次绘制上万图元,这个开销量变引起质变——profiling结果显示光虚函数分发就占了渲染帧时间的5%左右。
除了性能,接口设计不当会让这个变体很难维护。我总结出三条实操准则:
- 接口粒度要"窄而深",不要"宽而浅"。
DrawingAPI只有drawCircle一个方法吗?显然不够。但要设计成几十个方法的巨无霸接口吗?更不行。接口里的每个方法都应该是一个完整的"最小语义单元",让实现方不难实现、让调用方不难理解。 - 接口尽可能稳定。桥是抽象层和实现层之间的契约,一旦定下来就不宜频繁变动。加方法意味着所有实现类都要跟着改——如果这时候有第三方插件实现了你的接口,这就是破坏性变更。
- 区分数据传递方式。实现层的接口方法参数尽量用值传递或
const&传递,避免返回内部可变引用。跨模块传一个std::shared_ptr<InternalState>&出去,等于把封装撕了个口子。
3. 模板驱动的静态桥接:编译期去虚函数
3.1 用模板参数替代抽象接口
经典桥接的性能痛点在于虚函数。对于性能敏感且实现类型在编译期就知道的场景,我们可以用模板把它变成静态分发。思路很简单:抽象类不再持有指向实现接口的指针,而是直接把实现类型作为模板参数。
#include <iostream> // 两个具体实现,不需要继承任何公共接口 struct VectorImpl { void drawCircle(double x, double y, double r) const { std::cout << "Vector: (" << x << ", " << y << ") r=" << r << std::endl; } }; struct RasterImpl { void drawCircle(double x, double y, double r) const { std::cout << "Raster: (" << x << ", " << y << ") r=" << r << std::endl; } }; // 模板桥接:抽象类持有实现对象作为成员 template <typename Impl> class Shape { protected: Impl impl_; public: Shape() : impl_() {} virtual ~Shape() = default; virtual void draw() const = 0; virtual void resizeBy(double factor) = 0; protected: const Impl& impl() const { return impl_; } Impl& impl() { return impl_; } }; template <typename Impl> class Circle : public Shape<Impl> { double x_, y_, radius_; public: Circle(double x, double y, double r) : Shape<Impl>(), x_(x), y_(y), radius_(r) {} void draw() const override { this->impl().drawCircle(x_, y_, radius_); } void resizeBy(double factor) override { radius_ *= factor; } }; int main() { Circle<VectorImpl> c1(1, 2, 3); Circle<RasterImpl> c2(4, 5, 6); c1.draw(); // Vector c2.draw(); // Raster return 0; }注意关键区别:VectorImpl和RasterImpl没有公共基类,也不需要继承任何接口。Shape<Impl>模板通过impl_成员持有了具体实现对象,编译期就确定了调用目标。c1.draw()实际上是直接调用VectorImpl::drawCircle,没有任何虚函数表查找,还可能被内联。
3.2 运行时多态和编译期多态的取舍
你可能会问:模板桥接这么好,那我们是不是应该全面抛弃经典桥接?别急着下结论,两个变体的核心取舍非常清晰:
| 维度 | 经典指针桥接(运行时多态) | 模板静态桥接(编译期多态) |
|---|---|---|
| 虚函数开销 | 有 | 无,可内联 |
| 实现类型是否在编译期确定 | 否,可以运行时创建/选择 | 是,必须在编译期指定 |
| 代码膨胀 | 无(同一套代码) | 每种Impl组合生成一份实例代码 |
| 二进制接口稳定性 | 稳定,适合跨模块(.so/.dll) | 不稳定,模板在头文件里 |
| 单元测试mock | 容易,注入假实现 | 相对麻烦,也需要模板mock类 |
| 编译速度 | 快 | 慢,头文件里塞了大量模板代码 |
我的经验是:如果桥跨越模块边界(比如DLL动态加载插件),或者你需要在程序运行时决定哪个实现上,那老老实实用经典桥接;如果桥是模块内部实现细节,而且实现类型确定不变,那模板桥接能让你的代码快到极致。在嵌入式或者游戏引擎渲染这种对延迟敏感的地方,后者尤其有优势。
3.3 与CRTP等技巧的结合
模板桥接还有个进阶玩法:结合CRTP(奇异递归模板模式)把抽象层的某些通用行为也提取到基类中。比如:
template <typename Derived, typename Impl> class ShapeBase { protected: Impl impl_; public: void scaleAndDraw(double factor) { static_cast<Derived*>(this)->resizeBy(factor); static_cast<Derived*>(this)->draw(); } }; class MyCircle : public ShapeBase<MyCircle, VectorImpl> { public: void draw() const { impl_.drawCircle(0, 0, 1); } void resizeBy(double f) { /* ... */ } };CRTP的好处是可以在基类里实现依赖于派生类行为的通用方法,而不用额外虚函数。不过要提醒一下:过度使用模板技巧会让代码的可读性显著下降,团队里新人接手容易懵。我个人建议模板桥接如果只是单纯替换虚函数就已经足够,CRTP属于"再甜上加上糖霜"的选项,非必要不轻易上。
4. std::function回调式桥接:把"实现"降维成"行为"
4.1 什么时候桥接的不仅是对象,而是"操作"
经典桥接和模板桥接解决的都是"抽象对象拥有实现对象"的问题。但在实际开发中,你会发现很多场景的桥接粒度没那么大——我们可能只需要某个具体操作被替换,而不需要整个实现对象被替换。
举个例子:我做过一个日志系统,业务层发出"写日志"请求,但不关心底层是写到文件、写到网络、还是写到终端。日志系统内部维护一个Logger抽象类,它有一个log(level, message)方法。但后来需求变了——有的模块希望自己指定日志格式,有的模块希望把日志转发两份。如果继续用实现类的方式,就得给每个模块定制一个Logger子类,类数量再次膨胀。
这时候,把"实现"从"类"降维成"可调用对象",桥接模式就有了一个更轻快的变体——std::function回调注入。
4.2 用std::function替换继承体系
如果用std::function改写上面的绘图示例,代码会更简洁:
#include <functional> #include <iostream> class Shape { protected: // 桥变成一个可调用对象:接受圆心坐标和半径,返回void std::function<void(double, double, double)> drawCallback_; public: explicit Shape(std::function<void(double, double, double)> cb) : drawCallback_(std::move(cb)) {} virtual void draw(double x, double y, double r) { drawCallback_(x, y, r); } }; int main() { Shape vectorShape([](double x, double y, double r) { std::cout << "Vector circle at (" << x << "," << y << ") r=" << r << std::endl; }); Shape rasterShape([](double x, double y, double r) { std::cout << "Raster circle at (" << x << "," << y << ") r=" << r << std::endl; }); vectorShape.draw(1, 2, 3); rasterShape.draw(4, 5, 6); return 0; }看到这里你可能觉得这更像策略模式。没错,当桥接的粒度小到"一个操作"时,策略模式和桥接模式的实现手段会自然趋同。区别在于你在设计文档里怎么定位它:策略模式强调"算法可替换",桥接模式强调"抽象与实现解耦"。从工程角度看,这其实是个光谱而非两个孤岛,std::function的位置就在这个光谱的中间。
std::function变体的最大好处是灵活性极大:
- 你可以传lambda、函数指针、函数对象、成员函数绑定表达式;
- 做单元测试时,mock一个行为只需要一行lambda,不需要定义类;
- 可以在运行时动态替换行为,比如根据配置切换实现。
4.3 捕获列表、生命周期与性能陷阱
这个变体虽然爽,坑也不少,我挨个踩过:
第一,捕获引用导致悬垂。如果你在构造函数里这么做:
SomeObject obj; Shape s([&obj](double x, double y, double r) { obj.draw(x, y, r); }); // 危险! // obj生命周期可能比s短一旦obj被销毁,s持有一个悬垂引用,调用draw时就是未定义行为。我的建议是优先按值捕获shared_ptr,不要捕获裸引用。如果是异步场景,尤其要注意。
第二,std::function对象自身的开销。一个非空std::function通常会做小对象优化,但如果捕获大对象或者捕获很多变量,可能发生堆分配。每次调用还有一次间接跳转。跟虚函数相比,性能量级差不多,不会更优,但灵活性更高。
第三,可拷贝性与赋值。std::function要求可拷贝构造。如果你捕获了只移动对象(比如unique_ptr),那这个lambda无法存入std::function。需要移动语义时,可以考虑改用std::move_only_function(C++23),或者用模板再包一层。
5. std::variant访问者变体:窄化问题域的现代解法
5.1 用std::variant替代继承体系
C++17之后,很多原本靠继承体系表达"有限种类"的场景,都可以用std::variant来改写。和桥接模式有什么关系呢?大有关系。
回到最开始那个类爆炸的绘图例子。矩形、圆形、三角形是有限的图形种类,矢量、光栅是有限的渲染方式。既然种类都有限,我们不一定需要多态继承——可以用std::variant存放图形类型,用另一个std::variant存放渲染方式,然后通过std::visit做"双分派"。
来看代码。这里我用std::variant定义图形和渲染器,再用 visitor 组合遍历完成绘制操作:
#include <iostream> #include <variant> // 图形类型 struct Circle { double x, y, r; }; struct Rectangle { double x, y, w, h; }; using Shape = std::variant<Circle, Rectangle>; // 渲染方式 struct VectorRender { void paint(const Circle& c) const { std::cout << "Vector circle" << std::endl; } void paint(const Rectangle& r) const { std::cout << "Vector rectangle" << std::endl; } }; struct RasterRender { void paint(const Circle& c) const { std::cout << "Raster circle" << std::endl; } void paint(const Rectangle& r) const { std::cout << "Raster rectangle" << std::endl; } }; using Renderer = std::variant<VectorRender, RasterRender>; // 桥:把形状和渲染器匹配起来 struct DrawVisitor { const Renderer& renderer_; explicit DrawVisitor(const Renderer& r) : renderer_(r) {} void operator()(const Circle& c) const { std::visit([&](const auto& r) { r.paint(c); }, renderer_); } void operator()(const Rectangle& rect) const { std::visit([&](const auto& r) { r.paint(rect); }, renderer_); } }; int main() { Shape shape1 = Circle{1.0, 2.0, 3.0}; Shape shape2 = Rectangle{0, 0, 4, 5}; Renderer vector = VectorRender{}; Renderer raster = RasterRender{}; std::visit(DrawVisitor{vector}, shape1); // Vector circle std::visit(DrawVisitor{raster}, shape2); // Raster rectangle return 0; }这个变体消灭了继承体系——Shape不需要一个Shape基类和多个子类,Renderer也不需要。std::visit在编译期对"图形类型×渲染类型"的所有组合进行分派,和std::variant的存储紧密配合,不但没有虚函数开销,还保证了穷尽性检查:如果某天给Shape增加一个Triangle,编译器会强制你更新所有visitor,否则编译报错。这在传统继承体系里是做不到的——虚函数缺实现是运行时才炸,这里编译期就不让你过。
5.2 与模板技巧结合,自动生成笛卡尔积
如果你愿意再进一步,用模板加折叠表达式,可以让 visitor 自动覆盖所有组合,而不必为每个具体类型写重载。下面这段有点烧脑,但很实用:
template <typename... Renderers> struct Overload : Renderers... { using Renderers::operator()...; }; template <typename... Renderers> Overload(Renderers...) -> Overload<Renderers...>; // 绘图:一次性访问两个variant auto draw = [](const Shape& s, const Renderer& r) { std::visit(Overload{ [](const Circle& c, const VectorRender&) { std::cout << "Vector circle" << std::endl; }, [](const Circle& c, const RasterRender&) { std::cout << "Raster circle" << std::endl; }, [](const Rectangle& r, const VectorRender&) { std::cout << "Vector rectangle" << std::endl; }, [](const Rectangle& r, const RasterRender&) { std::cout << "Raster rectangle" << std::endl; }, }, s, r); };这里Overload继承了所有lambda的operator(),从而实现多重重载。每次给Shape或Renderer加类型,编译器会强制你在这个大括号列表里补组合,否则编译错误。对,这就是"编译期穷举保障",在代码库庞大、组合多的时候,这个特性比一堆手工测试更可靠。
5.3 这个变体的边界:可扩展性、代码膨胀与性能
用std::variant做桥接,最大的优点是编译期固定、类型安全、穷尽性检查。但缺点也明显:
- 双向扩展受限。继承体系的优势是"横向扩展容易"——加了新渲染方式不用改已有图形。但
std::variant的visitor需要重构所有组合。如果你预见"实现维度"会频繁增加,那这个变体不是好选择。 - 代码膨胀。每一次
std::visit都可能生成针对每种组合的分派代码。两个variant组合,组合数是乘积。如果形状有10种,渲染方式有10种,100个分支就出来了。编译产物和编译时间都会涨。 - 存储约束。
std::variant的存储大小等于最大成员的大小,如果有成员特别大,整个variant都会变大。如果需要存储std::string、std::vector这种复杂对象,内存占用需要考虑。
我的建议是:std::variant变体最适合"两个维度都比较封闭且规模较小(每个不超过5~6种)、需要更高性能和编译期检查"的场景。比如指令集解释器、事件分发器、渲染器的后端选择这类场景。我最近在做的一个状态机框架里就用它替代了原本的继承式实现,编译时间从30秒降到10秒,运行时开销也小了。
6. 选型决策与踩坑记录:我在项目中实践桥接模式的经验教训
6.1 四个变体怎么选,一张表看明白
我做技术决策时不喜欢凭感觉,下面是基于我这几年实践整理的选型判断表,可以直接抄:
| 场景特征 | 推荐变体 | 理由 |
|---|---|---|
| 需要跨模块(DLL/SO)暴露接口,实现由外部插件提供 | 经典指针桥接 | 拥有稳定的ABI,插件开发者只需要实现接口类 |
| 模块内部实现拆分,实现类型编译期确定,性能敏感 | 模板静态桥接 | 零虚函数开销,可内联 |
| 只需求换某个"行为",不需要整体替换实现对象 | std::function回调注入 | 最灵活,mock成本低 |
| 分支组合数量小,且需要编译期穷尽检查 | std::variant访问者 | 类型安全,无虚函数,编译器帮你保证覆盖 |
| 需要运行时动态切换实现(比如配置文件指定) | 经典指针桥接 / std::function | 只有运行时多态能满足 |
如果你实在拿不准,我的默认选择是经典指针桥接。原因很简单:它最通用、最容易被团队理解、维护成本最低。其他变体属于"特定条件下的优化",需要明确的条件触发才值得引入。
6.2 陷阱一:把桥接模式写成装饰器的"伪桥接"
我踩过的第一个坑是:为了让代码不违反"开闭原则",我在抽象层和实现层之间加了一个转发层,转发层的方法列表和接口一模一样,纯粹是透传——什么都不做,只是加了一层调用跳转。这看起来像桥接,实际上没有任何解耦价值,只会让调用链变长、调试变难。
真正的桥接必须满足一个条件:抽象层的方法和实现层接口的方法不是一一对应的。抽象层的业务方法往往包含一定逻辑(参数校验、状态维护、策略选择),它只是把"最终执行"委托给实现层。如果转发层只是void draw() { impl_->draw(); },那这个"桥"就是多余的。
怎么自查?把实现层彻底抽空,抽象层能否继续编译运行?如果能,说明抽象层自己做了太多事情,桥接可能没有真正把职责分干净。反过来,如果抽象层的方法必须依赖多个实现层方法来共同完成,这才是桥接在起作用的迹象。
6.3 陷阱二:接口设计盲目追求"大而全"
早期我做插件系统时,给插件接口设计了一个十几个方法的抽象类。前期觉得方便——插件作者什么都能干,但后期疯狂打脸:每次接口升级,所有插件都要重新编译,兼容问题一堆,而且很多插件作者只会实现其中三四个方法,其余返回"不支持"。这个现象在桥接模式里特别容易发生,因为抽象层业务逻辑丰富时,开发者不自觉地把所有可能出现的方法都塞进桥接口。
我的经验法则是:桥接接口的每个方法都应该有至少一个"真实且已验证"的实现场景。你想加一个方法,就先用文本记录下谁会调用它、哪个实现会真正去实现它。如果答不上来,那这个方法就应该留到未来真正需要时再加。宁可接口"贫血",也别让接口变成负担。记住一句老话:接口是合同,合同越短越美好。
6.4 陷阱三:生命周期管理边界模糊
我还要再强调一个生命周期问题,因为它在每个变体里都会出现。经典指针桥接里,最常见的问题是抽象层和实现层互相持有引用,形成循环引用(shared_ptr场景),或者抽象层被销毁后,实现层还在被异步任务使用(裸指针悬垂)。
我的两个工程习惯:
第一,一个方向持有所有权,另一个方向只持有弱引用。通常抽象层拥有所有权(持有unique_ptr<Impl>),实现层如果想回调抽象层,只能持有raw pointer或weak_ptr,并使用前检查。
第二,析构函数里显式断开桥。在抽象类的析构函数里做api_.reset()或者callback_ = nullptr,强制切断连接,避免在析构顺序不确定时还发生调用。尤其在多线程环境下,这个习惯能减少大量的偶发崩溃。
6.5 从实战出发的最终建议
说到底,设计模式是"工具箱"而不是"教条"。桥接模式的价值在于提醒我们:抽象和实现是两顶帽子,没必要扣在同一个脑袋上。继承树也好,组合也好,目的都是让变化点局部化、让依赖方向稳定。
我个人这几年最大的感受是:不需要一开始就追求最优的桥接实现,而是先写出能跑的结构,然后随着对需求的理解加深,再逐步重构到更合适的变体。比如临时需求先上std::function快速迭代,稳定后如果发现性能瓶颈再转成经典桥接。反过来,也可以先用经典桥接搭骨架,觉得接口太粗再引入模板细化。关键是,每一种变体你心里有数,知道它解决什么问题、会在哪里埋雷,这样到了项目十字路口,你才不会慌。