上一篇的 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() 可以。