corejava11(6.1 接口)

6.1 接口

在下面的部分中,您将了解Java接口是什么以及如何使用它们。您还可以了解在Java的最新版本中接口是如何变得更强大的。

6.1.1 接口概念

在Java编程语言中,接口不是类,而是要符合接口的类的一组需求。

通常,一些服务的供应商会说:“如果您的类符合特定的接口,那么我将执行该服务。”让我们来看一个具体的例子。Arrays类的sort方法承诺对对象数组进行排序,但有一个条件:对象必须属于实现Comparable接口。

以下是Comparable的接口的外观:

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

这意味着实现Comparable接口的任何类都需要有compareTo方法,并且该方法必须接受一个Object参数并返回一个整数。

注意

到了Java 5,Comparable接口已被增强为泛型类型。

public interface Comparable
{
    int compareTo(T other); // parameter has type T
}

例如,实现Comparable的类必须提供一个方法

int compareTo(Employee other)

您仍然可以使用不带类型参数的“原始”Comparable类型。然后compareTo方法有一个Object类型的参数,您必须手动将compareTo方法的参数转换为所需的类型。我们只做一会儿,这样你就不必同时担心两个新概念了。

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

当然,还有一个接口无法解释的附加要求:调用x.compareTo(y)时,compareTo方法实际上必须能够比较两个对象,并返回x或y是否更大的指示。如果x小于y,该方法应该返回一个负数;如果x等于y,则返回零;否则返回一个正数。

这个特殊的接口只有一个方法。有些接口有多种方法。正如您稍后将看到的,接口还可以定义常量。然而,更重要的是,接口不能提供什么。接口从来没有实例字段。在Java 8之前,从未在接口中实现方法。(如第306页第6.1.4节“静态和私有方法”和第307页第6.1.5节“默认方法”所示,现在可以在接口中提供简单的方法。当然,这些方法不能引用实例字段,接口没有。)

提供对其进行操作的实例字段和方法是实现接口的类的工作。可以将接口看作一个没有实例字段的抽象类。然而,这两个概念之间存在一些差异,我们稍后将详细介绍它们。

现在,假设我们想使用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);
}

这里,我们使用静态Double.compare方法,如果第一个参数小于第二个参数,则返回负值;如果它们相等,则返回0;否则返回正值。

小心

在接口声明中,compareTo方法未声明为public方法,因为接口中的所有方法都自动public。但是,在实现接口时,必须将该方法声明为public。否则,编译器假定该方法具有类的默认包访问权限。然后编译器会抱怨您试图提供一个更严格的访问权限。

我们可以通过为通用Comparable接口提供一个类型参数来做得更好:

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

请注意,Object参数的难看的强制转换已消失。

提示

Comparable接口的compareTo方法返回一个整数。如果对象不相等,则返回的负值或正值无关紧要。在比较整数字段时,这种灵活性非常有用。例如,假设每个员工都有一个唯一的整数id,并且您希望按员工ID号进行排序。然后您可以简单地返回id - other.id。如果第一个id号小于另一个id,该值将是一些负值;如果它们是相同的id,则为0;否则为一些正值。然而,有一个警告:整数的范围必须足够小,这样减法就不会溢出。如果您知道这些ID不是负数,或者它们的绝对值至多是(Integer.MAX_VALUE - 1) / 2,那么您是安全的。否则,调用静态的Integer.compare方法。

当然,减法不适用于浮点数。salary - other.salary,如果工资相近但不相同,则工资可以四舍五入为0。调用Double.compare(x, y)只返回-1(如果xy)。

注意

Comparable接口的文档表明compareTo方法应该与equals方法兼容。也就是说,当x.equals(y)时,x.compareto(y)应该正好为零。Java API中的大多数类,可以遵循这个建议实现Comparable接口。一个显著的例外是BigDecimal。考虑x = new BigDecimal("1.0")y = new BigDecimal("1.00")。那么x.equals(y)是false的,因为数字的精度不同。但是x.compareTo(y)是零。理想情况下,不应该这样,但没有明显的方法来决定谁应该排在第一位。

