设计模式修炼之路 ⑦ 桥接模式——m×n 降为 m+n
继承爆炸的解药、m×n 降为 m+n、在 JDBC 里找它
目录
上一篇适配器模式治好了"接口对不上",结尾留了个尾巴:当两个维度一起膨胀——形状要乘以颜色、品牌要乘以型号——继承图开始爆炸时,该请谁出场?答案就是结构型模式的第二位选手:桥接模式(Bridge Pattern)。适配器是事后买转接头,桥接是事前就想明白:"这事儿明明有两个会变的维度,为什么非用继承焊死在一起?"
一、什么是桥接模式?
先看定义:
将抽象部分与它的实现部分分离,使它们都可以独立地变化。
翻译成人话:当"做什么"和"怎么做"是两个独立变化的维度时,别用继承硬扛,把一个维度组合进另一个维度,让两边各自演化。
生活类比就用画图这件事本身:画图天生有两个维度——画什么(圆、方、三角……)和用什么颜色画(红、绿、蓝……)。没有人会为"红圆、绿方、蓝三角"每种组合专门备一支画笔——圆规、三角板是工具维度,红颜料、绿颜料是颜料维度,分开备货,用时蘸一下就组合。桥接模式干的就是这件事:把两个维度拆成两棵独立的继承树,再用组合把它们连起来。
桥接模式有四个角色:
- Abstraction(抽象化):形状维度的顶层。它知道"画什么",但不亲自动手,把"怎么上色"委托给手里的实现者;
- RefinedAbstraction(扩充抽象化):Abstraction 的子类,Circle、Square——形状维度上的具体成员;
- Implementor(实现者接口):颜色维度的接口,DrawAPI。注意它不实现 Abstraction,它是另一棵独立的树;
- ConcreteImplementor(具体实现者):RedDraw、GreenDraw——颜色维度上的具体成员。
最容易犯的迷糊在这里:定义里的"抽象"和"实现",不是"abstract 父类和它的子类"那种关系,而是两棵平行的树:一棵管"做什么"(形状),一棵管"怎么做"(上色)。Abstraction 持有 Implementor 的引用,这条跨树的组合关系,就是那座"桥"——名字由此而来。
二、桥接模式的优缺点
优点
- 两个维度独立扩展,互不牵连。加三角形只动形状树,加紫色只动颜色树,谁也不碰谁——完美符合开闭原则。
- 类数量从 m×n 降到 m+n。5 种形状 × 8 种颜色,继承写法要 40 个组合类,桥接只要 13 个(5 + 8)。维度越多、成员越多,这笔账越吓人。
- 运行时可切换实现。构造函数传哪支"颜料",图形就长什么色,同一棵形状树配任何颜色树都行。
- 细节对客户端透明。客户端只面对两棵树的抽象层做组合,不用关心具体类怎么配对。
缺点
- 得先看穿"有两个独立维度"。桥接是预防针,得在设计期就识别出变化维度;要是等继承图炸了才重构,成本不小。
- 增加理解成本。"Shape 里怎么攥着个 DrawAPI"——一条跨树引用不如一棵继承树直观,新人第一眼容易懵。
- 维度识别错了全白搭。如果两个维度其实强耦合(比如颜色离开具体形状毫无意义),硬拆反而把简单问题搞复杂。
三、桥接模式的应用场景
桥接模式在 JDK 和主流框架里都有教科书级的案例:
- JDBC:最经典的一例。你调
DriverManager.getConnection()拿到的是java.sql.Connection接口(抽象),底下真干活的,是 MySQL / Oracle / PostgreSQL 各家驱动包里的实现类(实现)。换数据库,改改 JDBC URL 和驱动依赖,业务代码一行不动——"SQL API"和"具体驱动"两个维度各自变化、各自升级,互不拖累。 - AWT:
java.awt.Component(按钮、文本框,抽象树)和ComponentPeer(Windows / X Window / Mac 的本地实现,实现树)。按钮不关心自己跑在哪个操作系统上,渲染的事全权委托给 Peer——Java GUI "一次编写到处运行",桥接立了大功。 - 日常业务里:支付场景——支付方式(微信/支付宝/银联)× 支付渠道(APP/H5/小程序),用继承写就是 WxAppPay、WxH5Pay、AliAppPay……8 个类起步,每加一个维度成员爆炸一轮;桥接拆成"方式树 + 渠道树"立刻清爽。消息推送同理:消息类型 × 发送通道(短信/邮件/站内信/钉钉)。
什么时候该用:需求里一眼能看出两个天生独立、且都会持续增加成员的维度(形状×颜色、方式×渠道、品牌×型号),组合又自由多变——提前用桥接拆树,而不是等继承图爆炸了再补。
四、桥接模式的实现方式
示例代码在 design-pattern 项目的 bridge 包里:
design-pattern/src/main/java
└── bridge // 桥接模式
├── DrawAPI.java // 实现者接口:颜色维度
├── RedDraw.java // 具体实现者:红
├── GreenDraw.java // 具体实现者:绿
├── BlueDraw.java // 具体实现者:蓝(演示"新增只加一个类")
├── Shape.java // 抽象化:形状维度,持有桥
├── Circle.java // 扩充抽象化:圆
├── Square.java // 扩充抽象化:方形
├── BridgeTest.java // 测试入口
└── explosion // 反面教材:继承爆炸
├── RedCircle.java
├── GreenCircle.java
├── RedSquare.java
└── GreenSquare.java
场景:画图软件要支持多种形状 × 多种颜色。先看用继承硬扛的下场,再看桥接怎么救。
1. 反面教材:继承爆炸
最直觉的写法:形状当父类,颜色当子类分支——每种"形状×颜色"组合一个类:
public class RedCircle {
public void draw() {
System.out.println("用 RedCircle 类画了一个红色的圆形");
}
}
GreenCircle、RedSquare、GreenSquare 全部照抄,只是类名和打印文案不同。2×2=4 个类,目前还忍得了,跑起来是这样:
===== 反面教材:继承爆炸 =====
用 RedCircle 类画了一个红色的圆形
用 GreenCircle 类画了一个绿色的圆形
用 RedSquare 类画了一个红色的方形
用 GreenSquare 类画了一个绿色的方形
但数学账不骗人:加一个三角形,4 变 6;再加一个蓝色,6 变 9;真实系统里 10 种形状 × 8 种颜色 = 80 个类,每个类都在复制粘贴大同小异的代码。更要命的是,新增任何一个维度的成员,都要横跨整个继承图补一排类——"一处新增,处处开工",开闭原则碎了一地。
2. 桥接版:两棵树 + 一条组合线
换个思路:形状和颜色明明是两个维度,凭什么让颜色"寄生"在形状的继承树里?把它请出来,单独站成一棵树。
先立实现者接口——颜色维度,只管"怎么画":
// 实现者接口:颜色维度,只管"怎么画"
public interface DrawAPI {
void draw(String shape);
}
再来两个具体实现者:
// 具体实现者:红色画笔
public class RedDraw implements DrawAPI {
@Override
public void draw(String shape) {
System.out.println("用红色画笔" + shape);
}
}
GreenDraw 照抄换个颜色字。BlueDraw 也一样——注意,它是后来"新增蓝色"时只需要新增的一个类,形状树一行没动:
// 具体实现者:蓝色画笔,新增颜色只加这一个类
public class BlueDraw implements DrawAPI {
@Override
public void draw(String shape) {
System.out.println("用蓝色画笔" + shape);
}
}
然后是主角,抽象化——形状维度,只管"画什么"。重点就一行:持有实现者的引用,这条组合关系就是桥:
// 抽象化:形状维度,只管"画什么"
public abstract class Shape {
protected final DrawAPI drawAPI; // 抽象化持有实现者的引用,这条组合关系就是桥
protected Shape(DrawAPI drawAPI) {
this.drawAPI = drawAPI;
}
public abstract void draw();
}
扩充抽象化继承它,画的时候把"上色"委托给手里的画笔:
// 扩充抽象化:圆形
public class Circle extends Shape {
private final double radius;
public Circle(DrawAPI drawAPI, double radius) {
super(drawAPI);
this.radius = radius;
}
@Override
public void draw() {
drawAPI.draw("画了一个半径" + radius + "的圆");
}
}
Square 只是把"半径"换成"边长",其余雷同。最后看测试代码——两个维度像两排积木,自由拼装:
private static void bridge() {
DrawAPI red = new RedDraw();
DrawAPI green = new GreenDraw();
DrawAPI blue = new BlueDraw();
Shape redCircle = new Circle(red, 3);
Shape greenCircle = new Circle(green, 3);
Shape blueCircle = new Circle(blue, 3);
Shape redSquare = new Square(red, 2);
Shape greenSquare = new Square(green, 2);
Shape blueSquare = new Square(blue, 2);
redCircle.draw();
greenCircle.draw();
blueCircle.draw();
redSquare.draw();
greenSquare.draw();
blueSquare.draw();
}
运行结果:
===== 桥接版:两个维度独立变化 =====
用红色画笔画了一个半径3.0的圆
用绿色画笔画了一个半径3.0的圆
用蓝色画笔画了一个半径3.0的圆
用红色画笔画了一个边长2.0的方形
用绿色画笔画了一个边长2.0的方形
用蓝色画笔画了一个边长2.0的方形
同样覆盖全部组合,账本完全不同:
- 反面教材覆盖 4 种组合用了 4 个类;桥接版覆盖 6 种组合,两棵树加起来 2 + 3 = 5 个类;
- 反面教材每加一种颜色,全树横着补一排类;桥接版加蓝色,只加一个 BlueDraw,Shape、Circle、Square 一个字没改;
- 反过来加三角形也一样——新建 Triangle extends Shape 完事,颜色树原地不动。
这正是组合优于继承的第二次大显身手。适配器篇说过它会反复出场:对象适配器用组合替换继承换来灵活,这次桥接用组合替换"多维继承"换来的是整个维度树的自由。继承表达的是"是一种"(圆是一种形状),颜色和形状之间可没有"是一种"关系,只有"搭配使用"关系——用继承表达搭配,从一开始就用错了工具。
五、适配器 vs 桥接:同样是"中间加一层"
桥接和适配器经常被新人搞混,因为乍看都是"两个东西之间加一层"。但仔细对照,差别全是本质性的:
| 适配器模式 | 桥接模式 | |
|---|---|---|
| 解决什么问题 | 接口不兼容 | 多维度组合膨胀 |
| 出场时机 | 事后补救:系统成型后发现对不上 | 事前设计:动工前就识别出变化维度 |
| 中间层的身份 | 转换器:把 A 的接口翻译成 B | 连接线:两棵树之间搭组合关系 |
| 有几棵树 | 一棵(Adaptee 被包进 Adapter) | 两棵(形状树 + 颜色树平行独立) |
| 扩展方向 | 对齐一个既有接口 | 两个维度各自加成员 |
| 类比 | 买转接头 | 提前分柜备货 |
一句话总结:适配器改变接口的"样子",桥接不改变任何接口,只是把"变化"本身分类安置。它俩甚至能配合出场——先用桥接把维度拆开,某天某个具体实现者和新体系接口对不上,再拿适配器救个急。
顺带澄清一个名字误会:上一篇提到的 log4j-over-slf4j 常被叫作"桥接包",但看它的结构——把老框架 API 翻译到新门面,干的是适配器的活儿,只是社区叫顺了口。判断模式看结构意图,别看名字。
写在最后
回顾这一篇:桥接模式把"两个一起膨胀的维度"从一棵继承树拆成两棵独立的树,用一条组合关系当桥,类数量从 m×n 降到 m+n;四个角色里,Abstraction 持有 Implementor 引用的那行代码就是全部精髓;JDBC 的 Connection 与各家驱动、AWT 的 Component 与 Peer,是它在 JDK 里的高光时刻;它和适配器的区别一句话——适配器是事后急救的转接头,桥接是事前预防的分柜设计。
下一篇继续结构型模式,第三位选手,也是一位编外人员:过滤器模式(Filter Pattern)。它不在 GoF 23 人名单里,却是业务代码里的常客:把一堆对象按条件筛来筛去,条件还能像积木一样自由拼接——与、或、非随便组合。列表页的多条件筛选、权限系统的规则判断,背后都是它的影子。