4.1 接口

第 3 章介绍了继承这个概念。我们知道,一个 Java 类只能继承一个类。这对我们要编写的面向对象程序来说是个相当严格的限制。Java 的设计者知道这一点,但他们也是为了确保 Java 实现面向对象编程的方式比其他语言(例如 C++)简单。

他们选择的方式是提出接口这个概念。和类一样,接口定义一种新的引用类型。如“接口”这个名称所示,接口的作用只是描绘 API,因此,接口提供类型的描述信息,以及实现这个 API 的类应该提供的方法(和签名)。

一般来说,Java 的接口不为它描述的方法提供实现代码。这些方法是强制要实现的——想实现接口的类必须实现这些方法。

不过,接口可能想把 API 中的某些方法标记为可选,如果实现接口的类不想实现就不用实现。这种机制通过 default 关键字实现,接口必须为可选的方法提供默认实现,未实现这些方法的类会使用默认实现。

4.1 接口 - 图1 接口中的可选方法是 Java 8 的新功能,之前的版本中没有。4.1.5 节会完整介绍如何使用可选方法(也叫默认方法)。

接口不能直接实例化,也不能创建这种接口类型的成员。接口必须通过类实现,而且类要提供所需的方法主体。

这个类的实例既属于这个类定义的类型,也属于这个接口定义的类型。不属于同一个类或超类的对象,通过实现同一个接口,也能属于同一种类型。

4.1.1 定义接口

定义接口的方式和定义类差不多,不过所有(非默认的)方法都是抽象方法,而且关键字 class 要换成 interface。例如,下述代码定义了一个名为 Centered 的接口。第 3 章定义的 Shape 类如果想设定和读取形状的中心点坐标,就可以实现这个接口。

  1. interface Centered {
  2. void setCenter(double x, double y);
  3. double getCenterX();
  4. double getCenterY();
  5. }

接口的成员有些限制。

  • 接口中所有强制方法都隐式使用 abstract 声明,不能有方法主体,要使用分号。可以使用 abstract 修饰符,但一般习惯省略。

  • 接口定义公开的 API。接口中的所有成员都隐式使用 public 声明,而且习惯省略不必要的 public 修饰符。如果在接口中使用 protectedprivate 定义方法,会导致编译时错误。

  • 接口不能定义任何实例字段。字段是实现细节,而接口是规格不是实现。在接口中只能定义同时使用 staticfinal 声明的常量。

  • 接口不能实例化,因此不定义构造方法。

  • 接口中可以包含嵌套类型。嵌套类型隐式使用 publicstatic 声明。4.4 节会完整介绍嵌套类型。

  • 从 Java 8 开始,接口中可以包含静态方法。Java 之前的版本不允许这么做,这被广泛认为是 Java 语言的一个设计缺陷。

4.1.2 扩展接口

接口可以扩展其他接口,而且和类的定义一样,接口的定义可以包含一个 extends 子句。接口扩展另一个接口时,会继承父接口中的所有方法和常量,而且可以定义新方法和常量。不过,和类不同的是,接口的 extends 子句可以包含多个父接口。例如,下述接口扩展了其他接口:

  1. interface Positionable extends Centered {
  2. void setUpperRightCorner(double x, double y);
  3. double getUpperRightX();
  4. double getUpperRightY();
  5. }
  6. interface Transformable extends Scalable, Translatable, Rotatable {}
  7. interface SuperShape extends Positionable, Transformable {}

扩展多个接口的接口,会继承每个父接口中的所有方法和常量,而且可以定义属于自己的方法和常量。实现这个接口的类必须实现这个接口直接定义的抽象方法,以及从所有父接口中继承的全部抽象方法。

4.1.3 实现接口

类使用 extends 指定超类,类似地,类使用 implements 列出它支持的一个或多个接口。implements 是一个 Java 关键字,可以出现在类声明中,但要放 在 extends 子句后面。implements 关键字后面是这个类要实现的一组接口,接口之间使用逗号分隔。

类在 implements 子句中声明接口时,表明这个类要为接口中的每个强制方法提供实现(即主体)。如果实现接口的类没有为接口中的每个强制方法提供实现,那么这个类从接口中继承未实现的抽象方法,而且这个类本身必须使用 abstract 声明。如果类实现多个接口,必须实现每个接口中的所有强制方法(否则这个类要使用 abstract 声明)。

