结构型模式关注已有类和对象如何组织成更容易使用、扩展的结构。本章依次讨论适配器、装饰器、代理、外观、桥接、组合和享元,并结合 Java I/O、反射和集合观察这些关系。
每个 Java 代码块可独立保存为 Demo.java,使用 JDK 17 或更高版本运行。
适配器模式(Adapter Pattern) ⭐⭐
接口转换
适配器使已有对象能够通过调用者需要的接口使用。调用者依赖目标接口,适配器负责把调用、参数和结果转换为被适配对象的协议。
例如,一个旧传感器返回华氏温度,新的业务要求摄氏温度。转换层需要处理数值含义,不能只改变方法名字。
类适配器与对象适配器
类适配器通过继承获取旧类能力,再实现目标接口。对象适配器通过字段保存旧对象,使用组合转发调用。
目标协议与旧接口的适配关系
目标接口描述调用者的需求,旧设备保持原协议。适配器负责转换含义,再把结果交回调用者。
// 适配器:调用者需要摄氏度接口,旧设备只提供华氏度。
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 | 无法继承 | 可以组合 |
| 实现切换 | 与继承类型绑定 | 可以注入不同对象 |
| 常见代价 | 对父类协议的耦合 | 增加一层转发 |
参数语义与错误处理
金额、时间和编码适配需要明确单位与精度。下面把业务金额转换成旧支付接口要求的“分”,使用精确转换避免静默截断。
// 金额适配:将业务金额精确转换为旧接口要求的整数分。
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 读取器的适配与包装分工
字节转字符属于协议适配,字符读取上增加缓冲属于包装增强。关闭最外层读取器会沿这条关系关闭底层资源。
// 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 收到的已经是带前缀的消息。
// 装饰器:保持 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 等接收底层流,同时继续提供输入流协议。下面给字节输出添加基础类型编码能力,再按相同顺序读取。
// 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) ⭐⭐⭐
控制对象访问
代理提供目标对象的访问入口,可以在访问前后实施权限检查、延迟创建或远程调用。调用者依赖统一接口,代理决定是否以及如何访问目标。
静态代理
静态代理显式实现接口并编写转发方法。结构容易阅读,编译器也能检查方法签名;接口方法很多时,转发代码会增加。
代理的访问检查与失败路径
权限失败时目标根本不会执行查询。代理与目标使用同一协议,但代理承担访问控制。
// 静态代理:先检查访问权限,再决定是否调用真实服务。
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 对象。
// 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,只要实现了所需接口,也可以通过该接口被代理。
调用从代理对象进入时才会经过处理器。目标方法在内部直接调用自己的另一个方法,调用仍发生在目标对象上,不会自动绕回代理。
用动态代理生成声明式客户端
代理不一定包装现成的目标对象。处理器也可以读取方法上的元数据,将接口调用转为其他协议。下面用自定义注解描述路径,用内存中的传输实现演示,避免依赖服务器。
// 声明式客户端:从接口注解读取路由,再调用传输协议。
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) ⭐
简化子系统的使用入口
外观为复杂子系统提供面向调用者的入口。调用者可以表达一个完整操作,不必自己了解每个子系统及其调用顺序。
下面的报告导出需要读取数据、格式化内容和写入结果。外观组织这三个步骤,具体工作仍由各组件完成。
报告导出的统一入口
图中的数字表示调用顺序:外观把读取结果交给格式化器,再把格式化结果交给写出器。格式化与写出规则保留在各自组件中,更换输出格式不必让外观承担格式细节。
// 外观:提供完整导出入口,组件继续承担各自职责。
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 协议交付。
// 桥接:消息类型和发送渠道各自扩展,在对象组装时组合。
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 相加,文件直接返回自身大小。递归来自目录内部的同接口调用,调用者不用逐层判断节点类型。
// 组合:目录和文件共用 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)
共享内部状态,分离外部状态
享元把大量对象中相同、可安全共享的状态集中到少量共享对象中。随使用位置变化的外部状态由调用者持有或在调用时传入。
文字样式的字体、字号、颜色可以作为内部状态,某个文字的内容和坐标是外部状态。共享样式不应存储当前位置,否则不同使用者会互相覆盖。
独立使用点与共享样式
内容与位置各自保存,前两个使用点复用相同的不可变样式。样式对象不能把某个位置写成自己的共享字段。
// 享元:相同样式共用对象,文字和位置仍由每个使用点保存。
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() 也会复用一定范围的装箱对象。
// 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 通过明确的转发方法实现委托。手写包装类方便检查契约,动态代理适合按接口统一拦截调用;选择时还要考虑类型约束、异常语义和调试成本。
练习:从变化检验对象关系
- 给消息装饰器增加成功计数,并改变它与日志装饰器的顺序。用会抛异常的发送器检查计数语义。
- 给动态代理接口增加一个声明受检异常的方法,验证处理器如何保留原始异常。
- 给桥接示例增加企业聊天渠道,检查是否需要修改普通消息和紧急消息。
- 给组合示例增加权限检查,决定检查在节点、遍历器还是外部服务中执行,并解释选择。
- 在享元示例中增加一千个使用位置,比较位置数量与共享样式数量,再检查样式键是否包含所有内部状态。