接口(interface)是只描述公开行为的引用类型。类通过 implements 承诺这些行为,调用方则可依赖接口类型替换具体实现。
default 并非笼统的兼容性保证;新增默认方法可能与别的接口冲突,也可能悄悄改变已有实现的行为。
保持接口小而明确,写清输入、失败与并发契约,并用完整重编译和替代实现测试每次接口变更。
是什么,为什么存在
Java 接口是一种不能直接实例化的引用类型。它为一组操作命名,却不要求所有实现共享同一父类或对象布局。类可以实现多个接口,接口也可以继承多个接口,因此行为契约不必绑在单继承的类层次上。
Java 采用 名义类型(nominal typing) 。一个类即使恰好拥有同名方法,也不会自动成为某个接口的实现;源码必须通过 implements 或继承关系明确建立类型关系。这样,方法签名相同只是巧合还是有意履行契约,不需要由调用方猜测。
接口类型带来 子类型多态(subtype polymorphism) 。例如,构造器接收 ShippingRule 时,可以得到固定费率、按重量计费或测试替身,而无需知道各自的类。运行时仍在实际对象上分派实例方法;接口引用不是包裹对象的新容器。
你会在集合、比较器、监听器、服务边界和依赖注入中遇到接口。它最适合表达调用方真正依赖的能力,尤其是多个不相关类都能履行的契约。若设计主要目的是共享实例状态、构造流程或受保护的辅助代码,抽象类通常更直接。
方法签名没有写出全部行为。空值规则、参数单位、返回值含义、异常、线程安全要求和副作用都属于契约。接口位于跨团队或公共库边界时,应把这些约束写进文档和契约测试。
工作原理
类用 implements 实现接口,接口用 extends 组合其他接口。非抽象类必须为继承到的抽象实例方法提供实现,除非某个更具体的默认方法已经满足要求。接口变量可以引用任何兼容实例,但只能直接调用其静态类型公开的成员。
接口没有构造器和实例字段。字段声明隐式为 public static final,因此保存的是接口级常量引用;若该引用指向可变集合,集合本身仍可修改。把可变对象放进接口字段会制造公开的全局状态,不会因为引用是 final 就变安全。
| 成员形式 | 是否有方法体 | 是否继承到实现类 | 典型用途 |
|---|---|---|---|
| 抽象实例方法 | 否 | 是 | 定义实现必须提供的行为 |
default 方法 | 是 | 是 | 基于现有契约提供通用实例行为 |
static 方法 | 是 | 否 | 与接口概念相关的工厂或辅助操作 |
private 方法 | 是 | 否 | 复用接口内部的默认或静态实现 |
| 字段 | 初始化表达式 | 通过限定名称访问 | 真正常量,而不是可变状态 |
抽象方法和默认方法隐式为 public;它们不能是 protected 或包可见。静态方法可以是 public 或 private,私有实例方法只能由接口自己的实例方法调用。实现类不能降低所实现方法的可见性。
默认方法(default method) 拥有实例方法体,可以调用其他实例方法和私有实例辅助方法。它没有自己的实例字段,所以其行为只能依赖参数、常量和接收对象通过契约暴露的状态。默认方法适合从已有原语推导便捷操作,不适合偷偷引入新的状态要求。
多个候选实例方法相遇时,编译器按下面的规则决定是否需要显式覆盖:
- 类或父类中的具体实例方法优先于接口默认方法。
- 一个接口比另一个更具体时,子接口声明的方法优先。
- 两个无关接口提供冲突的默认方法时,实现类必须覆盖;覆盖体可用
InterfaceName.super.method()选择某个直接父接口实现。
静态接口方法不参与这些规则,因为它不被子接口或实现类继承。应通过声明它的接口调用,例如 Rule.parse(),不能假设 ConcreteRule.parse() 存在。私有方法同样不被继承,也不能从实现类调用。
函数式接口(functional interface) 只有一个抽象方法契约,也称单一抽象方法(single abstract method,SAM)接口。默认、静态和私有方法不计入这个数量,签名与 Object 的公开实例方法对应的方法也有专门的排除规则。@FunctionalInterface 不是必须的,但它会让编译器在契约不再满足时立即报错。
Lambda 表达式和方法引用需要目标类型;目标函数式接口决定参数与返回类型。这种从函数值到 SAM 接口实例的适配常称为 SAM 转换(SAM conversion) 。Lambda 只提供那个函数方法的实现,接口中的默认方法仍然可用。
示例
下面三个程序从替代实现开始,再处理默认方法冲突,最后构造函数式接口组合。输出由本地 OpenJDK 21.0.12 使用 javac --release 21 -Xlint:all 编译并执行得到;所用语法和接口规则在目标 Java 25 中保持有效。
用接口隔离调用方与实现
计费函数只接收 ShippingRule。两个记录类保存不同数据并实现同一操作,而默认方法根据实际类给出诊断名称。
import java.util.List;
public class ShippingQuote {
interface ShippingRule {
int feeInCents(int weightGrams);
default String label() {
return getClass().getSimpleName();
}
}
record FlatRate(int fee) implements ShippingRule {
@Override
public int feeInCents(int weightGrams) {
requirePositive(weightGrams);
return fee;
}
}
record WeightRate(int centsPerKilogram) implements ShippingRule {
@Override
public int feeInCents(int weightGrams) {
requirePositive(weightGrams);
int kilograms = (weightGrams + 999) / 1_000;
return kilograms * centsPerKilogram;
}
}
static void requirePositive(int weightGrams) {
if (weightGrams <= 0) {
throw new IllegalArgumentException("weight must be positive");
}
}
public static void main(String[] args) {
var rules = List.<ShippingRule>of(new FlatRate(499), new WeightRate(300));
for (ShippingRule rule : rules) {
System.out.println(rule.label() + ": " + rule.feeInCents(1_200));
}
}
}FlatRate: 499
WeightRate: 600List.<ShippingRule>of(...) 明确把两个不同记录视为同一接口类型。循环中的调用仍落到各自的 feeInCents() 实现,label() 则复用接口中的默认实现。调用方无需用 instanceof 分支识别每个计费类。
这个接口尚未约束费率必须非负,第三个试验会暴露该缺口。约束属于记录构造器还是规则方法要由领域契约决定;仅把实现藏在接口后面不会自动验证对象状态。
解决默认方法的优先级与冲突
Child 同时继承父类方法和接口默认方法,类方法获胜。Transfer 实现两个无关接口,必须显式覆盖冲突的方法。
public class DispatchRules {
static class Parent {
public String source() {
return "class";
}
}
interface Named {
default String source() {
return "interface";
}
}
static final class Child extends Parent implements Named {}
interface Fast {
default String mode() {
return "fast";
}
}
interface Safe {
default String mode() {
return "safe";
}
}
static final class Transfer implements Fast, Safe {
@Override
public String mode() {
return Fast.super.mode() + "+" + Safe.super.mode();
}
}
public static void main(String[] args) {
System.out.println(new Child().source());
System.out.println(new Transfer().mode());
}
}class
fast+safe删除 Transfer.mode() 会产生编译错误,而不是在运行时随意选择一个默认实现。Fast.super.mode() 只能限定直接父接口,不能把任意祖先接口当作普通静态工具类调用。
Child 没有覆盖 source(),但父类提供的具体方法已经满足 Named 契约。父类甚至不必声明 implements Named;只要继承到的方法签名与可见性符合要求,子类声明就能建立接口关系。
组合函数式接口
Rule<T> 只有 test() 一个抽象方法,所以 Lambda 可以实现它。默认方法 and() 返回另一个 Rule<T>,静态方法提供一个有名称的基础规则。
import java.util.Objects;
public class ValidationPipeline {
@FunctionalInterface
interface Rule<T> {
boolean test(T value);
default Rule<T> and(Rule<? super T> other) {
Objects.requireNonNull(other);
return value -> test(value) && other.test(value);
}
static Rule<String> notBlank() {
return value -> value != null && !value.isBlank();
}
}
public static void main(String[] args) {
Rule<String> accountName = Rule.<String>notBlank()
.and(value -> value.startsWith("acct-"));
System.out.println(accountName.test("acct-alice"));
System.out.println(accountName.test(" "));
System.out.println(accountName.test("guest"));
}
}true
false
falseand() 使用短路求值。空白字符串在第一条规则处失败,不会执行 startsWith();null 也不会进入第二条规则。这项行为是组合器契约的一部分,应像参数类型一样接受测试。
给 Rule 增加第二个不等价的抽象方法会让 @FunctionalInterface 声明和所有 Lambda 目标一起失效。增加默认或静态方法不会改变函数描述符,但新默认方法仍可能与其他接口产生名称冲突。
把静态工厂与私有辅助方法留在接口中
静态工厂可以把简单实现藏在接口后面,默认方法则可复用私有辅助逻辑。两种方法都不会增加函数式接口的抽象方法数量。
import java.util.Locale;
import java.util.Objects;
public class InterfaceHelpers {
@FunctionalInterface
interface AccountId {
String raw();
default String normalized() {
return normalize(raw());
}
static AccountId of(String raw) {
Objects.requireNonNull(raw);
return () -> raw;
}
private static String normalize(String raw) {
return raw.strip().toLowerCase(Locale.ROOT);
}
}
public static void main(String[] args) {
AccountId account = AccountId.of(" ACCT-42 ");
System.out.println(account.normalized());
System.out.println(AccountId.of(" JOB-7 ").raw().strip());
}
}acct-42
JOB-7of() 必须通过 AccountId 调用,因为静态接口方法不被实现对象继承。normalize() 只能由接口内部调用,它允许默认方法共享实现,却没有扩大实现类或调用方可见的 API。
陷阱
把可变对象当作接口常量
修复方法: 接口只暴露真正不可变的值,并优先把配置与状态交给实现对象的构造器。若常量只供接口内部实现使用,放入私有静态方法或单独的内部实现类,避免扩大公共 API。
设计调用方无法履行的大接口
修复方法: 按调用方需要拆分能力,并应用 接口隔离原则(interface segregation principle) 。让方法参数依赖最小接口;确需成组能力时,再用子接口组合它们。
用默认方法隐藏失败策略
修复方法: 默认方法只组合已有操作,并保留原契约的失败语义。跨网络重试、授权失败与事务回滚应由拥有上下文的调用层决定;为正常缺失设计显式返回类型,不要吞掉意外异常。
假设默认方法自动向后兼容
修复方法: 用旧实现二进制运行兼容测试,再对全部实现与调用方做干净重编译。检查同名方法、返回类型、异常与副作用;公共接口变更需要发布说明和迁移路径。
把静态方法当作继承成员
修复方法: 始终用声明接口限定静态方法。若工厂必须根据实现类型变化,把创建行为放进独立工厂接口、构造器或注册表,不要试图用静态方法模拟多态分派。
设计稳定的契约边界
接口应从调用方的需求出发,而不是从某个现有类的全部公开方法复制。若调用方只需要 quote(),就不要同时暴露缓存清理、连接关闭和诊断状态。较小的接口让替代实现更容易履行完整契约,也减少未来新增方法的压力。
有些代码不需要接口。只有一个实现、没有替换边界且不会跨模块使用的内部助手,直接使用具体类可能更清楚。为了测试而给每个类机械创建接口,往往只把构造细节搬到另一层,并没有形成稳定契约。
接口与抽象类解决的约束不同:
| 设计约束 | 接口 | 抽象类 |
|---|---|---|
| 一个类承担多种不相关能力 | 可以实现多个 | 只能继承一个类 |
| 共享每个实例的字段与构造流程 | 不支持 | 支持 |
| 提供基于契约的通用方法 | 用 default | 用具体实例方法 |
| 限制实现集合 | 可用 sealed 接口 | 可用 sealed 抽象类 |
| 表达一个 Lambda 目标 | 函数式接口可以 | 不可以 |
默认方法应建立在接口已经承诺的抽象操作上。例如,Collection.isEmpty() 可以从 size() 推导,不需要知道实现字段。若默认行为需要新状态、锁、网络客户端或数据库事务,它已经超出接口自身能够拥有的上下文,通常应进入实现类或协调服务。
方法的行为契约需要覆盖有效输入,也要覆盖失败边界。若实现允许 null、阻塞调用、重复执行或并发调用,接口应明确说明。契约测试可以作为每个实现都要通过的测试套件,从而验证替换性,而不是只确认方法能编译。
接口类型也可以带泛型参数,例如 Comparator<T> 或 Repository<T>。类型参数应表达输入与输出间真实关系;通配符和擦除的细节属于泛型主题。这里更重要的是,替换实现必须对同一个参数化契约保持相同语义。
接口成员的边界规则
接口中的嵌套成员类和成员接口隐式为 public static。它们不携带某个接口实例,也不能访问所谓的接口实例字段,因为这种字段不存在。若辅助类型只是实现细节,把它公开嵌套在接口中会无意扩大 API。
接口可以重新声明 equals(Object)、hashCode() 或 toString() 为抽象方法,以表达文档意图,但不能为与 Object 的非私有实例方法覆盖等价的方法提供 default 实现。这个限制避免接口默认实现压过所有对象已有的类方法语义。
函数式接口的抽象方法数量不是简单数分号。继承而来的覆盖等价方法可能合并成一个函数描述符,而与 Object 公开方法对应的声明不会单独增加计数。@FunctionalInterface 把这套规则交给编译器检查,比人工数方法可靠。
默认方法冲突也不只发生在两个直接接口都写了相同方法时。子接口可以覆盖父接口默认方法,抽象声明也可能使继承关系重新要求实现。审查时应沿完整接口图收集覆盖等价签名,不能只搜索实现类的 implements 那一行。
InterfaceName.super.method() 是冲突解决语法,不是一般的父类型反射机制。限定名称必须指向允许的直接父接口,而且调用发生在实现类的实例方法体中。选择某一默认实现后,覆盖方法仍应维护最终公开接口承诺。
演进已经发布的接口
发布后的接口同时面对源码调用方、已经编译的实现类和运行中的混合版本。一次修改可以保持二进制链接,却让源码重编译失败,也可以两者都通过但改变结果。评审接口变更时,必须分别记录这三个维度。
新增抽象方法会让现有实现源码在下次编译时缺少实现。Java 语言规范把某些这类修改定义为对既有二进制兼容,但旧实现真正调用到新抽象方法时仍可能失败;「可以链接」不能替代端到端运行测试。
新增默认方法通常让旧实现继续运行,因为接口提供了方法体。然而,新方法可能与另一个接口形成冲突;Java 语言规范明确说明,某些混合二进制会在调用时抛出 IncompatibleClassChangeError。干净重编译还能发现运行旧二进制时看不到的源码歧义。
新增私有或静态方法不会要求实现类提供方法体,通常比扩大实例契约风险小。即便如此,公开静态方法仍扩大了 API,名字和行为会成为调用方依赖。删除成员、改变签名、收窄可见性或改变异常与副作用需要单独的兼容性分析。
接口字段的常量值还有编译期内联问题。调用方使用编译期常量时,旧二进制可能保留旧值,即使运行时加载了更新后的接口。不要把会变化的配置、协议版本或开关发布为接口常量,再期待只替换一个类文件就更新所有调用方。
函数式接口对演进尤其敏感。新增抽象方法会删除其 SAM 性质,使 Lambda 和方法引用无法重新编译;新增默认方法虽然不改变 SAM 数量,仍需检查名称冲突和行为变化。保留 @FunctionalInterface 能把第一类破坏变成接口声明处的明确错误。
公共接口变更应至少执行两组验证:所有源码对新接口做干净重编译,以及旧实现二进制在新接口旁运行。再用契约测试比较行为、异常和副作用。只跑当前模块的单元测试,无法覆盖外部实现者或混合部署。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题