现在,您看到了类必须做什么来利用排序服务,它必须实现compareTo方法。这是非常合理的。排序方法需要某种方式来比较对象。但是为什么Employee类不能在不实现Comparable接口的情况下简单地提供compareTo方法呢?

接口的原因是Java编程语言是强类型的。当进行方法调用时,编译器需要能够检查该方法是否实际存在。sort方法中的某个地方将有如下语句:

if (a[i].compareTo(a[j]) > 0)
{
    // rearrange a[i] and a[j]
    . . .
}

编译器必须知道a[i]实际上有一个compareTo方法。如果a是一个Comparable对象数组,那么方法的存在就可以得到保证,因为实现Comparable接口的每个类都必须提供该方法。

注意

您会期望Arrays类中的sort方法被定义为接受Comparable[]数组,这样,如果有人使用元素类型不实现Comparable接口的数组调用sort,编译器就会抱怨。可悲的是,事实并非如此。相反,sort方法接受Object[]数组并使用笨拙的强制转换:

// approach used in the standard library--
not recommended
if (((Comparable) a[i]).compareTo(a[j]) > 0)
{
    // rearrange a[i] and a[j]
    . . .
}

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

清单6.1给出了对类Employee的实例数组进行排序的完整代码(清单6.2)。

清单6.1 interfaces/EmployeeSortTest.java

package interfaces;
 
import java.util.*;
 
/**
* This program demonstrates the use of the Comparable interface.
* @version 1.30 2004-02-27
* @author Cay Horstmann
*/
public class EmployeeSortTest
{
   public static void main(String[] args)
   {
      var staff = new Employee[3];
 
      staff[0] = new Employee("Harry Hacker", 35000);
      staff[1] = new Employee("Carl Cracker", 75000);
      staff[2] = new Employee("Tony Tester", 38000);
 
      Arrays.sort(staff);
 
      // print out information about all Employee objects
      for (Employee e : staff)
         System.out.println("name=" + e.getName() + ",salary=" + e.getSalary());
   }
}

清单6.2 interfaces/Employee.java

package interfaces;
 
public class Employee implements Comparable
{
   private String name;
   private double salary;
 
   public Employee(String name, double salary)
   {
      this.name = name;
      this.salary = salary;
   }
 
   public String getName()
   {
      return name;
   }
 
   public double getSalary()
   {
      return salary;
   }
 
   public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }
 
   /**
    * Compares employees by salary
    * @param other another Employee object
    * @return a negative value if this employee has a lower salary than
    * otherObject, 0 if the salaries are the same, a positive value otherwise
    */
   public int compareTo(Employee other)
   {
      return Double.compare(salary, other.salary);
   }
}

java.lang.Comparable 1.0

  • int compareTo(T other)
    将此对象与other对象进行比较,如果此对象小于other对象,则返回负整数;如果它们相等,则返回零;否则返回正整数。

java.util.Arrays 1.2

  • static void sort(Object[] a)
    对数组a中的元素进行排序。数组中的所有元素都必须属于实现Comparable接口的类,并且它们必须相互比较。

java.lang.Integer 1.0

  • static int compare(int x, int y) 7
    如果x

java.lang.Double 1.0

  • static int compare(double x, double y) 1.4
    如果x

提示

根据语言标准:“实施者必须确保所有x和y的sgn(x.compareto(y)) = -sgn(y.compareto(x))。(这意味着,如果y.compareTo(x)抛出异常,x.compareTo(y)必须抛出异常。)”这里,sgn是数字的符号:sgn(n),如果n是负的是-1,n为0则为0,如果n是正的则为1。在简单的英语中,如果翻转compareTo的参数,结果的符号(但不一定是实际值)也必须翻转。

和equals方法一样,当继承开始起作用时,可能会出现问题。