下述代码展示了如何定义 CenteredRectangle 类,这个类扩展第 3 章定义的 Rectangle 类,而且实现 Centered 接口:

  1. public class CenteredRectangle extends Rectangle implements Centered {
  2. // 新实例字段
  3. private double cx, cy;
  4. // 构造方法
  5. public CenteredRectangle(double cx, double cy, double w, double h) {
  6. super(w, h);
  7. this.cx = cx;
  8. this.cy = cy;
  9. }
  10. // 继承了Rectangle类中的所有方法
  11. // 但要为Centered接口中的所有方法提供实现
  12. public void setCenter(double x, double y) { cx = x; cy = y; }
  13. public double getCenterX() { return cx; }
  14. public double getCenterY() { return cy; }
  15. }

假设我们按照 CenteredRectangle 类的实现方式实现了 CenteredCircleCenteredSquare 类。每个类都扩展 Shape 类,所以如前所示,这些类的实例都可以当成 Shape 类的实例。因为每个类都实现了 Centered 接口,所以这些实例还可以当成 Centered 类型的实例。下述代码演示了对象既可以作为类类型的成员,也可以作为接口类型的成员:

  1. Shape[] shapes = new Shape[3]; // 创建一个数组,保存形状对象
  2. // 创建一些Centered类型的形状,存储在这个Shape[]类型的数组中
  3. // 不用校正,因为都是放大转换
  4. shapes[0] = new CenteredCircle(1.0, 1.0, 1.0);
  5. shapes[1] = new CenteredSquare(2.5, 2, 3);
  6. shapes[2] = new CenteredRectangle(2.3, 4.5, 3, 4);
  7. // 计算这些形状的平均面积
  8. // 以及到原点的平均距离
  9. double totalArea = 0;
  10. double totalDistance = 0;
  11. for(int i = 0; i < shapes.length; i++) {
  12. totalArea += shapes[i].area(); // 计算这些形状的面积
  13. // 注意,一般来说,使用instanceof判断对象的运行时类型经常表明设计有问题
  14. if (shapes[i] instanceof Centered) { // 形状属于Centered类型
  15. // 注意,把Shape类型转换成Centered类型要校正
  16. // (不过,把CenteredSquare类型转换成Centered类型不用校正)
  17. Centered c = (Centered) shapes[i];
  18. double cx = c.getCenterX(); // 获取中心点的坐标
  19. double cy = c.getCenterY(); // 计算到原点的距离
  20. totalDistance += Math.sqrt(cx*cx + cy*cy);
  21. }
  22. }
  23. System.out.println("Average area: " + totalArea/shapes.length);
  24. System.out.println("Average distance: " + totalDistance/shapes.length);

4.1 接口 - 图2 在 Java 中,接口和类一样,也是数据类型。如果一个类实现了一个接口,那么这个类的实例可以赋值给这个接口类型的变量。

看过这个示例之后,别错误地认为必须先把 CenteredRectangle 对象赋值给 Centered 类型的变量才能调用 setCenter() 方法,或者要先赋值给 Shape 类型的变量才能调用 area() 方法。CenteredRectangle 类定义了 setCenter() 方法,而且从超类 Rectangle 中继承了 area() 方法,所以始终可以调用这两个方法。

4.1.4 实现多个接口

假设我们不仅想通过中心点摆放形状对象,也想通过右上角摆放形状对象,而且还想放大和缩小形状。还记得吗?虽然一个类只能扩展一个超类,但可以实现任意多个接口。假设我们已经定义好了合适的 UpperRightCorneredScalable 接口,那么可以按照下述方式声明类:

  1. public class SuperDuperSquare extends Shape
  2. implements Centered, UpperRightCornered, Scalable {
  3. // 类的成员省略了
  4. }

一个类实现多个接口只是表明这个类要实现所有接口中的全部抽象方法(即强制方法)。

4.1.5 默认方法

Java 8 出现后,接口中的方法可以包含实现了。本节介绍这种方法——在接口描述的 API 中通过可选的方法表示,一般叫作默认方法。首先说明为什么需要这种默认机制。

1. 向后兼容性

