01|OOP 与工程基础:契约、组合、Java 25 与 Gradle
配套代码:GitHub 仓库与运行说明 · 24种模式源码目录。使用 JDK 25、Gradle Wrapper 9.6.1,包名前缀为
com.hanserwei.patterns。
系列导航:Java 25 设计模式学习指南
设计模式容易学成类图记忆题:看到接口就叫策略,看到包装就叫代理,看到静态方法就叫工厂。要避免这种情况,先建立一套检查对象设计的方法。本篇不新增一个模式,而是为后续24篇准备共同的语言和可运行工程。
对象先负责行为,再维护状态
一个对象的价值不只是把几个字段放在一起。Draft 管理自己的标签,Manuscript 管理合法生命周期,CommandHistory 管理撤销顺序。每个对象都应该知道自己需要维护的规则,并通过方法保证这些规则不被普通调用者绕过。
private 只挡住直接字段访问。如果 getter 返回内部 ArrayList,调用者仍可以任意清空它;如果提供 public setState,调用者仍可以把草稿直接改成任何状态。封装需要同时检查字段可见性、返回引用和可调用的方法。
final 也有精确边界:final List 禁止字段重新赋值,但并不禁止列表内容变化。本系列经常使用 List.copyOf 或 Map.copyOf 保护容器边界;这类复制是浅层复制,对元素对象的可变状态还要单独判断。
接口表达契约,不只是方法名称
以策略篇的 FeePolicy 为例,输入是非负字数,返回值单位是“分”,溢出必须显式失败。两个实现都叫 fee 并不足以证明可替换;若一个返回元、另一个返回分,或者一个允许负数、另一个把负数解释为退款,上下文就无法安全替换算法。
下面是配套仓库中该接口的完整内容,路径为 src/main/java/com/hanserwei/patterns/strategy/FeePolicy.java:
package com.hanserwei.patterns.strategy;
/** 可替换的文章校对计费算法契约. */
public interface FeePolicy {
/** 对非负字数计算以分为单位的非负费用;溢出时抛出异常. */
long fee(int words);
}
接口契约至少需要回答:什么输入有效、返回值代表什么、哪些异常表示预期失败、是否产生副作用,以及调用者能否重复或并发调用。并非每个简单方法都要写成长篇说明,但关键语义不能靠读者猜测。
抽象类适合同时提供共享流程和受控扩展点,例如 Exporter 固定校验后调用工厂方法。接口适合描述跨实现的能力,不必为了复用两行代码建立父类。一个空接口如果没有实际契约意义,也不会自动改善设计。
组合与继承分别在哪里起作用
QuoteService 持有 FeePolicy,是“使用一个算法”的组合关系。MarkdownExporter 继承 ArticleExporter,是“遵守某个固定导出骨架”的扩展关系。选择时先看业务语义,不要只看哪种写法少几行代码。
以下是策略上下文的完整实现:
package com.hanserwei.patterns.strategy;
import java.util.Objects;
/** 通过注入策略完成报价的业务上下文. */
public final class QuoteService {
/** 当前服务使用的计费策略. */
private final FeePolicy policy;
/** 在装配时选择算法. */
public QuoteService(FeePolicy policy) {
this.policy = Objects.requireNonNull(policy, "policy");
}
/** 委托策略计算费用,返回以分为单位的金额. */
public long quote(int words) {
return policy.fee(words);
}
}
构造器注入让依赖在 new QuoteService(...) 的地方显式出现。服务内部既不查询全局注册表,也不检查策略具体类型。要测试另一个算法,只需装配另一个实现;这种依赖关系可以不借助任何注入框架完成。
继承意味着子类必须维持父类契约。父类允许的输入,子类不应无理由拒绝;父类承诺的结果,子类需要兑现。仅为了复用字段建立“看起来像父子”的关系,可能造成子类必须禁用父类方法的尴尬。
组合也有成本:多了一次委托、装配对象和生命周期管理。优先考虑组合,是在提醒我们分开独立变化的职责,不是禁止模板方法、状态层级等合理继承结构。
用 SOLID 检查变化,而不是背口号
| 原则 | 可操作的问题 | 本系列观察点 |
|---|---|---|
| 单一职责 SRP | 这个类会因哪几类不同需求而修改? | 适配器翻译接口,发送业务不复制旧 SDK 参数规则 |
| 开闭原则 OCP | 新增这一维变化时,已有稳定代码能否保持不动? | 策略增加算法,抽象工厂增加产品族 |
| 里氏替换 LSP | 子类和实现是否遵守调用者依赖的契约? | 所有计费策略拒绝负字数,迭代器耗尽抛规定异常 |
| 接口隔离 ISP | 调用者是否被迫依赖用不到的方法? | 组合的叶子不必实现无意义的 addChild |
| 依赖倒置 DIP | 高层流程依赖业务能力还是某个具体供应商? | 通知通过 NotificationChannel,代理通过 ArticleSource |
开闭原则始终相对于某个变化方向。抽象工厂容易加主题族,却不容易加全新的产品种类;访问者容易加操作,却要为新元素修改访问者接口。能指出受益方向和受损方向,比宣布“完全符合开闭原则”更有价值。
工程结构与运行入口
文章目录和代码仓库分开。以下树形结构相对于配套代码仓库根目录;strategy 只是24个独立包中的一个:
design-patterns-java25/
├── gradlew / gradlew.bat
├── gradle/wrapper/
├── build.gradle.kts
├── settings.gradle.kts
├── config/checkstyle/
├── src/main/java/com/hanserwei/patterns/strategy/
│ ├── FeePolicy.java
│ ├── FlatFee.java
│ ├── PerWordFee.java
│ ├── QuoteService.java
│ └── Demo.java
└── src/test/java/com/hanserwei/patterns/strategy/PatternTest.java
先安装完整 JDK 25,并让 JAVA_HOME 指向它。进入代码仓库后执行:
java -version
./gradlew --version
./gradlew -q javaToolchains
./gradlew clean check
./gradlew runStrategy
运行策略示例的业务输出为1000与1200,单位都是分。每篇给出自己的 run 任务名,全部任务可以通过 ./gradlew tasks --group examples 查看。任务名与包名直接对应,例如工厂方法是 runFactorymethod,没有隐藏的跨章节启动依赖。
Java Toolchain 指定25,options.release 也为25。前者选择编译和测试工具,后者约束编译目标及可用平台 API。所有示例入口都通过25号工具链运行;无需开启 preview,也不使用简化源文件或模块通配导入。
规范如何落实到每次修改
Java 源码遵循 Google 风格:UTF-8、两个空格缩进、正常包声明、每个顶级类型一个文件、显式导入与有意义的名称。详细规则以 Google Java Style Guide 为准,不通过单个“格式化成功”推断全部设计质量。
本仓库把三类检查同时接入 check:google-java-format 验证格式,Checkstyle 的固定版本 Google 配置检查命名及其他约束,额外 documentationCheck 检查所有可见性下的类、字段、构造器和方法注释。@Override、getter、测试方法和私有辅助方法都没有注释豁免。
./gradlew formatJava
./gradlew check
./gradlew javadoc
注释写意图和契约。例如“保存已校验参数的快照”比“构造一个对象”更有用;“返回不可变标签快照”比“获取标签”更能防止误用。简单方法可以使用单行 Javadoc,复杂输入、结果和异常可补充 @param、@return、@throws。现有中文 Javadoc 使用英文句点结尾,以匹配固定检查器的摘要句末规则。
工具版本都固定在构建脚本中,Wrapper 配置还固定了发行包校验和。首次下载需要联网,后续修改不依赖系统安装的任意版本 Gradle。不要手动删掉检查来消除报警,应先判断代码或注释是否需要调整。
测试应证明职责划分确实成立
测试不止是运行 Demo 后看到输出。装饰器要检查顺序,原型要检查修改隔离,迭代器要检查耗尽协议,状态要检查非法动作没有改变当前状态。每种模式至少有两项行为测试,覆盖正向行为与有意义的边界。
./gradlew test --tests 'com.hanserwei.patterns.prototype.PatternTest'
./gradlew test --tests 'com.hanserwei.patterns.state.PatternTest'
JUnit 测试通过显式断言判断结果,不依赖 JVM 的 assert 开关。演示类用于跟踪对象装配,测试类用于固定契约,两者承担不同任务。
学习时可以故意把原型复制改成共享列表、把缓存失效去掉,或让状态错误分支继续迁移,再观察测试失败。修复后运行 check,确保行为、格式和注释都回到要求之内。
进入模式章节之前的小练习
打开 QuoteService,指出它知道和不知道的信息;再打开 PerWordFee,解释为什么校验单价在构造器里、校验字数在方法里。然后把 Demo 装配成 FlatFee,确认 QuoteService 没有变化。
完成这几步后,再进入简单工厂,学习如何为具体对象的创建决策安排一个明确位置。