接口用来描述类应该做什么,而不指定它们具体应该如何做。一个类可以实现一个或多个接口。
接口的概念
在Java中,接口不是类,而是对希望符合这个接口的类的一组需求。我们经常听到服务提供商这样说:“如果你的类符合某个特定接口,我就会履行这项服务。”Arrays类中的sort方法承诺可以对对象数组进行排序,但要求满足下面这个条件:对象所属的类必须实现Comparable接口。
为了让类实现一个接口,通常需要完成下面两个步骤:
- 将类声明为实现给定的接口。
- 对接口中的所有方法提供定义。
接口的属性
- 不能使用new运算符实例化一个接口。
- 接口变量必须引用实现了这个接口的类对象。
- 如同使用instanceof检查一个对象是否属于某个特定类一样,也可以使用instanceof检查一个对象是否实现了某个特定的接口。
- 允许有多条接口链,从通用性较高的接口扩展到专用性较高的接口。
- 接口中不能包含实例字段,但是可以包含常量。
- 与接口中的方法都自动被设置为
public
一样,接口中的字段总是public static final
。
接口与抽象类
使用抽象类表示通用属性存在一个严重的问题。每个类只能扩展一个类。有些程序设计语言允许一个类有多个超类,这个特性称为多重继承(multiple inheritance)。Java的设计者选择了不支持多重继承,其主要原因是多重继承会让语言变得非常复杂,或者效率会降低。接口可以提供多重继承的大多数好处,同时还能避免多重继承的复杂性和低效性。
静态和私有方法
在Java 8中,允许在接口中增加静态方法。通常的做法都是将静态方法放在伴随类中。在标准库中,你会看到成对出现的接口和使用工具类,如Collection/Collections等。
在Java 9中,接口中的方法可以是private。private方法可以是静态方法或实例方法。由于私有方法只能在接口本身的方法中使用,所以它们的用法很有限,只能作为接口中其他方法的辅助方法。
默认方法
可以为接口方法提供一个默认实现。必须用default修饰符标记这样一个方法。
在有些情况下,默认方法可能会很有用,例如,Iterator接口,用于访问一个数据结构中的元素。这个接口声明了一个remove方法:
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() { throw new UnsupportedOperationException("remove"); }
}
如果你要实现一个迭代器,就需要提供hasNext和next方法。这些方法没有默认实现——它们依赖于你要遍历访问的数据结构。如果你的迭代器是只读的,就不用操心实现remove方法。
默认方法可以调用其他方法。如Collection接口可以定义一个遍历方法:
public interface Collection {
int size();
default boolean isEmpty() { return size() == 0; }
}
默认方法的一个重要用法是“接口演化”(interface evolution)。以Collection接口为例,这个接口作为Java的一部分已经有很多年了。假设很久以前你提供了这样一个类:
public class Bag implements Collection
后来,在Java 8中,又为这个接口增加了一个stream方法。
假设stream方法不是一个默认方法,那么Bag将不能编译,因为它没有实现这个新方法。为接口增加一个非默认方法不能保证“源代码兼容”(source compatible)。
不过,假设不重新编译这个类,而只是使用原先的一个包含这个类的JAR文件。这个类仍能正常加载,尽管没有这个新方法。程序仍然可以正常构造Bag实例,不会有意外发生。不过,如果程序在一个Bag实例上调用stream方法,就会出现一个AbstractMethodError。
将方法实现为一个默认(default)方法就可以解决这两个问题。Bag类又能正常编译了。另外如果没有重新编译而直接加载这个类,并在一个Bag实例上调用stream方法,将调用Collection.stream方法。
解决默认方法冲突
如果先在一个接口中将一个方法定义为默认方法,然后又在超类或另一个接口中定义同样方法,会发生什么情况?
Java的规则如下:
- 超类优先。如果超类提供了一个具体方法,同名而且有相同参数类型的默认方法会被忽略。
- 接口冲突。如果一个接口提供了一个默认方法,另一个接口提供了一个同名而且参数类型相同的方法,必须覆盖这个方法来解决冲突。
- 一个类扩展了一个超类,同时实现了一个接口,并从超类和接口继承了相同的方法。在这种情况下,只会考虑超类方法,接口的所有默认方法都会被忽略。这正是“类优先”规则。
“类优先”规则可以确保与Java 7的兼容性。如果为一个接口增加默认方法,这对于有这些默认方法之前能正常工作的代码不会有任何影响。
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方法要在比较器对象上调用,而不是在字符串本身上调用。
要对一个数组排序,需要为Arrays.sort方法传入一个LengthComparator对象:
String[] friends = {"peter","paul","mary"};
Arrays.sort(friends, new LengthComparator());
对象克隆
Cloneable接口指示一个类提供了一个安全的clone方法。
要了解克隆的具体含义,先来回忆为一个包含对象引用的变量建立副本时会发生什么。原变量和副变量都是同一个对象的引用。这说明,任何一个变量改变都会影响另一个变量。
var original = new Employee("john",50000);
Employee copy = original;
copy.raiseSalary(10);
如果希望copy是一个新对象,它的初始状态与original相同,但是之后它们各自会有自己不同的状态,这种情况下就要使用clone方法。
Employee copy = original.clone();
copy.raiseSalary(10);
不过并没有这么简单。clone方法是Object的一个protected方法,这说明你的代码不能直接调用这个方法。只有Employee类可以克隆Employee对象。这个限制是有原因的。想想看Object类如何实现clone。它对于这个对象一无所知,所以只能逐个字段地进行拷贝。如果对象中的所有数据字段都是数值或其他基本类型,拷贝这些字段没有任何问题。但是如果对象包含子对象的引用,拷贝字段就会得到相同子对象的另一个引用,这样一来,原对象和克隆对象仍然会共享一些信息。如图所示。
可以看到,默认的克隆操作是“浅拷贝 ”,并没有克隆对象中引用的其他对象。
浅拷贝会有什么影响吗?这要看具体情况。如果原对象和浅克隆对象共享的子对象是不可变的,那么这种共享就是安全的。如果子对象属于一个不可变的类,如String,就是这种情况。或者在对象生命周期中,子对象一直包含不变的常量,没有更改器方法会改变它,也没有方法会生成它的引用,这种情况下同样是安全的。
不过,通常子对象都是可变的,必须重新定义clone方法来建立一个深拷贝 (deep copy),同时克隆所有子对象。
对于每一个类,需要确定:
- 默认的clone方法是否满足要求;
- 是否可以在可变的子对象上调用clone来修补默认的clone方法;
- 是否不该使用clone。
实际上第3个选项是默认选项。如果选择第1项或第2项,类必须:
- 实现Cloneable接口;
- 重新定义clone方法,并指定public访问修饰符。
在这里,Cloneable接口的出现与接口的正常使用并没有关系。具体来说,它没有指定clone方法,这个方法是从Object类继承的。这个接口只是作为一个标记,指示类设计者了解克隆过程。对象对于克隆很“偏执”,如果一个对象请求克隆,但是没有实现这个接口,就会生成一个检查型异常。
Cloneable接口是Java提供的少数标记接口(tagging interface)之一。Comparable等接口的通常用途是确保一个类实现一个或一组特定的方法。标记接口不包含任何方法;它唯一的作用就是允许在类型查询中使用instanceof:
if (obj instanceof Cloneable) …
建议你自己的程序中不要使用标记接口。
即使clone的默认(浅拷贝)实现能够满足要求,还是需要实现Cloneable接口,将clone重新定义为public,再调用super.clone()。
class Employee implements Cloneable {
public Employee clone() throws CloneNotSupportedException {
return (Employee) super.clone();
}
}
与Object.clone提供的浅拷贝相比,前面看到的clone方法并没有为它增加任何功能。这里只是让这个方法是公共的。要建立深拷贝,还需要做更多工作,克隆对象中可变的实例字段。
class Employee implements Cloneable {
public Employee clone() throws CloneNotSupportedException {
// call Object.clone()
Employee cloned = (Employee) super.clone();
// clone mutalbe fields
cloned.hireDay = (Date) hireDay.clone();
return cloned;
}
}
如果在一个对象上调用clone,但这个对象的类并没有实现Cloneable接口,Object类的clone方法就会抛出一个CloneNotSupportedException。当然,Employee和Date类实现了Cloneable接口,所以不会抛出这个异常。不过,编译器并不了解这一点,因此,我们声明了这个异常。
捕获这个异常是不是更好一些?
public Employee clone() {
try {
Employee cloned = (Employee) super.clone();
} catch(CloneNotSupportedException e) {
return null;
}
}
这适用于final类。否则,最好还是保留throws说明符。这样就允许子类在不支持克隆时选择抛出一个CloneNotSupportedException。
public class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
Employee original = new Employee("john", 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);
}
}
package section5_5;
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;
this.hireDay = new Date();
}
public Employee clone() throws CloneNotSupportedException {
Employee cloned = (Employee) super.clone();
cloned.hireDay = (Date) hireDay.clone();
return cloned;
}
public void setHireDay(int year, int month, int day) {
Date newHireDay = new GregorianCalendar(year,month - 1,day).getTime();
hireDay.setTime(newHireDay.getTime());
}
public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
", salary=" + salary +
", hireDay=" + hireDay +
'}';
}
}