上一篇的 Account 和 SavingsAccount 是典型的“是一种”关系:储蓄账户本来就是账户,所以用 extends 共享余额逻辑很顺。现在换一个需求。

系统要把账户信息导出一行文本给报表用。同一份报表里还要放收据(Receipt)、对账单(Statement)。它们都要能被导出,但它们彼此不是一类东西——你不能让收据去继承账户,那在业务上是荒谬的。这一篇讲清楚:继承为什么在这里帮不上忙,接口是怎样补上这个缺口的,以及用接口类型的变量去操作不同实现类的对象时,究竟发生了什么。

继承卡在哪里

一个类只能 extends 一个父类。

1class Receipt extends Account, Voucher { }   // 编译错误

Java 为什么不让?假设允许同时继承两个父类,而这两个父类各自带着字段。新对象里就会出现两套来源不同的状态:同名字段谁先初始化、父类方法看到的到底是哪一个,都说不清。官方文档把这类麻烦叫“状态的多重继承”(multiple inheritance of state),并明确说这是 Java 不允许继承多个类的原因之一 1。

所以想让 Receipt 具备账户那种输出能力,“改它的父类”这条路走不通。更麻烦的是,能导出这件事跟继承链根本不对齐:账户、收据、对账单唯一的共同点只是“能被导出成一行字”,强行给它们找一个共同父类,等于逼它们共享一堆彼此无关的字段。

换个问法:不问“它是什么”,问“它能做什么”

继承描述的是身份(是不是一种),接口描述的是能力(能不能做)。Account 是账户,这是身份;Account 能被导成一行文本,这是能力。收据没有账户这个身份,但它具备这个能力。

接口(interface)的写法:

1public interface Reportable {
2    String toReportLine();
3}

和类的区别在于:这里只写了方法签名——叫什么名字、收什么参数、返回什么,最后用分号结束,没有花括号包起来的方法体。至于这件事具体怎么做,交给实现它的类去写。接口里的方法默认就是 public,通常不用把 public 写出来。接口也不能被 new:new Reportable() 编译不过,因为它只是一份“能做什么”的清单,本身不是一个完整的对象。

implements:接下这份承诺

让一个类具备接口描述的能力,用 implements:

1public class Account implements Reportable {
2    private String owner;
3    private int balance;
4
5    public Account(String who, int start) {
6        owner = who;
7        balance = start;
8    }
9
10    @Override
11    public String toReportLine() {
12        return owner + " 的余额:" + balance;
13    }
14}

Account implements Reportable 读作“Account 实现 Reportable”。实现意味着接下承诺:接口列出的每个方法,这个类都得给出方法体。少实现一个,编译就不过,除非你把这个类声明成 abstract——那等于说“我还没兑现完,交给我的子类” 2。方法签名也必须对上,包括 public:接口方法本来就是 public,实现时漏掉这个关键字,编译器会报“正在降低访问权限”。

@Override 不是必须写的,但写上以后编译器会帮你核对:这个方法是不是真的实现了接口里的某个方法,或者重写了父类的方法。拼错名字时它会立刻报错,值得养成习惯。

一个类可以有多个接口

1public class Receipt implements Reportable, Auditable { ... }

多个接口用逗号隔开。接口和继承也能同时用,extends 写在前面:

1public class SavingsAccount extends Account implements Auditable { ... }

这里正好接上上一篇的结论。Account 已经 implements Reportable,那么 SavingsAccount extends Account 之后,它自动也是 Reportable——因为 toReportLine() 的实现本来就跟着继承下来了。如果它嫌父类那版输出不够详细,在自己文件里再写一遍 toReportLine(),它仍然是 Reportable,只是运行时执行的是子类这版。

如果两个接口碰巧有同名同签名的方法,写一个实现就同时满足两者,不用写两遍。

接口是一种类型:统一操作不同对象

这是接口最有用的地方。接口名可以像类名一样当类型用:

1public class ReportPrinter {
2    public static void print(Reportable item) {
3        System.out.println(item.toReportLine());
4    }
5}

形参的类型是 Reportable,所以传进来的必须是实现了 Reportable 的对象:Account 对象、SavingsAccount 对象、Receipt 对象都行——哪怕它们分属两条完全无关的继承链。

编译器怎么检查这个方法体?只看 Reportable 这份清单。item.toReportLine() 合法,因为清单上有它。而 item.getBalance() 编译不过,因为参数类型是 Reportable,编译器并不知道传进来的对象其实是账户 4。

那运行时到底执行谁的 toReportLine()?看 item 变量指向的对象的实际类型,不看变量的声明类型 5。传 Account 对象就跑 Account 那版;传覆盖过这个方法的 SavingsAccount 对象,就跑子类那版。这和上一篇讲的重写是同一套机制,只是这次方法的“出处”是接口而不是父类。

这样写的好处立刻显现:print 只写一次,就能服务现在和以后所有能导出的类。以后新增一个能导出的类,ReportPrinter 一个字都不用改。标准库里大量能力就是这么组织的——List 是接口,ArrayList 和 LinkedList 都是它的实现,代码里用接口类型就能在两者之间自由替换。

用一张图看这个结构:

绘制中

边界和分工

接口既然这么好用,为什么不干脆用它取代继承?因为它不带对象状态。接口里写字段是允许的,但那个字段自动是 public static final——它是常量,不属于任何一个对象。接口也没有构造器。账户的余额、户名这类随对象变化的数据,它托管不了。

这也解释了 Java 为什么敢放开“一个类实现多个接口”:多个接口不会同时往同一个对象里塞各自的字段,前面说的状态冲突根本不会发生 1。类不能多继承,接口可以多实现,差别的根就在“带不带对象状态”这一点上。

选型时用一句话判断:

  • 两个类之间是“是一种”,而且要共享字段和已有的代码 → 用 extends。
  • 两个类之间只是“都具备某种能力”,彼此不在一条继承链上 → 抽一个接口,各自 implements。

还有一个边界值得知道:从 Java 8 起,接口里的方法可以带方法体,叫 default 方法,目的是让接口在不改动已有实现类的前提下新增方法。这等于把“实现的多重继承”又引了回来,于是两个接口可能给出同名的默认实现,编译器解决不了就报错,要求类自己重写一遍。这一层现在只需要知道“有这回事、看得懂编译器的报错”就够了。

检验自己有没有真的理解:拿到两个类,你能说出该用继承还是接口;能解释为什么 Reportable r = new Account("张三", 1000); 之后再写 r.getBalance() 会编译失败,而 r.toReportLine() 可以。