跳至主要內容

接口

Akkiri...大约 17 分钟JavaSEJava

⏱️时间安排:6天
⏳开始时间:2023-01-18
⌛结束时间:2023-01-23

接口

接口的概念

在 Java 程序设计语言中,接口不是类,而是对希望符合这个接口的类的一组要求。

下面给出一个具体示例。Arrays 类中的 sort 方法承诺可以对对象数组进行排序,但要求满足下面这个条件:对象所属的类必须实现 Comparable 接口。下面是 Comparable 接口的代码:

public interface Comparable{
  int compareTo(Object other);
}

这说明,任何实现 Comparable 接口的类都需要包含 compareTo 方法,这个方法有一个 Object 参数,并且返回一个整数。在调用 x.compareTo(y) 的时候,这个 compareTo 方法确实能够比较两个对象,并返回比较的结果。当 x 小于 y 时,返回一个负数;当 x 等于 y 时,返回 0;否则返回一个正数。

提示

接口中所有的方法都自动是 public 方法。因此,在接口中声明方法时,不必提供关键字 public。

接口可以包含一个方法,也可以包含多个方法。同时,接口还可以定义常量。不过,接口绝不会有实例字段,,在 Java 8 之前,接口中绝对不会实现方法(在后面可以看到,现在已经可以在接口中提供简单方法了。当然,这些方法不能引用实例字段——接口没有实例)。

提供实例字段和方法实现的任务应该由实现接口的类来完成。因此,可以将接口看成是没有实例字段的抽象类。

现在,假设希望使用 Arrays 类的 sort 方法对 Employee对象数组进行排序,Employee 类就必须实现 Comparable 接口。

为了让类实现一个接口,通常需要完成下面两个步骤:

  1. 将类声明为实现给定的接口。
  2. 对接口中的所有方法提供定义。

要将类声明为实现某个接口,需要使用关键字 implements:

class Employee implements Comparable

这里的 Employee 类需要提供 compareTo 方法。假设希望根据员工的薪水进行比较。以下是 compareTo 方法的实现:

public int compareTo(Object otherObject){
    Employee other = (Employee) otherObject;
    return Double.compare(salary, other.salary);
}

注意

在接口声明中,没有将 compareTo 方法声明为 public,这是因为在接口中的所有方法都默认为 public。不过,在实现接口时,必须把方法声明为 public;否则,编译器将认为这个方法的访问属性是 protected,这是类的默认访问属性。

我们可以做得更好一些。可以为泛型 Comparable 接口提供一个类型参数。

class Employee implements Comparable<Employee>{
    public int compareTo(Employee other){
        return Double.compare(salary, other.salary);
    }
}

这样,就不再需要对 Object 参数进行强制类型转换。

提示

Comparable 接口中的 compareTo 方法将返回一个整数。如果两个对象不相等,则返回一个正值或者一个负值。然而,这里通过两数相减的方法并不适用于浮点值。因为当 salary 和 other.salary 很接近但又不相等的时候,它们的差经过四舍五入后很有可能变成 0。当 x < y 时,Double.compare(x, y) 调用会返回 -1;如果 x > y 则返回 1。

现在,我们已经看到,要让一个类使用排序服务必须让它实现 compareTo 方法。这是理所当然的,因为要向 sort 方法提供对象的比较方式。但是为什么不能再 Employee 类中直接提供一个 compareTo 方法,而必须实现 Comparable 接口呢?

主要原因在于 Java 程序设计语言是一种强类型(strongly typed)语言。在调用方法的时候,编译器要能检查这个方法确实存在。在 sort 方法中可能会有下面这样的语句:

if(a[i].compareTo(a[i]) > 0){
    // 对 a[i] 和 a[j] 重新排序
}

编译器必须确认 a[i] 一定有一个 compareTo 方法。如果 a 是一个 Comparable 对象的数组,就可以确保拥有 compareTo 方法,因为每个实现 Comparable 接口的类都必须提供这个方法的定义。

提示

你可能认为,如果将 Arrays 类中的 sort 方法定义为接受一个 Comparable[] 数组,倘若调用 sort 方法时所提供的元素类型没有实现 Comparable 接口,编译器就会报错。遗憾的是,事实并非如此。实际上,sort 方法可以接受一个 Object[] 数组,并使用一个笨拙的强制类型转换:

if(((Comparable) a[i])).compareTo(a[j]) > 0){
    // 对 a[i] 和 a[j] 重新排序
}

如果 a[i] 不属于一个实现了 Comparable 接口的类,虚拟机就会抛出一个异常。

接口的属性

  1. 首先声明,接口不是类,不能使用 new 运算符实例化一个接口:
x = new Comparable(...); // ERROR

