继承最容易给人一种错觉:只要两个类有几行相同代码,就让其中一个 extends 另一个。这样做确实能少写几行,但也会把两个类型的公开承诺绑在一起。真正决定继承是否成立的,不是“像不像”,而是子类对象能不能在所有合理场景中替代父类对象,并继续满足父类已经答应调用者的规则。
我们会沿着一条完整路线推进:先判断 is-a 关系,再弄清成员继承与 Object 根类型;随后拆开构造链、重写、重载、字段隐藏和运行时分派;接着处理 protected、向上转型、模式匹配、抽象类和接口;最后比较组合与继承,并用内部类、匿名类和 lambda 把“类型复用”落到真实设计里。
示例以 Java SE 26 的语言规则为准。尤其要留意一个已经变化的旧结论:显式 super(...) 或 this(...) 不再被强制写成构造器的第一条语句。它前面可以有受限制的前置区,但此时对象还处于早期构造阶段,不能像构造完成后那样自由使用 this。
阅读时始终分清三个概念:变量的声明类型决定编译器允许你调用什么,引用指向对象的运行时类型决定被重写的实例方法最终执行哪一版,而字段与静态方法不会参与这种运行时动态选择。
继承表达的是“更具体的类型是更一般类型的一种”。BikeDelivery 可以是 DeliveryTask 的一种,ArrayList 可以是 List 的一种。相反,发动机是汽车的组成部分,路线规划器是配送任务使用的服务,这些是 has-a 或 uses-a,用字段组合更自然。这里的核心判断就是里氏替换原则(LSP)的直观版本:使用父类型的代码不应该因为换进一个子类型就失去原本成立的假设。
最实用的判断方式,是把子类放进只认识父类的代码里:这段代码是否仍然成立?
abstract class DeliveryTask {
private final String orderId;
protected DeliveryTask(String orderId) {
if (orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("订单号不能为空");
}
this.orderId = orderId;
}
public final String orderId() {
return orderId;
}
public abstract int estimateMinutes(double kilometers);
}
final class BikeDelivery extends DeliveryTask {
BikeDelivery(String orderId) {
super(orderId);
}
@Override
public int estimateMinutes(double kilometers) {
if (kilometers < 0) {
throw new IllegalArgumentException("距离不能为负数");
}
return (int) Math.ceil(kilometers / 15.0 * 60) + 5;
}
}如果一段调度代码只接收 DeliveryTask,它不需要知道当前对象是不是自行车配送。只要所有具体子类都接受父类契约允许的输入,并给出符合父类语义的结果,替换就成立。
把“子类可以替代父类”展开,至少要检查下面四件事:
例如,假设父类的 withdraw 明确允许在余额充足时取款,某个“冻结账户”子类却对任何金额都抛异常。这个类型在语法上可以继承,语义上却很难替代普通账户。更合理的做法通常是把“是否允许取款”建模成账户状态或策略,而不是制造一个拒绝父类核心能力的子类。
如果你只是想复用一段算法,可以提取普通方法、策略对象或工具类。继承会同时带来更多东西:子类获得父类的公开 API,父类变量可以指向子类对象,父类以后修改 protected 细节还可能影响子类。这个耦合远大于一次方法调用。
“组合优先”不是“禁止继承”。稳定的类型分类、明确的替换关系,以及需要共享受保护实现骨架时,继承很合适。变化频繁的算法、可在运行时替换的行为和单纯的代码去重,更适合组合。

