跳到主要内容

接口、抽象类与组合的选择

接口、抽象类和组合都能隔离变化,但建立的关系不同。接口描述调用方需要的能力,抽象类为一组子类提供共同状态和实现,组合让对象把工作交给另一个可替换的对象。

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);
}
}

CheckoutServicePricePolicy 是“使用”关系,不是父子类型。策略可以在构造时注入,也可以由工厂根据订单条件选择。

组合的主要收益是:

  • 对象只依赖协作者公开的契约,不依赖父类实现细节。
  • 不同变化维度可以分别建模,避免子类数量相乘。
  • 协作者容易用测试替身替换。
  • 可以在运行时选择实现,而不需要改变对象的实际类型。

组合也会增加对象和委托层次。如果行为稳定、没有替换需求,直接实现可能更清楚,不需要为了“面向接口”额外创建接口。

4. 三种方式怎样选择

判断条件接口抽象类组合
主要目的定义可替换的能力或契约共享状态、构造过程和部分实现复用或替换一个协作者的行为
类型关系实现类“具备这种能力”子类“是这种对象”当前对象“使用这个对象”
实现数量一个类可实现多个接口一个类只能直接继承一个类可以持有多个不同协作者
实例状态接口不能保存实例状态可以保存并保护共同状态状态由各自对象独立维护
运行时替换替换接口实现通常需要创建另一种子类对象可以替换字段中的协作者
主要代价接口过细会增加间接层子类与父类耦合较强需要委托代码和对象装配

实际设计中,三者经常一起使用:

  1. 用接口定义调用方需要的能力。
  2. 只有部分实现确实共享状态和稳定流程时,才提供可选的抽象基类。
  3. 业务对象通过组合使用接口,而不是继承某个实现类。

接口是契约,抽象类可以是契约的一种复用实现,组合则是对象之间的协作方式。它们不是只能三选一。

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 不需要增加类型判断,也不需要产生 MemberOrderCampaignOrder 等子类。

如果所有计价策略以后都需要共享复杂的参数校验,可以增加一个实现 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 方法适合提供小而通用的默认行为,不应被当作在接口中维护实例状态的工具。