接口

Java 接口定义可替换的行为契约;掌握成员规则、默认方法冲突、函数式接口与安全演进方式。

难度 进阶 时长 标准深度约 12分钟
版本 Java 25 LTS
what

接口(interface)是只描述公开行为的引用类型。类通过 implements 承诺这些行为,调用方则可依赖接口类型替换具体实现。

trap

default 并非笼统的兼容性保证;新增默认方法可能与别的接口冲突,也可能悄悄改变已有实现的行为。

fix

保持接口小而明确,写清输入、失败与并发契约,并用完整重编译和替代实现测试每次接口变更。

是什么,为什么存在

Java 接口是一种不能直接实例化的引用类型。它为一组操作命名,却不要求所有实现共享同一父类或对象布局。类可以实现多个接口,接口也可以继承多个接口,因此行为契约不必绑在单继承的类层次上。

Java 采用 名义类型(nominal typing) 。一个类即使恰好拥有同名方法,也不会自动成为某个接口的实现;源码必须通过 implements 或继承关系明确建立类型关系。这样,方法签名相同只是巧合还是有意履行契约,不需要由调用方猜测。

接口类型带来 子类型多态(subtype polymorphism) 。例如,构造器接收 ShippingRule 时,可以得到固定费率、按重量计费或测试替身,而无需知道各自的类。运行时仍在实际对象上分派实例方法;接口引用不是包裹对象的新容器。

你会在集合、比较器、监听器、服务边界和依赖注入中遇到接口。它最适合表达调用方真正依赖的能力,尤其是多个不相关类都能履行的契约。若设计主要目的是共享实例状态、构造流程或受保护的辅助代码,抽象类通常更直接。

方法签名没有写出全部行为。空值规则、参数单位、返回值含义、异常、线程安全要求和副作用都属于契约。接口位于跨团队或公共库边界时,应把这些约束写进文档和契约测试。

工作原理

类用 implements 实现接口,接口用 extends 组合其他接口。非抽象类必须为继承到的抽象实例方法提供实现,除非某个更具体的默认方法已经满足要求。接口变量可以引用任何兼容实例,但只能直接调用其静态类型公开的成员。

接口没有构造器和实例字段。字段声明隐式为 public static final,因此保存的是接口级常量引用;若该引用指向可变集合,集合本身仍可修改。把可变对象放进接口字段会制造公开的全局状态,不会因为引用是 final 就变安全。

成员形式是否有方法体是否继承到实现类典型用途
抽象实例方法定义实现必须提供的行为
default 方法基于现有契约提供通用实例行为
static 方法与接口概念相关的工厂或辅助操作
private 方法复用接口内部的默认或静态实现
字段初始化表达式通过限定名称访问真正常量,而不是可变状态

抽象方法和默认方法隐式为 public;它们不能是 protected 或包可见。静态方法可以是 publicprivate,私有实例方法只能由接口自己的实例方法调用。实现类不能降低所实现方法的可见性。

默认方法(default method) 拥有实例方法体,可以调用其他实例方法和私有实例辅助方法。它没有自己的实例字段,所以其行为只能依赖参数、常量和接收对象通过契约暴露的状态。默认方法适合从已有原语推导便捷操作,不适合偷偷引入新的状态要求。

多个候选实例方法相遇时,编译器按下面的规则决定是否需要显式覆盖:

  1. 类或父类中的具体实例方法优先于接口默认方法。
  2. 一个接口比另一个更具体时,子接口声明的方法优先。
  3. 两个无关接口提供冲突的默认方法时,实现类必须覆盖;覆盖体可用 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。两个记录类保存不同数据并实现同一操作,而默认方法根据实际类给出诊断名称。

ShippingQuote.java
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: 600

List.<ShippingRule>of(...) 明确把两个不同记录视为同一接口类型。循环中的调用仍落到各自的 feeInCents() 实现,label() 则复用接口中的默认实现。调用方无需用 instanceof 分支识别每个计费类。

这个接口尚未约束费率必须非负,第三个试验会暴露该缺口。约束属于记录构造器还是规则方法要由领域契约决定;仅把实现藏在接口后面不会自动验证对象状态。

解决默认方法的优先级与冲突

Child 同时继承父类方法和接口默认方法,类方法获胜。Transfer 实现两个无关接口,必须显式覆盖冲突的方法。

DispatchRules.java
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>,静态方法提供一个有名称的基础规则。

ValidationPipeline.java
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
false

and() 使用短路求值。空白字符串在第一条规则处失败,不会执行 startsWith()null 也不会进入第二条规则。这项行为是组合器契约的一部分,应像参数类型一样接受测试。

Rule 增加第二个不等价的抽象方法会让 @FunctionalInterface 声明和所有 Lambda 目标一起失效。增加默认或静态方法不会改变函数描述符,但新默认方法仍可能与其他接口产生名称冲突。

把静态工厂与私有辅助方法留在接口中

静态工厂可以把简单实现藏在接口后面,默认方法则可复用私有辅助逻辑。两种方法都不会增加函数式接口的抽象方法数量。

InterfaceHelpers.java
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-7

of() 必须通过 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 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?