这张决策图可以在设计类之前先走一遍。它不能替代契约测试,却能及时拦住“看到重复代码就 extends”的冲动。
普通类使用 extends 指定一个直接父类。Java 的类是单继承:一个类最多直接扩展一个类;但它可以同时实现多个接口。没有显式写 extends 的普通类,直接父类就是 java.lang.Object。
class DeliveryTask {
public String publicNote = "公开说明";
protected String routeCode = "R-01";
String packageMemo = "包内备注";
private String internalToken = "secret";
public void start() {
System.out.println("开始配送");
}
}
class BikeDelivery extends DeliveryTask {
public
这里要把“对象里有这份状态”和“子类继承了这个成员”分开。BikeDelivery 对象分配的空间包含父类声明的私有字段,因为父类方法仍要维护它们;但 internalToken 是父类的私有实现,子类既不继承这个成员,也不能直接访问它。子类应该通过父类提供的方法与这部分状态交互。
同包子类可以继承父类中未被隐藏、且不是 private 的可访问成员。跨包子类只继承其中 public 和 protected 的可访问成员。包访问成员在另一个包中不会突然因为“我是子类”而变得可见。
还有几类内容不参与继承:
private 成员不被子类继承。final 实例方法可以继承和调用,但不能重写。子类可以增加字段与方法,可以重写可重写的实例方法,也可以隐藏同名字段或静态方法。后两种行为看起来相似,绑定规则却完全不同,后面会专门拆开。
BikeDelivery bike = new BikeDelivery();
System.out.println(bike instanceof BikeDelivery); // true
System.out.println(bike instanceof DeliveryTask); // true
System.out.println(bike instanceof Object); // true
System.out.println(bike.getClass().getName());Object 是类层级的根。所有类对象以及数组对象都能使用它定义的方法,例如 getClass()、toString()、equals(Object) 和 hashCode()。本章关注的是“这些方法为何能被所有对象调用”以及重写如何参与动态分派,不重复展开相等性和复制策略。
final 类禁止再产生子类;sealed 类或接口可以列出允许的直接子类型。它们不是为了复用更多代码,而是把“谁有资格加入这个类型家族”写进类型声明。
构造器不会被继承,但创建子类对象时,父类构造过程一定参与。原因很直接:一个 BikeDelivery 对象内部含有 DeliveryTask 那部分状态,父类必须先有机会把自己的约束建立起来。
class DeliveryTask {
private final String orderId;
DeliveryTask(String orderId) {
System.out.println("父类构造:" + orderId);
this.orderId = orderId;
}
}
class BikeDelivery extends DeliveryTask {
private final int bagCapacity;
BikeDelivery(String orderId, int bagCapacity) {
在 Java SE 26 中,参数校验可以写在 super(orderId) 前面。super(...) 前的语句属于构造器前置区,super(...) 后是后置区。前置区适合验证参数、计算传给父类的值,以及在规则允许时先写入当前类字段。
父类构造器尚未完成时,当前对象还在早期构造上下文。为防止半成品对象逃逸,前置区对当前对象的使用有严格限制:不能使用 this 或 super 去观察、传出当前对象,不能调用当前对象的实例方法,也不能任意读取实例字段。局部变量、参数、静态成员和不会触碰当前对象的纯计算可以正常使用。
下面的思路是安全的:
class PricedDelivery extends DeliveryTask {
private final int priority;
PricedDelivery(String orderId, int priority) {
int checked = requirePriority(priority); // static 方法
this.priority = checked; // 先建立本类字段
super(orderId);
}
private static int requirePriority(int value) {
if
而在前置区调用 describe()、把 this 传入集合,或者读取一个尚未初始化的实例字段,都不是合法的早期构造操作。最容易记住的边界是:可以准备构造所需的数据,但不能把半成品对象当成已经构造好的接收者来使用。
如果构造器没有显式 this(...) 或 super(...),并且当前类不是 Object,编译器会隐式插入对直接父类无参构造器的 super() 调用。父类没有可访问的无参构造器时,子类必须显式选择一个可访问构造器,否则编译失败。
同一个构造器最多有一次显式构造器调用:
this(...) 调用同类另一个构造器,用于集中初始化逻辑。super(...) 调用直接父类构造器,用于建立父类部分。创建子类实例时,可以按下面的顺序推演:
this(...) 或 super(...)。沿着 super 链最终到达 Object,父类层层完成后再返回。super(...) 返回后,按源码顺序执行当前类的实例字段初始化器与实例初始化块。父类构造器里调用可重写实例方法尤其危险。动态分派仍会找到子类重写版本,而子类字段可能还只有默认值。Java 26 的前置区能在部分场景中提前建立字段,但更稳妥的设计仍是避免构造器调用可重写方法。
不要再写“super(...) 必须是构造器第一句”这条绝对规则。准确说法是:显式构造器调用可以有受限制的前置区;如果完全不写,编译器会隐式调用父类无参构造器。

顺着图中的编号读,你会发现“源码先写在子类构造器里”和“对象先把哪一层构造完成”是两回事。前置区只负责安全准备,父类链完成后才进入当前类的初始化器与后置区。
子类声明一个实例方法,若它与父类可访问实例方法满足重写关系,运行时可以用子类实现替代父类实现。@Override 不负责制造重写,它让编译器检查“我确实重写到了目标”,可以尽早发现参数写错、大小写写错或误把重载当重写。
class DeliveryTask {
public DeliveryTask copyFor(String newOrderId) throws java.io.IOException {
return new DeliveryTask();
}
protected Number fee() {
return 0;
}
}
class BikeDelivery extends DeliveryTask {
@Override
public BikeDelivery copyFor(String newOrderId) {
为了突出规则,上面的简化类省略了真实字段与构造参数。两处重写体现了几个实用边界:
DeliveryTask 收窄为 BikeDelivery。public,子类仍须是 public;父类是 protected,子类可以是 protected 或 public。final 实例方法明确禁止重写。private 方法不被继承,因此子类写出同签名方法,也只是一个与父类私有方法无重写关系的新方法。构造器不是方法,也不存在“重写构造器”。static 方法发生的是隐藏,后面会单独讨论。
接口方法的实现同样可以用 @Override。接口中的抽象实例方法隐式是 public,所以实现类的方法必须写成 public;漏写时得到的是访问级别过低的编译错误。
class AuditedDelivery extends BikeDelivery {
@Override
public Integer fee() {
Integer base = super.fee();
System.out.println("记录基础费用:" + base);
return base + 2;
}
}普通的 fee() 或 this.fee() 会按当前对象的运行时类型分派;super.fee() 明确从直接父类视角选择被覆盖的方法。它常用于“保留父类行为,再补充子类行为”,但不要机械调用。如果子类承诺的是完全不同的实现,强行执行父类逻辑反而会产生重复副作用。
重载和重写都可能出现同名方法,但它们回答的是两个不同问题。
class DeliveryTask {
public String summary() {
return "普通配送";
}
}
class BikeDelivery extends DeliveryTask {
@Override
public String summary() {
return "自行车配送";
}
}
class DeliveryPrinter {
public void print(DeliveryTask task) {
System.out.
输出是:
通用入口 -> 自行车配送编译器只看到实参变量 task 的声明类型是 DeliveryTask,所以重载阶段选中 print(DeliveryTask)。进入这个方法后,task.summary() 是可重写实例方法,运行时对象是 BikeDelivery,因此执行子类版本。
下面两个方法不能同时存在:
int parse(String text) { return 0; }
double parse(String text) { return 0.0; } // 编译错误调用表达式在看到返回值上下文前就需要确定方法,仅靠返回类型无法形成不同签名。合法重载必须改变参数个数或参数类型。使用装箱、可变参数和继承层级后,重载可能变得难以预测,应避免设计一组彼此过近的签名。
例如,show(String) 与 show(StringBuilder) 同时存在时,调用 show(null) 会因两个参数类型互不构成更具体关系而产生歧义。这里的问题发生在编译期,与运行时对象无关。
看到 receiver.method(argument) 时,先不要直接猜输出:
static、private 或通过 super 调用的方法,不做普通实例方法的动态重写选择。子类可以声明与父类同名的字段。两个字段会同时存在,子类字段只是把父类字段隐藏起来,不会替换父类对象布局中的那一份。字段访问根据引用表达式的编译期类型决定。
class ParentChannel {
String label = "父类字段";
static String kind() {
return "父类静态方法";
}
String message() {
return "父类实例方法";
}
}
class SmsChannel extends ParentChannel {
String label = "子类字段";
static String kind() {
return
这三行恰好构成一张绑定规则表:

不要只背“多态看运行时类型”。更可靠的做法是先识别成员形式:字段、静态方法与重载先在编译期定下来;只有已经选中的可重写实例方法,才继续按运行时对象寻找最终实现。
Java 允许写 channel.kind(),但这个写法会让人误以为它会根据对象动态分派。实际上即使 channel 指向子类对象,也仍按 channel 的声明类型选择静态方法。应分别写 ParentChannel.kind() 或 SmsChannel.kind(),让编译期归属一眼可见。
在子类内部,this.label 指当前类找到的字段,super.label 指直接父类视角中的字段。虽然语法允许这样做,真实设计里通常应该避免让可见字段重名。状态的语义如果相同,应由父类封装并提供受控方法;语义如果不同,应使用不同名称。
super 对字段的作用近似于切换到父类成员视角;对实例方法则不只是一次类型转换。super.message() 会绕过当前类的重写,直接选择父类实现,而 ((ParentChannel) this).message() 仍会动态分派回运行时类的重写版本。
protected 常被粗略解释成“子类都能访问”。这句话少了包边界与限定表达式,容易直接写出编译失败的代码。
先看同一个包:声明类所在包中的其他类可以访问 protected 成员,即使它们不是子类。因此,protected 同时包含“包内可访问”这一面。
跨包之后,规则收紧。假设父类在 core 包:
// core/BaseTask.java
package core;
public class BaseTask {
protected int priority = 1;
protected void audit() {
System.out.println("priority=" + priority);
}
}子类在另一个包:
// app/ExpressTask.java
package app;
import core.BaseTask;
public class ExpressTask extends BaseTask {
void compare(ExpressTask peer, BaseTask base) {
this.priority = 3; // 合法
peer.priority = 4; // 合法:限定表达式类型是 ExpressTask
audit(); // 合法:当前子类实现内部
// base.priority = 5; // 编译错误
// base.audit(); // 编译错误
}
}在跨包子类 ExpressTask 中,实例 protected 成员通过限定表达式访问时,限定表达式的编译期类型必须是 ExpressTask 或它的子类。即使 base 运行时恰好指向 ExpressTask 对象,只要变量声明成 BaseTask,也不能用 base.priority 绕过边界。

图中的合法与非法判断都看源码所在位置和限定表达式的编译期类型。把 base 在运行时换成 ExpressTask 对象,也不会让 base.priority 突然变得可访问。
跨包 protected 的意图是让子类负责“自己这一支对象”的实现,不是给子类一把钥匙,让它任意修改所有父类对象。ExpressTask 可以维护自己或同类实例的继承部分,却不能拿一个普通 BaseTask 引用去窥探父类受保护状态。
跨包子类可以在自己的构造器中通过 super(...) 调用父类的 protected 构造器,但这不等于它能在任意方法中写 new BaseTask(...)。受保护构造器主要授权的是子类构造过程,不是跨包自由实例化。跨包使用不带限定的类实例创建表达式时,只有同时声明匿名类的特殊形式才可能通过这层访问检查;日常代码如果需要直接创建父类对象,就应提供合适的 public 工厂或构造器。
把字段改成 protected 很方便,但任何子类都能直接写入,父类就很难维护范围、不变量和变更通知。更稳妥的方式通常是保留 private 字段,向子类提供窄而明确的 protected 方法:
public class BaseTask {
private int priority = 1;
protected final void changePriority(int value) {
if (value < 1 || value > 5) {
throw new IllegalArgumentException("优先级必须在 1 到 5 之间");
}
priority = value;
}
public final
这样父类允许扩展,但仍掌握状态规则。protected 应被当作面向扩展方的 API;一旦发布,随意改名或改变语义同样会破坏已有子类。
子类对象天然也是父类类型的对象,因此从子类引用赋给父类变量不需要显式转换,这叫向上转型:
abstract class DeliveryTask {
private final String orderId;
DeliveryTask(String orderId) {
this.orderId = orderId;
}
public String orderId() {
return orderId;
}
public abstract int estimateMinutes(double kilometers);
}
final class BikeDelivery extends DeliveryTask {
现在,一段代码可以只依赖父类:
static void printEstimates(java.util.List<DeliveryTask> tasks,
double kilometers) {
for (DeliveryTask task : tasks) {
System.out.printf("%s:%d 分钟%n",
task.orderId(), task.estimateMinutes(kilometers));
}
}列表里可以同时放 BikeDelivery 和 VanDelivery。循环体只有一份,estimateMinutes 每次根据元素的运行时类型执行对应实现。这就是多态最有价值的地方:调用方依赖稳定抽象,新类型通过重写接入,而不是让调用方不断增加 if/else。
DeliveryTask task = new BikeDelivery("A-17");
task.estimateMinutes(3.2); // 合法
// task.lockBike(); // 编译错误对象仍然是 BikeDelivery,但变量 task 的声明类型只承诺 DeliveryTask API。编译器不能因为当前赋值表达式是子类,就假定这个变量以后永远不会改指向 VanDelivery。
task.getClass() 能在运行时得到实际类,task instanceof BikeDelivery 能做类型测试。不过,如果一段通用业务频繁询问每个对象“你到底是哪一类”,往往说明抽象缺少一个该由多态完成的方法。
父类变量当然也能保存 null。此时调用实例方法会抛出 NullPointerException,因为动态查找需要真实接收者。null instanceof BikeDelivery 的结果则是 false,不会抛异常。

一根引用上同时存在“编译器允许看见的 API”和“运行时对象真正采用的实现”。只要把这两层分开,向上转型、动态分派和下一节的向下转型就会落到同一套规则里。
父类引用可能指向许多子类,所以从父类转成具体子类需要运行时检查。盲目强制转换可能编译通过,却在运行时抛出 ClassCastException:
DeliveryTask task = new VanDelivery("V-8");
BikeDelivery bike = (BikeDelivery) task; // 运行时失败现代写法通常使用带模式变量的 instanceof,把测试和转换合成一步:
static void finish(DeliveryTask task) {
if (task instanceof BikeDelivery bike) {
bike.lockBike();
}
System.out.println("配送流程结束");
}只有匹配成功,模式变量 bike 才被初始化,并且它的类型已经是 BikeDelivery。这避免了重复写类型名,也不会出现“测试了 A,却误转成 B”的手滑错误。
编译器会根据哪些路径已经证明匹配成功,决定模式变量在哪里可用:
static void secure(DeliveryTask task) {
if (!(task instanceof BikeDelivery bike)) {
return;
}
bike.lockBike(); // 能走到这里,就一定匹配成功
}而在 if 的外部直接使用普通正向分支里声明的 bike 通常不成立,因为条件为假时它没有值。用提前返回可以让成功路径更平坦。
强制转换既不会复制对象,也不会把 VanDelivery 变成 BikeDelivery。它只是要求运行时验证“当前引用所指对象与目标类型兼容”,成功后让编译器开放目标类型的成员。
DeliveryTask general = new BikeDelivery("B-9");
BikeDelivery specific = (BikeDelivery) general;
System.out.println(general == specific); // true两个变量仍指向同一个对象。转换后的引用不会得到一份独立状态。
偶尔在系统边界、序列化结果或混合容器中识别具体类型很正常。如果业务主流程到处是长串 instanceof,更好的做法通常是:
switch 处理确实封闭的类型集合。当一组类型确实共享状态、构造规则和部分实现,但某些关键行为必须由具体子类完成时,可以使用抽象类。抽象类不能直接 new,却可以声明字段、构造器、普通方法和抽象方法。
abstract class DeliveryWorkflow {
private final String orderId;
protected DeliveryWorkflow(String orderId) {
if (orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("订单号不能为空");
}
this.orderId = orderId;
}
public final void execute(double kilometers
execute 规定所有配送都遵循“校验—估时—准备—派发”的骨架,并声明为 final,防止子类跳过统一校验。estimateMinutes 和 dispatch 是必须填补的变化点;beforeDispatch 是有默认行为的可选钩子。这种结构常被称为模板方法。
抽象方法没有方法体,以分号结束。包含抽象方法的普通类必须声明为 abstract。具体子类如果没有实现继承到的全部抽象方法,就也必须保持抽象,不能实例化。
抽象类可以有构造器。创建具体子类时,构造链会执行抽象父类构造器和字段初始化器,只是不能直接创建抽象类实例。抽象类变量仍然可以保存具体子类引用:
DeliveryWorkflow workflow = new BikeWorkflow("B-10");
workflow.execute(2.5);即使父类是抽象类,构造期间的方法调用仍会动态分派。若抽象父类构造器调用 dispatch(),执行的会是子类实现,而子类字段可能尚未初始化。模板方法应在对象构造完成后由调用者显式启动,不要把多态业务流程塞进构造器。
抽象类适合这些情况:类型之间有稳定的共同状态;需要受保护的实现协作;希望用构造器统一建立不变量;共同算法骨架确实属于这个类型家族。如果调用方只需要一种能力,或实现类已经必须继承别的父类,接口通常更灵活。
接口特别适合表达“对象能做什么”,而不规定它必须属于哪条类继承链。一个类只能直接继承一个父类,却可以实现多个接口。
interface Trackable {
String currentPosition();
default String trackingText() {
return "当前位置:" + normalize(currentPosition());
}
static boolean isValidCode(String code) {
return code != null && code.matches("[A-Z]-\\d+");
}
private static
没有 private、default 或 static 修饰符的接口方法隐式是 public abstract。实现类必须用 public 方法兑现它。接口字段则隐式是 public static final 常量,接口不能给每个实现对象增加实例字段。
default 是带实现的实例方法。实现类可以直接继承,也可以重写。static 属于接口本身,通过 Trackable.isValidCode(...) 调用,不由实现对象继承成实例能力。private 接口方法用于复用接口内部实现,不会被实现类或子接口继承。它可以是实例方法,也可以与 static 同时使用。默认方法让接口可以在不强迫所有既有实现类立刻修改的前提下增加合理默认能力,但它仍必须遵守接口语义。不要用默认方法隐藏一个只有少数实现才成立的假设。
interface ChineseLabel {
default String label() {
return "配送";
}
}
interface EnglishLabel {
default String label() {
return "Delivery";
}
}
final class BilingualLabel implements ChineseLabel, EnglishLabel {
@Override
public String label() {
两个无关接口提供同签名默认方法时,实现类必须重写,消除歧义。可以用 接口名.super.方法() 选择某个直接父接口版本。若类继承链中已经有具体同签名方法,类方法优先于接口默认方法;若一个父接口比另一个更具体,则更具体的默认实现优先。
static void printTracking(Trackable target) {
System.out.println(target.trackingText());
}这个方法不关心对象是不是配送任务,也不需要看到保险金额。参数类型越贴近真实需要,调用者与实现越容易独立变化。接口变量、参数、返回类型和数组元素都可以保存任意实现该接口的对象,并通过实例方法动态分派。
继承把关系固定在类声明上,组合把另一个对象作为字段保存。若变化维度与主体类型并不一致,组合能避免子类数量相乘。
假设配送价格可能按普通、夜间、会员三种策略计算,同时交通方式又有自行车、货车和步行。如果为每一种组合创建子类,很快就会出现 NightBikeDelivery、MemberVanDelivery 等大量类型。
把价格算法提成接口更直接:
@FunctionalInterface
interface FeePolicy {
int calculate(double kilometers);
}
final class DeliveryService {
private final FeePolicy feePolicy;
DeliveryService(FeePolicy feePolicy) {
if (feePolicy == null) {
throw new IllegalArgumentException("计价策略不能为空");
}
this.feePolicy =
DeliveryService 使用 FeePolicy,却不是 FeePolicy 的一种。算法可以独立测试、按构造参数替换,也不需要暴露服务内部状态给子类。
组合不是简单地“多放一个字段”。要说明依赖由谁创建、能否为 null、是否可变、能否被多个对象共享。上例通过构造器注入并保存为 final,使 DeliveryService 创建后始终有一份可用策略,也便于测试传入一个可预测的假实现。
若一个类型只是想在另一个对象外面增加日志、缓存或权限检查,它通常不是被包装类型的语义子类。装饰器可以实现同一个接口并持有另一个接口对象,把调用委托出去;这样既保留多态,又不依赖具体父类实现细节。
有些辅助类型只服务于一个外部类,把它放在使用者附近能缩小可见范围。Java 把声明在另一个类或接口内部的类统称为嵌套类,其中又要区分静态嵌套类和内部类。
public class RoutePlan {
private final String routeName;
public RoutePlan(String routeName) {
this.routeName = routeName;
}
public static class Coordinate {
private final double latitude;
private final double longitude;
public Coordinate(double latitude, double longitude) {
Coordinate 是静态嵌套类,没有隐式外部对象,外部创建时写 new RoutePlan.Coordinate(...)。Checkpoint 是非静态成员内部类,每个实例都关联一个具体 RoutePlan 对象,外部创建形式是:
RoutePlan plan = new RoutePlan("北线");
RoutePlan.Checkpoint point = plan.new Checkpoint("仓库");内部类可以访问所关联外部对象的私有成员。这很方便,也意味着内部类对象可能让外部对象持续可达;如果并不需要外部实例,应优先使用静态嵌套类,减少隐式耦合。
匿名类在创建对象的位置同时声明一个没有名字的类:
FeePolicy capped = new FeePolicy() {
@Override
public int calculate(double kilometers) {
return Math.min(30, 10 + (int) Math.ceil(kilometers * 3));
}
};匿名类可以扩展一个类,或实现一个接口,并能声明额外字段和方法。它适合一次性但实现稍复杂的对象。若同一实现会重复使用,命名类通常更清楚、更易测试。
函数式接口只有一个需要实现的抽象方法,default、static 和 private 方法不增加抽象方法数量。这样的接口可以用 lambda 或方法引用创建实例:
FeePolicy capped = km -> Math.min(30, 10 + (int) Math.ceil(km * 3));lambda 不是“任何匿名类的缩写”。它只能面向函数式接口,并且 this 指向词法外层对象;匿名类中的 this 指向匿名类实例。两者捕获局部变量时,变量必须是 final 或事实上保持不变,也就是 effectively final。
内部类和 lambda 是实现位置的选择,不会改变接口方法的动态分派语义。调用者仍然只依赖 FeePolicy,具体对象可能来自命名类、匿名类、lambda 或方法引用。
最后做一次完整设计。需求是:不同配送方式共享订单号校验和状态流转;每种方式独立计算时间;所有方式都能输出追踪信息;计价算法可以按业务场景替换。
先把四种关系分开:
Delivery 接口表达调用方需要的配送能力。AbstractDelivery 提供共同状态、构造规则和最终流程。BikeDelivery、VanDelivery 是可替换的具体类型。FeePolicy 是被组合进调度服务的变化算法。
左侧类型轴回答“这些对象能否按同一份契约使用”,右侧策略轴回答“哪段算法需要独立替换”。中间的调度中心只依赖接口和组合关系,所以两条变化轴可以各自扩展。
import java.util.List;
import java.util.Objects;
interface Delivery {
String orderId();
int estimateMinutes(double kilometers);
void dispatch();
default String trackingText() {
return orderId() + " 已进入配送流程";
}
}
abstract class AbstractDelivery implements Delivery {
private
这份设计里,调用方没有按具体类型分支。新增步行配送时,实现 Delivery 或扩展 AbstractDelivery 即可;新增夜间计价时,只增加一个 FeePolicy 实现。继承轴与策略轴彼此独立,不会组合出一片子类森林。
至少检查这些边界:
Delivery 引用得到合理估时。dispatch() 的“不重复派发”规则对所有子类一致成立。FeePolicy 只改变价格,不改变配送对象的估时与状态规则。DispatchCenter 不需要修改类型判断分支。使用断言做一个最小回归检查:
Delivery bike = new BikeDelivery("T-1");
Delivery van = new VanDelivery("T-2");
assert bike.estimateMinutes(2) == 13;
assert van.estimateMinutes(2) == 17;
bike.dispatch();
try {
bike.dispatch();
throw new
运行断言测试时可使用 java -ea DeliveryApp 开启断言。正式项目更适合用测试框架把每个契约写成独立测试,并让所有子类复用同一组父契约测试。
@Override,访问级别、返回类型和受检异常是否兼容?protected 是否缩小为必要的扩展方法,而不是裸露状态?this(...),同类目标构造器已经负责初始化器,本构造器只继续自己的后置区。