由于Manager扩展了Employee,它实现了Comparable而不是 Comparable。如果Manager选择覆盖compareTo,则必须准备将经理与员工进行比较。它不能简单地将员工强制转换为经理:

class Manager extends Employee
{
    public int compareTo(Employee other)
    {
        Manager otherManager = (Manager) other; // NO
        . . .
    }
    . . .
}

这违反了“反对称”规则。如果x是Employee,y是Manager,那么调用x.compareTo(y)不会引发异常,它只是将x和y作为雇员进行比较。但反过来,y.compareTo(x)抛出了一个ClassCastException。

这与我们在第5章中讨论的equals方法的情况相同,并且补救方法也是相同的。有两种不同的场景。

如果子类具有不同的比较概念,那么应该禁止对属于不同类的对象进行比较。每个compareTo方法应从测试开始

if (getClass() != other.getClass()) throw new ClassCastException();

如果有比较子类对象的通用算法,只需在超类中提供一个compareTo方法,并将其声明为final。

例如,假设您希望经理比普通员工更好,而不考虑薪水。其他的子类如Executive和Secretary呢?如果需要建立一个优先顺序,请提供一个方法,例如在Employee类中rank。让每个子类重写rank,并实现一个将rank值考虑在内的compareTo方法。

6.1.2 接口的属性

接口不是类。尤其是,永远不能使用new运算符来实例化接口:

x = new Comparable(. . .); // ERROR

但是,即使不能构造接口对象,仍然可以声明接口变量。

Comparable x; // OK

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

x = new Employee(. . .); // OK provided Employee implements Comparable

接下来,就像使用instanceof检查对象是否属于特定类一样,可以使用instanceof检查对象是否实现了接口:

if (anObject instanceof Comparable) { . . . }

正如您可以构建类的层次结构一样,您也可以扩展接口。这允许多个接口链从更大的通用性到更大的专门化。例如,假设您有一个名为Moveable的接口。

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

然后,您可以想象一个名为Powered的接口扩展了它:

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

尽管不能将实例字段放在接口中,但可以在其中提供常量。例如:

public interface Powered extends Moveable
{
    double milesPerGallon();
    double SPEED_LIMIT = 95; // a public static final constant
}

正如接口中的方法自动public一样,字段始终是public static final。

注意

将接口方法标记为public是合法的,字段标记为public static final是合法的。有些程序员这样做,不是出于习惯,就是为了更清晰。然而,Java语言规范建议不提供冗余关键字,并且我们遵循该建议。

有些接口只定义常量,没有定义方法。例如,标准库包含一个接口SwingConstants,它定义了常量NORTH、SOUTH、HORIZONTAL等。任何选择实现SwingConstants接口的类都会自动继承这些常量。它的方法可以简单地引用NORTH而不是更麻烦的SwingConstants.NORTH。然而,这种接口的使用似乎相当退化,我们不建议这样做。

虽然每个类只能有一个超类,但类可以实现多个接口。这使您在定义类的行为时具有最大的灵活性。例如,Java编程语言内置了一个重要的接口,称为Cloneable。(我们将在第314页的第6.1.9节“对象克隆”中详细讨论这个接口。)如果您的类实现了Cloneable,则Object类中的clone方法将精确复制类的对象。如果您同时需要可克隆性和可比性,只需实现这两个接口。使用逗号分隔要实现的接口:

class Employee implements Cloneable, Comparable

6.1.3 接口和抽象类

如果你在第5章中阅读了有关抽象类的部分,你可能会奇怪为什么Java编程语言的设计者要麻烦地引入接口的概念。为什么不能简单地将Comparable做为抽象类:

abstract class Comparable // why not?
{
    public abstract int compareTo(Object other);
}

然后Employee类将简单地扩展这个抽象类并提供compareTo方法:

class Employee extends Comparable // why not?
{
    public int compareTo(Object other) { . . . }
}

