结构型模式关注已有类和对象如何组织成更容易使用、扩展的结构。本章依次讨论适配器、装饰器、代理、外观、桥接、组合和享元,并结合 Java I/O、反射和集合观察这些关系。

每个 Java 代码块可独立保存为 Demo.java,使用 JDK 17 或更高版本运行。

适配器模式(Adapter Pattern) ⭐⭐

接口转换

适配器使已有对象能够通过调用者需要的接口使用。调用者依赖目标接口,适配器负责把调用、参数和结果转换为被适配对象的协议。

例如,一个旧传感器返回华氏温度,新的业务要求摄氏温度。转换层需要处理数值含义,不能只改变方法名字。

类适配器与对象适配器

类适配器通过继承获取旧类能力,再实现目标接口。对象适配器通过字段保存旧对象,使用组合转发调用。

目标协议与旧接口的适配关系

目标接口描述调用者的需求,旧设备保持原协议。适配器负责转换含义,再把结果交回调用者。

Java
// 适配器:调用者需要摄氏度接口,旧设备只提供华氏度。
import java.util.Objects;
 
public class Demo {
    // 目标接口:业务希望调用的协议。
    interface CelsiusSensor {
        double readCelsius();
    }
 
    // 被适配对象:旧接口返回华氏温度。
    static class LegacyThermometer {
        double readFahrenheit() {
            return 86.0;
        }
    }
 
    // 类适配器通过继承复用旧设备,再实现目标接口。
    static final class ClassAdapter extends LegacyThermometer
            implements CelsiusSensor {
        @Override
        public double readCelsius() {
            return (readFahrenheit() - 32.0) * 5.0 / 9.0;
        }
    }
 
    static final class ObjectAdapter implements CelsiusSensor {
        // 对象适配器保存已有设备,通过组合转发。
        private final LegacyThermometer thermometer;
 
        ObjectAdapter(LegacyThermometer thermometer) {
            this.thermometer = Objects.requireNonNull(thermometer);
        }
 
        @Override
        public double readCelsius() {
            // 委托旧设备读取,再将数值按单位转换。
            return (thermometer.readFahrenheit() - 32.0) * 5.0 / 9.0;
        }
    }
 
    // 调用者只依赖目标接口,不需要了解旧设备。
    static void display(CelsiusSensor sensor) {
        System.out.printf("%.1f 摄氏度%n", sensor.readCelsius());
    }
 
    public static void main(String[] args) {
        display(new ClassAdapter());
        display(new ObjectAdapter(new LegacyThermometer()));
    }
}

输出都是 30.0 摄氏度。对象适配器可以包装已存在的对象,也能包装旧类的不同子类。类适配器则绑定继承关系,Java 的单类继承限制和 final 类限制也会影响使用。

维度类适配器对象适配器
复用关系继承旧类保存旧对象并委托
已有对象通常另建适配器实例可直接包装已有实例
旧类为 final无法继承可以组合
实现切换与继承类型绑定可以注入不同对象
常见代价对父类协议的耦合增加一层转发

参数语义与错误处理

金额、时间和编码适配需要明确单位与精度。下面把业务金额转换成旧支付接口要求的“分”,使用精确转换避免静默截断。

Java
// 金额适配:将业务金额精确转换为旧接口要求的整数分。
import java.math.BigDecimal;
import java.util.Objects;
 
public class Demo {
    // 目标支付接口用 BigDecimal 表达金额。
    interface PaymentGateway {
        void pay(BigDecimal amount);
    }
 
    static final class LegacyPaymentApi {
        // 旧接口只接受以分为单位的整数。
        void chargeInCents(long cents) {
            System.out.println("扣款 " + cents + " 分");
        }
    }
 
    static final class PaymentAdapter implements PaymentGateway {
        private final LegacyPaymentApi api;
 
        PaymentAdapter(LegacyPaymentApi api) {
            this.api = Objects.requireNonNull(api);
        }
 
        @Override
        public void pay(BigDecimal amount) {
            Objects.requireNonNull(amount);
            if (amount.signum() < 0) {
                throw new IllegalArgumentException("金额不能为负");
            }
            // 精确换算并检查精度和范围,拒绝静默截断。
            long cents = amount.movePointRight(2).longValueExact();
            // 只有转换成功才调用旧接口。
            api.chargeInCents(cents);
        }
    }
 
