说起来有点意思我第一次理解“桥接模式”这个C设计模式不是在什么设计模式的书里而是因为折腾虚拟机网络。虚拟机里的“桥接模式”就是把虚拟网卡和物理网卡桥接起来让两者都能独立工作、互不干扰。后来一对照C里的Bridge Pattern思路几乎一模一样把抽象部分和实现部分拆开各自变化中间用组合的方式搭一座桥。这篇文章就围绕C中的桥接模式展开讲清楚它解决什么问题、代码怎么写、跟那些长得像的模式怎么区分以及实际项目里有哪些值得留意的坑。适合正在学设计模式的C开发者、做跨平台项目的朋友还有准备面试要过“八股”关的选手。1. 桥接模式到底解决了什么问题1.1 先从“继承爆炸”这个痛点说起不举例子直接讲定义基本上记不住。我习惯用一个图形绘制系统的例子开场。假设你在做一个跨平台的图形编辑器形状有两种圆形、方形绘制引擎也有两种矢量绘制、像素绘制。第一反应是用继承做组合那就要写四个类VectorCircle矢量圆RasterCircle像素圆VectorSquare矢量方RasterSquare像素方现在看着还行但产品经理很快过来说要加第三种形状矩形。你咬着牙再加两个类。紧接着又要加第三种绘制引擎OpenGL引擎。这下一发不可收拾类的数量开始乘法式增长。规律很简单(类的数量 形状种类数 \times 绘制引擎种类数)。2种形状配2种引擎是4个类4种形状配3种引擎就是12个类。真正的项目里形状可能有几十种平台也可能有好几个这个继承树会膨胀到你根本不敢加需求。我见过最夸张的一次维护经历是一个老项目里图形模块有将近两百个类大部分类名格式就是“平台名业务名具体实现”。每次新增一个平台支持就要把全部业务类型复制一遍。后来终于有人受不了了推倒重来用桥接模式把“平台相关”和“业务逻辑”两条线拆开类数量直接从接近两百个降到三十多个。这个案例基本就是桥接模式的标准应用场景当你有两个维度都在独立变化而且后续都有扩展可能时就别再用继承硬凑组合了。1.2 拆开两个维度抽象与实现分离桥接模式的核心思想一句话就能讲完把“抽象部分”和“实现部分”分开让它们都可以独立变化。这里要特别注意“抽象”和“实现”这两个词在桥接模式里的含义和日常理解不太一样。桥接模式里的“抽象”指的是业务层面的概念比如Shape形状、Player播放器这些是面向用户的高层逻辑“实现”指的是具体平台、具体引擎相关的底层代码比如Windows平台绘制、Linux平台绘制。我常用的一个生活化类比是插座和电器。插座接口是稳定的电视、冰箱、吹风机这些电器各不相同但都可以插同一个插座。电器和供电网络的连接是通过插座这个“桥”完成的电器不需要关心电是从哪座发电厂来的供电系统也不需要关心你插的是什么电器。换成C代码结构桥接模式就是设计两个继承体系抽象层的继承树Shape是基类Circle、Square是它的派生类实现层的继承树DrawingAPI是基类VectorAPI、RasterAPI是它的派生类然后Shape里持有一个DrawingAPI的指针或引用。Shape的子类只负责“业务变化”DrawingAPI的子类只负责“平台变化”两边各做各的通过组合关系联系起来。类比刚说的插座Shape是电器DrawingAPI是插座Circle怎么画不重要用什么画也不重要重要的是中间有个稳定的桥。2. C实现桥接模式的关键细节2.1 一个可以直接编译的图形案例说得再多不如跑一段代码。下面这个例子我用纯C写选的是前面说的图形场景完整可编译适合自己动手试一遍。#include iostream #include memory // 实现层基类绘制接口 class DrawingAPI { public: virtual void drawCircle(double x, double y, double radius) 0; virtual void drawSquare(double x, double y, double side) 0; virtual ~DrawingAPI() default; }; // 实现层具体类矢量引擎 class VectorAPI : public DrawingAPI { public: void drawCircle(double x, double y, double radius) override { std::cout [Vector] circle at ( x , y ) r radius std::endl; } void drawSquare(double x, double y, double side) override { std::cout [Vector] square at ( x , y ) side side std::endl; } }; // 实现层具体类像素引擎 class RasterAPI : public DrawingAPI { public: void drawCircle(double x, double y, double radius) override { std::cout [Raster] circle at ( x , y ) r radius std::endl; } void drawSquare(double x, double y, double side) override { std::cout [Raster] square at ( x , y ) side side std::endl; } }; // 抽象层基类所有形状的公共接口 class Shape { protected: std::unique_ptrDrawingAPI api_; public: explicit Shape(std::unique_ptrDrawingAPI api) : api_(std::move(api)) {} virtual void draw() 0; virtual void scale(double factor) 0; virtual ~Shape() default; }; // 抽象层具体类圆形 class Circle : public Shape { private: double x_, y_, r_; public: Circle(double x, double y, double r, std::unique_ptrDrawingAPI api) : Shape(std::move(api)), x_(x), y_(y), r_(r) {} void draw() override { api_-drawCircle(x_, y_, r_); } void scale(double factor) override { r_ * factor; } }; // 抽象层具体类方形 class Square : public Shape { private: double x_, y_, side_; public: Square(double x, double y, double side, std::unique_ptrDrawingAPI api) : Shape(std::move(api)), x_(x), y_(y), side_(side) {} void draw() override { api_-drawSquare(x_, y_, side_); } void scale(double factor) override { side_ * factor; } }; int main() { auto circle std::make_uniqueCircle( 10.0, 20.0, 5.0, std::make_uniqueVectorAPI() ); circle-draw(); circle-scale(1.5); circle-draw(); auto square std::make_uniqueSquare( 0.0, 0.0, 4.0, std::make_uniqueRasterAPI() ); square-draw(); return 0; }这段代码的核心要点在于Circle和Square根本不关心自己用的是Vector还是Raster它们只在自己的draw函数里调用api_指向的接口。反过来VectorAPI和RasterAPI也不知道自己被哪个形状使用只负责“画”。编译命令很简单如果你已经配好了VS Code环境或者用命令行都可以g -stdc14 -o bridge bridge.cpp ./bridge输出结果大致是这样[Vector] circle at (10, 20) r5 [Vector] circle at (10, 20) r7.5 [Raster] square at (0, 0) side4运行起来之后你可以试试手动加一个Triangle类或者加一个GLAPI类会发现完全不需要动另一边已有的代码。这就是两个维度独立扩展带来的好处。2.2 对比JavaC实现桥接模式的独特之处用C实现桥接模式有一个绕不开的话题为什么不直接用interfaceJava和C#里的interface关键字让设计模式实现起来非常清晰接口就是接口实现就是实现。但C没有专门的interface关键字标准做法是用“只含纯虚函数的抽象类”来模拟接口。区别在于C的抽象类可以带数据成员、可以带非纯虚函数、也可以有构造函数和析构函数的定义所以实现桥接模式时的自由度反而更大。比如你可以在DrawingAPI里放一个公共的日志方法这在Java接口里就得靠default方法或者抽象类实现。另外C还有一个独特工具模板。模板可以在编译期完成“桥接”也就是静态多态版本。这一点对高性能系统特别重要因为虚函数调用有vtable查表开销而模板可以直接在编译期确定调用目标。同样是图形场景静态桥接可以写成templatetypename DrawImpl class ShapeT { protected: DrawImpl impl_; public: void drawCircle(double x, double y, double r) { impl_.drawCircle(x, y, r); } }; class FastVectorImpl { public: void drawCircle(double x, double y, double r) { // 编译期确定无虚函数开销 } };但代价是模板方案必须在编译期确定具体实现不能在运行时动态切换。举个实际例子如果播放器软件需要在运行时根据系统版本自动选择硬解还是软解那静态方案做不到必须用虚函数多态来做运行时桥接。C的优势就在于你可以先拿虚函数版本实现动态桥接跑通需求后再用模板把热点路径重构成静态桥接。两种风格不是互斥的而是同一个思路在不同约束下的变体。2.3 桥接模式和PIMPL是一对好搭档说到C特有的实现细节PIMPLPointer to Implementation是桥接模式的一个天然近亲很多人没意识到这点。PIMPL的原理是在类的头文件里只放一个指向内部实现类的指针所有私有数据和函数实现都挪到.cpp文件里的“impl类”中。这样头文件可以完全不暴露实现细节编译依赖和编译时间大幅下降。比如// Player.h class Player { public: Player(); ~Player(); void play(const std::string url); private: class Impl; std::unique_ptrImpl impl_; };PIMPL本质上就是“编译层面的桥接”类的外部接口和内部实现之间只通过一个impl指针打交道。前面桥接模式里Shape持有DrawingAPI和PIMPL里类持有Impl结构上非常相似区别只是PIMPL主要用于隐藏编译依赖而桥接模式主要用于让两个维度独立扩展。实际项目里这两者经常叠加使用。你在桥接模式抽象层的实现类里内部再用PIMPL来隔离平台相关的数据既享受运行时的多态解耦又享受编译期的依赖隔离。我在做跨平台客户端时经常这么干尤其是Windows和Linux差异比较大的代码这种组合拳能把平台脏代码隔离得非常干净。3. 实操过程与核心环节实现3.1 从需求到代码四步设计法拿到一个需求怎么判断该不该用桥接模式以及怎么落地我自己有一套固定的设计流程分享出来。第一步画出变化维度表。把所有可能变化的点列出来按“业务逻辑”和“底层实现”两个维度分类。业务逻辑是用户能感知的、跟业务规则相关的部分比如形状种类、播放器交互模式底层实现是跟平台、引擎、第三方库相关的部分比如图形API是DirectX还是OpenGL解码器是FFmpeg硬解还是软解。第二步判断两个维度是否真的独立。什么叫独立就是“形状”的变化不会导致“绘制引擎”的变化反之亦然。如果两个维度变化会互相牵制比如换一种引擎就必须换一套形状体系那桥接模式不适用老老实实用继承才是对的。第三步定义两侧的接口。先定义实现侧接口它应该尽量贴近底层能力比如DrawCircle、DrawSquare、OpenUrl、DecodeFrame。再定义抽象侧接口它应该面向业务语义比如Draw、Scale、Play、Stop。抽象侧接口的内部实现最终都是通过调用实现侧接口完成的。第四步组装和扩展。客户端代码在创建具体业务对象时同时传入具体实现对象。以后每增加一种新实现只需要新增一个实现类抽象侧不用动每增加一种新业务类型只需要新增一个业务类实现侧不用动。3.2 第二个实战案例跨平台播放器的代码解析图形例子偏教学我再给一个更接近真实项目场景的案例跨平台多媒体播放器。播放器至少有两个稳定变化方向播放器形态普通播放器、精简版播放器、列表循环播放器解码器实现FFmpeg软解、平台硬解、外挂第三方解码库如果用继承硬组合形态数乘以解码器数类数量又炸了。用桥接模式结构就很清晰。#include iostream #include memory #include string // 实现层解码器接口 class MediaDecoder { public: virtual bool open(const std::string url) 0; virtual void play() 0; virtual void stop() 0; virtual ~MediaDecoder() default; }; // 实现层FFmpeg软解解码器 class FFmpegDecoder : public MediaDecoder { public: bool open(const std::string url) override { std::cout [FFmpeg] opening url std::endl; return true; } void play() override { std::cout [FFmpeg] decoding by software std::endl; } void stop() override { std::cout [FFmpeg] stop decoding std::endl; } }; // 实现层平台硬解解码器 class HardwareDecoder : public MediaDecoder { public: bool open(const std::string url) override { std::cout [Hardware] opening url std::endl; return true; } void play() override { std::cout [Hardware] decoding by GPU std::endl; } void stop() override { std::cout [Hardware] stop decoding std::endl; } }; // 抽象层播放器接口 class Player { protected: std::unique_ptrMediaDecoder decoder_; public: explicit Player(std::unique_ptrMediaDecoder decoder) : decoder_(std::move(decoder)) {} virtual void play(const std::string url) { if (decoder_-open(url)) { decoder_-play(); } } virtual void stop() { decoder_-stop(); } virtual ~Player() default; }; // 抽象层列表循环播放器 class LoopPlayer : public Player { public: explicit LoopPlayer(std::unique_ptrMediaDecoder decoder) : Player(std::move(decoder)) {} void play(const std::string url) override { for (int i 0; i 3; i) { decoder_-open(url); decoder_-play(); } } }; int main() { // 软解普通播放器 Player normal_ffmpeg(std::make_uniqueFFmpegDecoder()); normal_ffmpeg.play(http://example.com/a.mp4); normal_ffmpeg.stop(); // 硬解循环播放器 LoopPlayer loop_hardware(std::make_uniqueHardwareDecoder()); loop_hardware.play(http://example.com/b.mp4); return 0; }这个例子比图形例子更贴近一个真实系统。注意看业务侧的LoopPlayer完全不关心解码器是软解还是硬解它只是在业务层面描述了“循环播放”这个行为。将来加一个“高清播放器”或者“倍速播放器”解码器侧完全不需要动反过来将来加一个“华为海思解码器”播放器侧也完全不用动。3.3 组装技巧如何从配置动态选择实现实际项目不会像例子这样在main里手动new具体类通常会结合工厂模式完成组装。比如播放器启动时读取配置文件或者系统信息决定创建哪个解码器std::unique_ptrMediaDecoder createDecoder() { // 读取系统支持的硬解能力这里简化 #ifdef __arm__ return std::make_uniqueHardwareDecoder(); #else return std::make_uniqueFFmpegDecoder(); #endif } int main() { LoopPlayer player(createDecoder()); player.play(http://example.com/c.mp4); return 0; }桥接模式经常和工厂模式搭配使用。桥接负责解耦工厂负责创建配合起来非常顺畅。抽象层的Player不需要知道decoder是哪个类只需要用交到手里的decoder做事情至于这个decoder是硬解还是软解由工厂在运行时决定。如果以后想做一个“解码器热切换”比如在软解卡顿的时候自动切到硬解也可以在Player内部留一个替换接口这里就不再展开了。思路是通的把decoder_换掉就行唯一要注意的是多线程环境下替换实现时的安全性。4. 桥接模式与易混淆模式的辨别4.1 桥接和适配器差在“设计时机”桥接模式最容易和适配器模式混淆。我见过不少人在面试的时候被问到两者的区别回答得支支吾吾。关键区别在于意图和时机。适配器模式解决的是“已有接口不兼容”的问题属于事后补救。比如老项目里有一个第三方库的接口长成A你的系统期望接口B你不能改第三方库的代码所以写一个适配器类把A转换成B。两者的核心目的都是“让不同接口配合工作”但适配器面对的是存量代码目标是把不兼容的接口翻译成兼容的接口。桥接模式解决的是“提前把两个变化维度拆开”的问题属于事前设计。你从一开始就不打算把业务逻辑和底层实现绑死它们本来就是两条独立的线。再举个例子。你现在有VectorAPI它的接口是drawCircle(double x, double y, double r)系统里预期的是drawEllipse(double cx, double cy, double rx, double ry)你写个包装类转换参数这是适配器。你从一开始就规定好DrawingAPI接口让VectorAPI和RasterAPI都实现这个接口Shape只依赖DrawingAPI这是桥接。看代码结构两者确实有点像都是持有另一个对象的引用。判断标准就是问一句这个接口是设计之初就规划好的稳定接口还是为了兼容已有代码而临时转换出来的接口前者是桥接后者是适配器。4.2 桥接和策略差在“解耦对象”策略模式也容易搞混。策略模式的核心是把一组可互换的算法抽取出来让使用方动态选择算法。比如一个压缩工具支持Zip算法和Gzip算法运行时可切换。这看起来和桥接很像“抽象的压缩器”持有“具体的压缩算法”指针调用时动态分发。这两个模式的结构图几乎长得一样区别主要看意图策略模式关注的是一段具体算法的封装和替换比如排序策略、压缩策略、加密策略桥接模式关注的是让两个维度可以独立演化业务抽象和底层实现彻底解耦我习惯这样判断如果变化的东西是“某个相同目标的算法实现”比如排序目标是一样但快排和冒泡算法不同这是策略模式如果变化的东西是“另一个独立维度”比如播放器的业务形态和解码器的底层平台各有各的变化规律这是桥接模式。有争议的地方在于有些场景用哪个都能自圆其说代码结构几乎一样。这时候别纠结名字重点在于你有没有把两个变化源真正解耦开既有的业务逻辑不会因为底层实现变化而改动。4.3 模板与虚函数静态桥接和动态桥接的取舍前面提过C里除了虚函数实现的动态桥接还可以用模板实现静态桥接。很多C开发者习惯一个模式走天下其实两个都可以用关键看运行时需求。动态桥接虚函数多态的好处是可以在运行时切换实现、配置驱动装配代码读起来直观。代价是每次虚函数调用多一次间接跳转编译器很难内联优化。静态桥接模板多态的好处是没有任何运行时开销编译器可以内联所有调用性能更好。代价是实现在编译期就确定了无法运行时灵活切换。而且模板实例化会生成多份代码造成代码体积膨胀。如果模板参数多编译时间也会变长。我在做高性能C服务时经常把热点路径改成静态桥接把非热点路径保留动态桥接。比如同一个数据结构在处理高频请求时直接指定一种实现拒绝“万能动态切换”因为动态切换一次的成本虽然不高但乘以每秒百万次调用就非常可观了。这也就是“高并发C”和“高性能C”里常说的区别高并发关注的是调度和扩展高性能关注的是单次路径上的零成本抽象桥接模式两种形态正好对应这两种诉求。5. 常见问题与排查技巧实录5.1 桥接模式容易踩的四个坑第一个坑忘记虚析构函数。这是C继承体系里最经典的问题。基类Shape如果析构函数不是虚函数那通过Shape*删除具体的Circle对象时只会调用Shape::~Shape()派生类的资源不会释放。解决方法很简单基类析构函数写virtual ~Shape() default;。这个坑踩一次能让你排查半天的内存泄漏记牢。第二个坑构造函数里调用虚函数。很多人下意识觉得在基类构造函数里调用虚函数会触发动态绑定其实不会。在构造函数执行期间对象的动态类型是基类所以调用到的是基类版本不是派生类版本。如果你试图在Shape构造函数里调用draw()做初始化结果会让你困惑。正确的做法是不要在构造函数里调用虚函数需要初始化逻辑就在派生类构造函数里做或者用工厂方法。第三个坑过度使用桥接模式导致类数量不减反增。桥接模式把N组类变成两三组类前提是确实存在两个独立的变化维度。如果只有一个变化源硬拆两层只会增加抽象层级和无谓的间接层。判断标准我前面给过画变化维度表如果维度之间是耦合的别硬拆。第四个坑所有权管理不清晰。Shape里持有的DrawingAPI指针到底谁是所有者Shape负责delete还是外部负责这个不定义清楚迟早出现悬垂指针或者双重释放。我在示例代码里用了std::unique_ptr所有权明确归Shape。如果采用外部持有时建议用引用或者shared_ptr让语义更清晰。很多旧代码用裸指针加注释约定结果现实就是注释没人看出了事故才追悔莫及。5.2 一个判断口诀三个问题决定用不用我在评审代码时遇到拿不准要不要用桥接模式的情况会快速问自己三个问题。一这里是否存在两个独立变化的方向如果答案是“只有业务逻辑会变底层引擎基本稳定”那不需要桥接普通接口抽象就够了。二这两个方向未来会不会出现大量组合如果预计形状会到二十种平台会到五种那必须拆开不拆就是一百个类在维护。三底层实现的变化用户能不能感知如果业务层根本不会受影响甚至不需要关心底层换没换那么桥接是有意义的因为它在业务层和底层之间建了一个缓冲区。如果底层一换业务逻辑必然跟着改那就不是简单桥接能解决的得考虑是否该调整抽象边界。这三个问题问完基本就能做出判断。我在GESP和项目评审里给初学者讲设计模式时也常用这套方法比死记定义有用得多。5.3 面试考点手写桥接模式的破题技巧桥接模式在C面试题里出现频率很高常作为“设计模式八股”之一来考察。常见的考法有三种。第一种直接让手写一个桥接模式例子。破题最快的思路就是构建“两个变化维度”第一维度是业务类比如Shape或者Message第二维度是实现类比如DrawingAPI或者Sender。然后画出两层继承关系业务类持有实现类指针调用实现类接口。代码结构稳定写一阵就行。第二种给出一个继承爆炸的场景让你优化。这时候要先指出痛点比如类数量是M×N再加一个维度就是M×N×K增长不可控。然后说明用桥接模式拆分为MN再补充一句“两个维度独立扩展互不影响”最后给出简化后的结构。第三种让讲与适配器模式、策略模式的区别。练习时用表格整理几个关键区分点会更清楚对比维度桥接模式适配器模式策略模式设计时机事前设计事后补救事前设计核心意图拆分两个变化的维度兼容不匹配的接口替换一组算法结构特征抽象持实现引用包装旧接口持有算法接口典型场景形状与绘制引擎独立变化第三方库适配压缩算法切换面试时如果能从“意图”而不是“结构相似性”切入考官一般会认可你有项目经验不是只会背模式图。6. 我的实践体会与最后建议用了这么多年桥接模式我最深的体会是设计模式的价值不在类图好看而在替未来的自己减少返工。代码结构是给机器执行的但更是给人看的。你写的每一个类、每一个抽象层最后都会变成别人或者三个月后的你维护的对象。桥接模式之所以值得用就是因为它能明确区分“业务上稳定的东西”和“技术上会变的东西”让这两类代码各自安分守己不会纠缠在一起。如果你正在从零学习C设计模式我建议不要急着背模式图先找一个自己项目里最头疼的“类膨胀”场景试着用桥接模式重构一遍。纸上得来终觉浅只有亲自把两个维度分开、再看到代码确实变清爽了才真正算是掌握了这个模式。最后分享一个实操小技巧写桥接模式代码时先在注释里写清楚两个接口各自的职责边界再动手写类。我见过很多走样的桥接代码问题都出在抽象层和实现层的边界模糊实现层里混着业务逻辑或者抽象层直接操作了底层细节。一个清晰的分界线比任何模式图都管用。
阅读完成 · 觉得有帮助?