Java里抽象类和接口经常被拿来对比,但在模块化设计中,真正要解决的不是选哪个,而是怎样让两者协同。抽象类适合提取同一族对象的公共状态和默认行为,接口适合定义模块对外的能力边界。如果一个模块既需要对外保持稳定契约,又需要在内部复用大量通用逻辑,单靠接口或单靠抽象类都会让代码走向僵硬。本文用一个支付模块的例子,分别演示接口拆分、抽象类骨架实现、适配器组合,以及接口演进时的默认方法策略,并总结常见的设计误区。

一、抽象类和接口的差异决定使用场景
从语法层面看,抽象类可以有实例字段、构造器、普通方法和抽象方法,子类只能单继承;接口在Java 8之后支持默认方法和静态方法,但不能维护实例状态,实现类可以实现多个接口。这个语法差异带来语义差异:抽象类回答“对象是什么”,接口回答“对象能做什么”。模块内部如果多个类共享同一组字段和初始化逻辑,选择抽象类更自然;如果只是承诺一组能力,且这些能力可能被不同继承体系的类实现,接口就是更好的选择。
举个例子,支付模块中有微信支付、支付宝支付、银行卡支付。它们都具备“发起支付”“查询订单”“退款”等能力,但底层渠道不同。如果把这些能力全部塞进一个抽象类,会导致后续新增一个只支持部分能力的渠道时被迫实现不相关方法;如果完全用接口而不抽公共逻辑,则校验、日志、异常转换等代码会在各个实现类里重复。正确方式是接口定义能力,抽象类承载公共部分,两者配合。
下面的代码展示一个简单的抽象类和接口对比:
// 接口:只描述能力
public interface PaymentGateway {
PaymentResult pay(PaymentRequest request);
PaymentStatus query(String orderId);
RefundResult refund(String orderId, long amount);
}
// 抽象类:包含公共状态和默认逻辑
public abstract class AbstractPaymentHandler implements PaymentGateway {
protected final ChannelConfig config;
protected final AuditLogger logger;
protected AbstractPaymentHandler(ChannelConfig config, AuditLogger logger) {
this.config = config;
this.logger = logger;
}
protected void validateRequest(PaymentRequest request) {
if (request == null || request.getAmount() <= 0) {
throw new IllegalArgumentException("非法支付请求");
}
}
}
接口里的三个方法,抽象类只实现了接口但未实现具体方法,子类继续负责实现。这样接口被模块外部引用,抽象类被模块内部继承,边界比较清晰。
二、用接口拆分模块契约,避免大而全
接口一旦设计得太大,就会逼迫所有实现类去实现自己不需要的方法。以PaymentGateway为例,它包含退款能力,但某些渠道如扫码支付初期并不支持退款。如果模块直接依赖这个大接口,渠道适配层要么抛UnsupportedOperationException,要么返回空结果,调用方还需要判断。更合理的做法是按能力拆分成多个小接口,遵循接口隔离原则。比如Payable负责支付,Refundable负责退款,Queryable负责查询。模块对外暴露的是这些细粒度能力,而不是一个臃肿的PaymentGateway。
拆分后的接口可以这样定义:
public interface Payable {
PaymentResult pay(PaymentRequest request);
}
public interface Queryable {
PaymentStatus query(String orderId);
}
public interface Refundable {
RefundResult refund(String orderId, long amount);
}
// 某渠道只支持支付和查询,不实现退款
public class WechatPayChannel implements Payable, Queryable {
// 实现 pay 和 query
}
调用方若需要退款能力,可以在运行时判断对象是否实现了Refundable,或者通过依赖注入只注入需要的接口。这样模块的契约更清晰,实现类也不再被迫空转。
接口还可以使用默认方法为某些能力提供通用实现,降低实现类负担。例如Queryable接口可以给一个默认的query实现,如果渠道没有特殊查询逻辑,直接复用即可。但默认方法不能持有状态,适合无状态逻辑,如将请求对象转换成Map、拼接URL等。不要在默认方法中访问可变实例字段,因为接口没有实例字段。这个限制正好强化了接口的契约属性:它只描述行为,不涉及内部状态。
三、用抽象类搭建模块内部骨架
抽象类在模块内部最大的价值是实现模板方法模式。支付流程通常有固定的步骤:参数校验、风控检查、调用渠道、记录日志、转换结果。其中参数校验和日志记录是公共逻辑,渠道调用是可变逻辑。可以把固定步骤放在抽象类的普通方法中,将渠道调用声明为抽象方法,由子类实现。这样每个渠道适配器只需要关心与第三方SDK交互的部分,通用流程不会散落各处。
下面是一个支付处理骨架的示例:
public abstract class AbstractPaymentHandler implements Payable, Queryable {
private final AuditLogger logger;
protected AbstractPaymentHandler(AuditLogger logger) {
this.logger = logger;
}
@Override
public final PaymentResult pay(PaymentRequest request) {
validateRequest(request);
logger.record("支付请求", request.getOrderId());
PaymentResult result = doPay(request);
logger.record("支付结果", result.getStatus());
return result;
}
protected void validateRequest(PaymentRequest request) {
if (request.getAmount() <= 0) {
throw new IllegalArgumentException("金额必须大于0");
}
}
protected abstract PaymentResult doPay(PaymentRequest request);
}
pay方法用final修饰,防止子类破坏流程;doPay是子类必须实现的扩展点。这种骨架实现可以放在模块内部,不对外暴露具体的AbstractPaymentHandler类,对外只暴露接口Payable和Queryable。调用方依赖接口,模块内部通过工厂或依赖注入创建具体渠道对象。
抽象类还可以承载公共状态,例如渠道配置、连接池、序列化工具等。通过构造器注入这些依赖,子类无需关心初始化过程。接口的默认方法做不到这一点,因为它没有实例字段。因此当公共逻辑需要读取配置或复用某个服务时,抽象类比接口默认方法更合适。但要注意抽象类单继承的限制:如果渠道适配器已经继承了某个第三方SDK的基类,就不能再继承AbstractPaymentHandler。此时可以让抽象类实现接口,渠道适配器组合一个内部处理器,通过委托来完成骨架流程。
四、组合抽象类与接口的实践技巧
模块设计时建议遵循“接口对外,抽象类对内”的原则。对外发布的API、其他模块依赖的类型,一律使用接口;抽象类只作为模块内部的辅助基类,不跨模块暴露。这样做的好处是模块的实现细节被隐藏,未来可以用接口默认方法或新的实现类替换抽象类,不影响调用方。例如支付模块可以对外发布Payable、Refundable等接口,而AbstractPaymentHandler放在内部包中,外部模块无法直接继承,只能通过工厂获取接口实例。
接口新增方法时优先考虑默认方法,避免破坏已有实现。例如随着业务发展,Queryable接口需要增加一个根据订单号列表批量查询的方法。如果直接添加抽象方法,所有实现类都要修改。可以先提供默认方法,内部基于已有的query方法循环调用,实现类可以根据需要覆盖。这样接口演进更平滑。但默认方法要谨慎使用,过多的默认方法会让接口变成工具类,模糊契约边界。
适配器模式是两者组合的典型场景。第三方支付SDK的API各不相同,例如微信使用WxPayApi,支付宝使用AlipayClient。可以在模块内部为每个渠道实现一个适配器,它们都实现Payable接口。如果这些适配器共享一些公共转换逻辑,可以让它们继承一个AbstractChannelAdapter抽象类,该类实现Payable并持有通用字段,同时定义抽象的buildChannelRequest方法。这样第三方SDK的差异被隔离在子类中,模块核心逻辑依赖统一接口。
public abstract class AbstractChannelAdapter implements Payable {
protected final ChannelProperties properties;
protected AbstractChannelAdapter(ChannelProperties properties) {
this.properties = properties;
}
@Override
public PaymentResult pay(PaymentRequest request) {
ChannelRequest channelRequest = buildChannelRequest(request);
String response = doChannelCall(channelRequest);
return parseResponse(response);
}
protected abstract ChannelRequest buildChannelRequest(PaymentRequest request);
protected abstract String doChannelCall(ChannelRequest channelRequest);
protected abstract PaymentResult parseResponse(String response);
}
不同渠道子类只需实现三个抽象方法,支付主流程被基类固定。这种设计把变化点压缩到最小,新增渠道时不会影响现有代码,符合开闭原则。
五、常见的抽象类与接口误区和重构建议
第一个误区是把接口当作常量仓库。一些旧项目会定义接口Constants,里面写满public static final String API_URL,然后让实现类通过implements Constants来偷懒使用常量。这不仅让实现类的公共接口被常量污染,还破坏了接口语义。常量应该放在final class或枚举中,通过静态导入使用。第二个误区是为每个类都创建接口,哪怕只有一个实现。这种过度设计增加了文件数量和跳转成本,并没有带来扩展性。如果当前没有第二个实现,可以先使用具体类,等真正出现多态需求时再提取接口。
第三个误区是抽象类中所有方法都是抽象方法,这样的抽象类实际上退化成接口,应该直接改用接口。第四个误区是接口方法太多,把支付、查询、退款、对账、分账全部放在一个接口里,导致实现类被迫实现无关方法。重构时可以先分析模块对外到底提供哪些独立能力,按调用方的使用频率和场景拆分接口。一般建议每个接口的方法数量控制在3到5个以内,职责单一。
下表总结了不同场景下的选择依据:
| 场景 | 优先选择 | 原因 |
|---|---|---|
| 需要共享字段或初始化逻辑 | 抽象类 | 接口不能维护实例状态 |
| 只约定能力,允许跨继承体系实现 | 接口 | 实现类可以多实现 |
| 固定流程步骤,变化点明确 | 抽象类模板方法 | 防止子类破坏流程 |
| 模块对外暴露的契约 | 接口 | 隐藏内部实现,降低耦合 |
| 需要平滑演进接口 | 接口默认方法 | 避免所有实现类修改 |
抽象类和接口不是竞争关系,而是模块设计中的两个不同维度的工具。好的模块通常对外用接口定义能力边界,对内用抽象类沉淀公共骨架,再通过组合和适配器模式隔离变化。当发现代码中出现大量重复逻辑时,考虑抽取抽象类;当发现调用方依赖了具体实现类时,考虑提取接口。通过持续重构,模块会逐渐形成清晰的层次,新增需求时也能保持稳定。