    public static void main(String[] args) {
        PaymentGateway gateway = new PaymentAdapter(new LegacyPaymentApi());
        gateway.pay(new BigDecimal("19.90")); // 1990 分
        try {
            // 超过分的精度会被拒绝,而不会悄悄扣成 1990 分。
            gateway.pay(new BigDecimal("19.901"));
        } catch (ArithmeticException e) {
            System.out.println("金额不能精确转换为分");
        }
    }
}

适配层还可能转换异常和数据格式,但要保留调用者需要判断的信息。将所有错误都变成同一个空结果,会隐藏失败原因。

JDK 应用:字节流转字符流

InputStreamReader 接收 InputStream,对外提供 Reader 的字符读取能力,并根据指定字符集解码。这是协议和数据表示同时转换的例子。

Java 读取器的适配与包装分工

字节转字符属于协议适配,字符读取上增加缓冲属于包装增强。关闭最外层读取器会沿这条关系关闭底层资源。

Java
// JDK 适配:字节流解码为字符流,再添加按行读取能力。
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
 
public class Demo {
    public static void main(String[] args) throws Exception {
        // 先准备 UTF-8 字节,模拟底层输入。
        byte[] bytes = "Java 设计模式\n第二行".getBytes(StandardCharsets.UTF_8);
        // 最外层读取器拥有关闭职责,退出作用域时沿包装链关闭。
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(
                // InputStreamReader 明确解码字符集,避免平台默认编码差异。
                new ByteArrayInputStream(bytes), StandardCharsets.UTF_8))) {
            // 业务以字符行协议读取,不必自己处理字节解码。
            System.out.println(reader.readLine());
            System.out.println(reader.readLine());
        }
    }
}

这里同时使用了适配与包装:InputStreamReader 完成字节到字符的转换,BufferedReader 在字符读取接口上提供缓冲及按行读取能力。关闭最外层读取器会沿包装关系关闭底层流。

装饰器模式(Decorator Pattern) ⭐

在同一接口上叠加行为

装饰器实现被装饰对象的接口,并保存一个同接口对象。调用时执行新增行为,再委托或在委托返回后处理结果。调用者继续通过同一个协议使用对象。

装饰器顺序影响内层看到的消息

图对应 first 的包装顺序。把 PrefixNotifier 移到最外层后,LoggingNotifier 收到的已经是带前缀的消息。

Java
// 装饰器:保持 Notifier 接口,把独立附加行为逐层包装。
import java.util.Objects;
 
public class Demo {
    interface Notifier {
        void send(String message);
    }
 
    static final class ConsoleNotifier implements Notifier {
        @Override
        public void send(String message) {
            System.out.println("发送:" + message);
        }
    }
 
    // 共同包装基类:实现相同接口并保存被包装对象。
    abstract static class NotifierDecorator implements Notifier {
        // delegate 可以是原始通知器,也可以是另一个装饰器。
        protected final Notifier delegate;
 
        NotifierDecorator(Notifier delegate) {
            this.delegate = Objects.requireNonNull(delegate);
        }
    }
 
    static final class PrefixNotifier extends NotifierDecorator {
        PrefixNotifier(Notifier delegate) {
            super(delegate);
        }
 
        @Override
        public void send(String message) {
            // 前缀装饰器改变传给内层的消息。
            delegate.send("[订单] " + message);
        }
    }
 
    static final class LoggingNotifier extends NotifierDecorator {
        LoggingNotifier(Notifier delegate) {
            super(delegate);
        }
 
        @Override
        public void send(String message) {
            // 日志装饰器记录它当前收到的消息,再向内委托。
            System.out.println("日志:" + message);
            delegate.send(message);
        }
    }
 
    public static void main(String[] args) {
        // 日志在外层:先记原消息,之后才加前缀。
        Notifier first = new LoggingNotifier(
                new PrefixNotifier(new ConsoleNotifier()));
        first.send("已发货");
 
        // 前缀在外层:日志与真实发送都看到带前缀的消息。
        Notifier second = new PrefixNotifier(
                new LoggingNotifier(new ConsoleNotifier()));
        second.send("已发货");
    }
}

第一个组合先记原始消息,再加前缀发送;第二个组合先加前缀,日志与发送都看到加过前缀的消息。装饰器的排列顺序属于行为的一部分。

新增能力可以独立组合,避免为每一种能力组合建立一个子类。不过包装层变多后,调试时需要追踪调用顺序,异常、资源关闭和身份比较也要有一致的协议。

与继承的区别

继承在类型层面确定能力。若通知同时有日志、统计、加前缀等独立选项,枚举所有子类组合会迅速增加类型数量。装饰器把能力拆成独立包装对象,在对象组装时选择组合。

