传统上,Java
程序的接口是将相关方法按照约定组合到一起的方式。实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题。
现实情况是,现存的实体类往往不在接口设计者的控制范围之内,这些实体类为了适配新的接口约定也需要进行修改。由于Java 8
的API
在现存的接口上引入了非常多的新方法,这种变化带来的问题也愈加严重,一个例子就是前面使用过的List
接口上的sort
方法。像Guava
和Apache Commons
这样的框架现在都需要修改实现了List
接口的所有类,为其添加sort
方法的实现。
Java 8
为了解决这一问题引入了一种新的机制。Java 8
中的接口现在支持在声明方法的同时提供实现,通过两种方式可以完成这种操作。其一,Java 8
允许在接口内声明静态方法。其二,Java 8
引入了一个新功能,叫默认方法,通过默认方法可以指定接口方法的默认实现。换句话说,接口能提供方法的具体实现。因此,实现接口的类如果不显式地提供该方法的具体实现,就会自动继承默认的实现。这种机制可以使你平滑地进行接口的优化和演进。实际上,到目前为止已经使用了多个默认方法。两个例子就是你前面已经见过的List
接口中的sort
,以及Collection
接口中的stream
。
List
接口中的sort
方法是Java 8
中全新的方法,它的定义如下:
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
注意返回类型之前的新default
修饰符。通过default
修饰符,能够知道一个方法是否为默认方法。这里sort
方法调用了Collections.sort
方法进行排序操作。由于有了这个新的方法,现在可以直接通过调用sort
,对列表中的元素进行排序。
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder());
不过除此之外,这段代码中还有些其他的新东西,Comparator.naturalOrder
方法。这是Comparator
接口的一个全新的静态方法,它返回一个Comparator
对象,并按自然序列对其中的元素进行排序(即标准的字母数字方式排序)。
Collection
中的stream
方法的定义如下:
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
这里stream
方法中调用了SteamSupport.stream
方法来返回一个流。
为什么要在乎默认方法?默认方法的主要目标用户是类库的设计者,默认方法的引入就是为了以兼容的方式解决像Java API
这样的类库的演进问题的,如下图所示:
简而言之,向接口添加方法是诸多问题的罪恶之源;一旦接口发生变化,实现这些接口的类往往也需要更新,提供新添方法的实现才能适配接口的变化。如果对接口以及它所有相关的实现有完全的控制,这可能不是个大问题。但是这种情况是极少的。这就是引入默认方法的目的:它让类可以自动地继承接口的一个默认实现。
默认方法为接口的演进提供了一种平滑的方式,你的改动将不会导致已有代码的修改。此外,默认方法为方法的多继承提供了一种更灵活的机制,可以更好地规划代码结构:Java 8
类可以从多个接口继承默认方法。
静态方法及接口
同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式,工具类定
义了与接口实例协作的很多静态方法。比如,Collections就是处理Collection对象的辅
助类。由于静态方法可以存在于接口内部,你代码中的这些辅助类就没有了存在的必要,你可
以把这些静态方法转移到接口内部。为了保持后向的兼容性,这些类依然会存在于Java应用程
序的接口之中。
假设你是一个流行Java
绘图库的设计者。库中包含了一个Resizable
接口,它定义了一个简单的可缩放形状必须支持的很多方法, 比如:setHeight、setWidth、getHeight、getWidth
以及setAbsoluteSize
。此外,还提供了几个额外的实现(out-of-box implementation),如正方形、长方形。由于这个库非常流行,一些用户使用Resizable
接口创建了他们自己感兴趣的实现,比如椭圆。
发布API
几个月之后,你突然意识到Resizable
接口遗漏了一些功能。比如,如果接口提供一个setRelativeSize
方法,可以接受参数实现对形状的大小进行调整,那么接口的易用性会更好。这看起来很容易:为Resizable
接口添加setRelativeSize
方法,再更新Square
和Rectangle
的实现就好了。不过,事情并非如此简单,要考虑已经使用了你接口的用户,他们已经按照自身的需求实现了Resizable
接口,他们该如何应对这样的变更呢?非常不幸,你无法访问,也无法改动他们实现了Resizable
接口的类。这也是Java
库的设计者需要改进Java API
时面对的问题。以一个具体的实例为例,深入探讨修改一个已发布接口的种种后果。
Resizable
接口的最初版本提供了下面这些方法:
public interface Resizable extends Drawable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
}
用户实现
一位用户根据自身的需求实现了Resizable
接口,创建了Ellipse
类:
public class Ellipse implements Resizable {
…
}
他实现了一个处理各种Resizable
形状(包括Ellipse
)的游戏:
public class Game {
public static void main(String...args) {
List<Resizable> resizableShapes =
Arrays.asList(new Square(), new Rectangle(), new Ellipse());
Utils.paint(resizableShapes);
}
}
public class Utils {
public static void paint(List<Resizable> l) {
l.forEach(r -> {
r.setAbsoluteSize(42, 42);
r.draw();
});
}
}
库上线使用几个月之后,你收到很多请求,要求你更新Resizable
的实现,让Square、Rectangle
以及其他的形状都能支持setRelativeSize
方法。为了满足这些新的需求,你发布了第二版API
,具体如下图所示:
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
void setRelativeSize(int wFactor, int hFactor);
}
用户面临的窘境
对Resizable
接口的更新导致了一系列的问题。首先,接口现在要求它所有的实现类添加setRelativeSize
方法的实现。但是用户最初实现的Ellipse
类并未包含setRelativeSize
方法。向接口添加新方法是二进制兼容的,这意味着如果不重新编译该类,即使不实现新的方法,现有类的实现依旧可以运行。不过,用户可能修改他的游戏,在他的Utils.paint
方法中调用setRelativeSize
方法,因为paint
方法接受一个Resizable
对象列表作为参数。如果传递的是一个Ellipse
对象,程序就会抛出一个运行时错误,因为它并未实现setRelativeSize
方法:
Exception in thread "main" java.lang.AbstractMethodError:
lambdasinaction.chap9.Ellipse.setRelativeSize(II)V
其次,如果用户试图重新编译整个应用(包括Ellipse
类),他会遭遇下面的编译错误:
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does not override abstract method setRelativeSize(int,int) in Resizable
最后,更新已发布API
会导致后向兼容性问题。这就是为什么对现存API
的演进,比如官方发布的Java Collection API
,会给用户带来麻烦。当然,还有其他方式能够实现对API
的改进,但是都不是明智的选择。比如,可以为你的API
创建不同的发布版本,同时维护老版本和新版本,但这是非常费时费力的,原因如下。
其一,这增加了你作为类库的设计者维护类库的复杂度。其次,类库的用户不得不同时使用一套代码的两个版本,而这会增大内存的消耗,延长程序的载入时间,因为这种方式下项目使用的类文件数量更多了。
这就是默认方法试图解决的问题。它让类库的设计者放心地改进应用程序接口,无需担忧对遗留代码的影响,这是因为实现更新接口的类现在会自动继承一个默认的方法实现。
不同类型的兼容性:二进制、源代码和函数行为
变更对Java程序的影响大体可以分成三种类型的兼容性,分别是:二进制级的兼容、源代
码级的兼容,以及函数行为的兼容。向接口添加新方法是二进制级的兼容,
但最终编译实现接口的类时却会发生编译错误。
二进制级的兼容性表示现有的二进制执行文件能无缝持续链接(包括验证、准备和解析)
和运行。比如,为接口添加一个方法就是二进制级的兼容,这种方式下,如果新添加的方法不
被调用,接口已经实现的方法可以继续运行,不会出现错误。
简单地说,源代码级的兼容性表示引入变化之后,现有的程序依然能成功编译通过。比如,
向接口添加新的方法就不是源码级的兼容,因为遗留代码并没有实现新引入的方法,所以它们
无法顺利通过编译。
最后,函数行为的兼容性表示变更发生之后,程序接受同样的输入能得到同样的结果。比
如,为接口添加新的方法就是函数行为兼容的,因为新添加的方法在程序中并未被调用(抑或
该接口在实现中被覆盖了)。