不过,尽管不能构造接口对象,却能声明接口的变量:

Comparable x; // OK

接口变量必须引用实现了这个接口的类对象:

x = new Employee(...); // OK,向变量 x 提供实现 Comparable 接口的类对象
  1. 接下来,如同使用 instanceof 检查一个对象是否属于某个特定的类一样,也可以使用 instanceof 检查一个对象是否实现了某个特定的接口:
if(anObject instanceof Comparable){
    ...
}
  1. 与建立类的继承层次一样,也可以扩展接口。

这里允许有多条接口链,从通用性较高的接口扩展到专用性较高的接口。例如,假设有一个名为 Moveable 的接口:

public interface Moveable{
	void move(double x, double y);
}

然后,可以假设一个名为 Powered 的接口扩展了以上 Moveable 接口:

public interface Powered extends Moveable{
    double milesPerGallon();
}

虽然在接口中不能包含实例字段,但是可以包含常量。例如:

public interface Powered extends Moveable{
	double milesPerGallon();
    double SPEED_LIMIT = 95; // 定义了一个公有的常量
}

与接口中的方法都被自动设置为 public 一样,接口中的字段总是 public static final

尽管每个类只能有一个超类,但却可以实现多个接口。这为定义类的行为提供了极大的灵活性。例如,Java 程序设计语言有一个非常重要的内置接口,名为 Cloneable。如果某个类实现了这个 Cloneable 接口,Object 类中的 clone 方法就可以创建你的类对象的一个准确副本。如果希望自己设计的类拥有克隆和比较的能力,只要实现这两个接口就可以了。可以使用逗号将想要实现的各个接口分隔开。

class Employee implements Comparable, Cloneable

接口与抽象类

为什么 Java 程序设计语言要引入接口这个概念呢?为什么不将 Comparable 直接设计成一个抽象类呢?如下所示:

abstract class Comparable{
    public abstract int compareTo(Object other);
}

这样一来,Employee 类只需要扩展这个抽象类,并提供 compareTo 方法的实现:

class Employee extends Comparable{
    public int compareTo(Object other){
        ...
    }
}

非常遗憾,使用抽象类表示通用属性存在一个严重的问题。每个类只能扩展一个类。假设 Employee 类已经扩展了另一个类,例如 Person,它就不能再扩展第二个类了。

class Employee extends Person, Comparable // Error

但是每个类可以实现多个接口,如下所示:

class Employee extends Person implements Comparable // OK

有些程序设计语言(如 C++)允许一个类有多个超类。我们将这个特性称为多重继承(multiple inheritance)。Java 的设计者选择了不支持多重继承,其主要原因是多重继承会让语言变得复杂,或者效率会降低。

实际上,接口可以提供多重继承的大多数好处,同时还能避免多重继承的复杂性和低效性。

静态和私有方法拓展知识

  • 静态方法

在 Java 8 中,允许在接口中增加静态方法。虽然理论上这是合法的,但是这样有违于将接口作为抽象规范的初衷。

目前为止,通常的做法都是将静态方法放在伴随类中。在标准库中,你会看到成对出现的接口和实用工具,如 Collection/Collections 或 Path/Paths。

可以由一个 URI 或字符串序列构造一个文件或目录的路径,如 Paths.get("jdk-11", "conf", "security")。在 Java 11 中,Path 接口提供了等价的方法:

public interface Path{
    public static Path of(URI uri){...}
    public static Path of(String first, String... more){...}
}

这样一来,Paths 类就不是必要的了。

类似地,实现你自己的接口时,没有理由再为实用工具方法另外提供一个伴随类。

  • 私有方法

在 Java 9 中,接口中的方法可以是 private。private 方法可以是静态方法或实例方法。由于私有方法只能在接口本身的方法中使用,所以它们的用法很有限,只能作为接口中其他方法的辅助方法。

默认方法

可以为接口方法提供一个默认的实现。默认方法需要用 default 修饰符标记。

public interface Comparable<T>{
    default int compareTo(T other){
        return 0;
    } // 通过默认方法,所有元素都是相同的
}

当然,默认方法在这并没有多大用处,因为 Comparable 的每一个具体实现都会覆盖这个方法。不过有些情况下,默认方法可能很有用。例如 Iterator 接口,用于访问一个数据结构中的元素。这个接口声明了一个 remove 方法,如下所示:

public interface Iterator<E>{
    boolean hasNext();
    E next();
    default void remove(){
        throw new UnsupportedOperationException("remove");
    }
}

如果需要实现一个迭代器,就需要提供 hasNext 和 next 方法。这些方法没有默认实现,因为它们依赖于你要遍历访问的数据结构。不过,当你的迭代器是只读的情况下,由于 remove 方法有默认实现,所以不用操心实现 remove 方法。

