13.1 不断演进的API

为了理解为什么一旦API发布之后,它的演进就变得非常困难,这里假设你是一个流行Java绘图库的设计者(为了说明本节的内容,我们做了这样的假想)。你的库中包含了一个Resizable接口,它定义了一个简单的可缩放形状必须支持的很多方法,比如:setHeightsetWidthgetHeightgetWidth以及setAbsoluteSize。此外,你还提供了几个额外的实现(out-of-the-box implementation),如正方形、长方形。由于你的库非常流行,因此你的一些用户使用Resizable接口创建了他们自己感兴趣的实现,比如椭圆。

发布API几个月之后,你突然意识到Resizable接口遗漏了一些功能。比如,如果接口提供一个setRelativeSize方法,可以接受参数实现对形状的大小进行调整,那么接口的易用性会更好。你会说这看起来很容易啊:为Resizable接口添加setRelativeSize方法,再更新SquareRectangle的实现就好了。不过,事情并非如此简单!你要考虑已经使用了你接口的用户,他们已经按照自身的需求实现了Resizable接口,又该如何应对这样的变更呢?非常不幸,你无法访问,也无法改动他们实现了Resizable接口的类。这也是Java库的设计者需要改进Java API时所面对的问题。让我们以一个具体的实例为例,深入探讨修改一个已发布接口的种种后果。

13.1.1 初始版本的API

Resizable接口的最初版本提供了下面这些方法:

  1. public interface Resizable extends Drawable{
  2. int getWidth();
  3. int getHeight();
  4. void setWidth(int width);
  5. void setHeight(int height);
  6. void setAbsoluteSize(int width, int height);
  7. }
用户实现

你的一位铁杆用户根据自身的需求实现了Resizable接口,创建了Ellipse类:

  1. public class Ellipse implements Resizable {
  2. ...
  3. }

他实现了一个处理各种Resizable形状(包括Ellipse)的游戏:

  1. public class Game{
  2. public static void main(String...args){
  3. List<Resizable> resizableShapes =
  4. Arrays.asList(new Square(), new Rectangle(), new Ellipse()); ←---- 可以调整大小的形状列表
  5. Utils.paint(resizableShapes);
  6. }
  7. }
  8. public class Utils{
  9. public static void paint(List<Resizable> l){
  10. l.forEach(r -> {
  11. r.setAbsoluteSize(42, 42); ←---- 调用每个形状自己的setAbsoluteSize方法
  12. r.draw();
  13. });
  14. }
  15. }

13.1.2 第二版API

库上线使用几个月之后,你收到很多请求,要求你更新Resizable的实现,让SquareRectangle以及其他的形状都能支持setRelativeSize方法。为了满足这些新的需求,你发布了第二版API,具体如图13-2所示。

  1. public interface Resizable {
  2. int getWidth();
  3. int getHeight();
  4. void setWidth(int width);
  5. void setHeight(int height);
  6. void setAbsoluteSize(int width, int height);
  7. void setRelativeSize(int wFactor, int hFactor); ←---- 第二版API添加了一个新方法
  8. }

13.1 不断演进的API - 图1

图 13-2 为Resizable接口添加新方法改进API。再次编译应用时会遭遇错误,因为它依赖的Resizable接口发生了变化

用户面临的窘境

Resizable接口的更新导致了一系列的问题。首先,接口现在要求它所有的实现类添加setRelativeSize方法的实现。但是用户最初实现的Ellipse类并未包含setRelativeSize方法。向接口添加新方法是二进制兼容的,这意味着只要不重新编译该类,即使不实现新的方法,现有类的实现依旧可以运行。这种情况下,即便在Resizable接口中添加setRelativeSize方法也不会影响游戏的持续运行。不过,用户可能修改他的游戏,在Utils.paint方法中调用setRelativeSize方法,因为paint方法接受一个Resizable对象列表作为参数。如果传递的是一个Ellipse对象,程序就会抛出一个运行时错误,因为它并未实现setRelativeSize方法:

  1. Exception in thread "main" java.lang.AbstractMethodError:
  2. lambdasinaction.chap9.Ellipse.setRelativeSize(II)V

其次,如果用户试图重新编译整个应用(包括Ellipse类),那么他会遭遇下面的编译错误:

  1. lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does
  2. not override abstract method setRelativeSize(int,int) in Resizable

最后,更新已发布API会导致后向兼容性问题。这就是为什么对现存API的演进,比如官方发布的Java Collection API,会给用户带来麻烦。当然,还有其他方式能够实现对API的改进,但是都不是明智的选择。比如,你可以为你的API创建不同的发布版本,同时维护老版本和新版本,但这是非常费时费力的,原因如下。其一,这增加了你作为类库的设计者维护类库的复杂度。其二,类库的用户不得不同时使用一套代码的两个版本,而这会增大内存的消耗,延长程序的载入时间,因为这种方式下项目使用的类文件数量更多了。

这就是默认方法试图解决的问题。它让类库的设计者放心地改进应用程序接口,无须担忧对遗留代码的影响,这是因为实现更新接口的类现在会自动继承一个默认的方法实现。

不同类型的兼容性:二进制、源代码和函数行为

变更对Java程序的影响大体可以分成三种类型的兼容性,分别是:二进制级的兼容、源代码级的兼容,以及函数行为的兼容。刚才我们看到,向接口添加新方法是二进制级的兼容,但最终编译实现接口的类时会发生编译错误。了解不同类型兼容性的特性是非常有益的,下面会深入介绍这部分内容。

二进制级的兼容性表示现有的二进制执行文件能无缝持续链接(包括验证、准备和解析)和运行。比如,为接口添加一个方法就是二进制级的兼容,这种方式下,如果新添加的方法不被调用,接口已经实现的方法就可以继续运行,不会出现错误。

简单地说,源代码级的兼容性表示引入变化之后,现有的程序依然能成功编译通过。比如,向接口添加新的方法就不是源码级的兼容,因为遗留代码并没有实现新引入的方法,所以它们无法顺利通过编译。

最后,函数行为的兼容性表示变更发生之后,程序接受同样的输入能得到同样的结果。比如,为接口添加新的方法就是函数行为兼容的,因为新添加的方法在程序中并未被调用(抑或该接口在实现中被覆盖了)。