少女祈祷中...
残云的学习笔记

桥接模式:把两个独立变化的维度拆开,以及它和策略模式到底差在哪

桥接不包层、也不动接口,而是把一棵继承树剪成两棵,让「抽象」和「实现」各自独立地变化。它也是结构型里最容易「以为懂了、其实用错了」的一个:结构几乎和策略模式一模一样,意图却完全不同。本文讲清四个角色与那条「桥」到底桥在哪、判断两个维度是否真正交的 3 条自检、桥接的三条边界,拆开 JDBC 这个教科书级案例,最后把桥接、策略、适配器三者的界限一次讲透。

一、问题的起点:两个维度一起变,继承会「乘积级」爆炸

先看一个再普通不过的场景:消息通知。

  • 消息有几种类型:普通消息、加急消息、定时消息;
  • 消息有几种发送渠道:站内信、短信、邮件。

这是两个各自独立的方向:产品会加新类型(比如「审批提醒」),运维会加新渠道(比如「企业微信」)。如果直接用继承把它们揉在一起:

class NormalSmsMessage    extends Message { }  // 普通 + 短信
class NormalEmailMessage  extends Message { }  // 普通 + 邮件
class NormalInAppMessage  extends Message { }  // 普通 + 站内信
class UrgentSmsMessage    extends Message { }  // 加急 + 短信
// ... 还有 5 个,一共 3 × 3 = 9 个子类

类数 = 类型数 × 渠道数。这个乘法是问题的核心:

变更继承方案桥接方案
初始 3 类型 × 3 渠道9 个类3 + 3 = 6 个类
新增 1 个渠道+3 个类(每种类型各加一个子类)+1 个类,不动已有代码
新增 1 个类型+3 个类+1 个类
变更波及面改一个渠道要动 3 处只动 1 处

所以桥接要解决的问题不是「少写几个类」,而是修改的波及面:继承方案里,加一个渠道会横向牵动所有类型;桥接方案里,两个维度各自生长、互不打扰。这就是开闭原则在结构层面的落地。

bridge-1-class-explosion

二、结构:四个角色,一条「桥」

桥接的结构图里会出现两棵继承树,中间用一条组合关系连起来——那条组合关系就是「桥」:

bridge-2-structure

  • Abstraction(抽象化):Message。定义抽象层的接口,并持有一个 Implementor 的引用——这个字段就是桥。它不再自己动手干活,而是把活交给桥对面的实现。
  • RefinedAbstraction(修正抽象化):NormalMessage、UrgentMessage。在抽象层扩展(加急 = 前缀 + 连发三次,这就是类型维度的差异)。
  • Implementor(实现化):MessageSender。实现部分的接口,只管「怎么发出去」。
  • ConcreteImplementor(具体实现化):SmsSender、EmailSender。具体渠道的落地。

关于「桥」这个名字:这是因为抽象层不自己干活,而是把活交给实现层——中间那条「持有一个实现」的组合关系,就是让两边独立生长的桥。桥这头是「消息是什么类型」,桥那头是「消息怎么发出去」;两边可以各自施工、各自加宽,而不用把整座桥拆了重铺。

两个容易忽略的点:

  1. Implementor 的接口通常比 Abstraction 的接口更「底层」。Message.send(target) 是业务语言,MessageSender.send(target, content) 是通道语言;抽象层负责把业务语言翻译成通道语言。这一点是它和策略模式最微妙的差别。
  2. 抽象侧的继承和实现侧的继承是独立的两棵树,桥只负责把它们连起来。所以两类变更各自局部化:加类型改左树,加渠道改右树。

三、手写一条完整的桥

下面是可直接运行的单文件版本(Java 8+):

/** Implementor:实现部分的接口——只回答「怎么发」,不关心「发什么类型的消息」 */
interface MessageSender {
    void send(String target, String content);
}

class SmsSender implements MessageSender {
    @Override
    public void send(String target, String content) {
        System.out.println("[短信 → " + target + "] " + content);
    }
}

class EmailSender implements MessageSender {
    @Override
    public void send(String target, String content) {
        System.out.println("[邮件 → " + target + "] " + content);
    }
}

/** Abstraction:抽象部分——持有实现部分的引用,这就是「桥」 */
abstract class Message {
    protected final MessageSender sender;        // 桥

    protected Message(MessageSender sender) {
        this.sender = sender;
    }

    /** 抽象层定义「发消息」这件事,具体怎么发交给桥对面的实现 */
    public abstract void send(String target);
}

/** RefinedAbstraction:修正抽象化——在抽象层做扩展 */
class NormalMessage extends Message {
    NormalMessage(MessageSender sender) {
        super(sender);
    }