默认方法可以调用其他方法。例如,Collection 接口可以定义一个便利方法:

public interface Collection{
    int size(); // 一个抽象方法
    default boolean isEmpty(){
        return size() = 0;
    }
    ...
}

这样实现 Collection 的程序员就不用操心实现 isEmpty 方法了。

  • 默认方法实现 “接口演化”

默认方法的一个重要用法是 “接口演化”(interface evolution)。以 Collection 接口为例,这个接口作为 Java 的一部分已经很久了。假设很久之前你提供了这样一个类:

public class Bag implements Collection

后来,在 Java 8 中,又为这个类增加了一个 stream 方法。

假设 stream 方法不是一个默认方法,那么 Bag 类将不能编译,因为它没有实现这个新方法。为接口增加一个非默认方法不能保证 “源代码兼容”(source compatible)。

提示

不过,假设不重新编译这个类,而是使用原先的一个包含这个类的 JAR 文件。这个类仍能正常加载,尽管没有这个新方法。程序仍然可以正常构造 Bag 实例,不会有意外发生(为接口增加方法可以保证 “二进制兼容”)。不过,如果程序在一个 Bag 实例上调用 stream 方法,就会出现 AbstractMethodError。

将新增的方法实现为一个默认方法就可以解决以上问题。Bag 类又能正常编译了。另外如果没有重新编译而直接加载这个类,并在一个 Bag 实例上调用 stream 方法,这将调用 Collection.stream 方法。

解决默认方法冲突

如果先在一个接口中将一个方法定义为默认方法,然后又在超类或另一个接口中定义同样的方法,可能会导致默认方法冲突。相较于其他有些语言对于解决这种二义性有一些复杂的规则,Java 的规则要简单得多。

  1. 接口冲突

如果一个接口提供了一个默认方法,另一个接口提供了一个同名而且参数类型(不论是否是默认参数)相同的方法,必须覆盖这个方法来解决冲突:

interface Person{
    default String getName(){
        return "";
    }
}

interface Named{
    default String getName(){
        return getClass().getName + "_" + hashCode();
    }
}

如果有一个类同时实现了这两个接口

class Student implements Person, Named{
    ...
}

类会继承 Person 和 Named 接口提供得两个不一致得 getName 方法。并不是从中选择一个,Java 编译器会报告一个错误,让程序员来解决这个二义性问题。只需要在 Student 类中提供一个 getName 方法即可。在这个方法中,可以选择两个冲突方法中得一个,如下所示:

class Student implements Person, Named{
	public String getName(){
        return Person.super.getName();
    }
}

现在假设 Named 接口没有为 getName 提供默认实现:

interface Named{
	String getName();
}

这种情况,好像 Student 类就会从 Person 接口继承默认方法。不过,Java 的设计者更强调一致性。两个接口如何冲突并不重要。如果至少有一个接口提供了实现,编译器就会报告错误,程序员就必须解决这个二义性。

  1. 超类优先

如果一个类扩展了一个超类,同时实现了一个接口,并从超类和接口继承了相同的方法。例如,假设 Person 是一个类,Student 定义为:

class Student extends Person implements Named{
    ...
}

在这种情况下,只会考虑超类方法,接口的所有默认方法都会被忽略。在我们的例子中,Student 从 Person 继承了 getName 方法,Named 接口是否为 getName 提供了默认实现并不重要。这正是 “类优先” 规则。

注意

千万不要让一个默认方法重新定义 Object 类中的某个方法。例如,不要为 toString 或 equals 定义默认方法,尽管对于 List 之类的接口这可

但是由于 “类优先” 规则,这样的方法绝对无法超越 Object.toString 或 Object.equals。

接口与回调

回调(callback)是一种常见的程序设计模式。在这种模式中,可以指定某个特定事件发生时应该采取的动作。例如,按下鼠标或选择某个菜单项时,你可能希望完成某个特定的动作。不过,由于至此还没有介绍如何实现用户界面,所以只能讨论一种与之类似但更简单的情况。

在 java.swing 包中有一个 Timer 类,如果希望经过一定时间间隔就得到通知,Timer 类就很有用。例如,假如程序中有一个时钟,可以请求每秒通知一次,以便更新时钟的表盘。

构造定时器时,需要设置一个时间间隔,并告述定时器经过这个时间间隔需要做些什么。

如何告诉定时器要做什么呢?在很多程序设计语言中,可以提供一个函数名,定时器要定期地调用这个函数。但是,Java 标准类库中的类采用的是面向对象方法。你可以向定时器传入某个类的对象,然后,定时器调用这个对象的方法,由于对象可以携带一些附加的信息,所以传递一个对象比传递一个函数要灵活得多。