不幸的是,使用抽象基类来表示泛型属性存在一个主要问题。类只能扩展单个类。假设Employee类已经扩展了一个不同的类,比如Person。那么它就不能扩展第二个类了。

class Employee extends Person, Comparable // ERROR

但是每个类可以实现任意多的接口:

class Employee extends Person implements Comparable // OK

其他编程语言,特别是C++,允许一个类具有不止一个超类。此功能称为多重继承。Java的设计者选择不支持多重继承,因为它使语言非常复杂(如C++)或效率较低(如Eiffel)。

相反,接口提供了多重继承的大部分好处,同时避免了复杂性和低效率。

C++注意

C++具有多重继承和随之出现的所有并发症,如虚拟基类、支配规则和横向指针转换。很少有C++程序员使用多重继承,有人说它不应该被使用。其他程序员建议只对继承的“混合”样式使用多重继承。在混合样式中,主基类描述父对象,其他基类(所谓的混合)可能提供辅助特性。该样式类似于具有单个超类和附加接口的Java类。

6.1.4 静态和私有方法

对于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方法可以是static方法或实例方法。由于私有方法只能在接口本身的方法中使用,因此它们的使用仅限于作为接口的其他方法的辅助方法。

6.1.5 默认方法

您可以为任何接口方法提供默认实现。必须使用default修饰符标记此类方法。

public interface Comparable
{
    default int compareTo(T other) { return 0; } // by default, all elements are the same
}

当然,这不是很有用,因为每一个Comparable的实际实现都会覆盖这个方法。但在其他情况下,默认方法也会很有用。例如,在第9章中,您将看到一个Iterator接口,用于访问数据结构中的元素。它声明了一个remove方法,如下所示:

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

如果实现迭代器,则需要提供hasNext和next方法。这些方法没有默认值,它们依赖于您正在遍历的数据结构。但是,如果迭代器是只读的,则不必担心remove方法。

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

public interface Collection
{
    int size(); // an abstract method
    default boolean isEmpty() { return size() == 0; }
    . . .
}

然后,实现Collection的程序员不必担心实现一个isEmpty方法。

注意

Java API中的Collection接口实际上并不这样做。相反,有一个类AbstractCollection实现了Collection,并根据size定义isEmpty。建议集合的实现者扩展AbstractCollection。这种技术已经过时了。只需在接口中实现方法。

默认方法的一个重要用途是接口演进。例如,考虑了多年来一直是Java的一部分的Collection接口。假设很久以前,你提供了一个类

public class Bag implements Collection

稍后,在Java 8中,将stream方法添加到接口中。

假设stream方法不是默认方法。然后Bag类将不再编译,因为它不实现新方法。向接口添加非默认方法与源不兼容。

但是假设您不重新编译类,只使用包含它的旧JAR文件。即使缺少方法,类仍将加载。程序仍然可以构造Bag实例,不会发生任何错误。(向接口添加方法是二进制兼容的。)但是,如果程序在包实例上调用流方法,则会发生AbstractMethodError。

将该方法设为default方法可以解决这两个问题。Bag类将再次编译。如果在不重新编译的情况下加载类,并且在包实例上调用stream方法,则调用Collection.stream方法。

6.1.6 解决默认方法冲突

如果在一个接口中将完全相同的方法定义为默认方法,然后再次定义为超类或其他接口的方法,会发生什么?语言如Scala和C++有复杂的规则来解决这样的歧义。幸运的是,Java中的规则要简单得多。它们是:

  1. 超类获胜。如果一个超类提供了一个具体的方法,那么具有相同名称和参数类型的默认方法将被忽略。
  2. 接口冲突。如果一个接口提供了默认方法,而另一个接口包含具有相同名称和参数类型(默认或非默认)的方法,则必须通过重写该方法来解决冲突。

让我们看看第二条规则。考虑使用getName方法的两个接口:

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