    @Override
    public void send(String target) {
        sender.send(target, "您有一条新消息");
    }
}

/** RefinedAbstraction:加急消息 = 加前缀 + 连发三次 */
class UrgentMessage extends Message {
    UrgentMessage(MessageSender sender) {
        super(sender);
    }

    @Override
    public void send(String target) {
        for (int i = 1; i <= 3; i++) {
            sender.send(target, "【加急】请立即处理(第 " + i + " 次)");
        }
    }
}

public class BridgeDemo {
    public static void main(String[] args) {
        // 两个维度自由组合:2 种类型 × 2 种渠道,只用了 4 个具体类
        Message m1 = new NormalMessage(new SmsSender());
        Message m2 = new UrgentMessage(new EmailSender());
        // 渠道还能在运行时替换(用 lambda 临时实现一个渠道),抽象层代码一行不用改
        // 注意:lambda 的参数必须与 MessageSender.send(String target, String content) 对齐
        Message m3 = new NormalMessage(
                (target, content) -> System.out.println("[内网 → " + target + "] " + content));

        m1.send("138****8000");
        m2.send("ops@example.com");
        m3.send("user-42");
    }
}

输出:

[短信 → 138****8000] 您有一条新消息
[邮件 → ops@example.com] 【加急】请立即处理(第 1 次)
[邮件 → ops@example.com] 【加急】请立即处理(第 2 次)
[邮件 → ops@example.com] 【加急】请立即处理(第 3 次)
[内网 → user-42] 您有一条新消息

三点值得注意:

  1. MessageSender 是函数式接口(唯一抽象方法 send(String target, String content)),所以最后一个例子能直接写 lambda 临时实现一个渠道——但要注意 lambda 的参数必须与这个方法对齐:写成 (target, content) -> ... 才对,只写一个 target -> ... 是编译不过的。这个技巧在写测试和 mock 时非常好用。
  2. 加急的「连发三次」写在抽象层(UrgentMessage),而「怎么发出去」写在实现层(SmsSender)——职责没有交叉,这正是桥接的收益。
  3. m3 把渠道换成了 lambda,Message 的代码一个字都没改。桥接让「换实现」退化成「注入不同对象」。

一次 UrgentMessage.send() 的调用,实际是这样过桥的:

bridge-3-sequence

抽象层负责业务语义(加急该怎么办),实现层负责通道细节(短信怎么发)。任何一侧改动,都不会顺着桥传到对岸——这就是「解耦」在代码里的具体样子。

四、桥接最重要的语义:正交

桥接成立的前提只有一个词:正交——两个维度可以自由组合、互不约束。落地前先用这 3 条自检:

#自检问题不通过意味着什么
1两个维度是否都会独立变化?只有一个会变 → 你要的是策略模式,不是桥接
2是否任意组合都成立?某些组合天生无意义(如「定时 × 站内信不支持」)→ 维度间有约束,拆开后这份约束就没人管了
3抽象层的方法是否不需要知道实现细节?需要 → 两个维度没真正解耦,桥是假的

第 2 条最容易被忽略。举几个对比:

  • 成立:消息类型 × 发送渠道——每一种组合都有意义;
  • 成立:报表数据源(订单/用户/财务)× 导出格式(Excel/CSV/PDF);
  • 不成立:支付方式 × 业务场景——「货到付款」天然不支持跨境场景,硬拆就得靠一堆 if 把约束塞回来。

还有一点反直觉但很实用:桥接让「换实现」变成注入。

// 构造注入(final):对象生命周期内实现不变,最稳,优先选它
class NormalMessage extends Message {
    NormalMessage(MessageSender sender) { super(sender); }
}

// Setter 注入:运行时可换渠道,灵活,但引入了可变状态
//(用这个变体时,基类 Message 里的 sender 要去掉 final)
class MutableMessage extends Message {
    MutableMessage(MessageSender sender) { super(sender); }

    public void setSender(MessageSender sender) {
        this.sender = sender;
    }
}

可变性也是一种设计决策:不需要运行时切换,就用构造注入(final 字段),别默认给出 Setter。

正交带来的收益,在「需求持续加码」时才看得最清楚——两个维度各自生长,互不影响:

bridge-4-orthogonal

新增「定时消息」只动左树(+1 个类),新增「企业微信」只动右树(+1 个类);而且两侧新增的类都能和对面已有的任意类型自由配对,不需要再补 M 个子类。这才是桥接真正的收益。

