读完这篇,你能做什么
能用接口隔离实现,并区分继承、多态与简单代码复用。
学习理由、准备要求与知识点
为什么学它
封装、接口与多态构成 Java 业务建模和 Spring 依赖注入的语言基础。
先读这些内容
知识点清单
- 封装业务不变量
- 构造器输入验证
- 继承的 is-a 关系
- 组合与复用选择
- 接口行为合同
- 实例方法动态分派
- 重载与重写
- static/字段访问边界
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
设计对象时,先决定它负责保护什么规则。余额对象可以提供存入和扣减方法,并检查金额不能为负、余额不能不足。如果外部仍能随意 setBalance,哪怕字段是 private,也没有真正守住规则。封装就是把数据和合法操作放在一起,让调用者通过这些操作修改数据。
继承不只是少写几行代码,还意味着子类应能履行父类型对调用者的承诺。只想复用工具方法时,通常可以把辅助对象作为字段并调用它,这叫组合。需要共用状态和部分实现时可以考虑抽象类;只想规定调用者可以使用哪些行为时,可以用接口。先说明它们的职责和关系,再选择语法。
多态让调用者通过接口调用不同实现的实例方法。例如报价器只依赖 DiscountPolicy,不必在循环里判断满减券、折扣券的具体类。编译期类型决定可访问哪些成员,运行时对象决定适用的重写实例方法实现。重载是同名不同参数的编译期选择,字段访问和 static 方法隐藏不能套用实例方法动态分派的解释。
文中这几个词,先认一认
- 封装
- 让外部通过规定的方法操作数据,并由对象检查这些修改是否合法。
- 重写
- 子类为继承来的实例方法提供符合规则的新实现。
- 多态
- 调用者通过共同的父类或接口类型调用实例方法,不同对象执行各自适用的实现,例如不同优惠券各自计算价格。
- 替换原则
- 用子类替换父类型时,仍应满足调用者依赖的输入要求、结果和业务规则。
02看看它怎么工作
接口不只规定方法叫什么、收什么参数、返回什么类型,还要说清它遵守的业务规则。例如 pay(base) 承诺:原价非负时,应付金额在零到原价之间。每个实现都要遵守,不能返回负数,也不能偷偷修改其他订单。这样调用者才能用同一种方式比较结果,不必为每种实现单独补救。编译器能检查方法声明和部分类型规则,但这些业务规则仍要靠设计和测试确认。
创建对象时,也要先把字段设置到符合规则的状态,再交给其他代码使用。有个容易出错的地方:父类构造器如果调用可被重写的方法,可能实际进入子类的方法,而这时子类字段还没完成初始化。构造器应尽量只做清楚的初始化和校验;需要使用完整对象的业务操作,放到对象创建完成之后再执行。
组合把变化点放在一个可替换依赖上,继承则把对象固定在某个类型层次里。两者都能复用行为,区别在耦合位置与生命周期。下图的报价过程只遍历 Offer 接口并调用 pay,不检查具体实现;新增优惠规则可以添加实现,但如果新增规则改变成可叠加优惠,原有最小值算法的业务契约就必须重设。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["报价调用者"] --> B["Offer 接口:应付金额契约"]
B --> C["固定减免实现"]
B --> D["折扣比例实现"]
C --> E["返回独立候选报价"]
D --> E
E --> F["统一验证并选择最低金额"]按顺序看,每一步做了什么
- 写清对象负责维护的不变量。
- 用接口描述调用者真正需要的行为。
- 在构造器或操作边界校验状态。
- 通过接口执行可替换实例方法。
- 测试每个实现是否履行相同契约。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/AbstractCollection.java · java.util.AbstractCollection#contains(Object)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.Iterator;
import java.util.Objects;
public final class SourceModel {
public static boolean contains(Iterable<?> source, Object wanted) {
Objects.requireNonNull(source, "source");
// 算法依赖迭代接口,不必知道元素存放于数组还是链表。
Iterator<?> cursor = source.iterator();
while (cursor.hasNext()) {
Object current = cursor.next();
if (Objects.equals(wanted, current)) return true;
}
return false;
}
}真实 AbstractCollection.contains 通过 iterator 获取遍历能力,再顺序检查元素是否匹配;具体集合可以提供自己的迭代器,也可以重写 contains 获得更合适实现。模型把接收者改为显式 Iterable 参数,展示接口调用与算法复用,不模拟抽象类的全部成员。
这段通用实现只要求迭代器正确提供 hasNext 和 next,就能逐项检查,不必读取子类私有字段,也不必猜内部是数组还是链表。复用依靠的是一组稳定操作,而不是暴露存储细节;因此算法和底层存储可以分别变化。
真实方法区分目标为 null 和非 null 的比较分支。模型使用 Objects.equals 汇总这项空值处理,仍然由目标对象的 equals 决定逻辑相等。接口多态并不自动保证 equals 正确;如果类型违反对称性或一致性,集合行为仍可能令人意外,需要在相等专篇处理。
顺序 contains 的成本与元素数量相关,因此继承一个默认实现只代表获得可用行为,不代表它适合所有存储结构。哈希集合可以选择自己的查找实现,在保持公开语义的同时改变性能。阅读骨架类应同时看依赖的方法、允许重写的扩展点和具体实现最终是否覆盖该方法。
代码先调用谁,再调用谁
- 调用集合 contains
- 骨架实现请求 iterator
- 具体迭代器提供元素
- 通过 equals 匹配并返回,具体子类也可重写整个查找
04动手写一个例子
通过优惠接口选择最低独立报价
给定非负 int 原价和一组互斥优惠,计算最低应付金额。固定减免最低到零;比例优惠按分向上取整。允许不使用优惠,拒绝不履行零到原价范围契约的实现。
- 输入 1
原价1000;减100、支付85%- 输出 1
850
- 输入 2
原价99;支付50%- 输出 2
50
- 输入 3
原价0;无优惠- 输出 3
0
先把不使用优惠的原价设为当前最优值,然后逐个请求候选报价并取较小者。所有优惠是互斥选择,不是依次对已优惠价格再打折,因此每个实现都接收同一个原价。这个固定输入使比较具有同一基准,避免迭代顺序改变业务结果。
策略负责自己规则的参数校验与金额计算,选择器负责校验返回值契约。双层检查并不重复:前者知道减免或比例是否合法,后者不知道具体实现,却需要防止异常候选破坏统一比较。比例计算使用 long 中间值,并因原价限定 int 非负而有明确乘法范围保证。
一次遍历足以找最小值,因为处理每个候选后,当前结果就是已经见过的所有报价及原价的最小值。平价时本题只返回金额,所以不需要规定哪张券优先;若返回券标识,则必须补充稳定的平价规则。算法正确仍依赖每次 pay 对相同原价表达可比较的独立报价。
为什么这样写是对的每轮结束后 best 是原价与所有已检查优惠报价中的最小合法金额。
k 个优惠各调用一次;通用情形成本应写为 O(k + 所有策略调用成本之和),不能忽略策略内可能执行的昂贵动作。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.List;
public class Main {
interface Offer { long pay(int base); }
record Flat(int reduction) implements Offer {
Flat { if (reduction < 0) throw new IllegalArgumentException("reduction"); }
public long pay(int base) { return Math.max(0L, (long)base - reduction); }
}
record Rate(int percent) implements Offer {
Rate { if (percent < 0 || percent > 100) throw new IllegalArgumentException("percent"); }
public long pay(int base) {
// 原价限制为非负 int,乘以百分比后用 long 精确计算并向上取整。
return ((long)base * percent + 99) / 100;
}
}
static long best(int base, List<Offer> offers) {
if (base < 0 || offers == null) throw new IllegalArgumentException("input");
long best = base; // 不用优惠也是合法候选。
for (Offer offer : offers) {
if (offer == null) throw new IllegalArgumentException("offer");
long candidate = offer.pay(base);
// 统一检查接口语义,不通过具体类名分支。
if (candidate < 0 || candidate > base)
throw new IllegalArgumentException("broken offer");
best = Math.min(best, candidate);
}
return best;
}
static void check(long wanted, int base, Offer... offers) {
if (best(base, List.of(offers)) != wanted) throw new AssertionError(wanted);
}
public static void main(String[] args) {
// 普通比较、舍入、零金额、过量减免和无优惠。
check(850,1000,new Flat(100),new Rate(85));
check(50,99,new Rate(50));
check(0,0);
check(0,100,new Flat(200));
check(100,100);
boolean rejected = false;
try { best(100,List.of(base -> -1L)); }
catch (IllegalArgumentException e) { rejected = true; }
if (!rejected) throw new AssertionError("contract");
System.out.println("PASS 6");
}
}这些特殊情况也要试一试
- 原价为零仍有合法报价。
- 固定减免超过原价时最低到零。
- 比例按分向上舍入。
- 异常策略返回负数或超原价会被拒绝。
进阶选读 · 可选深入:可叠加优惠与规则搜索
场景优惠之间存在互斥组、顺序和使用次数约束。
做法把合法组合建模为状态与转移,先明确舍入发生在哪一步,再根据规模选择回溯、动态规划或约束求解;接口只能封装动作,不能自动消除组合复杂度。
复杂度若枚举 k 项优惠的全部子集,最坏至少指数级;利用结构才能减少状态。
好处与代价更灵活规则增加解释与测试成本,应保留每步报价明细和确定的平价策略。
05项目里怎么用
接口的数量应由实际变化点决定,而不是每个类都机械配一个同名接口。对多个支付、存储或计价实现,共同接口能够限制调用者依赖;对只有一段稳定内部逻辑,额外层次可能只增加跳转。设计时优先明确规则和依赖方向,再决定需要哪种抽象。
继承层次太深,初始化、protected 成员和重写方法都更难追踪。子类不能以优化为由,拒绝父类型原本承诺接受的输入,或给出违反父类型约定的结果;若行为确实不同,应重新划分类型。组合会多一些转发和对象关系,但通常更方便独立测试协作者。
报价策略不宜在一个看起来纯计算的方法里隐藏网络调用、数据库写入或时钟依赖,否则最小值选择的可重复性与延迟分析都会变化。需要外部数据时先获取明确快照,再执行计算;输出应包含足够业务解释,使开发者和用户都能理解为什么选择某个结果。
容易踩的坑
- 封装仅剩私有字段加任意 setter。
- 为复用几行代码建立不成立的继承关系。
- 把字段或 static 方法当成实例多态。
- 实现者改变接口前置和结果范围却未更新契约。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 接口加组合 | 依赖可替换且状态边界清楚 | 需要维护语义契约与委托 | 多个实现或外部依赖 |
| 抽象类骨架 | 可复用状态与默认算法 | 子类受继承结构约束 | 稳定的共同类型层次 |
| 普通具体类 | 直接且容易理解 | 变化时可能需要再抽象 | 单一稳定实现 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么给字段生成 setter 不一定是封装?
展开参考答案与推导
如果调用者能够绕过业务步骤随意写任何值,对象仍不能维护不变量。封装应开放合法操作并在边界检查,而不是只改变字段可见性再完全恢复原来的任意写入能力。
02重载与重写如何区分?
展开参考答案与推导
重载改变参数列表,由编译期适用规则选择签名;重写为继承实例方法提供实现,调用时可依据运行时对象分派。返回类型 alone 不能作为重载差异,字段和静态方法也不遵循相同规则。
03默认骨架实现为什么不一定最快?
展开参考答案与推导
它只依赖最小抽象能力,因此可能采用通用线性遍历。具体存储结构有额外信息时可以重写实现获得更合适复杂度,但必须保持相同公开结果与边界语义。
换个情况,还会处理吗
01新增折扣后选择器开始判断具体类,说明什么?
展开参考答案与推导
先看统一接口是否缺少必要的业务行为,或新增规则是否实际上改变了互斥与叠加契约。不要简单继续堆 instanceof;稳定的独立报价应通过同一方法计算,组合规则则需独立建模。
02父构造器调用方法时子字段是默认值,为什么?
展开参考答案与推导
动态分派可能进入子类重写方法,而子类初始化尚未完成。避免在构造期间调用依赖子状态的可重写方法,把初始化与完整对象上的业务动作分开。
03某策略发网络请求导致报价很慢,怎样分析?
展开参考答案与推导
算法遍历次数仍为 k,但总延迟还包含所有策略调用成本。把网络数据获取与纯计算分离,定义超时和数据快照;不能因为外层只有一个循环就宣称整个业务是低成本操作。
写代码试一试
01新增满一千减一百规则
- 输入 / 条件
原价999与1000- 输出 / 预期
999与900
提示 实现同一接口。
展开参考答案与推导
写新的 Offer 实现,pay 中仅当 base 达到一千才减一百,否则返回原价;对阈值前一分、恰好阈值和零原价测试,无需修改选择器的类型判断。
02返回最优优惠下标
- 输入 / 条件
两个报价均为800- 输出 / 预期
返回先出现者
提示 仅在严格更小时更新下标。
展开参考答案与推导
同时记录 best 与 chosen,遇到 candidate < best 才更新;相等时保持已有选择。还应定义不使用优惠的下标,例如 -1,使原价平局行为明确可测。
03检测超原价策略
- 输入 / 条件
原价100,策略返回101- 输出 / 预期
抛 IllegalArgumentException
提示 复用契约检查。
展开参考答案与推导
增加返回 base+1L 的实现并断言拒绝。负值和超上界是两条独立边界;即使正常策略不会产生它们,测试选择器面对错误实现的防护仍有意义。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能用接口隔离实现,并区分继承、多态与简单代码复用。
本篇重点加练
重点加练业务不变量与接口行为合同,检验多态扩展是否保持原有承诺。
- 封装业务不变量
- 接口行为合同
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
设计
BankAccount,只允许非负余额,开户金额和取款金额均需验证。列出构造与公开方法的前置、后置条件,审查开放setBalance是否符合业务合同。- 提交内容
- 类接口及不变量表。
- 怎样算完成
- 合法状态由接口维护
- 失败时状态变化有明确合同
练到的知识点封装业务不变量 · 构造器输入验证
- 作业 02原理问答
课程播放器要支持下载、字幕与播放,部分直播不可下载。给出继承和组合两份模型,使用具体调用场景检验可替换性与复用成本。
- 提交内容
- 类图或对象图及方案比较。
- 怎样算完成
- is-a 判断有行为依据
- 复用方式与变化点对应
练到的知识点继承的 is-a 关系 · 组合与复用选择
综合应用
- 作业 03代码推演
定义接口 Speaker、父类 Base 和子类 Child,分别包含重写方法、一个重载方法、同名字段及 static 方法;用 Base 类型变量引用 Child,先预测调用绑定再编译运行。
- 提交内容
- 独立 Java 示例、预测表和实际记录。
- 怎样算完成
- 明确表达式静态类型与实际对象
- 四种成员情形分别论证
练到的知识点接口行为合同 · 实例方法动态分派 · 重载与重写 · static/字段访问边界
- 作业 04编程实践
实现运费接口与按件、按重量两种策略,输入包裹数量和克重,金额用整数最小单位;报价不得为负,非法输入必须有统一合同。
- 提交内容
- 完整 Java 实现、自写正常/零值/极值/非法输入测试及运行证据。
- 怎样算完成
- 调用方通过接口使用策略
- 构造与调用边界均受检验
- 不使用类型分支模拟多态
练到的知识点封装业务不变量 · 构造器输入验证 · 接口行为合同 · 实例方法动态分派
- 作业 05面试场景
新优惠策略在某些输入下返回高于原价的结果,选择器本身没有变化。评审接口文档、策略测试和调用方防御各自应承担的责任。
- 提交内容
- 责任分配表与契约验收案例。
- 怎样算完成
- 可替换性落实到输入输出
- 不只按继承语法判断正确性
练到的知识点封装业务不变量 · 继承的 is-a 关系 · 接口行为合同 · 实例方法动态分派
- 作业 06编程实践
实现容量固定的学习计划接口:新增课时不能超过总时间预算,删除后预算按合同变化;提供两个内部实现并运行同一套公共行为测试。
- 提交内容
- Java 实现、自写预算/重复/非法输入测试与运行证据。
- 怎样算完成
- 同一合同可跨实现验证
- 内部表示不影响调用方判断
练到的知识点封装业务不变量 · 接口行为合同
- 作业 07设计评审
账户允许冻结后继续查询但禁止支出,团队拟为所有字段生成 setter。写出合法状态转换与最小公开方法集合,并解释每个方法保护的约束。
- 提交内容
- 状态图和公开 API 清单。
- 怎样算完成
- 失败后状态有验收
- 可表达需求而不暴露无关修改
练到的知识点封装业务不变量
- 作业 08面试场景
某读取接口约定不改变输入,新实现为了缓存结果给输入对象附加字段。评审调用方可观察行为与实现优化的边界,给出可复现验收。
- 提交内容
- 合同对照和输入状态测试样本。
- 怎样算完成
- 优化收益与契约兼容分别评估
- 观察范围预先声明
练到的知识点接口行为合同
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
学完异常后:插件式计费策略会超时或抛错,评审是否允许策略直接访问数据库与网络,定义纯计算策略与外部服务策略的边界。
- 提交内容
- 接口拆分、失败政策与测试替身设计。
- 怎样算完成
- 运行成本纳入合同
- 职责拆分有可验证收益
练到的知识点继承的 is-a 关系 · 组合与复用选择 · 接口行为合同 · static/字段访问边界
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
“实现同一 Java 接口的类可以在任何业务位置互换”是否足够?构造一个编译通过的行为差异样本,分析需要补充哪些合同才能判断可替换性。
- 提交内容
- 最小类型草图、调用场景与合同补充。
- 怎样算完成
- 论证涉及输入输出或状态承诺
- 不只使用语法差异作为证据
练到的知识点封装业务不变量 · 接口行为合同
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读规范英文
Java 17 语言规范:类、方法与记录类 (新标签页)↗
按需阅读构造器、方法重载、static
/final、枚举和 record 的定义与约束。 - 实践规范英文
Java 17 语言规范:接口与注解接口 (新标签页)↗
阅读接口方法、注解成员、保留策略与重复注解的语言规则。
- 深入规范英文
Java 17 语言规范:名称与访问控制 (新标签页)↗
阅读作用域、名称遮蔽和 public
/protected/package/private访问规则。 - 深入参考文档英文
OpenJDK 17u java.util.AbstractCollection (新标签页)↗
定位
AbstractCollection.contains对 iterator 与 equals 的依赖,理解骨架实现和重写边界。 - 深入参考文档英文
Java17 AbstractCollection API (新标签页)↗
阅读骨架实现的目的、最小实现要求及方法覆盖建议。