装饰器仍需要遵循被装饰接口的契约。如果接口承诺原样保存数据,而某个包装器默默改变内容,调用者可能无法安全替换。应在接口契约或具体装饰器说明中明确新增行为。

JDK 应用:I/O 包装流

BufferedInputStream、DataInputStream 等接收底层流,同时继续提供输入流协议。下面给字节输出添加基础类型编码能力,再按相同顺序读取。

Java
// I/O 装饰器:在字节流上叠加基础类型读写协议。
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
 
public class Demo {
    public static void main(String[] args) throws Exception {
        // 底层字节存储,负责保存最终编码结果。
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        // 外层包装器增加 int 和字符串编码能力。
        try (DataOutputStream output = new DataOutputStream(buffer)) {
            output.writeInt(17);
            // writeUTF 使用 Java 的 modified UTF-8,与普通文本 UTF-8 不同。
            output.writeUTF("Java");
        }
 
        // 读取端使用对应的基础类型协议。
        try (DataInputStream input = new DataInputStream(
                new ByteArrayInputStream(buffer.toByteArray()))) {
            // 读取顺序必须与写入顺序一致。
            System.out.println(input.readInt());
            System.out.println(input.readUTF());
        }
    }
}

读写顺序和格式必须一致。writeUTF() 使用的是 Java 的 modified UTF-8 协议,不能直接当成普通 UTF-8 文本写入来解释。

代理模式(Proxy Pattern) ⭐⭐⭐

控制对象访问

代理提供目标对象的访问入口,可以在访问前后实施权限检查、延迟创建或远程调用。调用者依赖统一接口,代理决定是否以及如何访问目标。

静态代理

静态代理显式实现接口并编写转发方法。结构容易阅读,编译器也能检查方法签名;接口方法很多时,转发代码会增加。

代理的访问检查与失败路径

权限失败时目标根本不会执行查询。代理与目标使用同一协议,但代理承担访问控制。

Java
// 静态代理:先检查访问权限,再决定是否调用真实服务。
import java.util.Objects;
 
public class Demo {
    // 代理与目标实现同一个接口,调用者使用相同协议。
    interface ReportService {
        String load(String id);
    }
 
    static final class DatabaseReportService implements ReportService {
        @Override
        public String load(String id) {
            return "报告 " + id;
        }
    }
 
    static final class AuthorizedReportProxy implements ReportService {
        // 代理持有真实服务,控制进入目标的调用。
        private final ReportService target;
        private final boolean canRead;
 
        AuthorizedReportProxy(ReportService target, boolean canRead) {
            this.target = Objects.requireNonNull(target);
            this.canRead = canRead;
        }
 
        @Override
        public String load(String id) {
            // 权限不通过就终止调用,目标不会执行查询。
            if (!canRead) {
                throw new SecurityException("没有报告读取权限");
            }
            // 检查通过后才转发给目标。
            return target.load(id);
        }
    }
 
    public static void main(String[] args) {
        ReportService target = new DatabaseReportService();
        System.out.println(new AuthorizedReportProxy(target, true).load("R001"));
        try {
            // 失败路径仍从代理入口进入,验证访问控制。
            new AuthorizedReportProxy(target, false).load("R001");
        } catch (SecurityException e) {
            System.out.println(e.getMessage());
        }
    }
}

这里通过组装时传入的权限值演示代理关系。实际权限通常与当前调用者和资源有关,需要从明确的安全上下文获取,不能把一次检查的结果无限期复用给所有请求。

JDK 动态代理 ⭐⭐

java.lang.reflect.Proxy 在运行时创建实现指定接口的代理对象。方法调用进入 InvocationHandler,处理器可以决定转发方式。

接口方法经过动态代理的调用过程

代理对象实现接口,处理器决定业务调用如何转发。目标内部直接调用自身方法时,不会自动再进入 Proxy 对象。

Java
// JDK 动态代理:接口调用进入处理器,由处理器转发并维护协议。
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.util.Objects;
 
public class Demo {
    public interface Calculator {
        int divide(int left, int right);
    }
 
    public static final class BasicCalculator implements Calculator {
        @Override
        public int divide(int left, int right) {
            return left / right;
        }
    }
 