五、桥接的三条边界

  1. 只有一个维度在变 → 不要搭桥。变化的是「算法」就用策略,桥接会多出一层没必要的间接。
  2. 两个维度之间有强约束 → 不要硬拆。拆开之后约束无处安放,最终会以散落的 if 形式回到代码里,比不拆更糟。
  3. 维度还没定型 → 先别搭桥。桥接是把「变化点」显式建模成两棵树;如果连哪两个维度会变都还没想清楚,搭出来的桥大概率是要废弃的。桥接是事前设计,不是事后补救。

六、JDBC 就是桥接(教科书级案例)

Java 里最经典的桥接不是书上的例子,而是你每天都在用的 JDBC:

  • 抽象部分:java.sql 那一套——Connection、Statement、ResultSet;
  • 实现部分:各数据库厂商的驱动(MySQL 的 com.mysql.cj.jdbc.*、PostgreSQL 的 org.postgresql.*);
  • 桥:DriverManager.getConnection() 把请求转给已注册的具体 Driver 实现。

好处非常直观:业务代码只依赖 java.sql。从 MySQL 换到 PostgreSQL,改动是「换一个 jar 包 + 改连接串」,业务代码一行不动——两个维度(SQL 使用方式 / 数据库厂商)各自演化,互不牵动。

bridge-5-jdbc

(上图为简化示意:真实 JDK 中 DriverManager 通过注册的 Driver 实例拿到连接,厂商的 Connection 实现由驱动 jar 提供。)

// 业务代码只依赖 java.sql ——从 MySQL 换到 PostgreSQL,这一整段都不用动
try (Connection conn = DriverManager.getConnection(url, user, pwd);
     PreparedStatement ps = conn.prepareStatement("select * from dorm_room where id = ?")) {
    ps.setLong(1, 101L);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            System.out.println(rs.getString("room_no"));
        }
    }
}

其他真实例子:

  • AWT 的 Peer 架构:java.awt.Component(抽象层)与 java.awt.peer.ComponentPeer(实现层,各平台原生组件)分离,早期 Java 的跨平台 GUI 就靠这座桥。
  • SLF4J 与具体日志实现:slf4j-api(抽象)× logback / log4j2(实现),应用只依赖 API,换日志实现只换绑定。严格说它更常被称为「门面 + 绑定」,但它确实让两个维度独立演化——这正是桥接的思路。

七、桥接 vs 策略 vs 适配器

这三个(加装饰者)是结构型里最常被搞混的一组。它们的结构都是「组合」,差别在意图和演化方向:

桥接 Bridge策略 Strategy适配器 Adapter
意图分离两个独立变化的维度替换算法/行为转换不匹配的接口
继承树数量两棵(抽象侧 + 实现侧)一棵(策略实现)一棵(被适配者的体系)
抽象层会不会继续长出子类会(NormalMessage/UrgentMessage)一般不会不会
两侧接口的关系抽象层方法组合实现层的底层操作接口签名与上下文基本一致目标接口由调用方规定
使用时机事前设计运行时可切换事后兼容既有代码

一句话记忆:

  • 桥接:抽象层自己还会长新分支,实现层也会长新分支 → 两棵树。
  • 策略:上下文不怎么变,变的是「换哪种算法」→ 一棵树。
  • 适配器:接口已经对不上了,来补一块 → 事后补救,通常只有一棵树。

bridge-6-three-patterns

八、常见误区与踩坑

  1. 把策略当桥接:只有算法在变、上下文稳定,那就是策略。判断标准是「抽象侧是否会长出新分支」,而不是「用了组合」。
  2. 两个维度没正交就硬拆:拆完约束丢失,用一堆 if 补回来,代码比不拆更复杂。
  3. Implementor 设计成 Abstraction 的复制品:如果 MessageSender.send() 的签名和 Message.send() 一模一样,那实现层没提供任何东西,桥白搭——实现层要提供更基础的能力。
  4. 无脑给 Setter:运行时切换实现很爽,但会引入「对象在使用过程中被改掉」的不确定性。不需要切换就用 final 构造注入。
  5. 过度前瞻:为一个「可能永远只有一个实现」的维度搭桥,是典型的过度设计。桥接要在第二个实现出现时再补,而不是一开始就搭。

九、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》——桥接模式章节(意图与结构图的原始出处)
  • 《Head First 设计模式》——关于「用组合代替继承」与「变化点封装」的讨论
  • JDK 源码:java.sql.DriverManager、java.sql.Driver、java.awt.peer.ComponentPeer
  • 软考《软件设计师教程》——结构型设计模式章节
  • 本系列相关篇目:装饰者模式(叠职责)、适配器模式(改接口)、代理模式(控访问)
分享到

评论