接口、抽象类与组合的选择
接口、抽象类和组合都能隔离变化,但建立的关系不同。接口描述调用方需要的能力,抽象类为一组子类提供共同状态和实现,组合让对象把工作交给另一个可替换的对象。
1. 接口描述可以依赖的能力
接口适合定义调用方真正需要的方法,而不要求实现类来自同一个继承层次:
public interface PricePolicy {
long calculate(long originalPriceInCents);
}
public final class MemberPricePolicy implements PricePolicy {
@Override
public long calculate(long originalPriceInCents) {
return Math.round(originalPriceInCents * 0.9);
}
}
调用方只依赖 PricePolicy,可以替换会员价、活动价或测试实现。一个类还能实现多个接口,因此可以同时表达多种能力。
1.1 接口可以声明哪些成员
现代 Java 接口不只包含抽象方法:
- 普通接口方法默认是
public abstract。 default方法可以提供实例方法的默认实现。static方法属于接口本身,通过接口名调用。private方法可以复用接口内部的实现,只能由接口中的其他方法调用。- 接口字段只能是常量,隐式带有
public static final。
public interface PricePolicy {
long calculate(long originalPriceInCents);
default boolean supports(long originalPriceInCents) {
return originalPriceInCents > 0;
}
static PricePolicy originalPrice() {
return price -> price;
}
}
default 方法主要用于在兼容已有实现的前提下扩展接口。它不适合让接口逐渐承担实现类的全部职责,也不能保存每个对象独有的实例状态。
2. 抽象类提供共同状态和实现
抽象类不能直接实例化,但可以声明构造方法、实例字段、普通方法和抽象方法:
public abstract class BasePricePolicy implements PricePolicy {
private final long minimumPriceInCents;
protected BasePricePolicy(long minimumPriceInCents) {
if (minimumPriceInCents < 0) {
throw new IllegalArgumentException("minimum price must not be negative");
}
this.minimumPriceInCents = minimumPriceInCents;
}
@Override
public final long calculate(long originalPriceInCents) {
long discounted = discount(originalPriceInCents);
return Math.max(discounted, minimumPriceInCents);
}
protected abstract long discount(long originalPriceInCents);
}
这个抽象类固定了“先计算折扣,再保证最低价”的流程,并保存所有子类都需要的最低价。子类只实现变化的 discount 步骤。
抽象类适合以下情况:
- 多个子类确实属于同一类对象,并遵守共同契约。
- 子类需要共享实例状态、构造过程或受保护的实现。
- 父类能够提供稳定的模板流程,只把少量步骤交给子类完成。
Java 类只能直接继承一个父类。选择抽象类,就同时选择了这条继承层次,也让子类依赖父类的生命周期和可见实现。
3. 组合把变化交给协作者
组合通过字段保存另一个对象,在需要时委托它完成工作:
public final class CheckoutService {
private final PricePolicy pricePolicy;
public CheckoutService(PricePolicy pricePolicy) {
this.pricePolicy = pricePolicy;
}
public long checkout(long originalPriceInCents) {
if (!pricePolicy.supports(originalPriceInCents)) {
throw new IllegalArgumentException("unsupported price");
}
return pricePolicy.calculate(originalPriceInCents);
}
}
CheckoutService 与 PricePolicy 是“使用”关系,不是父子类型。策略可以在构造时注入,也可以由工厂根据订单条件选择。
组合的主要收益是:
- 对象只依赖协作者公开的契约,不依赖父类实现细节。
- 不同变化维度可以分别建模,避免子类数量相乘。
- 协作者容易用测试替身替换。
- 可以在运行时选择实现,而不需要改变对象的实际类型。
组合也会增加对象和委托层次。如果行为稳定、没有替换需求,直接实现可能更清楚,不需要为了“面向接口”额外创建接口。
4. 三种方式怎样选择
| 判断条件 | 接口 | 抽象类 | 组合 |
|---|---|---|---|
| 主要目的 | 定义可替换的能力或契约 | 共享状态、构造过程和部分实现 | 复用或替换一个协作者的行为 |
| 类型关系 | 实现类“具备这种能力” | 子类“是这种对象” | 当前对象“使用这个对象” |
| 实现数量 | 一个类可实现多个接口 | 一个类只能直接继承一个类 | 可以持有多个不同协作者 |
| 实例状态 | 接口不能保存实例状态 | 可以保存并保护共同状态 | 状态由各自对象独立维护 |
| 运行时替换 | 替换接口实现 | 通常需要创建另一种子类对象 | 可以替换字段中的协作者 |
| 主要代价 | 接口过细会增加间接层 | 子类与父类耦合较强 | 需要委托代码和对象装配 |
实际设计中,三者经常一起使用:
- 用接口定义调用方需要的能力。
- 只有部分实现确实共享状态和稳定流程时,才提供可选的抽象基类。
- 业务对象通过组合使用接口,而不是继承某个实现类。
接口是契约,抽象类可以是契约的一种复用实现,组合则是对象之间的协作方式。它们不是只能三选一。
5. 一个变化维度的完整示例
假设订单支持普通价和会员价:
public interface PricePolicy {
long calculate(long originalPriceInCents);
}
public final class RegularPricePolicy implements PricePolicy {
@Override
public long calculate(long originalPriceInCents) {
return originalPriceInCents;
}
}
public final class MemberPricePolicy implements PricePolicy {
@Override
public long calculate(long originalPriceInCents) {
return Math.round(originalPriceInCents * 0.9);
}
}
public final class Order {
private final long originalPriceInCents;
private final PricePolicy pricePolicy;
public Order(long originalPriceInCents, PricePolicy pricePolicy) {
this.originalPriceInCents = originalPriceInCents;
this.pricePolicy = pricePolicy;
}
public long finalPriceInCents() {
return pricePolicy.calculate(originalPriceInCents);
}
}
新增限时活动价时,只需要增加一个 PricePolicy 实现并在装配位置选择它。Order 不需要增加类型判断,也不需要产生 MemberOrder、CampaignOrder 等子类。
如果所有计价策略以后都需要共享复杂的参数校验,可以增加一个实现 PricePolicy 的抽象基类。但在真实共享需求出现之前,接口加独立实现已经足够。
6. 常见问题
6.1 抽象类可以没有抽象方法吗
可以。abstract 类不能直接实例化,但不要求一定声明抽象方法。这样做可以表示一个只允许作为基类使用的类型。不过,如果类既没有抽象行为,也没有合理的继承约束,需要重新判断它是否真的应该是抽象类。
6.2 接口可以有字段吗
可以声明字段,但这些字段隐式是 public static final,属于接口本身,不是每个实现对象的实例状态。可变实例状态应放在实现类或组合对象中。
6.3 两个接口提供同名 default 方法会怎样
如果一个类继承了两个互不相关接口的同签名 default 方法,编译器无法自动确定使用哪一个,实现类必须重写并明确处理:
interface Left {
default String name() {
return "left";
}
}
interface Right {
default String name() {
return "right";
}
}
final class Choice implements Left, Right {
@Override
public String name() {
return Left.super.name();
}
}
类中已有的实例方法优先于接口 default 方法;更具体的子接口方法也优先于父接口方法。只有无法按这些规则确定时,才需要实现类解决冲突。
6.4 为什么说“组合优于继承”不是绝对规则
组合更适合独立变化的协作者,继承更适合稳定的子类型和公共流程。如果所有子类都能遵守父类契约,而且父类确实需要控制构造、状态和模板步骤,抽象类比重复委托更直接。判断依据是关系和变化方向,不是一句固定口号。
7. 面试题
7.1 接口和抽象类有什么区别,应该怎样选择
出现公司:中科创达、网易、美团
考察重点
- 接口和抽象类分别能够声明哪些状态与方法。
- 单继承、多接口实现对设计的影响。
- 能否结合具体变化说明选择,而不只罗列语法差异。
相关内容:第 1 节“接口描述可以依赖的能力”、第 2 节“抽象类提供共同状态和实现”、第 4 节“三种方式怎样选择”。
参考回答
接口主要定义调用方可以依赖的能力,一个类可以实现多个接口。接口可以有抽象方法、默认方法、静态方法和私有辅助方法,但不能保存对象各自的实例状态。
抽象类可以保存实例字段、定义构造过程并提供 protected 或普通方法,适合一组真正属于同一类型、需要共享状态和稳定流程的子类。由于 Java 只有单类继承,选择抽象类也会占用并绑定这条继承层次。
如果只是让一个对象使用可替换的计价、存储或通知策略,我会定义一个小接口并通过组合注入实现。只有多个实现确实需要共同状态和模板流程时,再提供抽象基类。
7.2 接口的 default 方法解决了什么问题
出现公司:字节跳动
考察重点
default方法与抽象接口方法、静态方法的区别。- 为什么接口演进需要兼顾已有实现类。
- 多个默认实现冲突时怎样处理。
相关内容:第 1.1 节“接口可以声明哪些成员”、第 6.3 节关于默认方法冲突的说明。
参考回答
default 方法允许接口在增加实例方法时提供默认实现。已有实现类不必立即实现这个新方法,因此接口可以在一定范围内兼容演进。实现类仍然可以重写默认方法。
如果两个无继承关系的接口提供了相同签名的默认方法,实现类必须重写来消除歧义。类中已有的实例方法会优先于接口默认方法。default 方法适合提供小而通用的默认行为,不应被当作在接口中维护实例状态的工具。