如果您形成一个实现这两个接口的类,会发生什么?

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设计者决定支持一致性。两个接口如何冲突并不重要。如果至少有一个接口提供了一个实现,编译器会报告一个错误,程序员必须解决这种不确定性。

注意

当然,如果两个接口都不提供共享方法的默认值,那么我们处于Java 8之前的情况,并且没有冲突。一个实现类有两个选择:实现该方法,或者让它保持未实现状态。在后一种情况下,类本身是抽象的。

我们刚刚讨论了两个接口之间的名称冲突。现在考虑一个类,它扩展一个超类并实现一个接口,从两个类继承相同的方法。例如,假设Person是一个类,而Student定义为

class Student extends Person implements Named { . . . }

在这种情况下,只有超类方法才重要,接口中的任何默认方法都将被忽略。在我们的示例中,Student从Person继承了getName方法,不管Named接口是否为getName提供默认值,都没有任何区别。这是“类赢”规则。

“类赢”规则确保了与Java 7的兼容性。如果您将默认方法添加到接口中,那么它对在出现默认方法之前工作的代码没有影响。

小心

永远不能使默认方法重新定义Object类中的一个方法。例如,您不能为toString或equals定义默认方法,尽管这可能对List等接口很有吸引力。由于“类赢”规则,这样的方法永远无法战胜Object.toString或Objects.equals。

6.1.7 接口和回调

编程中的一个常见模式是回调模式。在这个模式中,您指定了在特定事件发生时应该发生的操作。例如,您可能希望在单击按钮或选择菜单项时发生特定操作。但是,由于您还没有看到如何实现用户界面,我们将考虑类似但更简单的情况。

javax.swing包包含一个Timer类,如果您希望在时间间隔结束时得到通知,该类很有用。例如,如果程序的某个部分包含时钟,您可以要求每秒收到通知,以便更新时钟面。

构造计时器时,设置时间间隔,并告诉它在时间间隔结束时应该做什么。

你怎么告诉计时器它应该做什么?在许多编程语言中,您提供了计时器应定期调用的函数的名称。然而,Java标准库中的类采用面向对象的方法。传递某个类的对象。然后,计时器调用该对象上的一个方法。传递对象比传递函数更灵活,因为对象可以携带附加信息。

当然,计时器需要知道调用什么方法。计时器要求指定实现java.awt.event包的ActionListener接口的类的对象。这是接口:

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

当时间间隔到期时,计时器调用actionPerformed方法。

假设你想打印一条信息,“At the tone, the time is . . . “,然后每秒钟发出一声蜂鸣。您将定义一个实现ActionListener接口的类。然后,您将把想要执行的任何语句放在actionPerformed方法中。

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

注意actionPerformed方法的ActionEvent参数。此参数提供有关事件的信息,例如事件发生的时间。调用event.getWhen()返回事件时间,单位为“epoch”(1970年1月1日)以来的毫秒数。通过将它传递给静态Instant.ofEpochMilli方法,我们得到了更易读的描述。

接下来,构造这个类的对象并将其传递给Timer构造函数。

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

Timer构造函数的第一个参数是通知之间必须经过的时间间隔,以毫秒为单位。我们希望每秒钟都得到通知。第二个参数是listener对象。

最后启动定时器

t.start();

每一秒,像

At the tone, the time is 2017-12-16T05:01:49.550Z

的消息被打印出来,同时伴随着一声蜂鸣。

清单6.3使计时器及其动作侦听器工作。计时器启动后,程序会弹出一个消息对话框,等待用户单击“确定”按钮停止。当程序等待用户时,每秒显示当前时间。(如果省略对话框,程序将在main方法退出后立即终止。)

清单6.3 timer/TimerTest.java

package timer;
 
/**
   @version 1.02 2017-12-14
   @author Cay Horstmann
*/
 
import java.awt.*;
import java.awt.event.*;
import java.time.*;
import javax.swing.*;
 
