ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java抽象类与接口:从设计思想到实战选型全解析

Java抽象类与接口:从设计思想到实战选型全解析 day08 抽象类和接口这个标题我在带学生和做技术分享的时候见过太多次了。基本上每次讲到这个节点课堂里就会出现一种好像懂了但又说不清的迷茫感。如果你也正处于这个阶段别慌——这个知识点之所以绕不是因为你笨而是因为它从语法问题上升到了设计思想问题是第一次真正逼着你用面向对象的方式去思考代码结构。先给这篇文章定个调我不打算只罗列抽象类不能实例化、接口方法必须实现这种区别表因为那玩意儿网上随便一搜就有。我想做的是带着你从头想明白两件事为什么Java要设计抽象类为什么要设计接口等你把这两个为什么想透了后面遇到Spring源码、各种框架的架构设计你会发现自己能看懂的东西突然多了很多。这篇文章适合正在学Java面向对象、准备面试、或者刚工作但觉得自己OOP基础不扎实的读者。抽象类和接口从语法层面看只是两个关键字但从设计层面看它们是Java整个类型系统的骨架。下面我用自己的教学经验和踩坑经历把这个话题掰开揉碎了讲。1. 从为什么需要抽象类说起模板方法的实际场景1.1 一段让你抓狂的重复代码我见过很多学生写报表导出功能最常见的就是复制粘贴三件套先是ExcelReport然后PdfReport再来一个CsvReport三个类长得极其相似都是加载数据→格式化数据→写入文件这三步但每一步的实现代码又完全不一样。如果你用普通父类来解决这个问题public class ReportGenerator { public void generate() { loadData(); formatData(); writeFile(); } private void loadData() { // 这里该写什么 // 三种报表的数据加载逻辑各不相同根本没法统一 } // ... }问题来了loadData()这个方法在Excel、Pdf、Csv三种报表里的实现完全不同。你在父类里写任何代码都是错的因为子类必须覆写它。但如果什么都不写又违背了父类提供通用实现的初衷。这时候你就需要抽象方法了父类只声明我有这一步但不写这一步的具体细节强制要求子类必须自己实现。1.2 抽象类解决了部分未知的问题抽象类的核心定位是一个完成了一半的类。它知道整个业务流程的骨架但故意把某些步骤留白给子类去填。看这个例子public abstract class ReportGenerator { // 模板方法定义整体流程骨架一般用final锁死 public final void generate() { loadData(); formatData(); writeFile(); } // 留白每种报表自己实现 protected abstract void loadData(); protected abstract void formatData(); protected abstract void writeFile(); // 钩子方法某些报表需要先校验数据默认不开 protected boolean needValidate() { return false; } }然后Excel报表这样写public class ExcelReportGenerator extends ReportGenerator { Override protected void loadData() { System.out.println(从数据库加载Excel所需的数据); } Override protected void formatData() { System.out.println(按Excel格式整理单元格); } Override protected void writeFile() { System.out.println(写入工作簿文件); } Override protected boolean needValidate() { // Excel对数据精度要求高开启校验 return true; } }这种父类定义流程、子类填充细节的模式就是经典的模板方法模式Template Method。为什么它好用因为流程是稳定不变的——永远都是加载、格式化、写入这三个大步骤的顺序不能乱。但是每一步的细节是变化的——Excel有Excel的写法Pdf有Pdf的写法。骨架和细节分离重复的流程代码只写一次变化的部分交给子类。1.3 抽象类到底能不能有普通方法很多初学者听到抽象类就以为里面全是抽象方法其实不是。抽象类可以同时拥有抽象方法和普通方法甚至可以有成员变量和构造方法。最常见的组合是这样普通方法负责公共逻辑抽象方法负责差异逻辑。这和接口最大的不同就在于——接口在Java 8之前几乎不能提供任何代码实现而抽象类可以。再来一个更真实的场景。我在项目中写过数据同步的逻辑抽象类长这样public abstract class AbstractDataSyncer { protected final Logger logger LoggerFactory.getLogger(getClass()); // 公共状态 private int retryCount 3; // 骨架流程 public final void sync() { ListString ids fetchRemoteIds(); for (String id : ids) { try { syncOne(id); } catch (Exception e) { logger.error(同步失败: {}, id, e); if (retryCount-- 0) { reuse(id); } } } } // 强制子类实现获取远程ID列表 protected abstract ListString fetchRemoteIds(); // 强制子类实现同步单条数据 protected abstract void syncOne(String id); // 可覆写失败后重新入队默认直接丢弃 protected void reuse(String id) { // no-op } }注意这里logger是普通字段retryCount是普通字段sync()是普通方法里面有完整逻辑只有两个方法是抽象的。这种普通方法抽象方法混搭才是抽象类真正的使用姿势。1.4 一个更容易被忽略的点钩子方法的价值上面代码里的needValidate()和reuse(String id)这类方法我称之为钩子方法。它的默认实现是空方法或者返回一个默认值子类可以选择覆写也可以不管。它的价值在于让父类的骨架具备灵活性开关但又不强制子类关心这个开关。举个例子你写了一个订单处理流程大部分订单不需要风控审核但高金额订单需要。那么父类里就可以有个protected boolean needRiskCheck() { return false; }子类根据金额大小覆写它。这样整个流程的代码改动量最小也符合开闭原则——对扩展开放对修改关闭。抽象类讲的差不多了核心记住一句话抽象类的意义是提供骨架和公共能力把必须由子类决定的部分抽象出来。接下来看接口。2. 接口的本质一份双方都要遵守的契约2.1 从插座标准说起如果只能用一个比喻来解释接口我会选插座。你家里的墙上插座不会管你插的是手机充电器、电风扇还是笔记本电脑它只规定了一件事电流是220V零线在左火线在右插孔形状是标准的。不管你是哪个品牌、什么功能的电器只要符合这个标准插上就能用。Java里的接口干的就是这件事。接口定义了一套标准——方法签名。任何类想插进这个接口就必须实现这些方法。调用方完全不关心实现类的内部逻辑只依赖接口这个标准。public interface PayService { boolean pay(BigDecimal amount); void refund(String orderId); }这就是一个支付接口——不是让你现在就实现它而是告诉所有想接支付的类你们必须提供pay和refund两个方法至于你们是走微信支付还是支付宝那是你们的事。2.2 面向接口编程到底在说什么面向接口编程这句话我觉得是Java教程里最容易被念歪的一句话。它不是说你应该多用接口这么简单而是说你的高层模块不应该依赖底层模块的具体实现。拿上面这个支付服务举例。假设你直接用了AlipayServiceImplpublic class OrderService { private AlipayServiceImpl alipayService new AlipayServiceImpl(); public void checkout(Order order) { alipayService.pay(order.getAmount()); } }今天用的是支付宝没毛病。但下周老板说我们要接入微信支付你怎么办把AlipayServiceImpl替换成WechatPayServiceImpl然后OrderService里的代码全都要改。如果OrderService里还有别的逻辑引用了alipayService的一些独有方法那就更惨。但如果你面向接口编程public class OrderService { // 只依赖接口不依赖具体实现 private PayService payService; public OrderService(PayService payService) { this.payService payService; } public void checkout(Order order) { payService.pay(order.getAmount()); } }新建一个WechatPayServiceImpl implements PayService然后在配置阶段把payService注入进去OrderService一行代码都不用改。这就是解耦就是依赖倒置原则依赖抽象不依赖具体。2.3 接口的语法演变从纯抽象到带默认实现很多老教程还在说接口里只能有抽象方法这其实过时了。从Java 8开始接口允许有default方法和static方法Java 9又加了private方法。public interface PayService { boolean pay(BigDecimal amount); void refund(String orderId); // 默认方法给所有实现类提供公共能力同时允许覆写 default boolean isSupported(String channel) { return default.equals(channel); } // 静态方法直接通过接口名调用 static PayService create(String channel) { // 根据渠道创建对应实现 return new WechatPayServiceImpl(); } }default方法的引入很大程度上缩小了接口和抽象类之间的差距。但它有一个致命短板接口不能有实例字段。接口里的字段被Java强制规定为public static final也就是常量。你想在接口里维护一个当前通道的心跳次数这种状态没门。这一点是抽象类不可替代的重要特征。2.4 接口的多实现能力Java的类只能单继承但接口可以多实现。这是接口设计上最巧妙的地方之一。举个例子一个订单处理器既需要执行订单逻辑又需要记录审计日志、又需要能被序列化public class OrderProcessor implements OrderHandler, AuditLogger, Serializable { Override public void handle(Order order) { // 处理订单 log(处理订单: order.getId()); } Override public void log(String message) { // 写日志 } }你想想如果用抽象类一个类只能继承一个父类你根本没法同时获得订单处理能力日志记录能力序列化能力。但接口没有这个问题它在语法层面允许你叠多个能力标签。这也是Java类型系统的一个核心设计思路单继承负责血缘关系多实现负责能力标签。抽象类是你的祖先接口是你的技能证书。一个人只能有一个生物学父亲但可以考很多本证。到这里接口的核心思想也可以用一句话收拢接口是纯粹的契约它比抽象类更彻底地拥抱了约定优于实现。接下来把两个概念放到同一张桌子上正面比较。3. 抽象类和接口的五大差异对照语法与设计思想双维度3.1 先上一张能背但更要能理解的表格面试常问、考试常考我把核心差异整理成表但请务必看完表后面的解释那才是真正拉开理解差距的地方。对比维度抽象类接口实例化不能new只能通过子类实例化不能new只能通过实现类实例化构造方法可以有由子类super()调用不能有构造方法方法实现既有抽象方法也有完整普通方法Java 8前全部是抽象方法之后有default、static、private方法字段可以有普通成员变量实例字段/状态只能有public static final常量不能有实例字段继承/实现单继承一个类只能继承一个抽象类多实现一个类能实现多个接口设计思想is-a 血缘关系模板复用can-do 能力契约行为解耦补充一点容易弄错的抽象类可以实现接口但接口不能实现抽象类。接口可以继承多个接口interface A extends B, C但一个类去实现接口时如果这个类是抽象类它可以只实现一部分方法剩下的继续标记为抽象方法交给更下层的子类——这个知识点后面第四章的具体案例里会用到。3.2 设计思想层面is-a 与 can-do 的本质区别is-a是血缘关系。我们说狗是一种动物所以Dog extends Animal假设Animal是抽象类这是描述狗的本质属性——它天生就属于动物这个类别动物具有的形态、行为、生理特征狗都有。用继承来描述这种血缘关系天然合理。can-do是能力契约。我们说鸟会飞飞机也会飞但鸟和飞机之间没有任何血缘关系。你不能让Airplane extends Bird那会被全世界的航空工程师打死。但你可以让它们都实现Flyable接口。这个接口不关心你是谁只关心你能不能飞。所以在实际建模时一个常见的判断方法是先问自己这个类和父类之间是不是is-a的关系如果是考虑抽象类再问这个类是不是需要具备某种能力如果多个类只是因为都有某种能力而联系起来那接口肯定是更合适的选择。3.3 抽象类和接口的模糊地带default方法带来的思考Java 8给接口加default方法之后很多人的第一反应是那我还要抽象类干嘛这是一个好问题值得认真对待。default方法确实让接口具备了给实现类提供公共逻辑的能力甚至可以给接口加私有辅助方法Java 9。但有一种东西是接口永远做不了的持有状态。接口里的字段必须是public static final这意味着它只能放置全局常量不能保存每个实现类自己的实例状态。而抽象类作为一个真正的类可以有private int retryCount 3;这样属于实例的状态字段也可以在构造方法里初始化依赖。所以说判断用抽象类还是接口可以加一个状态维度的测试如果这个场景需要共享实例状态必须用抽象类如果只是想把行为说清楚并展示能力用接口。4. 选型实战什么场景用抽象类什么场景用接口4.1 我拿到一个需求时的思考顺序做开发不是背口诀但在选择抽象类还是接口时我确实有一个比较稳定的思考套路先看多个类之间是否存在明显的血缘关系。如果它们属于同一个类别体系有公共的状态字段、公共的初始化逻辑那抽象类优先。再看这个体系是否需要对外暴露能力标准。如果需要让外部模块只依赖定义不关心实现那接口优先。最后看扩展方向。如果未来可能有一堆互不相关的类都来参与同一件事比如都要被序列化、都要被缓存、都要支持导出接口几乎是唯一选择。举一个我在电商项目中实际遇到的例子我们需要对订单做多渠道推送——短信、App推送、邮件。三个推送方式之间毫无血缘关系它们只是都可以推送。那这个场景非常明确用接口public interface PushSender { void push(String userId, String content); boolean isAsync(); }短信、App推送、邮件各自实现接口推送服务在运行时拿着ListPushSender挨个推送新增渠道时不用改原有代码。这个场景如果强行用抽象类你会被迫设计一个PushSender抽象父类然后让三个产品实现继承它——可它们之间完全没有is-a关系这种继承就是硬凑后期维护会越来越痛苦。4.2 抽象类发挥优势的经典场景反过来如果一个体系内多个类在同一个工作流的不同环节上有差异但整体骨架一致那抽象类就比接口合适。前面说的ReportGenerator是典型再举一个更接近日常业务的例子订单校验器。假设订单要经过库存校验、金额校验、风控校验三道大步骤但每一类订单普通订单、秒杀订单、预售订单在三步里的细节都不同。整体骨架固定细节开放这是最标准的抽象类场景public abstract class OrderValidator { public final boolean validate(Order order) { if (!checkStock(order)) { return false; } if (!checkAmount(order)) { return false; } return checkRisk(order); } protected abstract boolean checkStock(Order order); protected abstract boolean checkAmount(Order order); protected abstract boolean checkRisk(Order order); }这种情况下如果三个子类各自实现三个方法恰好三个方法组合起来代表一个完整流程。而流程本身有任何一个环节失败就整体失败的逻辑这段逻辑只写一遍放在父类的validate()方法里那就是极其漂亮的复用。4.3 使两个结合的正确姿势接口定能力抽象类给骨架刚开始工作那两年我最容易困惑的是到底用哪个后来看了一些开源框架的源码才缓过来。真正成熟的项目很少只用抽象类或只用接口而是接口定义能力和边界抽象类提供骨架和公共实现具体类做具体落地。举一个Spring生态里非常常见的组合。假设你要定义一套消息处理服务// 第一步接口定义能力 public interface MessageHandler { void handle(Message message); String supportedType(); } // 第二步抽象类提供骨架和公共逻辑 public abstract class AbstractMessageHandler implements MessageHandler { Override public void handle(Message message) { // 公共的前置逻辑校验、去重、埋点 MessageValidator.validate(message); logReceived(message); // 子类真正的业务处理 doHandle(message); } protected abstract void doHandle(Message message); private void logReceived(Message message) { System.out.println(收到消息: message.getId()); } } // 第三步具体类实现业务 public class OrderMessageHandler extends AbstractMessageHandler { Override public String supportedType() { return ORDER; } Override protected void doHandle(Message message) { // 订单消息的具体业务处理 } }为什么这样三级结构好因为接口层保证了所有Handler都长一个样可以统一注入、统一调度抽象类层消除了重复的公共代码校验、日志具体类只需要关心自己的业务。这种层次感是单一使用抽象类或接口难以达到的。4.4 顺便聊聊为什么框架爱用接口你去翻Spring源码会发现几乎每套核心机制都是先定义接口再给一套抽象类实现比如ApplicationContext本身就是一个接口下面有分支。这不单单是设计美学更是工程需要可替换性接口允许你在Spring里随意替换实现换个上下文容器不影响业务代码可测试性测试时可以mock接口不需要起整套容器文档化接口本身就是一套清晰的说明文档看接口方法签名就约等于看使用手册。这些点放到你自己写的业务系统里也一样成立。Service层给接口、DAO层给接口后续用Mock进行单元测试、用各种代理框架做增强都会顺畅很多。5. 初学最容易踩的坑实例化、多继承与命名习惯这个部分很多教程不写但是初学者在现场写代码时几乎都会踩。我一条一条过。5.1 抽象类和接口真的不能new吗——匿名实现类骗了你你要说抽象类不能实例化马上会有人反驳说我明明写了new AbstractClass() {}编译过了。这里有个极其常见的误解。// 这不是实例化抽象类而是创建了一个匿名子类 AbstractMessageHandler handler new AbstractMessageHandler() { Override protected void doHandle(Message message) { // 匿名子类必须实现抽象方法 } };注意那个花括号。new AbstractMessageHandler() { ... }的语义是创建一个AbstractMessageHandler的匿名子类然后把子类实例赋给父类引用。花括号里如果没有补全所有的抽象方法编译器会直接报错。接口也一样new PayService() { ... }本质上是在创建匿名实现类必须把所有方法实现完整。所以真正常见的漏网之鱼是初学者没意识到匿名类必须实现所有的抽象方法写了个空壳就报错。这其实不是抽象类和接口的问题而是对多态和匿名内部类理解不到位。5.2 实现多个接口时default方法冲突一个类实现了两个接口刚好两个接口都有同名的default方法会怎样编译器会强迫你在这个类里重写这个方法否则编译不过。public interface A { default void hello() { System.out.println(A.hello); } } public interface B { default void hello() { System.out.println(B.hello); } } public class C implements A, B { // 必须重写否则报错 Override public void hello() { // 可以选择调用其中一个接口的默认实现 A.super.hello(); } }这里A.super.hello()的写法很多新手没见过。它的作用是显式调用接口A的default方法。如果你两个都不调用那就完全自己写实现这也算是解决冲突的一种方式。5.3 接口常量的坑不是你想的多态接口里的public static final常量是静态绑定的不存在多态。看这段代码public interface Color { String RED 红色; }实际项目里这种接口常量风格已经逐渐被推荐不要过度使用了。因为接口的定位是行为契约往里面塞一堆全局常量会让接口背负不属于它的职责。真要定义常量组优先考虑枚举或专门的常量类。这是我写代码这几年实打实的一个教训——维护过那种大而全的接口里面几十个常量看起来像接口实际上就是个常量仓库耦合了一片天。5.4 命名习惯从名字就能看出设计意图Java社区有比较强的命名共识抽象类的名字通常以Abstract开头比如AbstractList、AbstractQueuedSynchronizer接口的名字常见以I开头比如老代码里的IPayService或使用能力型后缀Runnable、Comparable、Handler、Listener。现代Java社区更推荐后者——PayService、OrderHandler、MessageListener一眼看去它就是个契约。这些命名不只是约定更是代码可读性的重要组成。看到AbstractSomething读者心里有数这是个模板骨架我得继承它;看到SomethingHandle读者知道这是个能力标记我找实现类就好。5.5 抽象类里没有抽象方法——那它和普通类的边界在哪在Java语法上一个抽象类完全可以没有任何抽象方法。它仍然不能被new必须通过子类实例化。这种写法看起来没什么用但有一种应用场景你想告诉别人这个类就是用来被继承的但暂时还没准备好抽象步骤。相当于在代码里立了一个请勿直接使用请继承的牌子。不过我要提醒一句如果用这种空抽象类只是为了做一个父类那它是合理的但如果只是为了让某个类不能被实例化而把它设成抽象类那通常是有问题的私有构造方法才是实现工具类的正确方式。6. 面试官真正想考的经典考题与回答思路6.1 抽象类和接口有什么区别——不要上来就背表这道题是Java面试的镇场题十有八九会问。但很多人一上来就背语法区别背到一半卡住或者背完面试官追问两句就露馅。我给一个更稳的回答顺序先给核心定性抽象类是半成品类接口是行为契约。这句话能瞬间让面试官觉得你不是在背答案。再展开三层语法层抽象类可以有构造方法、实例字段、具体方法和抽象方法接口只能有常量和抽象方法外加Java 8以后的default/static/private方法。层级层类只能单继承抽象类但能多实现接口抽象类是is-a血缘接口是can-do能力。应用层多个类共享公共状态和公共流程用抽象类需要解耦、跨体系定义行为标准用接口成熟的系统往往是接口抽象类组合。面试官如果追问为什么接口可以多实现、类只能单继承你要能接住因为多继承会遇到菱形继承问题即一个类继承多个父类时若多个父类里有同名方法到底用谁的会有歧义。Java为了避免这个问题砍掉了类的多继承保留接口的多实现。由于接口在Java 8之前不包含实现代码多个接口里的同名方法不会产生实际的实现冲突。6.2 为什么抽象类不能new但可以有构造方法这是经典追问也是理解抽象类本质的钥匙。抽象类可以有构造方法但它的构造方法和普通类不同——它的主要用途是给子类调用的。每一个子类实例化的第一行都要先调用父类的构造方法隐式super()把父类部分初始化好然后才轮到子类的构造逻辑。抽象类不能new是因为它内部有尚未实现的抽象方法一个不完整的方法没法被真正调用。如果让你new AbstractReportGenerator()那调用generate()时走到loadData()这一行程序根本不知道该执行什么代码。禁止实例化就是防止这种半成品被当成品用的情况。6.3 设计接口时除了方法签名还要考虑什么现在面试越来越不满足于语法了特别是社招。如果面试官追问接口设计你可以讲两点工程经验第一接口的方法要考虑幂等性。尤其对外接口调用方可能会重试设计时就要考虑同一个请求执行多次和一次的效果相同。比如退款接口加一个业务流水号作为唯一键重复调用时直接返回上次的结果而不是再退一次款。这虽然不是Javainterface关键字层面的语法内容但真正应用到项目中时这个方法设计的好坏直接决定系统稳定性。第二接口要有足够好的文档。接口本身就是一份契约文档要求方法命名清晰、参数和返回值边界明确。很多团队要求对外暴露的接口必须附带开放接口文档default方法也好、具体实现也好最终都要提供给调用方一个无歧义的使用说明。这和我前面说的接口即契约是一个意思契约立好了后面怎么实现都不乱了。6.4 抽象类A已经实现了接口B我要不要重复实现B里的方法这题非常容易答错。答案是如果A是抽象类它可以只实现B的部分方法剩下没实现的方法继续被标记为抽象方法如果A是普通类它必须实现B的全部方法。public interface B { void method1(); void method2(); } // 抽象类A只实现method1method2继续抽象 public abstract class A implements B { Override public void method1() { System.out.println(method1 在所有子类中通用); } // method2 没有实现A保持抽象 } // C继承A只需要实现剩下的method2即可 public class C extends A { Override public void method2() { System.out.println(C 实现了 method2); } }这种写法就是前面说的接口定能力、抽象类给骨架的底层机理。理解这一点之后再看Spring里很多AbstractXxx类会很有感觉。6.5 一道经典的OO设计题猫、狗、鱼怎么建模这是我非常喜欢拿来考人或者自测的小设计题。猫、狗、鱼都是动物都会吃东西但只有猫和狗会奔跑只有鱼会游泳。请设计它们的类结构。常见错误答案把run()写在Animal父类里然后鱼继承Animal的时候被迫实现一个鱼根本不跑的run()方法里面抛异常或者干脆空实现。这就是设计味道极其糟糕的实现。正确的思路非常简单public abstract class Animal { protected abstract void eat(); } public interface Runnable { void run(); } public interface Swimmable { void swim(); } public class Dog extends Animal implements Runnable { Override public void eat() { } Override public void run() { } } public class Cat extends Animal implements Runnable { Override public void eat() { } Override public void run() { } } public class Fish extends Animal implements Swimmable { Override public void eat() { } Override public void swim() { } }Animal定义的是血缘——所有动物都会吃东西Runnable和Swimmable定义的是能力——会跑的动物才能实现跑步。这个模型里没有任何一个类被迫实现它不该有的方法。这题做顺手了抽象类和接口的设计思想才算真正落地。最后再分享一点我个人在实操中的体会。做了这么多年开发和教学我见过太多人在这两个概念上纠结其实真正的成长不是把语法背下来而是去读代码、写代码。建议你学完这节之后随便打开一个你项目里的Service层或者Spring的ApplicationContext、MyBatis的SqlSession找出哪些用了接口、哪些继承抽象类试着自己分析为什么这么设计。第一次可能只能看懂一层但没关系多看几个之后这种设计直觉就会慢慢长出来到那时候你再看抽象类和接口就不需要背区别表了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进