先说个真实经历。几个月前我重构内部渲染模块时,面对一份到处是虚函数指针、还混着 dynamic_cast 的“教科书式桥接模式”代码,心里非常复杂。教科书上的桥接模式从来没写过它有什么错,但直接照搬进 C++ 工程里,很快就会发现它扛不住需求变化,也会浪费 C++ 独有的编译期能力和资源管理特性。今天这篇分享,就专门聊聊 C++ 中桥接模式的几种实用变体——基于模板策略的编译期桥接、基于 std::function 的类型擦除桥接、还有基于 Pimpl 惯用法的接口与实现分离桥接。这些变体不是替代品,它们解决的是传统桥接在不同场景下的真实短板。
1. 传统桥接模式在 C++ 中的标准写法与三个痛点
1.1 传统写法:抽象和实现分成两条继承链
先快速回顾一下标准桥接模式在 C++ 里的样子。原书《设计模式》里用的是 Window 和 WindowImp 的例子,至今仍然很有代表性。
抽象部分是一条继承链,比如 Window 派生出 ApplicationWindow、IconWindow;实现部分是一条独立的继承链,比如 WindowImp 派生出 XWindowImp、PMWindowImp。两者通过 Window 内部持有的 WindowImp* 组合起来:
cpp复制// 实现部分的抽象基类
class WindowImp {
public:
virtual void DeviceRect(int x1, int y1, int x2, int y2) = 0;
virtual void DeviceText(const std::string& text, int x, int y) = 0;
virtual ~WindowImp() = default;
};
// 具体实现:X11 平台
class XWindowImp : public WindowImp {
public:
void DeviceRect(int x1, int y1, int x2, int y2) override {
// 调用 X11 的 XDrawRectangle
}
void DeviceText(const std::string& text, int x, int y) override {
// 调用 X11 的 XDrawString
}
};
// 具体实现:PM 平台(OS/2 的 Presentation Manager)
class PMWindowImp : public WindowImp {
public:
void DeviceRect(int x1, int y1, int x2, int y2) override {
// 调用 PM 的 GpiBox 等
}
void DeviceText(const std::string& text, int x, int y) override {
// 调用 PM 的 GpiCharString 等
}
};
// 抽象部分
class Window {
public:
explicit Window(WindowImp* imp) : imp_(imp) {}
virtual void DrawRect(int x1, int y1, int x2, int y2) {
imp_->DeviceRect(x1, y1, x2, y2);
}
virtual void DrawText(const std::string& text, int x, int y) {
imp_->DeviceText(text, x, y);
}
virtual ~Window() = default;
protected:
WindowImp* imp_; // 桥接的关键:抽象持有实现的引用
};
这个设计的核心思想是:抽象部分的接口(Window 的 DrawRect、DrawText)不直接依赖某个平台,而是通过 WindowImp 这个“实现接口”间接调用底层设备原语。平台细节变化时只影响实现部分,窗口逻辑变化时只影响抽象部分,两边可以独立演化。这也是桥接模式和其他结构性模式最大的区别。
1.2 痛点一:虚函数调度成本在热路径上不可忽略
传统桥接跑起来很直观,但它默认把所有跨层调用都设计成了虚函数。虚函数本身没有多贵,可一旦出现在每帧要执行成千上万次的循环里,问题就放大了。
虚函数调用的本质是运行时查 vptr,再跳转到实际函数地址。这个间接跳转对现代 CPU 的分支预测器很不友好,因为它不像普通函数跳转那样有固定目标。内联更是不可能——编译器在编译调用点的时候根本不知道最终会调用哪个 WindowImp 的成员函数。
我在一个粒子系统测试工程里实测过:同样绘制 10 万个矩形,传统桥接版比模板策略版在开启 -O2 后慢了两三倍,主要差距就是虚调用无法内联,导致每个像素操作前都要做一次间接跳转。如果你写的是普通业务代码,这个开销无所谓;但渲染、物理、网络序列化这类热点路径,成本就是真实的帧率差距。
1.3 痛点二:扩展方向一旦猜错,继承体系很快失控
桥接模式最理想的应用场景是“抽象维度”和“实现维度”都在频繁变化。可实际工程里,两个维度的变化频率往往是不均等的。
假如我今天新增一个“全屏窗口”的窗口类别,抽象部分多一个类,没什么问题。可如果我要新增一个“圆角矩形”的绘制能力,按照传统桥接的做法,需要在 WindowImp 里加一个纯虚函数。这意味着所有平台实现——XWindowImp、PMWindowImp,以及未来所有的新平台实现——都必须同步修改。这就是著名的“实现接口污染”问题:抽象层的需求变化反向压到了实现层。
我见过不少项目从这里开始失控的。一开始是平台实现里有几处空实现,后来是抛“未支持”异常,再后来干脆有人绕过 WindowImp,直接写平台特化的 if/else。到这个时候,桥接模式实际上已经名存实亡了。
1.4 痛点三:实现方被强制继承同一个抽象基类
传统桥接对实现方有一个隐性要求:必须继承 WindowImp。这听起来合理,却带来了实际的耦合。
比如第三方引擎要接入你的渲染框架,他们自己已经有一套高效的 Rect 绘制函数。但你的框架要求他写一个 class XXXEngineImp : public WindowImp,老老实实实现这两个纯虚函数。他必须引入你的头文件、接受你的基类布局、跟着你的虚函数签名走。即便他的底层能力能覆盖这些需求,这个过程也很别扭。
如果业务场景是“一个团队专门维护抽象层,多个外部团队各自维护实现层”,这种强制继承的侵入性就会变成协作摩擦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模板策略版桥接:把多态从运行期搬到编译期
2.1 核心思路:用模板参数直接携带实现类型
既然虚函数调度的昂贵之处在于运行期才决定目标地址,那能不能把这个决定过程提前到编译期?C++ 的模板天然适合做这件事。
模板策略版桥接的基本形态是:抽象类不再是普通类,而是一个类模板,模板参数就是实现策略(policy)。策略不通过继承来约束,只要求它提供对应名称的成员函数:
cpp复制// 策略类:X11 实现
struct X11Policy {
static void DeviceRect(int x1, int y1, int x2, int y2) {
// 调用 X11 的 XDrawRectangle
}
static void DeviceText(const std::string& text, int x, int y) {
// 调用 X11 的 XDrawString
}
};
// 策略类:PM 实现
struct PMPolicy {
static void DeviceRect(int x1, int y1, int x2, int y2) {
// 调用 PM 的 GpiBox 等
}
static void DeviceText(const std::string& text, int x, int y) {
// 调用 PM 的 GpiCharString 等
}
};
// 抽象部分:类模板
template <typename Policy>
class Window {
public:
void DrawRect(int x1, int y1, int x2, int y2) {
Policy::DeviceRect(x1, y1, x2, y2);
}
void DrawText(const std::string& text, int x, int y) {
Policy::DeviceText(text, x, y);
}
};
// 使用方式:类型即配置
using X11Window = Window<X11Policy>;
using PMWindow = Window<PMPolicy>;
注意这里我把策略类的成员函数设计成了静态函数。这是策略模板最常见的写法,因为你不需要在 Window 内部存一个策略对象。静态成员函数没有 this 指针,调用方式和普通函数一致,编译器完全有能力内联。
如果你需要状态,也可以让策略类有实例成员,然后在 Window 里按值持有策略对象:
cpp复制template <typename Policy>
class Window {
public:
explicit Window(Policy policy = Policy{}) : policy_(std::move(policy)) {}
void DrawRect(int x1, int y1, int x2, int y2) {
policy_.DeviceRect(x1, y1, x2, y2);
}
private:
Policy policy_;
};
两种写法都成立,取决于策略是否需要维护内部状态。静态策略适合纯平台原语,实例策略适合带配置的渲染上下文。
2.2 零开销到底有多零:实测和内联分析
模板策略版的优势非常直接:没有虚函数表,没有间接跳转,调用点看到的是完整实现代码。开启优化后,Window
我在同样的测试工程里对比过:模板策略版绘制 10 万个矩形的时间几乎是传统桥接版的 1/2 到 1/3。把优化级别开到 -O2 后,反汇编里根本看不出还有 Window 这个抽象层。这个特性对嵌入式、游戏引擎、高频交易的场景是致命的吸引力。
更重要的是,编译器还能在编译期做类型检查。如果你写了一个 Window
2.3 代价分析:灵活性、二进制体积、头文件暴露
模板策略版不是没有代价。最大的代价是灵活性——它把实现集合固定死在了编译期。你写死了 X11Window 就是一个 X11 窗口,运行期再怎么也不能把它替换成 PMWindow。这本质上是用运行时多态换编译期多态,适合“实现集合在编译期明确”的场景,不适合“运行期才决定用哪个实现”的场景。
第二个代价是代码膨胀。每实例化一个 Window
第三个代价比较隐蔽:模板定义几乎必须放在头文件里,否则其他翻译单元无法实例化。这意味着抽象层的完整实现细节暴露给了所有使用方。头文件里布满策略类的具体行为,你做不了二进制层面的信息隐藏。这跟后面要说的 Pimpl 正好相反。
3. std::function 类型擦除版桥接:让实现集合摆脱继承约束
3.1 为什么要用类型擦除来处理桥接
传统桥接要求实现方从同一个抽象基类继承,模板策略则要求实现在编译期可见。如果业务需求是“实现集合在运行期动态变化”,同时“实现方不想继承我们的基类”,这两个方案就都不合适了。
std::function 在这里刚好派上用场。它本身是一种类型擦除机制——把任意可调用对象统一包装成“一个函数签名”。桥接模式关心的是抽象与实现的分离,并不关心实现部分具体长什么样。只要一个可调用对象能满足操作签名,它就可以被塞进 std::function 里,成为实现层的一份子。
有点像一个只约定“你要能画画”的合同,而不是约定“你必须是某个画家的徒弟”。画家本人画也行,AI 画也行,用打印机画也行,只要完成作画行为。
3.2 具体实现:用 std::function 作为抽象的接口协议
看一个实际例子。用桥接模式做一个简单的 UI 按钮控件,需要在不同平台上绘制,同时让按钮逻辑与平台实现解耦:
cpp复制class Button {
public:
// 用一个函数对象指针来桥接“按钮行为”与“平台绘制”
using DrawImpl = std::function<void(const Button&)>;
Button(std::string label, DrawImpl drawImpl)
: label_(std::move(label)), drawImpl_(std::move(drawImpl)) {}
void setDrawImpl(DrawImpl impl) {
drawImpl_ = std::move(impl);
}
void draw() const {
if (drawImpl_) {
drawImpl_(*this);
}
}
const std::string& label() const noexcept { return label_; }
private:
std::string label_;
DrawImpl drawImpl_;
};
// 使用方:注入 X11 平台绘制实现
auto createX11Button(const std::string& label) {
return Button(label, [](const Button& btn) {
// 调用 X11 的绘制函数,从 btn.label() 读取文字
});
}
// 使用方:注入 PM 平台绘制实现
auto createPMButton(const std::string& label) {
return Button(label, [](const Button& btn) {
// 调用 PM 的绘制函数
});
}
这个结构与标准桥接非常接近:抽象部分是 Button,实现部分被抽象成了 DrawImpl 这个 std::function 签名。但实现方没有任何继承约束,不需要引入你的基类头文件,也不需要关心虚函数表。一个普通的 lambda 就能作为实现。
关键变化是:连接抽象与实现的“接口”,从纯虚函数变成了一个可调用签名。std::function 负责把不同类型的可调用对象收敛到同一个类型上,也就是所谓的“类型擦除”。
3.3 运行时装配能力带来的新可能性
std::function 版本真正有优势的地方在运行时装配:
cpp复制Button btn("connect", createX11Button("connect"));
// 用户切到了 PM 平台,运行期直接换实现
btn.setDrawImpl([](const Button& b) {
// 调用 PM 绘制
});
传统桥接虽然也支持运行期换实现(把 WindowImp* 指针换成另一个子类对象),但它要求所有实现都是同一个继承体系的成员。std::function 则完全不关心这个,只要签名匹配,随便谁的 lambda 都能接进来。这在插件系统里非常好用:主程序定义桥接接口,插件开发者甚至不需要链接主程序的头文件,只需要按 ABI 协议导出函数或者提供 lambda,就能接入主程序。
3.4 细节陷阱:SBO、拷贝开销、异常安全
std::function 不是没有代价的,它的内部机制有几个点需要特别注意。
第一是小对象优化(SBO)。std::function 内部通常有一小块缓冲区,如果包装的可调用对象足够小,就直接存在这块缓冲区里,不触发堆分配。比如一个不捕获任何状态的函数指针,或者只捕获一个 int 的 lambda,都可以原地存储。但如果 lambda 捕获了较大的结构体,比如一个有很多成员的对象,std::function 无法把它放进小缓冲区,只能在构造时做一次堆分配。对绘制这种高频调用场景来说,堆分配的代价不是闹着玩的。
务实的做法是:用 std::function 做桥接时,确认你的实现对象是否超过 SBO 阈值(通常是 16 到 32 字节),必要的话用 shared_ptr 捕获大状态,把 lambda 本身控制得很轻量。
第二是拷贝语义。std::function 的拷贝会复制内部包装对象。如果捕获的 lambda 里持有了 shared_ptr 或者大容器,拷贝成本很高。传参和赋值时要注意移动语义。
第三是异常安全。std::function 调用包装函数时,如果被调用的 lambda 抛异常,异常会原样透传出来。在被调用方类型不确定的情况下,接口层面最好约定清晰的异常处理策略。
4. Pimpl 版桥接:接口与实现之间的二进制防火墙
4.1 Pimpl 本质上也是一种桥接变体
Pimpl(Pointer to Implementation)经常被当成一个独立惯用法,相当多的人没意识到它其实就是桥接模式的一种变体。Pimpl 的经典定义是:一个类的公共接口和它的实现细节被拆分到两个不同的编译单元,用户只看到一个轻量外壳,真正的逻辑全在 Impl 类里。
对应到桥接模式的术语:抽象部分是那个公共外壳(Widget),实现部分是 Widget::Impl。两者通过 unique_ptr 桥接起来。这个模式和标准桥接的结构高度相似,只是它的设计目标不是“抽象与实现独立扩展”,而是“接口与实现解耦编译依赖、稳定 ABI”。在 SDK 开发、跨模块协作中,Pimpl 几乎是指定动作。
4.2 完整代码示例:头文件几乎不暴露内部依赖
看一下 Pimpl 的典型写法:
cpp复制// widget.h
#include <memory>
#include <string>
class Widget {
public:
Widget();
~Widget();
// 移动语义
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
// 拷贝语义
Widget(const Widget&);
Widget& operator=(const Widget&);
void setTitle(const std::string& title);
std::string title() const;
void draw();
private:
class Impl; // 前置声明
std::unique_ptr<Impl> pImpl_;
};
cpp复制// widget.cpp
#include "widget.h"
#include <iostream>
class Widget::Impl {
public:
void setTitle(const std::string& title) {
title_ = title;
}
std::string title() const {
return title_;
}
void draw() {
// 真正的绘制实现,可以依赖第三方库、平台 API
}
private:
std::string title_;
// 这里可以放任意实现依赖的对象
};
// 构造函数:创建实现对象
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
// 析构函数:必须在 cpp 中定义,因为 Impl 是不完整类型
Widget::~Widget() = default;
// 移动操作
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = 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;
}
void Widget::setTitle(const std::string& title) {
pImpl_->setTitle(title);
}
std::string Widget::title() const {
return pImpl_->title();
}
void Widget::draw() {
pImpl_->draw();
}
品一品这个设计的价值。widget.h 里没有任何第三方库的头文件,没有平台 API,没有内部数据成员。用户只依赖一个前置声明的 Impl 类,以及在头文件里对 move-only 类型 unique_ptr 的声明。
这样做带来的第一个好处是编译防火墙。你改 Impl 内部任何实现,比如把 std::string title_ 换成 std::wstring,只要不修改 Widget 公共接口,用户代码压根不需要重新编译。对大型项目和高频发布的 SDK 来说,编译时间节省非常可观。
第二个好处是 ABI 稳定。因为 Widget 类的内存布局只有那个 unique_ptr 指针,它的尺寸和布局不随实现变化而变。只要你保持公共接口不变,实现层随便换,动态库里已经导出的符号不需要变更,老版本客户端甚至不用升级。
第三个好处是信息隐藏。你的实现依赖、内部数据结构、平台相关的类型全部关在 widget.cpp 的匿名空间和 Impl 类里,头文件干干净净。这对闭源 SDK 的发布形态非常友好。
4.3 踩坑记录:unique_ptr、析构位置、拷贝语义
在我用 Pimpl 的经验里,代码本身不复杂,坑全在 C++ 的内存语义细节上。
第一个坑:析构函数不能在头文件里默认实现。unique_ptr
第二个坑:拷贝操作必须自定义。unique_ptr 是 move-only,编译器隐式生成的拷贝构造函数会试图拷贝 unique_ptr,直接报错。你需要自己实现深拷贝——也就是 new 一个 Impl(*other.pImpl_)——为的是两个 Widget 对象各自持有独立的实现副本。
第三个坑:移动构造和移动赋值也得显式声明并定义。很多人以为 = default 就够了,但别忘了,一旦你自定义了析构函数,编译器就不会隐式生成移动操作。所以你要么显式声明 Widget(Widget&&) noexcept = default,要么一切都别声明,直接在头文件里写 unique_ptr,否则会遇到莫名其妙的编译失败。
第四个坑:当类经常在小对象容器里被搬来搬去时,Pimpl 的堆分配开销会变得显著。因为每次构造都要 make_unique 一次,拷贝又要 new 一次。这种场景下,还不如直接用值语义的普通类,把实现细节塞进头文件,用编译时间换性能。
4.4 边界情况:什么时候不适合 Pimpl
Pimpl 也不是万能的。如果你的类特别小,比如只有一两个 int 成员,本身就该做成值类型,没必要为了保持 ABI 稳定而多一次堆分配。还有,如果类的公共接口本身非常容易变化,Pimpl 的收益会打折扣,因为每次改接口照样要改头文件,ABI 照样变。
另一个容易被忽视的问题是:Pimpl 会消耗一次额外的指针间接访问。每次访问成员字段都要先解引用 pImpl_,虽然现代 CPU 缓存能吸收大部分代价,但热路径上仍然会有影响。我见过有人把每秒调用百万次的对象写成 Pimpl,最后 profiling 发现 pImpl_ 的指针追踪成了小热点。它不是大问题,但值得留意。
5. 三种变体如何选型:从性能、耦合度、扩展方向对比
5.1 一张表看清各自的取舍
做了这么多细节分析,选型的时候往往需要一张明白的表。我把传统桥接和三种变体放在一起对比:
| 维度 | 传统继承型桥接 | 模板策略型 | std::function 型 | Pimpl 型 |
|---|---|---|---|---|
| 多态调度方式 | 虚函数,运行期动态 | 编译期静态绑定 | 运行期类型擦除 | 一次指针间接访问 |
| 性能开销 | 虚调用+间接跳转 | 无,可内联 | 轻量函数对象调用 | 一次指针解引用 |
| 运行期换实现 | 支持(换指针) | 不支持 | 支持 | 不支持 |
| 对实现的侵入度 | 必须继承抽象基类 | 要求静态接口一致 | 低,无继承约束 | 中等,需实现 Impl |
| 编译/ABI 解耦 | 中 | 低(模板全暴露在头文件) | 低 | 极高 |
| 主要适用场景 | 大型多态体系 | 高性能、实现集合编译期确定 | 插件、回调、动态装配 | SDK 封装、稳定 ABI |
总体来看,这四种方案其实是在“运行期灵活性”和“编译期确定性”之间做光谱排布。传统继承把灵活性放在运行期,模板策略把确定性放在编译期,std::function 兼顾运行期灵活和低侵入,Pimpl 不关心什么时候绑定,它关心的是物理层面能不能解耦。
5.2 我在选型时的三连问
具体到项目里,我一般先问自己三个问题,答案出来后,选型基本就出来了。
第一问:实现集合在编译期是否确定,并且几乎不变?
如果答案是“是”,无脑选模板策略。平台有时候就两种:Windows 和 Linux;协议就一种:内部私有协议。运行期根本不换,何必背上虚函数表的开销。模板策略还能配合 if constexpr 做编译期特性分支,比运行期判断更快更安全。
第二问:运行期是否需要动态切换实现?
需要的话,模板策略出局。此时看实现方对侵入度的容忍程度:如果大家都能接受继承抽象基类,传统桥接花不了多少代码;如果有外部团队参与、又不想强依赖基类,std::function 是更顺利的协作契约。
第三问:是否在做跨进程、跨模块、动态库边界的 SDK?
是的话,优先考虑 Pimpl。它能稳定导出符号、隐藏内部依赖、大幅缩短用户编译时间。Pimpl 内部还可以再组合其他方案,它和模板策略、std::function 并不冲突,反而经常一起出现。
5.3 组合使用的经验和资源生命周期陷阱
近几年我做架构设计,越来越倾向于组合使用而不是二选一。一个比较成熟的分层方式是这样的:对外暴露的公共类用 Pimpl 封装,保证 SDK 的 ABI 稳定;内部的实现里,把策略对象用 std::function 装配,方便运行时动态切;真正高频的核心路径再用模板特化优化。
组合使用时要特别注意资源生命周期。尤其是 Pimpl 和 std::function 组合的时候,std::function 捕获的 lambda 里如果有 shared_ptr 指向外部对象,而这个 lambda 又存在 Pimpl 的 Impl 里,那么 Impl 的析构顺序会决定这些资源何时释放。如果 lambda 捕获了 Widget 自身的裸指针,那基本就是在埋雷——Widget 析构之后 Impl 先析构,lambda 里再访问捕获的裸指针就是悬垂访问。改成捕获 shared_ptr
这类生命周期问题在纯教科书桥接里不常见,但一旦升级到 std::function 变体,就一定会碰到。我的建议是:写 std::function 桥接代码之前,先想清楚这个 function 和它的宿主对象谁先死、谁后死、谁持谁的生命周期。否则你会发现,功能一切正常,但程序偶尔在退出时崩溃,崩溃点还总在析构区域附近。
5.4 从重构角度再补一句
如果你恰好要重构一段按传统桥接写的旧代码,别指望把整个体系一次性翻完。一次全量替换风险极大,编译错误铺天盖地,运行期行为还难以验证。更稳妥的路径是:先挑一两个热点路径,改成模板策略版跑测试,确认性能收益;再把对外 SDK 边界改成 Pimpl,观察编译时间和链接变化;最后如果确实有插件化需求,再把某个模块的桥接接口切到 std::function。小步替换,每步都能验证,后面维护起来心里也有底。
根据我个人经验,桥接模式在 C++ 里的价值远远不止“把抽象和实现分开”这一句话。C++ 的编译期能力、类型擦除能力、资源管理语义,为这个经典模式提供了远比教科书丰富的变化空间。理解这些变体的核心思路和取舍,比背住某个具体写法更有用——毕竟需求一变,今天的最优方案明天可能就变成了负债。当你下次再面对“抽象与实现分离”的问题时,别急着写抽象基类,先想清楚:这些实现对编译期敏感吗?运行期需要变化吗?外部协作方能不能接受你的接口协议?想明白这三个问题,选型自然就出来了。