public class TimerTest
{ 
   public static void main(String[] args)
   { 
      var listener = new TimePrinter();
 
      // construct a timer that calls the listener
      // once every second
      var timer = new Timer(1000, listener);
      timer.start();
 
      // keep program running until the user selects "OK"
      JOptionPane.showMessageDialog(null, "Quit program?");
      System.exit(0);
   }
}
 
class TimePrinter implements ActionListener
{ 
   public void actionPerformed(ActionEvent event)
   { 
      System.out.println("At the tone, the time is "
         + Instant.ofEpochMilli(event.getWhen()));
      Toolkit.getDefaultToolkit().beep();
   }
}

javax.swing.JOptionPane 1.2

  • static void showMessageDialog(Component parent, Object message)

    显示带有消息提示和“OK”按钮的对话框。对话框位于parent组件的中心。如果parent为null,则对话框在屏幕上居中。

javax.swing.Timer 1.2

  • Timer(int interval, ActionListener listener)
    构造一个计时器,每当interval毫秒已过时通知listener。
  • void start()
    启动计时器。一旦启动,计时器就调用其listener上面的actionPerformed方法。
  • void stop()
    停止计时器。一旦停止,计时器就不再调用其listeners上面的actionPerformed方法。

java.awt.Toolkit 1.0

  • static Toolkit getDefaultToolkit()
    获取默认工具箱。工具箱包含有关GUI环境的信息。
  • void beep()
    发出嘟嘟声。

6.1.8 Comparator接口

在第296页的第6.1.1节“接口概念”中,您已经看到了如何对对象数组进行排序,前提是它们是实现Comparable接口的类的实例。例如,可以对字符串数组进行排序,因为String类实现Comparable,String.compareTo方法按字典顺序比较字符串。

现在假设我们想通过增加字符串的长度来排序,而不是按照字典的顺序。我们不能让String类以两种方式实现compareTo方法,无论如何,String类不是我们要修改的。

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

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

要按长度比较字符串,请定义实现Comparator的类:

class LengthComparator implements Comparator
{
    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方法是在comparator对象上调用的,而不是字符串本身。

注意

即使LengthComparator对象没有状态,您仍然需要为其创建一个实例。您需要实例来调用compare方法,它不是静态方法。

要对数组排序,请将LengthComparator对象传递给Arrays.sort方法:

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

现在数组是["Paul", "Mary", "Peter"]或者["Mary", "Paul", "Peter"]

在第322页的第6.2节“lambda表达式”中,您将看到如何更容易地对lambda表达式使用Comparator。

6.1.9 对象克隆

在本节中,我们将讨论Cloneable接口,该接口指示类提供了一个安全的clone方法。由于克隆技术并不是那么普遍,而且其细节也非常技术化,所以您可能只需要浏览一下这些材料,直到需要它为止。

要理解克隆的含义,请回想一下在复制保存对象引用的变量时会发生什么。原件和副本是对同一对象的引用(见图6.1)。这意味着对任一变量的更改也会影响另一个变量。

corejava11(6.1 接口)_第1张图片

图6.1 拷贝和克隆

var original = new Employee("John Public", 50000);
Employee copy = original;
copy.raiseSalary(10); // oops--also changed original

如果您希望copy成为一个新对象,该对象的生命开始时与original对象相同,但其状态会随着时间而变化,请使用clone方法。

Employee copy = original.clone();
copy.raiseSalary(10); // OK--original unchanged

但这并不那么简单。clone方法是Object的protected方法,这意味着代码不能简单地调用它。只有Employee类可以克隆Employee对象。这种限制是有原因的。考虑Object类实现克隆的方式。它对对象一无所知,因此只能进行逐字段复制。如果对象中的所有数据字段都是数字或其他基本类型,那么复制这些字段就可以了。但是,如果对象包含对子对象的引用,那么复制该字段将为您提供对同一个子对象的另一个引用,因此原始对象和克隆对象仍然共享一些信息。

要实现这一点,请考虑第4章中介绍的Employee类。图6.2显示了使用Object类的clone方法克隆这样一个Employee对象时会发生什么。如您所见,默认的克隆操作是“浅拷贝”——它不会克隆在其他对象中引用的对象。(图中显示了共享Date对象。由于很快就会清楚的原因,此示例使用Employee类的一个版本,其中雇用日表示为Date。)

corejava11(6.1 接口)_第2张图片

图6.2 一个浅拷贝

如果拷贝太浅有关系吗?视情况而定。如果原始克隆和浅克隆之间共享的子对象是不可变的,则共享是安全的。如果子对象属于不可变类(如String),则肯定会发生这种情况。或者,子对象可能只是在对象的整个生命周期中保持不变,没有任何修改器接触到它,也没有任何方法产生对它的引用。

但是,子对象通常是可变的,您必须重新定义clone方法以生成一个深度复制来克隆子对象。在我们的示例中,hireDay字段是一个Date,它是可变的,因此它也是可克隆的。(因此,此示例使用Date类型的字段(而不是LocalDate)来演示克隆过程。如果hireDay是不可变LocalDate类的实例,则不需要采取进一步的操作。)

对于每个类,你都要决定是否

