我第一次被桥接模式“教育”。 是在一个看起来很体面的项目里。
那项目不缺人。 也不缺架构图。
缺的只有一件事。
它要跑在三套系统上。 还要支持两套渲染后端。
而我们当时的写法。 很“正统”。
用继承。
你也知道。 C++ 工程师最擅长的传统艺能。
先写一棵类树。
再长出另一棵类树。
然后用 #ifdef 给它浇水。
两个月后。 你会收获一片叫不出名字的森林。
那天我在代码里看到一个类名。 长得像一串咒语。
OpenGLCircle。
我当时还没意识到。 这不是一个类。
这是一个信号弹。
它在告诉你。
“你的系统里。 变化的维度。 已经不止一个了。”
1. 先讲点历史:1994 年那本书,其实是在给大家擦屁股
九十年代的 C++ 项目。 跟今天不一样。
库少。 工具链慢。 跨平台是硬伤。
你写个 UI。 在 Windows 上是 Win32。 在 Unix 上是 X11。 再后来是 Motif。
你想用同一套业务逻辑。 跑在不同平台。
怎么办。
最自然的办法是继承。
上层抽一个“窗口”。 底下分别写 WindowsWindow。 X11Window。
这就够了吗。
一开始够。
直到有一天。 你又多了一个维度。
比如“窗口类型”。 对话框。 主窗口。 弹窗。
再比如“渲染后端”。 OpenGL。 Vulkan。 软件渲染。
这个时候你会发现。 继承开始长獠牙了。
GoF 那帮人。 1994 年把这些“工程现场的痛苦”。 整理成模式。
Bridge 这一章。 很像是在对你说:
别再用一棵树去扛两个维度。 树会爆。
2. 继承爆炸:它不是理论,是你迟早会遇到的现实
我们先把问题压缩成一个很小的例子。
你要画图形。
图形有类型。 圆。 矩形。
图形还要能画到不同后端。 OpenGL。 Vulkan。
如果你只用继承。 很容易写成这样。
struct Shape {
virtual ~Shape() = default;
virtual void draw() = 0;
};
然后你开始长子类。
struct OpenGLCircle : Shape {
void draw() override;
};
struct VulkanCircle : Shape {
void draw() override;
};
圆先写完。 看起来挺顺。
矩形也来。
struct OpenGLRect : Shape {
void draw() override;
};
struct VulkanRect : Shape {
void draw() override;
};
到这里你会觉得。 也没多糟。
但你脑子里应该开始响警报。
因为你已经在写:
“形状 × 后端”。
两维度相乘。 类名就开始膨胀。
后面还会有什么。
线型。 填充。 抗锯齿。
再来一个 Metal。 再来一个 DirectX。
你不需要学过组合数学。 也能猜到结局。
你会把时间花在两件事上。
起名。 和复制粘贴。
3. 桥接的核心:承认你有“两棵树”,然后让它们握手
桥接模式其实很直白。
你别把两个维度焊死。
抽象是一棵树。 实现是另一棵树。
两边用组合连起来。
你想像一下。
你写 Circle 的时候。
它不应该知道自己是 OpenGL 画的。
还是 Vulkan 画的。
它应该只知道。
“有人会画圆。”
先把“会画圆的人”抽出来。
struct Renderer {
virtual ~Renderer() = default;
virtual void drawCircle(float x, float y, float r) = 0;
};
OpenGL 版。
struct OpenGLRenderer : Renderer {
void drawCircle(float x, float y, float r) override;
};
Vulkan 版。
struct VulkanRenderer : Renderer {
void drawCircle(float x, float y, float r) override;
};
然后轮到“抽象”那棵树。
Shape 不再负责“怎么画”。
它只负责“画什么”。
class Shape {
public:
explicit Shape(std::shared_ptr<Renderer> r)
: r_(std::move(r)) {}
virtual ~Shape() = default;
virtual void draw() = 0;
protected:
std::shared_ptr<Renderer> r_;
};
圆形只关心自己的参数。 然后把“怎么画”交出去。
class Circle final : public Shape {
public:
Circle(std::shared_ptr<Renderer> r, float x, float y, float rad)
: Shape(std::move(r)), x_(x), y_(y), rad_(rad) {}
void draw() override {
r_->drawCircle(x_, y_, rad_);
}
private:
float x_{};
float y_{};
float rad_{};
};
你看到了吗。
Circle 的代码里。
没有 OpenGL。
也没有 Vulkan。
它甚至不需要知道“后端”这个词。
它只知道自己拿着一个 Renderer。
这就是桥。
桥的两端。 互相不再掐脖子。
4. 运行时握手:选择后端这件事,终于不需要改类名了
桥接的一个很现实的好处。
你可以在运行时切换实现。
比如启动参数决定用哪个后端。
std::shared_ptr<Renderer> r;
if (useVulkan) {
r = std::make_shared<VulkanRenderer>();
} else {
r = std::make_shared<OpenGLRenderer>();
}
Circle c{r, 10.f, 20.f, 5.f};
c.draw();
这段代码很“普通”。
但它不普通。
它意味着。
你不需要再造 VulkanCircle。
也不需要再造 OpenGLCircle。
你把“后端”变成了一个对象。
而不是类名的一部分。
老项目里最值钱的进步。 往往就发生在这种小地方。
从“编译期绑死”。 变成“运行时可插拔”。
5. 这模式像不像 pImpl?像。
很多人第一次看 Bridge。 会说一句。
“这不就是组合吗?”
是。
桥接就是“非常有目的的组合”。
它还有一个亲戚。 C++ 程序员天天用。
pImpl。
class Widget {
public:
Widget();
~Widget();
void paint();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
pImpl 的心态也很像。
外面这层想稳定。
里面那层想随便改。
于是中间放一座桥。
只是 pImpl 通常不需要“多态实现树”。
它更关心 ABI。
更关心编译依赖。
而 GoF Bridge 更像是。
你真的有两棵会长的树。 你得让它们长期和平共处。
6. 你什么时候真的需要 Bridge
如果你现在的系统。 只有一个变化维度。
比如。 只有“形状”。 没有“后端”。
那你别急着上桥。
桥不是免费的。 它带来一次间接调用。 带来一次对象关系。 也带来更多抽象。
但当你清清楚楚地看到。
“抽象”和“实现”在各自变。 而且它们互相独立。
这时候 Bridge 就很划算。
因为它帮你避免的。 不是几行代码。
是未来几年。
类名爆炸。 复制粘贴。 和那种“改一个地方牵一串”的恐惧。
我自己判断 Bridge 的方式很土。
我会盯着类名。
当你的类名开始长成:
SomethingOpenGL
SomethingVulkan
SomethingMetal
而且每个 Something 下面。
还要再分一堆子类。
那多半就是桥要来了。
不是因为书上这么写。
是因为你已经在用类名。 替系统表达“两个维度”。
这表达方式。 太贵了。
7. 收个尾:桥接模式的真正价值,是让变化有“各自的地盘”
Bridge 这个名字。 很形象。
桥两边的人。 不用住在同一间屋子里。
抽象那边。 继续用业务语言长。
实现那边。 继续用平台语言折腾。
它们只在桥上握手。
然后各回各家。
这才是工程里最难得的秩序。
把变化拆开。 不是为了优雅。 是为了让团队能长期合作。
你下次再看到 OpenGLCircle 这种类名。
别急着骂同事。
那可能是他在用类名喊救命。