在 《Effective Java 第三版》中,作者 Joshua Bloch 对“接口优于抽象类”这一原则有着深入的探讨。
一、接口 vs 抽象类:本质区别
在理解“接口优于抽象类”之前,我们需要明确二者的本质区别:
特性 | 接口(Interface) | 抽象类(Abstract Class) |
---|---|---|
多重继承支持 | 一个类可以实现多个接口(Java 提供多继承的能力)。 | 一个类只能继承一个抽象类(单继承限制)。 |
构造函数 | 接口没有构造函数,无法保存实现细节或状态。 | 抽象类可以有构造函数,可以用来初始化子类状态。 |
默认方法 | 从 Java 8 开始支持默认方法(default 方法)。 | 抽象类可以有默认的实现方法,无需借助 default 。 |
实现细节 | 只能定义方法签名和常量(从 Java 8 开始,可以有默认方法和静态方法)。 | 抽象类允许拥有实例变量、方法实现,以及访问权限控制。 |
继承设计 | 表现“类的行为”,强调 “是做什么”(What it can do) | 表现“类的本质”,强调 “是什么”(What it is) |
二、为什么接口优于抽象类?
书中的核心观点是:接口提供了更大的灵活性、更广泛的适用性以及更好的未来兼容性。因此,Joshua Bloch 在条目20中推荐开发者优先选择接口,原因如下:
1. 接口支持多重类型继承
- Java 中的类只能继承一个父类(单继承),但可以实现多个接口。
- 使用接口可以赋予一个类多种行为,而不受单继承限制。例如,一个类可以同时实现
Serializable
接口(序列化)、Comparable
接口(比较)、Observer
接口(观察者模式)。
示例:接口的多重继承
public interface Flyable {
void fly();
}
public interface Swimmable {
void swim();
}
public class Duck implements Flyable, Swimmable { // 同时实现两个接口
@Override
public void fly() {
System.out.println("Flying like a duck.");
}
@Override
public void swim() {
System.out.println("Swimming like a duck.");
}
}
相比抽象类,接口让一个类可以轻松拥有“多个行为”(复合式能力),而不需要受到单继承的限制。
2. 接口更灵活,适用性更广(关注行为)
- 接口更适用于定义“行为契约”(What it can do),而不是具体实现。
- 例如:如果我们需要定义多种具有排序能力的对象,可以直接用接口而不是抽象类:
public interface Comparable<T> {
int compareTo(T o);
}
public class Person implements Comparable<Person> { // 定义排序规则
private String name;
private int age;
@Override
public int compareTo(Person other) {
return Integer.compare(this.age, other.age); // 按年龄比较
}
}
书中例子常见的场景: Comparable
和 Comparator
模式
- Comparable 定义了对象的自然排序规则。
- Comparator 则是可扩展的排序工具,用户可以根据自己的场景灵活实现。
相比抽象类,接口更加关注对象“能做什么”(like sort, compare, etc.),而不是“是什么”。
3. 避免类层次结构的限制
- 抽象类引入的层次结构会限制代码的灵活性,因为类的子类只能从单一抽象类继承。
- 接口可以避免这种问题:一个类实现多个接口时,不会强制它继承某个具体的类层级。
书中反例:继承限制类的灵活性
假设我们有一个 AbstractVehicle
抽象类提供基本的移动行为,但后来我们需要使车辆继承另一个功能(如网络通信功能),会遇到继承限制的问题。
public abstract class AbstractVehicle {
public abstract void move();
}
public abstract class AbstractNetworkDevice {
public abstract void connect();
}
// 问题:Java 类无法同时继承两个抽象类!
public class SmartCar extends AbstractVehicle, AbstractNetworkDevice {
...
}
解决:改用接口
public interface Moveable {
void move();
}
public interface Connectable {
void connect();
}
public class SmartCar implements Moveable, Connectable {
@Override
public void move() {
System.out.println("SmartCar is moving.");
}
@Override
public void connect() {
System.out.println("SmartCar is connecting to the network.");
}
}
接口让我们摆脱了类层次结构的束缚,避免了单继承的限制。
4. 默认方法用于增强兼容性
Java 8 引入了默认方法(default
),解决了接口在 API 演化过程中的兼容性问题。这让接口不仅能够定义行为,还能提供部分默认实现,同时避免破坏已有实现类。
示例:向后兼容的默认方法
public interface Animal {
void eat();
// 默认实现
default void walk() {
System.out.println("This animal is walking");
}
}
子类可以复用默认行为:
public class Dog implements Animal {
@Override
public void eat() {
System.out.println("Dog eats bones");
}
}
优点:
- 如果没有默认方法,新增接口方法(如
walk()
)会强制所有实现类改动,影响兼容性。 - 有了
default
,我们可以保持对已有代码的向后兼容,同时为新需求提供扩展(如新增方法)。
5. 结合接口和默认方法设计“混合类型(mixins)”
在一些场景中,接口(配合默认方法)能够提供类似多个父类行为的能力,称为"混合类型"。
示例:混合默认行为
public interface Logger {
default void log(String message) {
System.out.println("Default log: " + message);
}
}
public interface FileWriter {
default void write(String message) {
System.out.println("Writing to file: " + message);
}
}
// 混合类型的实现
public class FileLogger implements Logger, FileWriter {
public void save(String message) {
log(message); // 使用 Logger 的默认方法
write(message); // 使用 FileWriter 的默认方法
}
}
public class Main {
public static void main(String[] args) {
FileLogger logger = new FileLogger();
logger.save("Hello, World!");
// 输出:
// Default log: Hello, World!
// Writing to file: Hello, World!
}
}
接口通过 default
方法支持行为扩展,并允许类灵活地混合多个行为,这比抽象类的单继承限制更具优势。
6. 符合面向接口编程的原则
Joshua Bloch 强调面向接口编程的好处:与其对实现(实现类或抽象类)编程,不如对接口编程。
优秀设计:根据协议编程
public interface List<E> {
boolean add(E e);
E get(int index);
}
List<String> myList = new ArrayList<>(); // 面向接口编程
通过依赖接口,而非具体实现(如 ArrayList
或 LinkedList
),我们可以轻松切换实现而无须修改代码。
7.骨架实现(skeletal implementation)类
骨架实现,是指为一个接口提供一个抽象基本实现类(骨架类),这个实现类:
- 是一个 抽象类。
- 实现了接口的大多数方法(可能包括接口中复杂方法的默认实现)。
- 子类只需关注实现少量的方法,无需全盘实现接口的所有方法。
- 这种模式可以组合接口的灵活性和抽象类的代码复用性,通过 “让接口更加易用” 提高程序的设计质量。
7.1 骨架实现的例子
7.1 示例一:List接口与AbstractList骨架类
List 接口的设计挑战
List
作为 Java 中的一个主要集合接口,定义了大量方法,比如 add
、iterator
、get
、size
等。如果开发者要实现一个自定义的 List
,需要提供所有这些方法的实现。
为了让开发者不需要处理大部分通用逻辑,Java 提供了一个抽象类 AbstractList
,它是 List
的骨架实现。
AbstractList 骨架类(骨架实现)
AbstractList
提供了 List
大部分方法的默认实现。开发者只需要重写部分核心方法(例如 get(int index)
和 size()
),就能完成一个最小的 List
实现。
如果我们用自定义的方式实现一个简单的 List
:
import java.util.AbstractList;
import java.util.List;
public class SimpleList<E> extends AbstractList<E> {
private E[] elements;
@SuppressWarnings("unchecked")
public SimpleList(int capacity) {
elements = (E[]) new Object[capacity];
}
@Override
public E get(int index) {
if (index < 0 || index >= size()) {
throw new IndexOutOfBoundsException("Index out of bounds: " + index);
}
return elements[index];
}
@Override
public int size() {
return elements.length;
}
@Override
public E set(int index, E element) {
if (index < 0 || index >= size()) {
throw new IndexOutOfBoundsException("Index out of bounds: " + index);
}
E oldValue = elements[index];
elements[index] = element;
return oldValue;
}
@Override
public E remove(int index) {
throw new UnsupportedOperationException("Remove not supported");
}
}
- 当我们实现
AbstractList
的子类时,只需关注get
和size
方法,无需实现所有的List
方法。 AbstractList
提供了许多方法的默认实现,例如iterator()
和contains()
,这些逻辑是共用的,可以直接被复用。
AbstractList 的具体价值
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
private int cursor = 0; // 游标指示当前元素
@Override
public boolean hasNext() {
return cursor < size(); // 用 size() 骨架方法检查是否有更多元素
}
@Override
public E next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return get(cursor++); // 用 get() 骨架方法获取元素
}
};
}
从上面的代码可以看到:
AbstractList
通过子类实现的get()
和size()
,实现了完整的iterator
方法。- 骨架类根据接口契约提供了默认实现,使得继承
AbstractList
的子类无需关心复杂方法的实现。
7.2 示例二:SkeletonSet 和 Comparator
骨架实现:Comparator 的典型案例
书中还提到了 Comparator
的骨架实现,作为如何组合接口和抽象类的一个优秀例子。
Java 8 提供了一些 Comparator
的静态方法和默认方法。我们可以通过这些方法实现复杂比较器的骨架逻辑。
import java.util.Comparator;
public abstract class SkeletonComparator<T> implements Comparator<T> {
@Override
public Comparator<T> reversed() {
return (a, b) -> compare(b, a); // 利用骨架方法
}
@Override
public Comparator<T> thenComparing(Comparator<? super T> other) {
return (a, b) -> {
int res = compare(a, b); // 利用核心实现
return (res != 0) ? res : other.compare(a, b);
};
}
@Override
public Comparator<T> thenComparingInt(ToIntFunction<? super T> keyExtractor) {
// ...
}
}
子类只需实现 compare
方法的基本逻辑,而骨架实现提供了用于组合的高级能力(如 reversed
和 thenComparing
)。
骨架实现经常结合模板方法模式(Template Method Pattern)进行设计:
- 在骨架类中定义一个方法的模板或算法骨架,子类只需实现骨架方法的某些部分。
- 父类控制整体逻辑流程,子类扩展具体步骤。
三、何时选择抽象类?
尽管接口有诸多优点,但也并非完全取代抽象类。根据书中的观点,当以下情况成立时,可以考虑使用抽象类:
-
需要方法或字段的共享实现:
- 如果多个子类需要共享一些状态或行为,抽象类是更好的选择。
- 接口无法保存成员变量或提供状态信息。
示例:
public abstract class Shape { protected int x, y; // 所有子类共享的状态 public abstract double area(); }
-
需要构造器:
- 当子类初始化时需要通过父类共享的构造器来初始化一部分状态时,应考虑抽象类。
- 因为接口没有构造器,无法直接支持初始化逻辑。
四、总结
《Effective Java》在“接口优于抽象类”这一条目中给出了明确的指导:
-
优先选择接口:
- 接口支持多继承,能更灵活地表达类的“行为”。
- 接口的默认方法允许向后兼容,同时支持混合行为的设计。
- 接口更加轻量化和灵活,摆脱了继承层次的限制。
-
考虑抽象类的情况:
- 当需要在父类中共享状态或实现,或需要构造器初始化时,可以选择抽象类。