    static <T> T loggingProxy(Class<T> contract, T target) {
        Objects.requireNonNull(target);
        // 先检查输入关系,JDK Proxy 代理的是接口。
        if (!contract.isInterface() || !contract.isInstance(target)) {
            throw new IllegalArgumentException("需要接口及其实现对象");
        }
 
        // 运行时生成实现 contract 接口的代理对象。
        Object proxy = Proxy.newProxyInstance(
                contract.getClassLoader(),
                new Class<?>[]{contract},
                (self, method, args) -> {
                    // equals、hashCode、toString 也会被拦截,需要明确身份语义。
                    if (method.getDeclaringClass() == Object.class) {
                        return switch (method.getName()) {
                            // 这里选择代理自身身份,确保代理与自身相等。
                            case "equals" -> self == args[0];
                            case "hashCode" -> System.identityHashCode(self);
                            case "toString" -> "LoggingProxy(" + contract.getSimpleName() + ")";
                            default -> throw new UnsupportedOperationException(method.getName());
                        };
                    }
                    System.out.println("调用:" + method.getName());
                    try {
                        // 业务方法经反射调用真实目标,保留参数和返回值。
                        return method.invoke(target, args);
                    } catch (InvocationTargetException e) {
                        // 解开反射异常包装,让调用者看到目标方法原本的异常。
                        throw e.getCause();
                    }
                });
        // 按接口取得代理,不能强转为目标实现类。
        return contract.cast(proxy);
    }
 
    public static void main(String[] args) {
        Calculator calculator = loggingProxy(Calculator.class, new BasicCalculator());
        System.out.println(calculator.divide(12, 3)); // 4
        System.out.println(calculator.equals(calculator)); // true
        System.out.println(calculator);
        try {
            calculator.divide(1, 0);
        } catch (ArithmeticException e) {
            System.out.println("保留目标方法的异常:" + e.getClass().getSimpleName());
        }
    }
}

异常转发

反射调用把目标方法抛出的异常包装为 InvocationTargetException。解开包装并抛出原始原因,调用者才能按接口原有语义处理异常。

若处理器抛出的受检异常不在接口方法声明范围内,代理调用可能进一步包装为 UndeclaredThrowableException。设计统一处理器时要保持接口声明与异常语义一致。

Object 方法与代理身份

equals()、hashCode()、toString() 也会进入处理器。示例明确选择代理自身的身份语义,避免简单把 equals 转发给目标后出现代理与自身不相等的问题。处理器中直接打印代理对象也会再次调用 toString(),没有妥善处理时可能递归。

实际业务如果需要按业务标识比较代理,应成对设计 equals() 和 hashCode()。代理与目标是不是同一个对象,也应由调用者明确区分。

能代理什么

JDK 动态代理实现接口,不会变成目标具体类的子类。调用者应使用接口类型,不能把代理强制转换为 BasicCalculator。目标具体类即使是 final,只要实现了所需接口,也可以通过该接口被代理。

调用从代理对象进入时才会经过处理器。目标方法在内部直接调用自己的另一个方法,调用仍发生在目标对象上,不会自动绕回代理。

用动态代理生成声明式客户端

代理不一定包装现成的目标对象。处理器也可以读取方法上的元数据,将接口调用转为其他协议。下面用自定义注解描述路径,用内存中的传输实现演示,避免依赖服务器。

Java
// 声明式客户端:从接口注解读取路由,再调用传输协议。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Proxy;
 
public class Demo {
    // 注解保留到运行时,处理器才能通过反射读取。
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Get {
        String value();
    }
 
    // 接口描述请求,调用者使用普通方法调用。
    public interface CatalogApi {
        @Get("/books")
        String books();
    }
 
    // 传输层与路由解释分开,这里可以用内存实现演示。
    interface Transport {
        String get(String path);
    }
 
    static <T> T createClient(Class<T> api, Transport transport) {
        if (!api.isInterface()) {
            throw new IllegalArgumentException("API 必须是接口");
        }
        Object proxy = Proxy.newProxyInstance(api.getClassLoader(),
                new Class<?>[]{api}, (self, method, args) -> {
                    if (method.getDeclaringClass() == Object.class) {
                        return switch (method.getName()) {
                            case "equals" -> self == args[0];
                            case "hashCode" -> System.identityHashCode(self);
                            case "toString" -> "Client(" + api.getSimpleName() + ")";
                            default -> throw new UnsupportedOperationException();
                        };
                    }
                    // 把方法元数据解释为请求路径。
                    Get route = method.getAnnotation(Get.class);
                    // 显式限定示例支持的签名,避免错误方法被默默执行。
                    if (route == null || method.getParameterCount() != 0
                            || method.getReturnType() != String.class) {
                        throw new IllegalArgumentException("示例仅支持无参数的 String GET 方法");
                    }
                    // 把声明式调用转成实际传输操作。
                    return transport.get(route.value());
                });
        return api.cast(proxy);
    }
 
