在 《Effective Java》第三版中,Joshua Bloch 对 缺省方法(default
方法) 的使用提出了重要的建议。他提到,尽管 Java 8 引入 default
方法旨在解决长期以来接口扩展上的兼容性问题,但过度或滥用 default
方法可能带来严重的问题。因此,书中推荐尽量避免使用 default
方法在现有接口上添加新的方法,除非真的别无选择。
一、为什么尽量避免利用 default
方法在现有接口上添加方法?
default
方法的初衷是为了解决接口的向后兼容性问题:当我们想给已有接口添加新方法时,如果没有 default
,所有已经实现了这个接口的类都需要实现这个新方法。这可能破坏对向后兼容的需求(特别是维护那些我们无法修改的第三方实现)。
虽然 default
方法能够在不破坏现有实现的情况下增加新方法,但它仍然有一些潜在问题:
1. 设计的透明性:破坏了接口的纯粹性(契约)
接口原本的设计哲学是纯粹的:定义行为,而不提供任何实现。接口应该是一个明确的"契约",只是声明 “对象必须支持的方法”,具体如何实现则由实现类负责。
然而,default
方法会为接口注入实现细节,这就模糊了接口设计与实现分离的界限。例如:
public interface List<E> {
default void printAll() {
for (E e : this) {
System.out.println(e);
}
}
}
- 实现类不再知道
printAll()
到底是一个被强制实现的行为,还是一个已经默认注入的实现。 - 如果
default
方法的实现无法考虑到所有实现类的语义差异,这会导致不一致的行为。
2. 实现者的行为语义可能不一致
由于接口是被多个实现类继承的,当为接口引入一个新的 default
方法时,默认实现可能不适合现有的实现类。例如,新增的方法可能需要特定的上下文或数据,而某些实现类可能没有这些条件。
示例:默认方法与接口实现的冲突
public interface Shape {
double getArea();
// 添加一个新方法,并提供默认实现
default void draw() {
System.out.println("Drawing a generic shape.");
}
}
// 现有类:一个实现
public class Rectangle implements Shape {
@Override
public double getArea() {
return length * width;
}
}
// 现有类:另一个实现
public class Circle implements Shape {
@Override
public double getArea() {
return Math.PI * radius * radius;
}
}
- 添加
draw()
方法后,现有的Rectangle
和Circle
实现类都会继承draw
的默认实现。 - 然而,
draw()
的行为是System.out.println("Drawing a generic shape.")
,这显然无法满足具体的Rectangle
或Circle
的语义需求。(一个矩形和一个圆形是有特定的绘制方法的)
后果:
- 使用者可能会在代码运行时发现意料之外的不一致行为。
- 实现类本身不知道新方法是何时、为何添加的,从而引入了潜在的设计错误。
3. 滥用会导致接口膨胀
滥用 default
方法会使接口变得复杂而膨胀,超出了原本简单“行为契约”的设计目标。
随着项目的演化,我们可能会不断地为接口添加新 default
方法,最终让接口变得难以理解,甚至成为一个"功能垃圾堆"。
错误示例:不断膨胀的接口
public interface DataService {
void save(String data);
String load(int id);
// 新添加的功能,可能完全不相关
default void exportToXML(String filename) {
System.out.println("Exporting to XML: " + filename);
}
default void exportToJSON(String filename) {
System.out.println("Exporting to JSON: " + filename);
}
}
- 接口开始承担过多职责,超出其设计初衷。
- 这种设计违背 单一职责原则(SRP, Single Responsibility Principle)。
4. 默认方法可能造成兼容性问题
虽然 default
方法旨在解决向后兼容性问题,但它并不能完全避免实现间的不兼容。例如:
- 冲突问题:如果两个接口都定义了相同签名的
default
方法,且一个类同时实现了这两个接口,必须显式解决冲突。
示例:冲突的默认方法
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements A, B {
@Override
public void hello() {
B.super.hello(); // 必须显式选择调用哪个接口的默认实现
System.out.println("Hello from C");
}
}
- 虽然这种冲突是可解决的,但它增加了代码复杂性和维护成本,特别是当接口和实现涉及多个开发人员或模块时。
5. 增加维护成本
由于 default
方法的新增行为在实现类中是"隐形的",它可能会在实现类中引入行为变化,而开发或维护人员不一定意识到这些变化。随着代码库复杂度的增加,问题会进一步放大。
二、改进的方案:如何优雅地扩展接口?
尽量避免为了添加新功能而直接在已有接口中添加 default
方法,而应优先选择以下更恰当的做法:
1. 创建新的接口
如果需要在现有接口上扩展新功能,而这个功能对现有实现类的意义不强,可以选择创建一个新接口,并用它补充现有接口。
示例:分离新功能
public interface Shape {
double getArea();
}
// 新功能的扩展
public interface Drawable {
void draw();
}
// 现有实现类可以选择是否实现新接口
public class Rectangle implements Shape, Drawable {
@Override
public double getArea() {
return length * width;
}
@Override
public void draw() {
System.out.println("Drawing a rectangle.");
}
}
public class Circle implements Shape, Drawable {
@Override
public double getArea() {
return Math.PI * radius * radius;
}
@Override
public void draw() {
System.out.println("Drawing a circle.");
}
}
- 好处:职责分离更加清晰,没有污染原有的
Shape
接口。 - 针对需要扩展功能的类,可以选择性地实现扩展接口。
2. 利用抽象类提供新功能
如果需要为接口提供某些通用功能,可以设计一个抽象类来实现接口,同时为需要默认行为的子类提供基本实现。
示例:骨架实现抽象类
public interface Shape {
double getArea();
void draw();
}
// 提供骨架实现类
public abstract class AbstractShape implements Shape {
@Override
public void draw() {
System.out.println("Drawing a generic shape.");
}
}
子类可以继承 AbstractShape
并选择重写 draw()
方法:
public class Rectangle extends AbstractShape {
@Override
public double getArea() {
return length * width;
}
@Override
public void draw() {
System.out.println("Drawing a rectangle.");
}
}
- 好处:骨架实现避免了
default
方法污染原始接口,同时可复用代码逻辑。
3. 通过工具类实现新逻辑
可以通过独立的工具类或静态方法引入新功能,而不是直接扩展接口。
示例:工具类扩展功能
public interface Shape {
double getArea();
}
public class ShapeUtils {
public static void draw(Shape shape) {
if (shape instanceof Rectangle) {
System.out.println("Drawing a rectangle.");
} else if (shape instanceof Circle) {
System.out.println("Drawing a circle.");
} else {
System.out.println("Cannot draw the shape.");
}
}
}
- 好处:工具类解耦了扩展逻辑和接口本身,不会破坏已有实现。
4. 如果确实要使用 default
方法
如果别无选择,添加 default
方法时,请确保:
- 默认行为对现有的实现类是安全的,且语义一致。
- 在文档中清楚地说明默认方法的目的、适用场景以及如何重写。
三、总结
- 尽量避免在现有接口上利用
default
方法添加新方法,因为这可能破坏接口原本的纯粹性、职责划分和实现类的行为一致性。 - 优先采用创建新接口、抽象骨架类或工具类的方式扩展功能,让设计更加清晰、灵活且易于维护。
- 如果必须使用
default
方法,请确保它适用于所有现有实现,并严格文档化它的行为和意图。
接口的设计应始终以清晰职责和长期兼容性为目标,避免滥用缺省方法导致代码的不一致性和复杂性。