当然,定时器需要知道调用哪一个方法,并要求传递的对象所属的类实现了 java.awt.event 包的 ActionListener 接口。下面是这个接口:

public interface ActionListener{
    void actionPerformed(ActionEvent event);
}

当到达指定的时间间隔时,定时器就调用 actionPerformed 方法。

假设希望每隔 1 秒钟打印一条消息 “At the tone, the time is...”,然后再响一声,那么可以定义一个实现 ActionListener 接口的类,然后将想要执行的语句放在 actionPerformed 方法中。

class TimePrinter implements ActionListener{
    @Override
	public void actionPerformed(ActionEvent e) {
		System.out.println("At the tone, the time is " + Instant.ofEpochMilli(e.getWhen()));
		Toolkit.getDefaultToolkit().beep();
    }
}

需要注意 actionPerformed 方法的 ActionEvent 参数。这个参数提供了事件的相关信息,例如,发生这个事件的时间。event.getWhen() 调用会返回这个事件时间,表示为 “纪元”(1970 年 1 月 1 日)以来的毫秒数。如果把它传入静态方法 Instant.ofEpochMilli,可以得到一个更可读的描述。

接下来,构造这个类的一个对象,并将它传递给 Timer 构造器。

var listener = new TimePrinter();
Timer t = new Timer(1000, listener);

Timer 构造器的第一个参数是时间间隔(单位是毫秒),即经过多长时间通知一次。这里希望每隔 1 秒钟通知一次。第二个参数是监听器对象。

最后,启动定时器:

t.start();

每隔一秒钟就会显示下面的消息,然后响一声铃。

At the tone, the time is 2023-01-22T09:36:56.152Z

Comparator 接口

在前面,我们已经了解了如何对一个对象数组进行排序,前提是这些对象是实现了 Comparable 接口的类的实例。例如,可以对一个字符串数组排序,因为 String 类实现了 Comparable<String>,而且 String.compareTo 方法可以按字典顺序比较字符串。

现在假设我们希望按照长度递增的顺序对字符串进行排序,而不是按字典顺序进行排序。看到不能让 String 类用两种不同的方式实现 compareTo 方法——更何况,String 类也不应由我们来修改。

要处理这种情况,Arrays.sort 方法还有第二个版本,有一个数组和一个比较器(comparator)作为参数,比较器是实现了 Comparator 接口的类的实例。

public interface Comparator<T>{
	int compare(T first, T second);
}

要按长度比较字符串,可以如下定义一个实现 Comparator<String> 的类:

class LengthComparator implements Comparator<String>{
	public int compare(String first, String second){
		return first.length() - second.length();
    }
}

具体完成比较时,需要建立一个实例:

var comp = new LengthComparator();
if(comp.compare(words[i], words[j]) > 0){
	...
}

将这个调用与 words[i].compareTo(words[j]) 进行比较。这个 compare 方法要在比较器对象上调用,而不是在字符串本身上调用。

提示

尽管 LengthComparator 对象没有状态,不过还是需要建立这个对象的一个实例。我们需要这个实例来调用 compare 方法——它不是一个静态方法。

要对一个数组排序,需要为 Arrays.sort 方法传入一个 LengthComparator 对象:

String[] friends = {"Peter", "Paul", "Tom"};
Arrays.sort(friends, new LengthComparator());

现在这个数组将被按字典顺序排序。

对象克隆待补全

本节我们会讨论 Cloneable 接口,这个接口指示一个类提供一个安全的 clone 方法。

要了解克隆的具体含义,先来回忆为一个包含对象引用的变量创建副本时会发生什么。原变量和副本都是同一个对象的引用。(见下图)这说明,任何一个变量改变都会影响另一个变量。

var original = new Employee("Tom", 10000);
Employee copy = original;
copy.raiseSalary(10); // 同时会改变 original

如果希望 copy 是一个新对象,它的初始状态与 original 相同,但是之后它们各自会有自己不同的状态,这种情况下就要使用 clone 方法。

Employee copy = original.clone();
copy.raiseSalary(10); // 不会改变 original

不过并没有这么简单。clone 方法是 Object 的一个 protected 方法,你的代码不能直接调用这个方法。只有 Employee 类可以克隆 Employee 对象。这个限制是有原因的。想想看 Object 类如何实现 clone。它对于这个对象一无所知,所以只能逐个字段地进行拷贝。如果对象中的所有数据字段都是数值或其它基本类型,拷贝这些字段没有任何问题。但是如果对象包含子对象的引用,拷贝字段就会得到相同子对象的另一个引用,这样一来,原对象和克隆的对象仍会共享一些信息。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.5