    public static void main(String[] args) {
        Transport transport = path -> "GET " + path + ":Java 设计模式";
        CatalogApi api = createClient(CatalogApi.class, transport);
        System.out.println(api.books());
    }
}

接口承担声明,处理器承担协议转换。真实客户端还需要参数编码、响应反序列化、超时和错误处理,具体设计会同时包含工厂、适配器等对象关系。

外观模式(Facade Pattern) ⭐

简化子系统的使用入口

外观为复杂子系统提供面向调用者的入口。调用者可以表达一个完整操作,不必自己了解每个子系统及其调用顺序。

下面的报告导出需要读取数据、格式化内容和写入结果。外观组织这三个步骤,具体工作仍由各组件完成。

报告导出的统一入口

图中的数字表示调用顺序:外观把读取结果交给格式化器,再把格式化结果交给写出器。格式化与写出规则保留在各自组件中,更换输出格式不必让外观承担格式细节。

Java
// 外观:提供完整导出入口,组件继续承担各自职责。
import java.util.List;
 
public class Demo {
    // 子系统一:取得报告数据。
    static final class ReportRepository {
        List<String> load(String id) {
            return List.of("报告编号:" + id, "销售数量:12");
        }
    }
 
    // 子系统二:决定报告输出格式。
    static final class ReportFormatter {
        String format(List<String> rows) {
            return String.join(System.lineSeparator(), rows);
        }
    }
 
    // 子系统三:写出报告内容。
    static final class ReportWriter {
        void write(String content) {
            System.out.println("导出结果:");
            System.out.println(content);
        }
    }
 
    // 外观只组织这些协作者,不吸收它们的实现。
    static final class ReportFacade {
        private final ReportRepository repository;
        private final ReportFormatter formatter;
        private final ReportWriter writer;
 
        ReportFacade(ReportRepository repository, ReportFormatter formatter,
                     ReportWriter writer) {
            this.repository = repository;
            this.formatter = formatter;
            this.writer = writer;
        }
 
        void export(String id) {
            // 固定常用操作的顺序:读取、格式化、写出。
            List<String> rows = repository.load(id);
            String content = formatter.format(rows);
            writer.write(content);
        }
    }
 
    public static void main(String[] args) {
        ReportFacade facade = new ReportFacade(new ReportRepository(),
                new ReportFormatter(), new ReportWriter());
        facade.export("R001");
    }
}

外观可以继续暴露更细的能力,也可以只提供模块边界上约定的入口。它不要求封锁所有底层 API,关键是让常用操作有清晰的使用协议。

外观的职责边界

如果报告外观又负责用户注册、付款和设备管理,它会变成集中所有业务的对象。应按子系统或用例划分入口,并把可独立变化的算法留在协作者中。

外观组织跨组件调用时还要明确失败语义。文件导出失败是否重试,读取是否占用需要关闭的资源,都不能仅靠增加一个入口解决。

JDK 的 Files.readString(path) 让调用者通过一个操作完成常见文件读取,展示了简化入口的价值。底层资源和编码行为仍由 API 契约明确规定。

桥接模式(Bridge Pattern)

让两个变化维度独立扩展

桥接将抽象一侧与实现一侧分离,通过组合联系起来。这里的“抽象”通常是业务类型,“实现”是完成底层操作的能力,并不要求名称分别含有 abstract 或 Impl。

例如,消息分为普通消息和紧急消息,发送渠道分为邮件和短信。若为每一种组合创建一个子类,两类变化会混在继承树里。桥接把消息类型和渠道分别建模。

两个独立变化的类型维度

左侧扩展消息类型,右侧扩展发送渠道;中间的字段关联把两侧连接起来。增加渠道时,已有消息类型继续按 Channel 协议交付。

Java
// 桥接:消息类型和发送渠道各自扩展,在对象组装时组合。
import java.util.Objects;
 
public class Demo {
    // 实现一侧:定义渠道交付能力。
    interface Channel {
        void deliver(String recipient, String content);
    }
 
    static final class EmailChannel implements Channel {
        @Override
        public void deliver(String recipient, String content) {
            System.out.println("邮件给 " + recipient + ":" + content);
        }
    }
 
    static final class SmsChannel implements Channel {
        @Override
        public void deliver(String recipient, String content) {
            System.out.println("短信给 " + recipient + ":" + content);
        }
    }
 
    // 抽象一侧:消息类型通过字段连接到渠道能力。
    abstract static class Message {
        // 这条组合关系就是桥,不把每种组合写成子类。
        protected final Channel channel;
 
