在下面的部分中,您将了解Java接口是什么以及如何使用它们。您还可以了解在Java的最新版本中接口是如何变得更强大的。
在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接口。
要使类实现接口,请执行两个步骤:
要声明类实现了接口,请使用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
java.util.Arrays 1.2
java.lang.Integer 1.0
java.lang.Double 1.0
提示
根据语言标准:“实施者必须确保所有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方法。
接口不是类。尤其是,永远不能使用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
如果你在第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类。
对于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方法或实例方法。由于私有方法只能在接口本身的方法中使用,因此它们的使用仅限于作为接口的其他方法的辅助方法。
您可以为任何接口方法提供默认实现。必须使用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方法。
如果在一个接口中将完全相同的方法定义为默认方法,然后再次定义为超类或其他接口的方法,会发生什么?语言如Scala和C++有复杂的规则来解决这样的歧义。幸运的是,Java中的规则要简单得多。它们是:
让我们看看第二条规则。考虑使用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。
编程中的一个常见模式是回调模式。在这个模式中,您指定了在特定事件发生时应该发生的操作。例如,您可能希望在单击按钮或选择菜单项时发生特定操作。但是,由于您还没有看到如何实现用户界面,我们将考虑类似但更简单的情况。
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
java.awt.Toolkit 1.0
在第296页的第6.1.1节“接口概念”中,您已经看到了如何对对象数组进行排序,前提是它们是实现Comparable接口的类的实例。例如,可以对字符串数组进行排序,因为String类实现Comparable
现在假设我们想通过增加字符串的长度来排序,而不是按照字典的顺序。我们不能让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。
在本节中,我们将讨论Cloneable接口,该接口指示类提供了一个安全的clone方法。由于克隆技术并不是那么普遍,而且其细节也非常技术化,所以您可能只需要浏览一下这些材料,直到需要它为止。
要理解克隆的含义,请回想一下在复制保存对象引用的变量时会发生什么。原件和副本是对同一对象的引用(见图6.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。)
图6.2 一个浅拷贝
如果拷贝太浅有关系吗?视情况而定。如果原始克隆和浅克隆之间共享的子对象是不可变的,则共享是安全的。如果子对象属于不可变类(如String),则肯定会发生这种情况。或者,子对象可能只是在对象的整个生命周期中保持不变,没有任何修改器接触到它,也没有任何方法产生对它的引用。
但是,子对象通常是可变的,您必须重新定义clone方法以生成一个深度复制来克隆子对象。在我们的示例中,hireDay字段是一个Date,它是可变的,因此它也是可克隆的。(因此,此示例使用Date类型的字段(而不是LocalDate)来演示克隆过程。如果hireDay是不可变LocalDate类的实例,则不需要采取进一步的操作。)
对于每个类,你都要决定是否
第三个选项实际上是默认值。要选择第一个或第二个选项,类必须
注意
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 + "]";
}
}