「设计模式与范式」系列 Day03
写在前面
Day02 打完了四大特性的地基,这篇不再逐个讲概念,而是用一个真实场景把接口、抽象类、组合、继承这几个知识点串起来:给电商系统加支付渠道。这是个几乎所有后端都会遇到的需求,也是"继承一开始很好用,后来突然绷不住"的典型案例。
一、是什么:接口和抽象类的核心区别
| 抽象类 | 接口 | |
|---|---|---|
| 能否实例化 | 不能,只能被继承 | 不能,只能被实现 |
| 包含什么 | 属性 + 方法(可以有实现,也可以是抽象方法) | 只能声明方法,不能有实现,不能有属性 |
| 表达的关系 | is-a,解决代码复用 | has-a,表达一组行为约定 |
一句话记住区别:抽象类回答"这是什么",接口回答"这个东西能做什么"。带着这个区别看下面的案例。
二、为什么:继承一开始好用,后来为什么绷不住
第一版:一个抽象类,很优雅
需求只有支付宝、微信两个渠道时,模板方法式的继承设计非常合适------支付流程(校验、扣款、通知商户)是固定的,只有"具体怎么扣款"这一步因渠道而异:
java
public abstract class AbstractPaymentChannel {
public final void pay(Order order) {
validate(order);
doPay(order); // 唯一因渠道而异的一步
notifyMerchant(order);
}
protected void validate(Order order) { /* 通用校验逻辑 */ }
protected abstract void doPay(Order order);
protected void notifyMerchant(Order order) { /* 通用通知逻辑 */ }
}
public class AlipayChannel extends AbstractPaymentChannel {
protected void doPay(Order order) { /* 调支付宝SDK */ }
}
这时候用继承没有任何问题------流程稳定、继承层次只有一层,这正是 Day02 说的"继承结构稳定、层次浅,可以放心用"的场景。
第二版:加了退款、分账需求,继承开始爆炸
后来产品提了新需求:支付宝、微信要支持退款;银联不支持退款但支持分账;某个新渠道两者都要支持。如果继续在继承体系里加方法,马上会撞上 Day02 讲过的老问题------父类要不要给所有子类都加上 refund()、split()?
- 不加:每个需要的子类各自定义方法,接口不统一,调用方没法用同一种方式判断"这个渠道支不支持退款"
- 硬加到父类:银联、不支持退款的渠道也被迫继承一个用不上的方法,这就是 Day02 说的"父类里放子类才需要的方法"这个反模式
- 用继承体系表达"既支持退款又支持分账":Java 单继承,走不通,只能搞出
RefundableAndSplittableChannel这类为了应付排列组合而生的怪异子类
问题的根源是:退款、分账是"渠道具备的能力",这是 has-a 关系,不是"渠道是什么"的 is-a 关系------用继承去表达能力组合,天生就是错的工具。
第三版:把能力拆成接口,用组合而不是继承拼装
java
public interface PaymentChannel {
void pay(Order order);
}
public interface Refundable {
void refund(Order order);
}
public interface Splittable {
void split(Order order);
}
// 一个类可以实现多个接口,能力想要几个就实现几个,不受单继承限制
public class AlipayChannel implements PaymentChannel, Refundable {
public void pay(Order order) { /* ... */ }
public void refund(Order order) { /* ... */ }
}
public class UnionPayChannel implements PaymentChannel, Splittable {
public void pay(Order order) { /* ... */ }
public void split(Order order) { /* ... */ }
}
原来那段"校验 + 通知商户"的通用流程怎么办?不再靠继承一个基类,而是组合一个流程编排对象,各渠道实现类内部持有它、委托给它执行:
java
public class AlipayChannel implements PaymentChannel, Refundable {
private final PaymentFlowTemplate flow = new PaymentFlowTemplate();
public void pay(Order order) {
flow.execute(order, this::doPay); // 通用流程委托给flow,doPay才是自己的事
}
private void doPay(Order order) { /* 调支付宝SDK */ }
public void refund(Order order) { /* ... */ }
}
调用方全程只依赖 PaymentChannel 这个接口:
java
public class PaymentService {
private final Map<String, PaymentChannel> channels; // 依赖接口,不关心具体实现
public void pay(String channelCode, Order order) {
channels.get(channelCode).pay(order);
}
}
新增渠道、给某个渠道加退款能力,PaymentService 这段代码一行都不用改------这就是"基于接口而非实现编程"的价值:调用方依赖的是稳定契约,实现细节可以自由增删组合,不会传导到调用方。
现实世界里同样的思路
这不是我编出来的套路,JDK 和 Spring 里到处都是这个模式:List<String> list = new ArrayList<>();------声明变量类型永远用接口 List 而不是具体的 ArrayList,换成 LinkedList 完全不影响后面的代码;Spring 的 JdbcTemplate 只依赖 DataSource 这一个接口,底层连接池换成 HikariCP 还是 Druid,JdbcTemplate 的代码根本不用改一行。
三、怎么用:两条可以直接套用的判断标准
- 要不要抽接口:只有当某个功能存在多种实现、或者未来有被替换/组合的可能时才值得抽象出接口(案例里的退款、分账能力);只有一种实现且未来基本不变的,直接用实现类,强行加接口只是徒增复杂度。
- 该用继承还是组合:继承结构稳定、层次浅、关系不复杂(案例第一版),放心用继承;一旦出现"能力交叉组合"的迹象(案例第二版),就是继承要绷不住的信号,优先拆成接口用组合。设计模式里也有约定:模板方法模式固定用继承;装饰器模式、策略模式、组合模式固定用组合,后面写到这几个模式时还会再遇到这个案例的影子。
四、面试追问
Q1:接口和抽象类最核心的区别是什么?
抽象类是对成员变量和方法的抽象,表达 is-a 关系,目的是代码复用;接口只对方法进行抽象,不能有属性和实现,表达 has-a 关系(具备某种行为能力),目的是隔离约定和实现,解决解耦问题。
Q2:什么时候应该用继承,什么时候应该改成接口 + 组合?
继承结构稳定、层次浅、关系不复杂时可以放心用继承。一旦出现"某些子类需要能力 A,某些需要能力 B,还有子类同时需要 A 和 B"这种能力交叉组合的需求,继承(尤其是 Java 单继承)就会力不从心,应该把能力拆成独立接口,用组合把需要的能力装配到具体类上。
Q3:怎么用一个普通类模拟出抽象类和接口的效果?
核心手法是"私有化构造方法 + 方法直接抛异常占位":模拟抽象类时把构造方法设为 protected,让外部拿不到裸实例,抽象方法体里 throw new UnsupportedOperationException() 强制子类重写;模拟接口时只声明方法(同样用异常占位),不定义任何属性。这只是帮助理解两者本质的思维实验,实际项目里直接用 abstract class/interface 语法,编译期就能强制约束,比运行时抛异常安全得多。
Q4:是不是每个类都应该定义一个对应的接口?
不是。接口的价值在于让调用方依赖稳定契约、不被具体实现绑死,只有存在多种实现或未来可能变化时才值得抽象。功能单一且稳定的场景,直接用实现类,强行加接口只是不必要的开发成本。
下一篇预告
Day04 是面向对象思想的实战篇:拿一个真实需求做一次完整的设计走查,顺带讲清楚贫血模型与充血模型该怎么选。