ITEM 20: PREFER INTERFACES TO ABSTRACT CLASSES
Java有两种机制来定义允许多种实现的类型:接口和抽象类。由于在Java 8 [JLS 9.4.3] 中引入了接口的默认方法,这两种机制都允许为一些实例方法提供实现。一个主要的区别是,要实现抽象类定义的类型,类必须是抽象类的子类。由于Java只允许单继承,对抽象类的这种限制严重限制了它们作为类型定义的使用。任何定义并实现了所有必需方法并遵守通用契约的类都可以实现接口,不管类位于类层次结构中的哪个位置。
现有类实现新的接口可以很容易地做到。您所要做的就是添加所需的方法(如果它们还不存在),并向类声明添加一个implements子句。例如,许多现有的类在被添加到平台时进行了改进,以实现可比较的、可迭代的和可自动关闭的接口。通常,不能对现有类进行改造以扩展新的抽象类。如果您想让两个类扩展同一个抽象类,您必须将它放在类型层次结构的上层,在那里它是两个类的祖先。不幸的是,这可能会对类型层次结构造成巨大的附带损害,迫使新抽象类的所有后代子类化它,无论是否合适。
接口是定义 mixin 的理想选择。松散地说,mixin 是一个类除了“主类型”之外还可以实现的类型,用来声明它提供了一些可选的行为。例如,Comparable 是一个 mixin 接口,它允许一个类声明它的实例相对于其他相互比较的对象是有序的。这样的接口称为 mixin,因为它允许将可选功能“混合”到类型的主要功能中。抽象类不能用于定义mixin,原因与它们不能被重新安装到现有类相同:一个类不能有多个父类,而且在类层次结构中没有插入 mixin 的合理位置。
接口允许构造非分层类型框架。类型层次结构非常适合组织某些内容,但是其他内容不能整齐地归入严格的层次结构。例如,假设我们有一个表示歌手的接口和另一个表示词曲作者的接口:
public interface Singer {
AudioClip sing(Song s);
}
public interface Songwriter {
Song compose(int chartPosition);
}
在现实生活中,一些歌手也是词曲作者。因为我们使用接口而不是抽象类来定义这些类型,所以一个类完全可以同时实现 Singer 和 composer。事实上,我们可以定义第三个接口,扩展歌手和词曲作者,并添加适合组合的新方法:
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
您并不总是需要这种级别的灵活性,但是当您需要时,接口是一个救星。另一种方法是一个膨胀的类层次结构,其中为每个受支持的属性组合包含一个单独的类。如果类型系统中有n个属性,那么您可能需要支持2n种可能的组合。这就是所谓的组合爆炸。膨胀的类层次结构会导致类的膨胀,因为类层次结构中没有捕捉常见行为的类型,所以许多方法只在参数类型上有所不同。
接口通常被用于 wrapper,来支持安全、强大的功能增强。如果使用抽象类来定义类型,那么想要添加功能的程序员就只有继承了。生成的类比包装器类功能更弱,也更脆弱。
如果接口方法的实现与其他接口方法类似,那么可以考虑以默认方法的形式为程序员提供实现帮助。有关此技术的示例,请参见104页中的removeIf方法。如果提供了默认方法,请确保使用 @implSpec Javadoc标记(第19项)将它们记录下来以便继承。使用默认方法可以提供多少实现帮助是有限制的。尽管许多接口指定了对象方法(如equals 和 hashCode) 的行为,但不允许为它们提供默认方法。此外,接口不允许包含实例字段或非公共静态成员(私有静态方法除外)。最后,不能向不受控制的接口添加默认方法。
但是,您可以通过提供一个抽象的框架实现类来结合接口和抽象类的优点。接口定义类型,可能提供一些默认方法,而骨架实现类在基本接口方法之上实现其余的非基本接口方法。扩展一个框架实现需要实现接口的大部分工作。这是模板方法模式[Gamma95]。
按照惯例,骨架实现类称为Abstract Interface,其中 Interface 是它们实现的接口的名称。例如,Collections 框架提供了一个骨架实现,以配合每个主集合接口: AbstractCollection、AbstractSet、AbstractList 和 AbstractMap。将它们称为 SkeletalCollection、SkeletalSet、SkeletalList 和 SkeletalMap 也是有意义的,但是抽象约定现在已经牢固地建立起来了。如果设计得当,框架实现(不管是单独的抽象类,还是仅仅由接口上的默认方法组成)可以让程序员很容易地提供自己的接口实现。例如,这里有一个静态工厂方法,包含一个完整的、功能齐全的 List 实现在AbstractList 之上:
// Concrete implementation built atop skeletal implementation
static List<Integer> intArrayAsList(int[] a) {
Objects.requireNonNull(a);
// The diamond operator is only legal here in Java 9 and later
// If you're using an earlier release, specify <Integer>
return new AbstractList<>() {
@Override
public Integer get(int i) {
return a[i]; // Autoboxing (Item 6)
}
@Override
public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
@Override
public int size() {
return a.length;
}
};
}
当您考虑列表实现为您所做的一切时,这个例子是对框架实现的强大功能的一个令人印象深刻的演示。顺便提一下,这个例子是一个适配器[Gamma95],它允许将一个int数组看作一个整数实例列表。由于int值和整数实例之间来回转换(装箱和拆箱),它的性能不是很好。注意,实现采用匿名类的形式(第24项)。
骨架实现类的美妙之处在于,它们提供了抽象类的所有实现支持,而没有强加抽象类作为类型定义时强加的严格约束。对于具有骨架实现类的接口的大多数实现者来说,扩展该类是显而易见的选择,但它是严格可选的。如果不能使类扩展框架实现,则该类始终可以直接实现接口。类仍然受益于接口本身的任何默认方法。此外,框架实现仍然可以帮助实现者完成任务。实现接口的类可以将接口方法的调用转发到扩展框架实现的私有内部类的包含实例。这种技术称为模拟多重继承,与第18项中讨论的包装器类习惯用法密切相关。它提供了多重继承的许多好处,同时避免了陷阱。
编写一个骨架实现是一个相对简单的过程,尽管有点乏味。首先,研究接口并确定哪些方法是可以实现其他方法的基本类型。这些原语将是骨架实现中的抽象方法。接下来,在接口中为所有可以直接在原语之上实现的方法提供默认方法,但是请记住,您可能不会为对象方法(如equals和hashCode)提供默认方法。如果原语和默认方法覆盖了接口,那么就完成了,不需要骨架实现类。否则,编写一个声明来实现接口的类,使用所有剩余接口方法的实现。该类可以包含适合于任务的任何非公共字段和方法。
作为一个简单的例子,考虑 Map.Entry 接口。最明显的原语是getKey、getValue和(可选的) setValue。该接口指定 equals 和 hashCode 的行为,并且在基本类型方面有一个明显的 toString 实现。由于不允许为对象方法提供默认实现,所以所有实现都放在骨架实现类中:
// Skeletal implementation class
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
// Entries in a modifiable map must override this method @Override public V
setValue(V value) { throw new UnsupportedOperationException(); }
// Implements the general contract of Map.Entry.equals @Override public boolean
equals(Object o) {
if (o == this) return true;
if (!(o instanceof Map.Entry)) return false;
Map.Entry<?,?> e = (Map.Entry) o;
return Objects.equals(e.getKey(), getKey()) && Objects.equals(e.getValue(), getValue());
}
// Implements the general contract of Map.Entry.hashCode @Override public int
hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
@Override
public String toString() { return getKey() + "=" + getValue();}
}
注意,这个框架实现不能在 Map.Entry 实现。因为默认方法不允许覆盖对象方法,比如 equals、hashCode 和 toString。
因为骨架实现是为继承而设计的,所以您应该遵循项目19中的所有设计和文档指南。为了简单起见,在前面的示例中省略了文档注释,但是好的文档对于框架实现来说是绝对必要的,无论它是由接口上的默认方法还是单独的抽象类组成。
骨架实现的一个小变体是简单实现,AbstractMap.SimpleEntry 就是一个例子。一个简单的实现就像一个骨架实现,因为它实现了一个接口,并且是为继承而设计的,但是它的不同之处在于它不是抽象的:它是最简单的可行的工作实现。您可以按原样使用它,也可以根据情况对它进行子类化。
总之,接口通常是定义允许多种实现的类型的最佳方法。如果您导出了一个重要的接口,那么您应该强烈考虑提供一个框架实现。在可能的情况下,应该通过接口上的默认方法提供框架实现,以便接口的所有实现者都可以使用它。也就是说,对接口的限制通常要求框架实现采用抽象类的形式。
网友评论