        Message(Channel channel) {
            this.channel = Objects.requireNonNull(channel);
        }
 
        abstract void send(String recipient, String content);
    }
 
    static final class NormalMessage extends Message {
        NormalMessage(Channel channel) {
            super(channel);
        }
 
        @Override
        void send(String recipient, String content) {
            channel.deliver(recipient, content);
        }
    }
 
    static final class UrgentMessage extends Message {
        UrgentMessage(Channel channel) {
            super(channel);
        }
 
        @Override
        void send(String recipient, String content) {
            // 消息类型决定内容规则,渠道决定如何交付。
            channel.deliver(recipient, "[紧急] " + content);
        }
    }
 
    public static void main(String[] args) {
        new NormalMessage(new EmailChannel()).send("小林", "周报已生成");
        // 紧急消息与短信渠道独立选择。
        new UrgentMessage(new SmsChannel()).send("小林", "任务执行失败");
        // 同一消息类型也可以组合邮件渠道。
        new UrgentMessage(new EmailChannel()).send("小林", "任务执行失败");
    }
}

新增消息类型主要影响消息一侧,新增渠道主要影响渠道一侧。若两侧分别有 m 和 n 种变化,分开的类型数量通常约为 m+n,运行时仍可形成 m×n 种组合。

多维度变化的边界

第三个独立维度也可以用协作者表达,例如再传入 MessageFormatter。但前提是维度确实独立:某些渠道只支持特定格式时,需要在组装位置验证组合,不能假定所有组合都有效。

桥接的价值是两侧都有独立扩展需求。 只给一个固定实现加一个接口,尚不足以说明存在桥接关系。

桥接、策略与适配器

桥接面向多个变化维度,策略面向可替换的算法,适配器面向已有接口的不兼容。它们可能都使用字段和委托,区别来自设计动机及对象关系。

JDBC 的业务调用通过标准接口使用具体驱动实现,可以帮助理解标准抽象与底层实现分离的价值。分析某段代码是否属于桥接,还要看抽象一侧是否也存在独立的类型变化。

组合模式(Composite Pattern)

用统一协议处理树形结构

组合模式让叶子和容器实现共同接口,容器内部再保存同接口对象。调用者可以对单个对象或一组对象发起同一种操作。

文件目录、菜单、表达式树和组织结构都可能采用这种关系。下面先用只读目录树展示核心关系,目录大小通过递归聚合子节点得到。

同一协议递归处理目录与文件

目录把子节点的 size 相加,文件直接返回自身大小。递归来自目录内部的同接口调用,调用者不用逐层判断节点类型。

Java
// 组合:目录和文件共用 Node 协议,目录递归处理子节点。
import java.util.List;
import java.util.Objects;
 
public class Demo {
    // 共同协议供调用者使用;sealed 将示例限定为这两种节点。
    sealed interface Node permits FileNode, Directory {
        long size();
        void display(String indent);
    }
 
    // 叶子直接报告自己的大小,不包含其他节点。
    record FileNode(String name, long bytes) implements Node {
        FileNode {
            Objects.requireNonNull(name);
            if (bytes < 0) {
                throw new IllegalArgumentException("文件大小不能为负");
            }
        }
 
        @Override
        public long size() {
            return bytes;
        }
 
        @Override
        public void display(String indent) {
            System.out.println(indent + name + " (" + bytes + "B)");
        }
    }
 
    // 容器保存同接口的子节点,允许递归包含目录。
    record Directory(String name, List<Node> children) implements Node {
        Directory {
            Objects.requireNonNull(name);
            // 构造时复制列表,保持只读结构,先聚焦递归关系。
            children = List.copyOf(children);
        }
 
        @Override
        public long size() {
            long total = 0;
            for (Node child : children) {
                // 无论子节点是文件还是目录,都统一调用 size 并汇总。
                total = Math.addExact(total, child.size());
            }
            return total;
        }
 
        @Override
        public void display(String indent) {
            System.out.println(indent + name + "/");
            for (Node child : children) {
                child.display(indent + "  ");
            }
        }
    }
 
    public static void main(String[] args) {
        Directory source = new Directory("src", List.of(new FileNode("Main.java", 2048)));
        // 从已有叶子和子目录逐层组装树。
        Directory root = new Directory("project", List.of(
                source, new FileNode("README.md", 512)));
        root.display("");
        System.out.println("总大小:" + root.size()); // 2560
    }
}

调用者通过 Node.size() 统一计算大小,递归藏在目录内部。sealed 限定节点只有这两种实现,两种 record 都只保存不可变状态,目录构造时还复制子节点列表,因此正常构造可以从叶子逐层组装只读结构。

