1. 先别急着背八股文,看看“选错之后”的现场
我先问一个很现实的问题:你写代码的时候,有多少次是这样想的——“这里抽象一下以后好扩展,那就建个接口吧”,或者“这俩类有公共逻辑,那就抽个抽象类出来”?如果你点头了,那这篇文章就是写给你看的。
作为一个在 Java 世界里泡了十多年、看过无数老代码和“年轻代码”的人,我最大的感受就是:接口和抽象类选错的成本,不是当时写的时候能感受到的,而是三个月后、半年后,当需求开始变化的时候才会爆发。 我见过有的项目里接口满天飞,一个只有两个实现类的功能,硬生生抽出三层接口,每层还各挂两三个默认方法;也见过抽象类被当成了“工具类收纳箱”,所有共用的 static 方法全往里面塞,最后整个类几百行,职责乱成一锅粥。
其实这个问题的标准答案,每个人都能背两句:“接口是能力的约定,抽象类是模板的复用。”但真到了写代码时,光靠这句话根本不够用。因为在 Java 8 引入了 default 方法之后,接口和抽象类的边界本身就模糊了。你要是拿“接口只能定义抽象方法”这种老黄历去套今天的 Java,那才是真正的不靠谱。
所以这篇我不打算给你念教科书,我把这个问题的拆解方式换成:先从字节码层面和你聊聊接口和抽象类的本质差异,再回到设计场景里逐条说怎么选,最后放进真实框架和面试题里检验。 这么说吧,看完之后你不仅能答上“接口和抽象类的区别”,还能在评审别人代码的时候有理有据地说出那句:“这里用抽象类更合适,因为……”——而且对方反驳不了你。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不看“语法区别”,先看接口和抽象类在 JVM 层面的“人设”
很多初学者喜欢背那种对比表格:接口用 interface 关键字,抽象类用 abstract class;接口没有构造方法,抽象类有;接口多继承,抽象类单继承……这些都对,但都是表象。如果只在语法层面理解,你怎么都绕不明白一个根本问题:为什么 Java 要同时保留这两个东西?
这个问题的答案,藏在 JVM 的方法查找机制和类的继承体系里。
2.1 从“类的方法表”看抽象类和普通类的相似性
先说抽象类。抽象类首先是一个类。JVM 在加载一个类的时候,会在方法区给这个类维护一张“方法表”,里面存放着这个类所有可调用方法的直接引用——包括它从父类继承来的、自己覆写的、以及自己新增的。当你调用一个对象的方法时,JVM 做的方法是“方法表查找”:从对象的实际类型开始,沿着类继承链向上找,直到找到匹配的方法签名。
抽象类呢?它作为一个类,同样有完整的方法表,只是它的某些方法条目没有方法体(就是抽象方法),在字节码里用 ACC_ABSTRACT 标志标记。子类继承抽象类时,本质上是在父类的方法表基础上做扩展和覆写。这是一条非常纯正的面向对象继承链路,和普通类的继承机制没有任何区别,唯一的差异就是你不能直接 new 一个抽象类,因为它的方法表里有“空槽位”。
所以,抽象类的本质是什么?它是一段还没完全写好的类。它把公共的、已经实现好的逻辑放在父类里,把需要子类定制的逻辑留成空槽。这就是经典的“模板方法”思想的载体。
2.2 接口在字节码层面是“完全不同的物种”
再来看接口。在字节码层面,接口不是一个“半成品类”,它更像是一份调用约定合同。类实现接口时,JVM 并不会把接口当成父类去构建方法表链,接口的方法是通过 invokeinterface 指令调用的。这条指令和 invokevirtual(用于调用实例方法)在查找逻辑上有一个关键区别:invokevirtual 查找的是接收者实际类型的方法表,而 invokeinterface 需要 JVM 在运行时去搜索接收者对象的类实现了哪个接口、以及接口方法对应的是哪个实现。
这带来一个非常直接的后果:接口的调用成本理论上比类方法调用略高,而且在 JIT 优化不充分时会更明显。 当然,现代 JVM 对接口调用做了很多优化,比如内联缓存(inline cache),实际性能差异微乎其微,我提这点不是为了让你为了性能去选抽象类,而是为了让你理解接口和类在 JVM 眼里本来就是两套机制。
但真正重要的是设计层面的差异:接口描述的是“这个对象能干什么”,完全不关心“它怎么干”。 一个类可以实现多个接口,因为现实世界里的能力本来就是可以叠加的——一个类可以“能比较”“能序列化”“能跑多线程”,这些能力彼此独立,完全可以同时拥有。而一个类只能继承一个抽象类,因为“是什么”只能回答一次。
2.3 字节码视角对“怎么选”的启发
我做了一个简单的实验来验证上面的说法。写一个接口 IAnimal、一个抽象类 AbstractAnimal,再分别让两个子类去实现/继承它们,然后用 javap -c 去看调用处的字节码。
java复制// 声明部分
public interface IAnimal {
void speak();
}
public abstract class AbstractAnimal {
public abstract void speak();
public void eat() { ... }
}
// 调用处
public void callInterface(IAnimal animal) {
animal.speak();
}
public void callAbstractClass(AbstractAnimal animal) {
animal.speak();
}
对应的关键字节码如下:
text复制// 调用接口方法
invokeinterface #7, 1; // InterfaceMethod IAnimal.speak:()V
// 调用抽象类中的具体方法(实际是父类方法)
invokevirtual #10; // Method AbstractAnimal.eat:()V
看明白了吗?接口方法走的是 invokeinterface 指令,它要求 JVM 在运行时确认“这个对象的类确实实现了 IAnimal,并且能正确路由到实现类的方法”,而父类里已经实现的方法走的是普通的 invokevirtual 方法表查找。这就是为什么说:抽象类可以给你“现成的能力”,而接口只给你“能力的名单”。
注意一点:上面说的是 Java 8 以前、以及默认方法出现之前的经典模型。Java 8 给接口加了 default 方法之后,接口也能带方法体了,这在字节码层面是通过接口的静态方法 + 方法表里的合成桥接来实现的。底层机制变了,设计心智模型也要跟着更新,这部分后面单独说。
3. 从“要解决的问题”倒推:什么时候必须用接口,什么时候只能靠抽象类
很多网上的文章只讲“接口是对行为的抽象,抽象类是对类的抽象”,这句话本身没错,但太抽象了。我换个说法,你拿这句话去套真实需求试试,立马能得出答案。
3.1 判断句式一:“主语的本质是什么”——继承就是一个“是”的关系
如果你要抽象的东西,本质上是一组对象的类别归属,比如“猫是一种动物”“狗是一种动物”“企鹅是一种动物”——这时候抽象类天然合适。因为动物这个类别内部有太多公共的东西:都需要进食、都有心跳、都通过某种方式呼吸。你把这些公共逻辑放在抽象类里,子类直接复用,然后把 emitSound() 这种差异点留成抽象方法让子类各自实现。
有人说,这不也能用接口吗?定义一个 Animal 接口,然后猫实现它、狗实现它,公共逻辑写成 default 方法?
能是能,但很快就别扭了。比如你需要在抽象类里保存一个状态字段:protected int energyLevel = 100;,所有动物子类都能用这个字段记录能量值,然后 eat() 方法统一增加能量,move() 方法统一消耗能量。接口里的字段默认是 public static final 的,你没法定义一个可被子类修改的实例字段。你只能在每个实现类里各自维护一份状态,然后反复地写重复代码。这就违背了你当初“抽公共逻辑”的初衷。
所以第一句话记牢了:当你要共享状态/字段,或者在多个子类间复用一套具体的可执行方法时,抽象类是正解,因为只有类才能拥有真正的实例状态。
3.2 判断句式二:“能力可以叠加”——接口解决的是“有一组能力但彼此无血缘关系”
另一种场景是“能力组合”。比如你要写一个支付系统,里面有支付宝支付、微信支付、银行卡支付。这三种支付方式找共同点是能找到一些——都接收金额、都返回支付结果、都需要校验签名。但问题是,这三个支付类在业务上没有任何“血缘关系”,它们不属于同一个事物类别。未来可能还要接一个“礼品卡支付”,那又是另一种完全不同的东西。
如果硬用抽象类做基类,过段时间你会陷入两难:支付类已经有父类了怎么办?比如有些老系统里,支付宝支付类已经继承了某个远程通信基类,而微信支付类可能继承了另一个本地缓存基类。Java 的类单继承机制直接堵死了用抽象类统一支付逻辑的路。
这时候接口才是救星。定义 PaymentService 接口,声明 pay(BigDecimal amount)、refund(String tradeNo) 等方法,支付网关类、本地钱包类、甚至以后接的虚拟货币支付类,想去实现就实现。它们各自可以继续继承各自的业务基类,互不冲突。
第二句话记牢了:当核心诉求是“约定一组可插拔的能力”,且这些能力的实现者各有各的来路时,必须用接口。
3.3 用一张表把常见场景的最终选择列清楚
结合上面两句话,我把这么多年积累的、判断时的思维路径整理成了下面这张表,可以直接当“决策清单”用。
| 判断条件 | 优先选择 | 核心原因 |
|---|---|---|
| 需要共享实例字段,并且子类的方法要操作这些字段 | 抽象类 | 接口字段只能是常量,无法维护可变的实例状态 |
| 有一批公共方法逻辑完全相同,希望直接复用父类里的实现 | 抽象类 | 类的继承天然自带方法复用,接口的 default 方法本质是妥协产物 |
| 多个不相关的类都要具备同一种能力(如序列化、比较、日志输出) | 接口 | 能力是可叠加的,类实现了多个接口不影响各自的继承体系 |
| 需要对外暴露一套纯粹的调用契约,实现细节完全不允许嵌入 | 接口 | 接口的抽象最彻底,调用方只依赖契约不依赖任何实现 |
| 框架层需要定义“骨架流程”,固定算法步骤,细节留给子类 | 抽象类 | 模板方法模式的最佳载体,让父类控制流程、子类填充步骤 |
| 设计回调、事件监听、策略注入、插件机制 | 接口 | 这类场景强调运行时替换实现,接口天然适合解耦,且一个类可以同时实现多个监听接口 |
| 代码层级上想强制约束“只能有一个父类归属” | 抽象类 | 单继承从语法层面帮你限制了类的家族归属,不会出现一个类有两个“爸爸”的混乱局面 |
4. Java 8 之后,default 方法到底是“帮手”还是“搅局者”?
在 Java 8 之前,接口和抽象类的界限特别清晰,接口只能定义抽象方法,实现类必须全部重写。这个“必须全部重写”在接口比较大的时候非常痛苦。于是 Java 8 引入了 default 方法,允许接口里放方法体。
这个新特性有点像是往“合同的签署栏”里塞了一叠“公共附件”,本来合同只需要签名,现在甲方可以直接把条款写进合同里,乙方签了字默认接受条款。好处是方便了:“我不想改所有实现类,我就给接口加个 default 方法。”坏处是模糊了两个概念的边界,导致一大批人开始用 default 方法去干抽象类的活。
我给你讲个真实踩坑的案例。之前维护过一个内部组件库,里面有个 CacheService 接口,一开始只有两个实现类,Redis 和本地内存。后来不知道哪个同事图省事,往接口里塞了两个 default 方法,比如 getWithRetry() 这种带重试逻辑的方法。刚开始只有他自己一个实现类,override 也用不上,一切岁月静好。直到几个月后,一个新的同事要基于这个接口实现数据库缓存,他天真地以为“接口里 default 方法都写好了,我就 override 几个抽象方法就行”。结果等他实现了才发现,getWithRetry() 的重试策略和他在数据库连接上需要的重试策略完全不一样,他只能 override 掉看似“不用管”的 default 方法。这时他猛然明白,default 方法带来的“默认实现”在接口里往往是不靠谱的约定,因为你根本不知道未来会有多少个实现类,以及它们的场景与默认假设有多大的偏差。
所以我对 default 方法的态度是:它是为了“向后兼容”而生的,不是为了让你在接口里写业务逻辑的。
如果你真的需要在“接口方法”里放业务逻辑,先问自己三个问题:
- 这个逻辑是百分之百所有实现类都必须用同样的方式执行吗?——如果是,它适合做成抽象类的普通方法,而不适合默认方法。因为默认方法可以被任何实现类静默覆盖,一旦覆盖了,接口的“默认行为”就名存实亡,这种隐式的多态是非常难排查的。
- 这个逻辑依赖实例状态吗?——如果依赖,你只能去用抽象类,因为接口里没有实例字段。
- 你需要它保持“契约”的同时提供可选增强能力吗?——这时 default 方法才有真正的用武之地,典型的例子是 Java 标准库里的
Iterator.remove(),默认抛出UnsupportedOperationException,给那些不支持删除的迭代器一个默认归宿。这种“默认兜底行为”是 default 方法最合理的使用场景。
总结一句话:default 方法的出现,没改变接口和抽象类的分工本质,它只是给接口打上了补丁。选型时,别因为 default 方法的存在而在应该用抽象类的地方硬写接口。
5. 真正最值钱的选择心法:模板方法模式是抽象类的主场,但不是唯一主场
光判断“什么时候用接口、什么时候用抽象类”已经略显初级了。资深的开发者还会进一步问:既然抽象类这么适合做模板,那我用抽象类搭好了骨架之后,子类之间怎么协作?这个骨架和外面的接口又是什么关系?
5.1 模板方法模式:抽象类用武之地中的模范案例
来写一个具体的例子。假设你在做一个数据同步框架,不同数据源(MySQL、Oracle、Kafka)的同步流程在大的步骤上是一样的:读取数据 -> 清洗校验 -> 写入目标 -> 记录同步日志。但每一步的具体操作差别巨大,MySQL 的读取要分页、Kafka 的读取要维护 offset、校验规则也完全不同。
这时候抽象类作为“骨架定义者”登场:
java复制public abstract class AbstractDataSyncTask {
// 模板方法:固定算法骨架,用 final 防止子类篡改流程顺序
public final void execute() {
// 1. 开启同步任务,记录 startTime 等公共信息
SyncContext context = createContext();
// 2. 读取数据
List<RawData> rawDataList = readData(context);
// 3. 清洗校验
List<ValidData> validDataList = validateAndClean(context, rawDataList);
// 4. 写入目标
writeToTarget(context, validDataList);
// 5. 记录日志
recordSyncLog(context);
}
// 以下是抽象方法,子类各自实现差异点
protected abstract SyncContext createContext();
protected abstract List<RawData> readData(SyncContext context);
protected abstract List<ValidData> validateAndClean(SyncContext context, List<RawData> rawDataList);
protected abstract void writeToTarget(SyncContext context, List<ValidData> validDataList);
// 公共方法:所有子类都用同一套日志记录逻辑
private void recordSyncLog(SyncContext context) {
// 统一写日志表/发送通知
}
}
这是模板方法模式的教科书式写法。父类把控整个流程的顺序,子类只负责填充差异点。这样做的好处非常明显:以后新增一种数据源,开发者只需要继承 AbstractDataSyncTask,实现四个抽象方法,不需要关心主流程怎么编排,也不可能不小心把写入日志的步骤漏掉,因为骨架已经写死了。
如果你把这段代码改成接口:
java复制public interface DataSyncTask {
void execute();
SyncContext createContext();
List<RawData> readData(SyncContext context);
// ...
}
问题马上暴露:
execute()里的流程编排逻辑没法写在接口里,只能每个实现类重复写一遍,或者写成一个工具方法。- 如果用 default 方法写
execute(),你就得让子类去 override 那一堆抽象方法。这勉强可行,但一旦某个实现类需要的清洗步骤不止“校验”,还要多一步“脱敏”,它就得把整套 execute 的逻辑抄一遍,或者硬塞到 validateAndClean 里。这在抽象类方案里根本不需要担心——父类留一个可选的扩展点就可以了。
5.2 骨架和接口是组合关系,不是替代关系
你别误会,我说了这么多抽象类的好话,不代表项目里尽量用抽象类就对了。真正的最佳实践是:对外暴露接口契约,对内使用抽象类收敛公共实现。 说白了就是,让接口和抽象类配合使用,而不是二选一。
经典的模式是这样的:
- 定义一个接口
DataSyncTask,里面放最核心的方法——比如void sync()。业务方依赖这个接口,和具体实现彻底解耦。 - 定义一个抽象类
AbstractDataSyncTask,实现DataSyncTask接口,然后在里面定义模板方法execute(),把通用流程固化,子类只需要填空。 - 具体的数据源实现类,比如
MysqlDataSyncTask、KafkaDataSyncTask,继承AbstractDataSyncTask,只实现自己的差异逻辑。
这样设计以后,整个代码库的依赖关系非常干净:调用方只认识接口,不知道也不关心背后是抽象类还是具体用户类;而抽象类负责“复用”,接口负责“契约”,各司其职。
你在 Java 标准库里能看到大量这种组合的实例,比如 java.util.List 接口 + AbstractList 抽象类,又或者 java.util.Map 接口 + AbstractMap 抽象类,无不如是。我建议你平时读源码时多观察这些抽象骨架实现类,比如 AbstractList、AbstractSet、AbstractMap,你就会明白“接口负责多态契约,抽象类负责复用实现”是一种多么高效的配合。
提示:
AbstractList已经将get()声明为抽象方法,而把add()默认实现为抛出UnsupportedOperationException。所以当你继承AbstractList实现只读列表时,只需要重写get()和size(),就能得到一个功能完整的只读列表。如果你不走抽象类直接实现List接口,那几十个方法够你写到崩溃。
6. 框架源码里藏着选型的标准答案
理论讲完了,我们拿真实框架来验证。如果你能看懂主流框架的设计者在接口和抽象类之间做的取舍,你自然就会了。
6.1 Spring 里的模板与回调:一个接口、一个抽象类
看 Spring 最核心的 JdbcTemplate,它就是一套经典的“接口 -> 抽象支持类 -> 具体实现”结构。JdbcTemplate 本身继承自抽象类 JdbcAccessor,JdbcAccessor 主要负责公共配置:DataSource 数据源、ExceptionTranslator 异常转换器等公共属性就放在这里,子类一继承,这些基础配置能力就到手了。而操作数据库具体的 execute 和 query 方法则实现在 JdbcTemplate 里,通过 PreparedStatementSetter、RowMapper 这类接口让调用方提供定制的行为。
这里的设计动机就是:公共属性放在抽象类中,节省每个子类重复声明和初始化的成本;可变的操作行为则通过接口注入,方便用户以匿名内部类或者 Lambda 方式传入不同逻辑。
再看 Spring 的事件监听机制,ApplicationListener<E extends ApplicationEvent> 就是纯接口。为什么不用抽象类呢?因为监听器本身没有公共字段和公共方法逻辑要复用,Spring 只关心“你实现了 onApplicationEvent 方法,并且把这个 Bean 注册到了容器里”。这种能力型接口,如果你非要加一个抽象类 AbstractApplicationListener,那就是纯粹的画蛇添足。
6.2 MyBatis 的 Mapper:为什么它死活不用抽象类
MyBatis 的 Mapper 是接口哲学走到极致的一个例子。你用 MyBatis 的时候,定义 Mapper 只需要写一个接口,例如:
java复制public interface UserMapper {
User selectById(Long id);
List<User> selectByName(String name);
}
注意,这里连实现类都没有!MyBatis 会在运行时为这个接口动态生成一个代理对象(JDK 动态代理),你的接口方法的调用最终会被路由到对应的 SQL 语句执行。
为什么 MyBatis 的 Mapper 不用抽象类?因为如果用了抽象类,MyBatis 就只能靠继承生成具体子类,而 Java 的类继承是单根的,这就意味着一个 Mapper 要想同时具备用户查询和订单查询的能力就非常不方便。而动态代理只认接口,一个 Mapper 接口可以同时继承多个其他接口,把通用能力聚合起来,例如:
java复制public interface BaseMapper<T> {
T selectById(Long id);
int insert(T entity);
}
public interface UserMapper extends BaseMapper<User> {
User selectByName(String name);
}
所以框架层面的规则是固定下来的:凡是要做动态代理、要运行时生成实现、要真正的完全解耦,一律选接口。凡是要做代码复用、要固定流程、要共享状态,抽象类才有存在感。
6.3 一个小实验帮你验证自己的判断是否靠谱
我平时带人的时候会让他们做一个特别小的练习,分享出来你也可以试试。拿着一份公司现有的业务代码,去找三处用了抽象类的地方和三处用了接口的地方,然后挨个回答下面三个问题:
- 如果把这个抽象类改成接口,且用 default 方法实现原来的那些公共方法,你会遇到什么困难?
- 如果把这个接口改成抽象类,会破坏原有哪些“多实现”/“代理”的灵活性?
- 把原来的类继承关系画出来,看清楚抽象类在这个体系里到底充当的是“能力来源”还是“模板骨架”。
做完这个练习后你会发现,能同时解释清楚第1、2两个问题的人,才是真正明白了接口和抽象类取舍的人。
7. 面试到底考什么:几道高频“接口 vs 抽象类”题目的破解思路
说了这么多,对很多人来说,这篇文章直接要解决的需求可能是“面试遇到这类题怎么答”。其实面试官问这个问题,大都不是为了让你背定义,而是想看你的设计思维:你到底能不能根据具体的需求场景,做一个合理的选型决策,并且说出理由。
7.1 高频问题一:接口和抽象类的区别是什么?
基础回答可以说:
| 比较项 | 接口 | 抽象类 |
|---|---|---|
| 关键字 | interface |
abstract class |
| 是否能包含实现方法 | Java 8 之后可以含 default/static 方法 | 可以包含抽象方法和具体方法 |
| 字段 | 只能定义 public static final 常量 |
可以定义实例字段和静态字段 |
| 构造方法 | 不能有构造方法(但可以隐含 Object 构造) |
可以有构造方法,给子类初始化用 |
| 继承/实现数量 | 一个类可以实现多个接口 | 一个类只能继承一个抽象类 |
| 访问修饰符 | 默认 public,方法不能是 private/protected(Java 9 以后接口方法允许 private,但仅作辅助用) | 方法可以使用任意访问修饰符 |
| 设计语义 | 能力的约定(can-do) | 体系的归属(is-a) |
但这一份表背诵熟练只是“及格线”。要加分,你就得补上这句话:**“接口和抽象类在 JVM 底层的调用机制完全不同。接口方法调用是 invokeinterface,本质上是能力的路由;抽象类的方法调用本质上是方法表的继承查找。所以在设计上,接口是为了把能力契约稳定地暴露给外部,而抽象类是为了把类内部的公共实现收敛起来。”**这会让面试官觉得你不是背的,是真正理解过。
7.2 高频问题二:什么时候用接口,什么时候用抽象类?
展开成两类场景作答,思路如下:
- 用接口的场景:系统设计时想定义层与层之间的调用边界,比如 Controller 调 Service,Service 的定义直接用接口,这样方便做 mock、做动态代理、做多实现切换;还有策略模式、监听器模式、回调机制等“运行时换实现”的地方几乎都是接口。
- 用抽象类的场景:多个类有相同的字段和相同的公共方法逻辑,不想每个类重复写一遍;或者你要做成一个“模板方法”,父类已经定义好了算法骨架,子类只需要填某些缺口;或者你明确知道这些类之间有真正的层次关系。有一个典型的例子:做报表导出。Excel 导出和 PDF 导出的流程都是“准备数据 -> 设置样式 -> 写入流 -> 输出文件”,你就可以抽一个
AbstractReportExporter抽象类,把流程写死,把 setStyle 等抽象方法留给子类。
如果面试官再追问,“你怎么看接口加 default 方法之后,接口和抽象类的边界越来越模糊?”你就拿上面第4节的观点来答:default 方法存在的首要意义是兼容老代码,让接口在演进时不必强行破坏已有实现类。所以你理解它的定位,而不是把接口当成抽象类来用。
7.3 高频问题三:能举一个“实际开发中遇到接口和抽象类如何配合”的例子吗?
这是一个很考验“实战经验”的题目,很多人挂在“没做过”上。你可以照着第 5.2 节的思路说:自己做权限校验框架时,先定义了一个 PermissionChecker 接口,暴露 boolean check(User user, Permission perm) 方法;然后定义 AbstractPermissionChecker 抽象类实现这个接口,把里面公用的操作日志记录、用户黑白名单校验等逻辑直接写在抽象类里;具体实现类再继承抽象类,只需要实现 doCheck 就好。调用方只注入 PermissionChecker 接口,与背后的实现完全解耦。
这个例子中,接口的价值在于“规范可替换的能力”,抽象类的价值在于“收敛公共流程,减少重复代码”,只要把这两句话说清楚,面试官就知道你是有真实代码量的人。
8. 如果让我给现在的团队定一条最简单的代码规范,我会这样定
接口和抽象类的话题在网上古今中外讨论了很多年,没有哪一条规则能无脑覆盖所有场景。但根据这些年自己的实际体会,我认为可以把选型原则压缩成几句话,作为日常开发的指导规范。
- 第一条:如果你不确定两种方案都各自有什么优势,那首选接口。因为接口的侵入性小,一个类实现之后仍然保留继承其他类的自由;接口也更适合单元测试里用 mock 工具去模拟。除非你非常明确地要复用一个不可拆分的逻辑体,否则不要一上来就建抽象类。
- 第二条:当两个以上的类之间,共享了字段 + 方法逻辑 + 生命周期的组合体时,才值得抽取抽象类。只共享一个方法,那就提取工具方法就好;只共享行为签名,那用接口。
- 第三条:接口里尽量不写 default 方法,如果一定要写,就必须在 Javadoc 里写清楚默认实现的假设是什么,避免实现类误解。我见过太多以为“接口方法都不用管”的开发者被 default 方法的默认行为坑到怀疑人生。
- 第四条:写模板方法时,骨架方法(通常叫 execute/process/run)最好声明成 final,防止别人在你意想不到的地方重写。然后用 protected 限定扩展点方法的可见性,让外部调用方根本无法感知这些方法,只能继承时去动它。这其实是抽象类最值得利用的一个特性,接口虽然可以通过私有方法模拟,但本质上没有这么顺手。
9. 一个小习惯,帮你避免以后写出让后人骂的代码
最后分享一个很个人化的、但确实管用的判断习惯:当你准备建一个接口或抽一个抽象类时,先尝试着为它取一个名字。 这个方法听起来很玄,但操作起来让你瞬间认清自己的想法。
如果名字呼之欲出的是“某某 Service”、“某某 Handler”、“某某 Listener”,大概率你需要的是一个接口——因为这些词表达的是“承担一类职责的能力”;如果名字呼之欲出的是“Abstract 某某”、“Base 某某”、“某某 Template”,那你需要的多半是抽象类——因为你已经清楚地意识到这是众多具体类共用的基座了。
再配合我在第 3.3 节整理的决策清单去多过两遍,形成肌肉记忆之后,遇到这种选择根本不会再纠结。你也不希望自己在三年后的某次 code review 上,被新来的同事指着你当年用错的那层接口问:“这里为什么不用抽象类?”——不是我吓唬你,这种事在真实的开发环境里,真的很常见。