Java 平台始终关注向后兼容性。这意味着,为前一版平台编写(或者已经编译)的代码在最新版平台中必须能继续使用。这个原则让开发团队坚信,升级 JDK 或 JRE 后不会破坏之前能正常运行的应用。

向后兼容性是 Java 平台的一大优势,但是为此,Java 平台有诸多约束。其中一个约束是,新发布的接口不能添加新的强制方法。

例如,假设我们要升级 Positionable 接口,添加获取和设定左下角顶点的功能:

  1. public interface Positionable extends Centered {
  2. void setUpperRightCorner(double x, double y);
  3. double getUpperRightX();
  4. double getUpperRightY();
  5. void setLowerLeftCorner(double x, double y);
  6. double getLowerLeftX();
  7. double getLowerLeftY();
  8. }

重新定义接口之后,如果尝试在为旧接口编写的代码中使用这个新接口,不会成功,因为现有的代码中没有 setLowerLeftCorner()getLowerLeftX()getLowerLeftY() 这三个强制方法。

4.1 接口 - 图3 在你的代码中可以轻易地看到效果。编译一个依赖接口的类文件,在接口中添加一个新的强制方法,然后使用新版接口和旧的类文件尝试运行程序。你会看到程序崩溃,抛出 NoClassDefError 异常。

Java 8 的设计者注意到了这个缺陷,因为设计者的目标之一是升级 Java 核心中的集合库,引入使用 lambda 表达式的方法。

若想解决这个问题,需要一种新机制。这种机制必须要允许向接口中添加可选的新方法,而不破坏向后兼容性。

2. 实现默认方法

在接口中添加新方法而不破坏向后兼容性,这需要为接口的旧实现提供一些新实现,以便接口能继续使用。这个机制是默认方法,在 JDK 8 中首次添加到 Java 平台。

4.1 接口 - 图4 默认方法(有时也叫可选方法)可以添加到任何接口中。默认方法必须包含实现,即默认实现,写在接口定义中。

默认方法的基本行为如下:

  • 实现接口的类可以(但不是必须)实现默认方法;

  • 如果实现接口的类实现了默认方法,那么使用这个类中的实现;

  • 如果找不到其他实现,就使用默认实现。

sort() 方法是默认方法的一例,JDK 8 把它添加到 java.util.List 接口中,定义如下:

  1. // 句法<E>是Java编写泛型的方式,详情参见下一节
  2. // 如果不熟悉泛型,暂且忽略这个句法
  3. interface List<E> {
  4. // 省略了其他成员
  5. public default void sort(Comparator<? super E> c) {
  6. Collections.<E>sort(this, c);
  7. }
  8. }

因此,从 Java 8 开始,实现 List 接口的对象都有一个名为 sort() 的实例方法,使用合适的 Comparator 排序列表。因为返回类型是 void,所以我们猜测这是就地排序,而事实确实如此。

4.1.6 标记接口

有时,定义全空的接口很有用。类实现这种接口时只需在 implements 子句中列出这个接口,而不用实现任何方法。此时,这个类的任何实例都是这个接口的有效实例。Java 代码可以使用 instanceof 运算符检查实例是否属于这个接口,因此这种技术是为对象提供额外信息的有力方式。

java.io.Serializable 接口就是一种标记接口。实现 Serializable 接口的类告诉 ObjectOutputStream 类,这个类的实例可以安全地序列化。java.util.RandomAccess 也是标记接口:java.util.List 接口实现了这个接口,表明这个接口能快速随机访问列表中的元素。例如,ArrayList 类实现了 RandomAccess 接口,而 LinkedList 类没实现。注重随机访问操作性能的算法可以使用下述方式测试 RandomAccess

  1. // 排序任意长度的列表元素之前,我们或许想确认列表是否支持快速随机访问
  2. // 如果不支持,先创建一个支持随机访问的副本再排序,速度可能更快
  3. // 注意,使用java.util.Collections.sort()时不必这么做
  4. List l = ...; // 随意一个列表
  5. if (l.size() > 2 && !(l instanceof RandomAccess)) l = new ArrayList(l);
  6. sortListInPlace(l);

后面会看到,Java 的类型系统和类型的名称联系紧密,这种方式叫作名义类型(nominal typing)。标记接口是个很好的例子,因为它除了名称什么都没有。