qinghuan
← Java SE / 全部笔记

单元 17 / Java SE

P0 · 必会主线L1 · 基础

面向对象:从封装、继承到接口多态

从优惠券和余额的规则出发,理解对象怎样保护数据,以及接口怎样让不同实现被统一使用。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能用接口隔离实现,并区分继承、多态与简单代码复用。

学习理由、准备要求与知识点

为什么学它

封装、接口与多态构成 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["统一验证并选择最低金额"]

按顺序看,每一步做了什么

  1. 写清对象负责维护的不变量。
  2. 用接口描述调用者真正需要的行为。
  3. 在构造器或操作边界校验状态。
  4. 通过接口执行可替换实例方法。
  5. 测试每个实现是否履行相同契约。

03对照实现代码

骨架实现如何通过 iterator 实现通用 contains (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/AbstractCollection.java · java.util.AbstractCollection#contains(Object)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
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 的成本与元素数量相关,因此继承一个默认实现只代表获得可用行为,不代表它适合所有存储结构。哈希集合可以选择自己的查找实现,在保持公开语义的同时改变性能。阅读骨架类应同时看依赖的方法、允许重写的扩展点和具体实现最终是否覆盖该方法。

代码先调用谁,再调用谁

  1. 调用集合 contains
  2. 骨架实现请求 iterator
  3. 具体迭代器提供元素
  4. 通过 equals 匹配并返回,具体子类也可重写整个查找

04动手写一个例子

通过优惠接口选择最低独立报价

给定非负 int 原价和一组互斥优惠,计算最低应付金额。固定减免最低到零;比例优惠按分向上取整。允许不使用优惠,拒绝不履行零到原价范围契约的实现。

输入 1
原价1000;减100、支付85%
输出 1
850
输入 2
原价99;支付50%
输出 2
50
输入 3
原价0;无优惠
输出 3
0

先把不使用优惠的原价设为当前最优值,然后逐个请求候选报价并取较小者。所有优惠是互斥选择,不是依次对已优惠价格再打折,因此每个实现都接收同一个原价。这个固定输入使比较具有同一基准,避免迭代顺序改变业务结果。

策略负责自己规则的参数校验与金额计算,选择器负责校验返回值契约。双层检查并不重复:前者知道减免或比例是否合法,后者不知道具体实现,却需要防止异常候选破坏统一比较。比例计算使用 long 中间值,并因原价限定 int 非负而有明确乘法范围保证。

一次遍历足以找最小值,因为处理每个候选后,当前结果就是已经见过的所有报价及原价的最小值。平价时本题只返回金额,所以不需要规定哪张券优先;若返回券标识,则必须补充稳定的平价规则。算法正确仍依赖每次 pay 对相同原价表达可比较的独立报价。

为什么这样写是对的每轮结束后 best 是原价与所有已检查优惠报价中的最小合法金额。

时间 O(k),前提是每个报价策略计算为 O(1)。额外空间 O(1) 额外空间。

k 个优惠各调用一次;通用情形成本应写为 O(k + 所有策略调用成本之和),不能忽略策略内可能执行的昂贵动作。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 44 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能用接口隔离实现,并区分继承、多态与简单代码复用。

本篇重点加练

重点加练业务不变量与接口行为合同,检验多态扩展是否保持原有承诺。

  • 封装业务不变量
  • 接口行为合同

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    设计 BankAccount,只允许非负余额,开户金额和取款金额均需验证。列出构造与公开方法的前置、后置条件,审查开放 setBalance 是否符合业务合同。

    提交内容
    类接口及不变量表。
    怎样算完成
    • 合法状态由接口维护
    • 失败时状态变化有明确合同

    练到的知识点封装业务不变量 · 构造器输入验证

  2. 作业 02原理问答

    课程播放器要支持下载、字幕与播放,部分直播不可下载。给出继承和组合两份模型,使用具体调用场景检验可替换性与复用成本。

    提交内容
    类图或对象图及方案比较。
    怎样算完成
    • is-a 判断有行为依据
    • 复用方式与变化点对应

    练到的知识点继承的 is-a 关系 · 组合与复用选择

综合应用

  1. 作业 03代码推演

    定义接口 Speaker、父类 Base 和子类 Child,分别包含重写方法、一个重载方法、同名字段及 static 方法;用 Base 类型变量引用 Child,先预测调用绑定再编译运行。

    提交内容
    独立 Java 示例、预测表和实际记录。
    怎样算完成
    • 明确表达式静态类型与实际对象
    • 四种成员情形分别论证

    练到的知识点接口行为合同 · 实例方法动态分派 · 重载与重写 · static/字段访问边界

  2. 作业 04编程实践

    实现运费接口与按件、按重量两种策略,输入包裹数量和克重,金额用整数最小单位;报价不得为负,非法输入必须有统一合同。

    提交内容
    完整 Java 实现、自写正常/零值/极值/非法输入测试及运行证据。
    怎样算完成
    • 调用方通过接口使用策略
    • 构造与调用边界均受检验
    • 不使用类型分支模拟多态

    练到的知识点封装业务不变量 · 构造器输入验证 · 接口行为合同 · 实例方法动态分派

  3. 作业 05面试场景

    新优惠策略在某些输入下返回高于原价的结果,选择器本身没有变化。评审接口文档、策略测试和调用方防御各自应承担的责任。

    提交内容
    责任分配表与契约验收案例。
    怎样算完成
    • 可替换性落实到输入输出
    • 不只按继承语法判断正确性

    练到的知识点封装业务不变量 · 继承的 is-a 关系 · 接口行为合同 · 实例方法动态分派

  4. 作业 06编程实践

    实现容量固定的学习计划接口:新增课时不能超过总时间预算,删除后预算按合同变化;提供两个内部实现并运行同一套公共行为测试。

    提交内容
    Java 实现、自写预算/重复/非法输入测试与运行证据。
    怎样算完成
    • 同一合同可跨实现验证
    • 内部表示不影响调用方判断

    练到的知识点封装业务不变量 · 接口行为合同

  5. 作业 07设计评审

    账户允许冻结后继续查询但禁止支出,团队拟为所有字段生成 setter。写出合法状态转换与最小公开方法集合,并解释每个方法保护的约束。

    提交内容
    状态图和公开 API 清单。
    怎样算完成
    • 失败后状态有验收
    • 可表达需求而不暴露无关修改

    练到的知识点封装业务不变量

  6. 作业 08面试场景

    某读取接口约定不改变输入,新实现为了缓存结果给输入对象附加字段。评审调用方可观察行为与实现优化的边界,给出可复现验收。

    提交内容
    合同对照和输入状态测试样本。
    怎样算完成
    • 优化收益与契约兼容分别评估
    • 观察范围预先声明

    练到的知识点接口行为合同

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    学完异常后:插件式计费策略会超时或抛错,评审是否允许策略直接访问数据库与网络,定义纯计算策略与外部服务策略的边界。

    提交内容
    接口拆分、失败政策与测试替身设计。
    怎样算完成
    • 运行成本纳入合同
    • 职责拆分有可验证收益

    练到的知识点继承的 is-a 关系 · 组合与复用选择 · 接口行为合同 · static/字段访问边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    “实现同一 Java 接口的类可以在任何业务位置互换”是否足够?构造一个编译通过的行为差异样本,分析需要补充哪些合同才能判断可替换性。

    提交内容
    最小类型草图、调用场景与合同补充。
    怎样算完成
    • 论证涉及输入输出或状态承诺
    • 不只使用语法差异作为证据

    练到的知识点封装业务不变量 · 接口行为合同

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