组合模式的核心是叶子与容器实现同一个业务协议。 这里的 record、sealed 和只读列表是帮助限定示例边界的 Java 写法,普通类也能实现同样的模式。

这里允许同一节点在多个位置被引用,每次出现都会参与计算。若改成可编辑目录,需要另行阻止把祖先加入后代形成循环;严格树结构还要管理父节点并拒绝重复挂载。这些结构约束可以在掌握递归调用后再加入。

透明式与安全式

透明式把 add()、remove() 也放进共同接口,调用者操作统一,但叶子需要拒绝不适用的操作。安全式只让容器公开子节点管理方法,叶子只实现共同业务操作。

上面的示例只提供遍历能力。增加编辑功能时,安全式可以只在 Directory 提供 add()、remove(),让 Node 保持统一的计算和展示协议。选择取决于调用者是主要遍历结构,还是需要统一编辑各种节点。

Java 场景中的组合

Swing 的组件与容器体系体现了递归组织 UI 的关系。编译器语法树和规则表达式也常通过共同协议聚合子表达式。Java File 同时表示文件与目录,但它的某些操作依赖实际文件类型,因此仍需阅读方法契约,不能把它当成上述安全式接口的直接对应。

深度很大的树还需要考虑调用栈限制,可采用显式栈遍历。节点可变时,应明确遍历中能否修改结构和是否需要同步。

享元模式(Flyweight Pattern)

共享内部状态,分离外部状态

享元把大量对象中相同、可安全共享的状态集中到少量共享对象中。随使用位置变化的外部状态由调用者持有或在调用时传入。

文字样式的字体、字号、颜色可以作为内部状态,某个文字的内容和坐标是外部状态。共享样式不应存储当前位置,否则不同使用者会互相覆盖。

独立使用点与共享样式

内容与位置各自保存,前两个使用点复用相同的不可变样式。样式对象不能把某个位置写成自己的共享字段。

Java
// 享元:相同样式共用对象,文字和位置仍由每个使用点保存。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
 
public class Demo {
    // 内部状态的全部取值组成键,相同键才可以共用样式。
    record StyleKey(String font, int size, String color) {}
 
    static final class TextStyle {
        // 共享样式不可变,不保存某个使用点的位置。
        private final StyleKey key;
 
        TextStyle(StyleKey key) {
            this.key = key;
        }
 
        // 外部状态在调用时传入,不写回共享对象。
        void draw(String text, int x, int y) {
            System.out.println(text + " @(" + x + "," + y + ") " + key);
        }
    }
 
    static final class StyleFactory {
        private final ConcurrentMap<StyleKey, TextStyle> styles = new ConcurrentHashMap<>();
 
        TextStyle get(String font, int size, String color) {
            if (font == null || color == null || size <= 0) {
                throw new IllegalArgumentException("样式参数无效");
            }
            StyleKey key = new StyleKey(font, size, color);
            // 工厂按键取得已有享元,缺少时创建并缓存。
            return styles.computeIfAbsent(key, TextStyle::new);
        }
 
        int sharedCount() {
            return styles.size();
        }
    }
 
    // 每个位置独立持有内容、坐标和一个共享样式引用。
    record Glyph(String text, int x, int y, TextStyle style) {
        void draw() {
            style.draw(text, x, y);
        }
    }
 
    public static void main(String[] args) {
        StyleFactory factory = new StyleFactory();
        List<Glyph> glyphs = new ArrayList<>();
        glyphs.add(new Glyph("Java", 10, 20, factory.get("Serif", 16, "black")));
        glyphs.add(new Glyph("模式", 50, 20, factory.get("Serif", 16, "black")));
        glyphs.add(new Glyph("标题", 10, 40, factory.get("Serif", 20, "blue")));
 
        glyphs.forEach(Glyph::draw);
        // 检查前两个使用点是否真的引用同一个样式。
        System.out.println(glyphs.get(0).style() == glyphs.get(1).style()); // true
        System.out.println("共享样式数:" + factory.sharedCount()); // 2
    }
}

工厂按全部内部状态建立键,computeIfAbsent() 为同键取得共享对象。共享样式不可变,位置与文本放在 Glyph 中。线程安全映射保证工厂的并发操作,但享元对象自身若增加可变状态,仍要单独处理同步。

内存收益与缓存生命周期

设有 N 个使用位置、K 种样式,每份样式内部状态占 S,每个位置外部状态占 E。粗略比较为:未共享时 N×(S+E),共享后 K×S+N×(E+引用开销),还要计入映射和键的开销。

