接口在Java里的地位,我觉得怎么强调都不过分。很多初学者写着写着就发现,自己写的代码一旦要加需求,就像往行李箱塞衣服——硬塞能塞进去,但拉链快爆了。而接口,本质上就是你提前说好"行李箱能装多少、怎么分层",让后面的代码不用拆箱子也能扩展。这篇"新Java基础(二十四):接口",我会用实际项目里的思路来讲清楚:接口到底解决什么问题、语法上有哪些容易被忽略的细节、跟抽象类怎么选、以及日常开发里那些让人挠头的坑。适合刚开始学面向对象不久、或者写了一阵子代码但对接口只停留在"知道概念、用不深刻"的朋友。
1. 接口到底解决什么问题:先说设计层面的价值
1.1 从"协议"的角度看待接口,而不是"语法"
很多教程一上来就讲interface怎么定义、怎么implements,然后举一个猫啊狗啊的动物例子,学生听完一脸懵:这玩意儿不就是个空壳吗?我直接写类继承不也行?问题的根源在于,我们太早进入语法细节,反而忽略了接口存在的真实动机——它是一份协议,是调用方与实现方之间的契约。
举一个贴近生活的例子。你去餐厅点餐,服务员递给你一份菜单,菜单上写着菜名和价格。你不需要知道后厨是怎么炒菜的,也不需要关心厨师用的是什么锅,你只关心"按菜单点,厨房能给我端出对应的菜"。菜单就是接口,厨房就是实现类。只要菜单不变,哪怕后厨把厨师换了、把煤气灶换成电磁炉,食客这边完全不受影响。反过来,只要后厨严格按照菜单做菜,哪怕是换了新厨师,也不会出现客人点了宫保鸡丁、端上来鱼香肉丝的情况。
这就是接口的第一大作用:把"做什么"和"怎么做"分离。调用方(食客)只依赖"菜单"(接口),不依赖具体的实现(后厨细节)。在代码里,这意味着你的业务层不需要知道数据到底从数据库来、从缓存来、还是从远程HTTP接口来,它只需要面向一个接口编程,具体实现随便换。
这个思路落地到代码中就是"面向接口编程"。比如一个订单服务,内部要调用支付能力,你不会直接在订单服务里new一个AlipayService,而是定义一个PaymentService接口,在里面声明pay(Order order)方法。订单服务只持有这个接口,真正创建支付实现的活儿交给Spring容器或者其他组装代码去做。这样一来,今天接支付宝,明天接微信支付,后天甚至接一个跨境支付渠道,订单服务一行都不用改。
1.2 依赖倒置与接口隔离:接口背后的两条设计原则
既然说到设计层面,就绕不开SOLID原则里的两个关键项:
依赖倒置原则(DIP):高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。翻译成人话就是:业务代码不应该直接依赖工具代码,业务代码和工具代码都应该依赖接口。比如发送短信这件事,一个注册服务如果直接依赖"阿里云短信SDK",那下次换成腾讯云短信、换成自研短信通道,注册服务就得跟着改。如果注册服务依赖的是一个SmsSender接口,底下有一堆实现类,那注册服务永远不用动。
接口隔离原则(ISP):客户端不应该被迫依赖它不使用的接口。这句话的意思是,如果一个大接口里有10个方法,而某个实现类只用到其中2个,那这个接口就该拆了。否则实现类就得为了用2个方法,白白实现另外8个空方法或者抛异常,非常别扭。
接口隔离原则在真实项目里的典型场景是配置刷新。假设你有一个DynamicConfigService接口,里面有getString(key)、getInt(key)、refresh()、addListener(listener)四个方法。消耗配置的业务方只需要前两个读方法,但管理层需要后两个刷新和监听方法。如果只用一个接口,业务方就会看到刷新和监听的方法,容易误用,也增加了mock测试时的负担。更合理的做法是拆成ConfigReader接口(提供读取方法)和ConfigManager接口(在ConfigReader基础上提供刷新、监听方法),这样业务方依赖最小化。
接口在这里不只是语法,它强制你把"协议"想清楚。你定义接口的时候,其实是在回答一个问题:我这模块对外承诺了什么能力?承诺得越清晰、越小,模块的屁股就越干净,将来越好替换、好测试。
2. 接口语法演进:从最基础到容易被忽略的细节
2.1 定义一个接口并实现它:最基础的姿势
先看看最经典的写法。定义一个接口,声明一个抽象方法,然后让类去实现:
public interface PaymentService { /** * 执行支付 * @param order 订单信息 * @return 支付结果 */ boolean pay(Order order); } public class AlipayServiceImpl implements PaymentService { @Override public boolean pay(Order order) { // 调用支付宝SDK的具体逻辑 System.out.println("使用支付宝支付,订单号:" + order.getOrderNo()); return true; } }几个关键细节值得注意:
- 接口里的方法默认是
public abstract的,即使你不写这两个关键字,编译后也会自动加上。所以接口方法只能是公开的,不可能是什么protected或者private(Java 9以后有私有方法,那是另一回事,后面细说)。 - 实现类中的方法必须用
public修饰。这一点特别容易被新手踩坑,因为实现方法时你想省事,直接写成boolean pay(Order order),编译器立刻报错——说什么"attempting to assign weaker access privileges"。原因很简单:接口方法本来就是public,你实现的时候权限不能比public小,否则外部通过接口调用就访问不到这个方法了。 - 实现类可以用
@Override注解,虽然不写也能编译,但写上能让你及时发现签名写错的问题。比如接口方法参数是Order,你实现时写成了order少个类型,或者方法名拼错了,@Override会直接编译报错,相当于多了一道保险。
我见过很多刚入行的同学,接口方法签名改来改去,结果实现类忘了同步更新,运行时报AbstractMethodError——这就是典型的"签名不一致"问题。解决方式就是:改接口方法时全项目编译一遍,别只编译自己那个模块。
2.2 默认方法与静态方法:Java 8给接口带来的转折
Java 8之前,接口里只能有抽象方法,这就导致一个问题:当JDK想给一个已有接口加新方法时,所有实现类都必须跟着改。比如List接口如果加一个forEach方法,那所有实现List的类都得手写一遍实现,这显然不现实。
于是Java 8引入了默认方法(default method)。用default关键字修饰,方法带上方法体,实现类可以选择重写,也可以直接用默认实现:
public interface PaymentService { boolean pay(Order order); // 默认方法:支付成功后发送通知,默认不发送,实现类按需重写 default void sendNotification(Order order) { System.out.println("支付成功,默认不发送通知"); } }这个特性在实际开发中最大的价值是平滑演进。比如你有一个接口被项目里20个类实现了,现在所有支付场景都要加一个"支付前校验幂等"的扩展点。如果往接口里加抽象方法,20个类都得改;但如果提供默认方法,一个都不用动,需要扩展的类重写即可。
同时Java 8还允许接口里定义静态方法。接口里的静态方法跟类的静态方法类似,属于接口本身,不属于实现类:
public interface PaymentService { static PaymentService createDefault() { return new AlipayServiceImpl(); } }调用方式就是PaymentService.createDefault()。静态方法通常用来提供"默认工厂方法"或者"工具逻辑"。比如你有个FileStorage接口,里面可以写一个静态方法createLocalStorage(String basePath),返回一个本地文件存储实现,方便没有Spring注入的场景快速使用。
关于默认方法,有一个坑必须说:默认方法与接口继承、类继承发生冲突时,规则比较绕。如果一个类实现两个接口,两个接口里有同名同参的默认方法,那么这个类必须重写该方法,否则编译报错。更复杂的情况是"类优先"原则:如果一个类的父类里有一个具体方法,跟接口默认方法同名同参,那么类的实现优先于接口默认方法。这条规则我在后面"常见问题"章节还会展开讲。
2.3 Java 9之后的私有方法:让默认方法之间不重复
Java 9给接口加了一个新能力——私有方法。接口里可以写private方法,这些方法只能在接口内部被默认方法或者静态方法调用,实现类看不见,外部也看不见。
你可能要问:这有什么用?答案是消除默认方法之间的重复代码。比如你有三个默认方法都要做参数校验,区别只是校验完之后调用的逻辑不同。如果每段都写一遍参数校验,代码就重复了。这时候可以把公共逻辑抽成一个private方法:
public interface PaymentService { boolean pay(Order order); default void payByBalance(Order order) { checkOrder(order); System.out.println("余额支付"); } default void payByCoupon(Order order) { checkOrder(order); System.out.println("优惠券支付"); } private void checkOrder(Order order) { if (order == null || order.getAmount() <= 0) { throw new IllegalArgumentException("订单不合法"); } } }注意,这里的private方法是Java 9+才有的,如果你的项目基于Java 8,别用,编译直接不通过。我见过有些同学在Java 8项目里写接口私有方法,编译报错后一脸茫然——其实就是版本没搞明白。
2.4 接口字段:全部都是常量,别指望它存状态
很多新手会疑惑:接口里能不能定义变量?答案是:接口里的字段只能是public static final的常量,即使你不写这三个修饰符,编译器也会自动给你补上。换句话说,接口没有实例字段,它不能存储对象状态。
这背后的设计逻辑很清晰:接口描述的是"能做什么",而不是"有什么"。状态属于实现类的内部事务,跟协议无关。如果你真的在接口里定义了一个"变量",比如:
public interface PaymentService { int CODE_SUCCESS = 200; int CODE_FAIL = 500; }这里的CODE_SUCCESS和CODE_FAIL就是常量,使用方式是PaymentService.CODE_SUCCESS,而且必须在定义时初始化。有些团队喜欢把这类常量放在接口里当常量池用,但从代码整洁角度,我不太推荐——常量应该放在真正归属的业务类里,接口存在的意义还是方法的契约。
Java 17里接口还引入了sealed相关的能力(密封接口),允许限制哪些类可以实现该接口。这块属于偏进阶的语法,用在框架设计场景比较多,基础阶段知道名字即可。
3. 接口与抽象类:到底怎么选,别靠背
3.1 设计理念差异:契约 vs 模板骨架
我在面试中经常问候选人一个问题:接口和抽象类有什么区别?很多人能背出"接口多继承、抽象类单继承""接口方法默认public abstract、抽象类可以有具体方法""接口字段是常量、抽象类可以有实例字段"这些条条框框,但问到"你的代码什么场景用接口、什么场景用抽象类",就支支吾吾了。
我个人的理解是这样的:接口是"能做什么"的协议,抽象类是"是什么"的模板骨架。接口通常用-able或者-or这类后缀命名,比如Runnable(可运行的)、Comparable(可比较的)、PaymentService(支付服务),强调的是能力。抽象类通常是对一类事物进行模板化,比如AbstractHttpServlet(一个HTTP处理模板)、BaseExportHandler(一个导出流程骨架),强调的是"这是什么东西,它的大体流程长什么样"。
这里有一个操作性很强的判断依据:当你关心的是调用方怎么写代码时,用接口;当你关心的是实现方怎么复用代码时,用抽象类。前者解决"外部怎么用",后者解决"内部怎么省事"。
3.2 实际项目中的选择场景
场景一:策略模式。你有多种支付方式,每种支付方式的入参、出参一致,但算法不同。这时候必然选接口。因为调用方只需要面向PaymentService,不需要关心底下是支付宝还是微信。如果选了抽象类,虽然也能实现多态,但等于强迫所有实现类继承同一个父类,这不好——万一某个实现类已经有父类了呢?Java是单继承,抽象类直接把扩展路径堵死了。
场景二:模板方法模式。你要导出一个报表,导出流程是固定的:查询数据 -> 格式化数据 -> 生成文件 -> 上传存储。每个步骤在不同报表里细节不同,但骨架一致。这时候适合抽象类,把所有流程定义好,把变化的步骤做成抽象方法,让子类填充。用接口也可以做,但得把公共骨架逻辑抽到一个工具类里,代码不如抽象类简洁。
场景三:既要模板骨架,又要对外契约。这是很多框架实际做的事:一个抽象类实现了某个接口,把公共逻辑写好,只留扩展点给子类。比如AbstractPaymentService implements PaymentService,它把"参数校验 + 幂等检查 + 加锁"这些公共流程都写死,再把doPay留给子类。这样外部依赖PaymentService,内部实现复用AbstractPaymentService,两头的好处都占了。这个组合模式是Java开发里最常见的套路,值得记下来。
我再补充一个实操判断点:多态的场景先想接口,代码复用的场景先想抽象类。如果一个类"既是某种东西,又有某个能力",往往优先用类继承解决"是什么",再用接口实现解决"能做什么"。举个库里的例子:ArrayList是抽象类AbstractList的子类,同时又实现了List、RandomAccess、Cloneable、Serializable等多个接口。这就是一个类一个本质 + 多种能力的典型模型。
4. 项目实战:用接口重构一段代码
4.1 先看一段"没有接口"的坏味道代码
假设你现在接手一个电商项目,里面有一个下单后通知用户的工具类。最初的代码可能是这样的:
public class OrderNotifier { private EmailSender emailSender; public OrderNotifier() { this.emailSender = new EmailSender(); } public void notifyUser(Order order) { String message = "您的订单 " + order.getOrderNo() + " 已生成,请及时支付"; emailSender.send(order.getUserEmail(), "订单通知", message); } }这个代码有什么问题?问题大了。通知方式被写死成邮件,如果产品经理明天说要加短信通知、加站内信通知、加微信模板消息通知,这个类就得不停地改if分支。更致命的是,OrderNotifier自己new EmailSender(),导致单元测试时很难模拟邮件发送失败的情况——你没法用测试替身。
4.2 通过接口拆解:定义协议、实现替换
第一步,定义一个通知接口:
public interface UserNotifier { void send(Order order, String message); }第二步,把邮件发送做成接口实现:
public class EmailNotifier implements UserNotifier { @Override public void send(Order order, String message) { // 具体发送邮件的逻辑,依赖注入 EmailSender System.out.println("发送邮件给 " + order.getUserEmail() + ":" + message); } }第三步,通知服务只面向接口:
public class OrderNotifier { private UserNotifier notifier; // 通过构造方法注入,而不是自己new public OrderNotifier(UserNotifier notifier) { this.notifier = notifier; } public void notifyUser(Order order) { String message = "您的订单 " + order.getOrderNo() + " 已生成,请及时支付"; notifier.send(order, message); } }重构完之后,通知方式变成了可插拔的。在单元测试里,你可以轻轻松松写一个MockNotifier实现UserNotifier,用来断言"消息是否被发送出去":
public class MockNotifier implements UserNotifier { private List<String> messages = new ArrayList<>(); private Order lastOrder; @Override public void send(Order order, String message) { this.lastOrder = order; this.messages.add(message); } public List<String> getMessages() { return messages; } }然后在测试里替换掉真实发送器,断言通知内容是否符合预期。这个改动背后真正起作用的是"接口 + 依赖注入"的组合。前者定义了协议,后者让协议的使用方不负责创建具体实现。
4.3 配合JDK动态代理实现更灵活的扩展
接口还有一个非常有用的特性:配合JDK动态代理。java.lang.reflect.Proxy只能代理接口,不能代理没有接口的类。这意味着如果你的代码面向接口设计,那么做AOP(切面编程)时就特别方便——比如在不修改EmailNotifier源码的情况下,给所有通知方法加上日志、加上重试、加上监控。
一个最简单的动态代理例子,给UserNotifier添加方法耗时统计:
UserNotifier target = new EmailNotifier(); UserNotifier proxy = (UserNotifier) Proxy.newProxyInstance( UserNotifier.class.getClassLoader(), new Class[]{UserNotifier.class}, (proxyInstance, method, args) -> { long start = System.currentTimeMillis(); Object result = method.invoke(target, args); long cost = System.currentTimeMillis() - start; System.out.println("方法 " + method.getName() + " 耗时 " + cost + "ms"); return result; } );Spring的@Transactional、@Async等注解底层大量使用这种机制。你面向接口编程,Spring才能在你调用notifier.send()时悄无声息地织入事务、日志、异常处理等逻辑。如果你直接依赖EmailNotifier这个类,Spring想拦截就麻烦了,得用CGLIB生成子类代理,限制更多一些。
所以我在实际项目里有个习惯:跨模块的调用一律通过接口暴露。模块内部的类想怎么new就怎么new,但模块之间的依赖,我必须用接口隔开。这样后续做单元测试、做代理增强、做实现替换,都留好了后路。
5. 接口在真实框架与面试中的角色
5.1 JDK里那些你天天用但不觉得是接口的接口
Java开发天天跟接口打交道,只是很多初学者没意识到。比如:
List、Set、Map全是接口,你写List<String> list = new ArrayList<>(),本质上就是面向接口编程。你换一个LinkedList实现,代码其他地方完全不用改,这就是接口替换能力在集合框架里的体现。Comparable接口:你的类实现compareTo方法,就能用Collections.sort()排序。这也是一个典型的"能力契约"——只要你的对象"可以被比较",排序工具就能处理它。Runnable接口:创建线程时可以传一个Runnable对象,线程执行的逻辑跟你具体的线程管理策略解耦。Iterator接口:集合遍历的协议。你在for-each里写的操作,底层全是Iterator在干活;你甚至可以让自己的类实现Iterable,然后用for-each遍历,这是一种很好的领域模型设计技巧。
面试的时候我经常建议候选人从这些日常接口入手,讲清楚"我在用什么接口、它解决了什么问题",远比干巴巴背诵interface语法有说服力。
5.2 框架里接口的三种典型用法
第一个用法:策略接口的注入。Spring里你写一个PaymentStrategy接口,用@Component实现多个策略,Map<String, PaymentStrategy>自动注入所有策略实现类,然后通过paymentStrategyMap.get(strategyId)来选择策略。这个模式在项目里处理"多种类型、同一种操作"时特别好用,比如不同订单类型走不同计价规则、不同渠道走不同清结算逻辑。
第二个用法:SPI机制。java.util.ServiceLoader通过接口而非实现类去加载扩展实现,很多中间件、日志框架(如SLF4J绑定logback或者log4j2)就是这么做的。你写代码时面向org.slf4j.Logger这个接口,实际的日志实现通过SPI机制在运行时确定,这意味着你的日志代码跟具体日志框架解耦了。这是接口在"可插拔架构"里的完美示范。
第三个用法:模板接口。Spring里的InitializingBean(有一个afterPropertiesSet()方法)、ApplicationListener(监听应用事件),只要实现这些接口,Spring容器在特定时间点就会回调你的方法。这种"回调接口"机制巧妙地把控制权从框架反转给业务代码,底层借助的是"接口多态 + 容器扫描"。
框架用接口的设计思路总结起来就一句话:框架提供上下文和控制流,业务通过实现指定接口来参与其中。你理解了这个模式,以后看任何框架源码都会有章法。
5.3 高频面试考点:从概念到实战
接口在Java面试里出现频率极高,我把常见考点和推荐答案思路列一下:
问:接口和抽象类有什么区别? 答:语法层面有三处主要区别:接口支持多实现,抽象类只能单继承;接口字段必须是public static final常量,抽象类可以有实例字段;接口方法默认public abstract,Java 8后有默认方法和静态方法,抽象类可以有构造器和具体方法。设计层面,接口强调"能力契约",抽象类强调"模板骨架",项目里经常组合使用。
问:Java 8为什么引入默认方法? 答:为了让已经发布的接口在添加新方法时,不破坏所有现有实现类。这个问题的核心是"二进制兼容性",可以举List接口加spliterator等例子。如果回答得好,说明你有真实版本演进经验。
问:一个类可以实现多个接口,如果两个接口有同名默认方法怎么办? 答:实现类必须重写这个冲突的方法,否则编译报错。重写时可以调用某个接口的默认实现:InterfaceA.super.method()。这个问题考察是否踩过多继承冲突的坑,有实际编码经验的人一般能答上。
问:接口可以实例化吗? 答:直接new不行,接口没有构造器。但可以通过匿名内部类或者Lambda表达式创建接口的实例(本质上是创建了一个实现类的匿名对象)。Java 8后,对于只有一个抽象方法的接口(函数式接口),Lambda的写法更加简洁。
问:@FunctionalInterface是什么? 答:标注在只有一个抽象方法的接口上,编译器会强制校验你确实只有一个抽象方法。默认方法和静态方法不计入。它保证了接口可以用Lambda表达式表示,是Java函数式编程的基础。
6. 实操中会踩到的坑与排查技巧
6.1 默认方法的多继承冲突,规则记不住就试
默认方法冲突确实容易让人晕,我工作中真遇到过因为这个问题导致的线上Bug。事情是这样的:项目里有一个AuditLogService接口,后来加了一个默认方法recordLog(String action),另一个历史接口EventBusListener里也有一个默认方法recordLog(String eventName),一个监听器类同时实现了这两个接口,编译直接报错。
解决方式很简单:在实现类里重写这个方法,并且显式指定调哪个接口的默认实现。语法是AuditLogService.super.recordLog(action),Java允许这样做:
public class OrderEventListener implements AuditLogService, EventBusListener { @Override public void recordLog(String action) { // 选择其中一个实现,或者彻底自己实现 AuditLogService.super.recordLog(action); } @Override public void pay(Order order) { // 中略... } }还有一个优先级规则需要记住:类的方法优先于接口默认方法。如果一个类继承了父类的public void recordLog(String s)方法,同时这个类又实现了带同名默认方法的接口,那么最终执行的父类的方法,接口默认方法被忽略。这是比较反直觉的一条规则,但Java规范就是这么定的。
我的建议是:能不用默认方法尽量不用,尤其是大型项目里。默认方法虽然方便了接口演进,但会给"选择困难症患者"增加心智负担。真要加扩展点,优先考虑定义一个新的接口,让旧实现类按需实现新接口。
6.2 Lambda表达式与函数式接口
Java 8之后,接口经常跟Lambda绑定。比如Runnable、Comparator、Consumer这些接口,你不需要写实现类,直接用Lambda表达式:
Comparator<Order> byAmount = (o1, o2) -> Double.compare(o1.getAmount(), o2.getAmount());这里有个限制:只有函数式接口(只有一个抽象方法的接口)才能使用Lambda。如果你在接口里不小心写了两个抽象方法,Lambda就不能用了。所以如果你的接口设计成给调用方传Lambda用的,记得加上@FunctionalInterface注解做检查,防止后续有人加抽象方法导致编译失败。
这个设计在业务里也很常见,比如事件回调:
public interface PaymentCallback { void onSuccess(Order order); } // 调用方直接传Lambda paymentService.pay(order, order -> { System.out.println("支付成功,更新订单状态:" + order.getOrderNo()); });这种"微接口"模式用得好,代码会非常简洁清爽。它跟"大接口"形成鲜明对比——大接口往往是坏味道,十几个方法堆在接口里,实现类苦不堪言。
6.3 接口与序列化、克隆的交互
如果你设计了一个接口,实现类需要序列化,有几个细节要注意。首先,序列化接口Serializable是一个标记接口,没有任何方法。你的实现类如果通过RPC传输、或者存Redis,通常要加上implements Serializable,并且定义serialVersionUID。这个ID是序列化版本号,如果接口签名改了而ID没改,反序列化时字段对不上就会出问题。
另外,如果你在接口里定义了默认方法,它对序列化没有特殊影响,但如果你用JDK动态代理生成的对象要序列化,得注意:代理对象本身能否序列化取决于代理类和被代理对象是否都实现了Serializable。这个细节比较边缘,但确实有同事在把代理对象存Redis时踩过坑,报NotSerializableException。
6.4 接口命名与团队规范:这是经验,也是坑
最后分享一个我踩过很多坑之后的体会。接口命名这件事,团队一致性比个人偏好重要得多。有的团队习惯接口前缀大写的I,比如IPaymentService;有的团队习惯直接用名词或者带-able、-or后缀,比如PaymentService、PaymentProvider。
我个人更倾向于后者,原因有两点:第一,不用I前缀的话,实现类命名空间更自由,比如PaymentService接口 +AlipayPaymentService实现,语义自然;第二,很多现代框架(比如Spring)源码里,接口命名普遍不带I前缀,跟社区主流保持一致,新同事进来容易适应。
除了命名,还有一个重要规范:接口应该定义在"被依赖方"所在的包。比如支付能力如果被订单模块依赖,接口PaymentService就应该放在payment-api模块里,订单模块只依赖这个api模块,不依赖支付实现模块。这是模块化设计的基础,也是Maven多模块项目里最常见的依赖关系控制手段。你如果一开始不重视接口的位置,项目后期做模块拆分时,会改到怀疑人生。
6.5 常见问题速查表
| 问题现象 | 原因 | 解决办法 |
|---|---|---|
| 编译报错:实现方法权限不够 | 实现方法没有用public修饰 | 实现类方法必须显式写public |
| 编译报错:默认方法冲突 | 类实现了多个带同名默认方法的接口 | 在实现类里重写冲突方法,用XxxInterface.super.method()指定 |
| 运行时AbstractMethodError | 新增了实现类没实现的方法,或者接口方法签名被改过 | 编译全模块,确保所有实现类同步更新 |
| Lambda表达式报错"not a functional interface" | 接口里有不止一个抽象方法 | 加上@FunctionalInterface检查,或拆分接口 |
| 序列化报NotSerializableException | 通过接口引用的对象未实现Serializable | 确保实现类和代理对象实现Serializable并定义serialVersionUID |
| 接口反射拿不到字段值 | 忘了接口字段是public static final常量,试图通过实现类构造函数初始化 | 把状态放到实现类中,接口只定义常量 |
7. 写在最后:我的一点体会
接口这个东西,越用越能体会到"约束即自由"的道理。初学的时候总觉得它多余——明明可以直接调用类的方法,为什么要绕一圈?但当你接手过那种到处new、改一个需求牵连十来个类的老旧系统,就会明白:提前用接口把边界划清楚,等于给你的代码留了呼吸空间。
我在实际开发中的习惯是:新建一个跨模块的协作点时,先停下敲键盘的手,花三分钟想一想,这个协作点有哪些变化的方向。如果变化方向不止一个,那就定义一个接口;如果只有一个,也要考虑将来测试怎么办。这个思考习惯坚持下来,你写的代码会慢慢从"能跑"变成"好改"。
接口也不是越多越好。一个总共没几个类的小模块里塞十来个接口,反而是过度设计。我见过一些刚学设计模式的同学,写个HelloWorld都要套三层接口三层抽象类,结果代码看完一圈愣是没找到方法在哪。接口设计的度,就是"多一个太冗余、少一个太僵化"的那个位置。怎么拿捏?没有捷径,就是多看多写多复盘,踩了坑就记住了。
另外还想提一个很实际的操作:写接口时一定要写Javadoc注释,把每个方法的用途、参数、返回值、异常都讲清楚。因为接口是给别人看的契约,注释写在接口上,比写在实现类里重要得多。很多团队里调用方依赖出问题,翻开源码注释一看,里面只有个空荡荡的方法签名,那体验真的劝退。
这个系列还有不少内容可以继续往下挖,比如泛型与接口的配合、接口在集合框架里的深度应用、用接口做SPI扩展。如果你们感兴趣,下一篇我可以把接口和泛型结合的那点事讲透。