  1. 默认clone方式足够好了
  2. 可以通过对可变子对象调用clone来修补默认的clone方法;或者
  3. 不应尝试clone。

第三个选项实际上是默认值。要选择第一个或第二个选项,类必须

  1. 实现Cloneable接口;以及
  2. 使用public修饰符重新定义clone方法。

注意

clone方法在Object类中声明为protected,因此代码不能简单地调用anObject.clone()。但是protected方法不是被任何子类可访问吗,每个类不都是Object的子类吗?幸运的是,protected访问的规则更加微妙(见第5章)。子类只能调用受保护的clone方法来克隆自己的对象。必须将clone重新定义为public才能允许任何方法克隆对象。

在这种情况下,Cloneable接口的出现与接口的正常使用无关。特别是,它没有指定方法从Object类继承的clone方法。接口只是作为一个标记,指示类设计器理解克隆过程。对象对于克隆如此偏执,以至于当对象请求克隆但不实现该接口时,它们会生成一个选中的异常。

注意

Cloneable接口是Java提供的少量标记接口之一。(有些程序员称之为标记接口。)回想一下,接口(如Comparable)的通常用途是确保类实现特定的方法或方法集。标记接口没有方法;其唯一目的是允许在类型查询中使用instanceof:

if (obj instanceof Cloneable) . . .

我们建议您不要在自己的程序中使用标记接口。

即使clone的默认(浅拷贝)实现足够,您仍然需要实现Cloneable接口,将clone重新定义为公共的,并调用super.clone()。下面是一个例子:

class Employee implements Cloneable
{
    // public access, change return type
    public Employee clone() throws CloneNotSupportedException
    {
    	return (Employee) super.clone();
    }
    . . .
}

注意

到Java 1.4,克隆方法总是具有返回类型Object。现在,您可以为clone方法指定正确的返回类型。这是协变返回类型的一个例子(参见第5章)。

刚才看到的clone方法没有为Object.clone提供的浅拷贝添加任何功能。它只是公开了这个方法。要进行深度复制,您必须更加努力,克隆可变的实例字段。

下面是一个创建深度复制的clone方法示例:

class Employee implements Cloneable
{
    . . .
    public Employee clone() throws CloneNotSupportedException
    {
        // call Object.clone()
        Employee cloned = (Employee) super.clone();
        // clone mutable fields
        cloned.hireDay = (Date) hireDay.clone();
        return cloned;
    }
}

Object类的clone方法威胁要引发CloneNoSupportedException,每当对其类不实现Cloneable接口的对象调用clone时,都会发生这种情况。当然,Employee和Date类实现了Cloneable的接口,因此不会引发异常。但是,编译器不知道这一点。因此,我们宣布了例外情况:

public Employee clone() throws CloneNotSupportedException

注意

相反,捕获异常会更好吗?(有关捕获异常的详细信息,请参阅第7章。)

public Employee clone()
{
    try
    {
        Employee cloned = (Employee) super.clone();
        . . .
    } 
    catch (CloneNotSupportedException e) { return null; }
    // this won't happen, since we are Cloneable
}

这适用于final类。否则,最好保留throws说明符。如果子类不支持克隆,那么它们可以选择抛出一个CloneNotSupportedException。

你必须小心克隆子类。例如,一旦为Employee类定义了clone方法,任何人都可以使用它来克隆Manager对象。Employee克隆方法可以完成这项工作吗?这取决于Manager类的字段。在我们的例子中,没有问题,因为bonus字段有原始类型。但Manager可能已经获得了需要深度复制或不可克隆的字段。不能保证子类的实现者已经修复了clone来做正确的事情。因此,clone方法在Object类中声明为protected。但是如果您希望类的用户调用clone,那么您就没有这种奢侈了。

您应该在自己的类中实现clone吗?如果你的客户需要做深度复制,那么你可能应该做。一些作者认为您应该避免完全clone,而是为相同的目的实现另一个方法。我们同意clone是相当笨拙的,但是如果您将责任转移到另一种方法,您将遇到同样的问题。无论如何,克隆并没有你想象的那么普遍。标准库中不到5%的类实现clone。

清单6.4中的程序克隆了类Employee的一个实例(清单6.5),然后调用两个mutator。raiseSalary方法更改“salary”字段的值,而setHireDay方法更改“hireDay”字段的状态。两种变异都不会影响原始对象,因为clone已被定义为进行深度复制。

注意

所有数组类型都有一个公共的、不受保护的clone方法。您可以使用它生成一个包含所有元素副本的新数组。例如:

int[] luckyNumbers = { 2, 3, 5, 7, 11, 13 };
int[] cloned = luckyNumbers.clone();
cloned[5] = 12; // doesn't change luckyNumbers[5]

注意

第二卷第2章展示了使用Java的对象序列化特性来克隆对象的替代机制。这种机制容易实现,安全,但效率不高。

清单6.4 clone/CloneTest.java

package clone;

/**
 * This program demonstrates cloning.
 * @version 1.11 2018-03-16
 * @author Cay Horstmann
 */
public class CloneTest
{
   public static void main(String[] args) throws CloneNotSupportedException
   {
      var original = new Employee("John Q. Public", 50000);
      original.setHireDay(2000, 1, 1);
      Employee copy = original.clone();
      copy.raiseSalary(10);
      copy.setHireDay(2002, 12, 31);
      System.out.println("original=" + original);
      System.out.println("copy=" + copy);
   }
}

清单6.5 clone/Employee.java

package clone;

import java.util.Date;
import java.util.GregorianCalendar;

public class Employee implements Cloneable
{
   private String name;
   private double salary;
   private Date hireDay;

   public Employee(String name, double salary)
   {
      this.name = name;
      this.salary = salary;
      hireDay = new Date();
   }

   public Employee clone() throws CloneNotSupportedException
   {
      // call Object.clone()
      Employee cloned = (Employee) super.clone();

      // clone mutable fields
      cloned.hireDay = (Date) hireDay.clone();

      return cloned;
   }

   /**
    * Set the hire day to a given date. 
    * @param year the year of the hire day
    * @param month the month of the hire day
    * @param day the day of the hire day
    */
   public void setHireDay(int year, int month, int day)
   {
      Date newHireDay = new GregorianCalendar(year, month - 1, day).getTime();
      
      // example of instance field mutation
      hireDay.setTime(newHireDay.getTime());
   }

   public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }

   public String toString()
   {
      return "Employee[name=" + name + ",salary=" + salary + ",hireDay=" + hireDay + "]";
   }
}

你可能感兴趣的:(corejava11)