当 N 很大、K 明显更小且 S 较大时,共享更可能有收益。若几乎每个位置都有独特样式,工厂缓存反而增加管理成本。

共享对象需要生命周期策略。 动态产生任意字体、大小和颜色组合时,缓存可能持续增长。可以使用受限键空间、容量控制或合适的引用策略,不能因为对象是不可变的就无限保存。

JDK 应用:字符串与装箱缓存

字符串常量和 intern() 使相同内容可使用池中的规范引用。Integer.valueOf() 也会复用一定范围的装箱对象。

Java
// JDK 共享实例:对象身份与内容相等需要分开判断。
public class Demo {
    public static void main(String[] args) {
        String first = "java";
        String second = "ja" + "va";
        // 显式构造一个不同身份的字符串,内容与字面量相同。
        String separate = new String("java");
        System.out.println(first == second);           // true,编译期常量
        System.out.println(first == separate);         // false,不同对象
        System.out.println(first.equals(separate));    // true,内容相等
        System.out.println(first == separate.intern()); // true,规范引用
 
        // valueOf 在保证缓存的范围内复用装箱对象。
        Integer smallA = Integer.valueOf(127);
        Integer smallB = Integer.valueOf(127);
        // 较大整数是否复用取决于实现与配置,不应依赖它。
        Integer largeA = Integer.valueOf(1000);
        Integer largeB = Integer.valueOf(1000);
        System.out.println(smallA == smallB);          // true,保证缓存的范围内
        // 业务比较数值用 equals,避免把引用身份当成数值。
        System.out.println(largeA.equals(largeB));      // true,比较数值
        System.out.println("大整数是否同引用:" + (largeA == largeB));
    }
}

Integer.valueOf() 至少缓存 -128 到 127,较大值是否复用不能一概断言。比较包装类型的数值应使用 equals() 或适当拆箱,不能依赖 == 判断对象身份。拆箱前还需要考虑 null。

享元允许多个值对应多个共享实例;单例强调受控的一个实例;对象池通常把可变资源借出、使用后归还。这些生命周期与共享语义不同。

七种模式的辨析

模式主要问题关键对象关系
适配器已有接口不符合调用者需要实现目标接口,调用旧协议
装饰器同一能力需要叠加额外行为同接口包装,可组合多层
代理目标访问需要受控或间接完成通过代理决定如何访问目标
外观子系统常用操作步骤复杂用一个入口协调多个组件
桥接多个类型维度独立变化抽象一侧组合实现一侧
组合叶子和容器需要统一处理同协议递归包含子节点
享元大量对象存在相同状态内部状态共享,外部状态分离

适配器、装饰器和代理都可能包装对象。判断时应观察调用者需要的协议是否变化,包装层是否为了增加能力,以及它是否承担目标访问控制。同一个对象也可能同时发挥多种作用。

观察包装层适配器装饰器代理
调用者使用的协议转成目标需要的协议保持原有协议提供目标访问协议
核心职责转换接口或数据含义叠加独立能力控制或间接完成访问
转发前的典型动作换算单位、编码参数加前缀、压缩、记日志鉴权、延迟创建、定位远程目标
示例中的判断依据华氏输入转成摄氏结果多个通知包装器可以叠加权限失败时目标不执行

只看到 delegate.method() 时,信息还不足以分类。继续看调用者期望什么、包装层负责什么,以及新增需求应修改哪个对象,才更容易区分这些相似结构。

外观与中介者也会协调对象:外观主要服务外部调用者,中介者主要组织参与者之间的交互。桥接与策略都能替换协作者,但桥接还强调抽象一侧和实现一侧分别扩展。

Java 通过明确的转发方法实现委托。手写包装类方便检查契约,动态代理适合按接口统一拦截调用;选择时还要考虑类型约束、异常语义和调试成本。

练习:从变化检验对象关系

  1. 给消息装饰器增加成功计数,并改变它与日志装饰器的顺序。用会抛异常的发送器检查计数语义。
  2. 给动态代理接口增加一个声明受检异常的方法,验证处理器如何保留原始异常。
  3. 给桥接示例增加企业聊天渠道,检查是否需要修改普通消息和紧急消息。
  4. 给组合示例增加权限检查,决定检查在节点、遍历器还是外部服务中执行,并解释选择。
  5. 在享元示例中增加一千个使用位置,比较位置数量与共享样式数量,再检查样式键是否包含所有内部状态。

上一章:创建型模式 · 返回 Java 设计模式 · 下一章:行为型模式