澳门新浦京娱乐游戏谨慎使用Java8的默认方法

Java 8的默认方法试图尝试更进一步简化Java
API。不幸的是,这一最近的语言扩展带来了一系列复杂的规则,但只有少部分Java开发者意识到这一点。这篇文章告诉你为什么引入默认方法会破坏你的(用户的)代码。

默认方法给JVM的指令集增加了一个非常不错的新特性。使用了默认方法之后,如果库中的接口增加了新的方法,实现了这个接口的用户类能够自动获得这个方法的默认实现。一旦用户想更新他的实现类的话,只需覆盖一下这个默认方法就可以了,取而代之的是一个在特定场景下更有意义的实现。更棒的是,用户可以在重写的方法里面调用接口的默认实现来增加一些额外的功能。

1.简述

在Java8之前,Java程序接口是将相关方法按照约定组合到一起的方式。实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题。现实情况是,现存的实体类往往不在接口设计者的控制范围之内,这些实体类为了适配新的接口约定也需要进行修改。由于Java8的API在现存的接口上引入了非常多的新方法,这种变化带来的问题也愈加严重。

在Java8中为了解决这个问题引入了一种新的机制。Java8中的接口现在支持在声明方法的同时提供实现。有两种方式可以完成这种操作。其一,Java8允许在接口内声明静态方法。其二,Java8引入了一个新功能,叫默认方法。通过默认方法,即使实现接口的方法也可以自动继承默认的实现,你可以让你的接口可以平滑地进行接口的进化和演进。比如我们的List接口中的sort方法是java8中全新的方法,定义如下:

default void sort(Comparator<? super E> c){
    Collections.sort(this, c);
}

在方法有个default修饰符用来表示这是默认方法。

起初看来,默认方法给Java虚拟机的指令集带来了很多新的特性。最终,开发库的人能够在不带来客户端代码的兼容性问题的情况下,升级API。使用默认方法,任何实现库接口的类都自动适应接口引入的默认方法。一旦用户更新了他实现的类,就能够很简单使用更有意义的方法来覆盖原有默认方法。更好的是,用户可以在覆盖方法时候,调用接口的默认实现,同时增加业务逻辑。

目前为止一切都还不错。然而,给现有的Java接口增加默认方法可能会导致代码的不兼容。看个例子就很容易能明白了。假设有一个库,它需要用户实现它的一个接口作为输入:

2.进化的API

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

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

到现在为止,一切都是很好。但是,在创建接口的时候增加默认方法可能使得Java代码不兼容。这个从下面的例子可以很容易弄明白。我们假设一个库需要它的一个接口的作为输入:

interface SimpleInput {
 void foo();
 void bar();
}
 
abstract class SimpleInputAdapter implements SimpleInput {
 @Override
 public void bar() {
 // some default behavior ...
 }
}

2.1初始化版本的API

Resizable最开始的版本如下:

public interface Resizable{
    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 {
    @Override
    public int getWidth() {
        return 0;
    }

    @Override
    public int getHeight() {
        return 0;
    }

    @Override
    public void setWidth(int width) {

    }

    @Override
    public void setHeight(int height) {

    }

    @Override
    public void setAbsoluteSize(int width, int height) {

    }
}
interface SimpleInput {
  void foo();
  void bar();
}

abstract class SimpleInputAdapter implements SimpleInput {
  @Override
  public void bar() {
    // some default behavior ...
  }
}

在Java
8以前,上述这种接口和一个对应的适配器类的组合在Java语言中是一种很常见的模式。类库的开发人员提供了一个适配器来减少库使用者的编码量。然而提供这个接口的目的其实是为了能实现某种类似多重继承的关系。

2.2第二版本API

库上线使用几个月之后,你收到很多请求,要求你更新Resizable的实现,所以你更新了一个方法。

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);//第二版本API
}

接下来用户便会面临很多问题。首先,接口现在要求它所有的实现类添加setRelativeSize方法的实现。但我们刚才的用户最初实现的Ellipse类并未包含setRelativeSize方法。向接口添加新方法是二进制兼容的,这意味着如果不重新编译该类,即使不实现新的方法,现有类的实现依旧可以运行。但是这种情况少之又少,基本项目每次发布时都会重新编译,所以必定会报错。

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

这就是我们默认方法所要做的工作。它让我们的类库设计者放心地改进应用程序接口,无需担忧对遗留代码的影响。

Java
8之前,类似于上面联合使用一个接口和一个适配器类的方式,是Java程序语言中一种非常常用的设计模式。该适配器通常由库提供者提供,用于节省库的使用者的某些操作。但是,如果采用接口的方式提供,就类似允许多重继承了。

我们假设有一个用户使用了这个适配器:

3.详解默认方法

经过前述的介绍,我们已经了解了向已发布的API添加方法,会对我们现存的代码会造成多大的危害。默认方法是Java8中引入的一个新特性,依靠他我们可以在实现类中不用提供实现。
我们要使用我们的默认方法非常简单,只需要在我们要实现的方法签名前面添加default修饰符进行修饰,并像类中声明的其他方法一样包含方法体。如下面的接口一样:

public interface Sized {
    int size();
    default boolean isEmpty(){
        return size() == 0;
    }
}

这样任何一个实现了Sized接口的类都会自动继承isEmpty的实现。

我们进一步假设一个用户使用了如下的适配器:

class MyInput extends SimpleInputAdapter{
 @Override
 public void foo() {
 // do something ...
 }
 @Override
 public void bar() {
 super.bar();
 // do something additionally ...
 }
}

3.1默认方法的使用模式

class MyInput extends SimpleInputAdapter {
  @Override
  public void foo() {
    // do something ...
  }
  @Override
  public void bar() {
    super.bar();
    // do something additionally ...
  }
}

有了这个实现,用户可以和库进行交互了。注意这个实现是如何重写bar方法来给默认的实现增加额外的功能的。

3.1.1可选方法

你有时候会碰到这种情况,类实现了接口,不过却可以将一些方法的实现留白。比如我们Iterator接口,我们一般不会去实现remove方法,经常实现都会留白,在Java8中为了解决这种办法回味我们的remove方法添加默认的实现,如下:

public interface Iterator<E> {

    boolean hasNext();

    E next();

    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
}

通过这种方式,我们可以减少无效的模板代码。实现Iterator接口的每一个类都不需要再次实现remove的模板方法了。

通过这种实现方式,我们最终可以和库进行交互。注意我们是怎样覆盖bar方法,并为默认的实现增加额外的功能的。

那如果这个库迁移到Java
8的话会怎样?首先,这个库很可能会废弃掉这个适配器类并将这个功能迁移到默认方法里。最终这个接口看起来会是这样的:

3.1.2多继承

默认方法让之前的Java是不支持多继承,但是默认方法的出现让多继承在java中变得可能了。

Java的类只能继承单一的类,但是一个类可以实现多接口。要确认也很简单,下面是Java
API中对ArrayList类的定义:

public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable,
Serializable, Iterable<E>, Collection<E> {
}

如果将该库移植到Java
8,将会发生什么呢?首先,该库很大可能性会废弃适配器类,而使用默认方法提供该功能。最终,该接口的形式类似如下所示:

interface SimpleInput {
 void foo();
 default void bar() {
 // some default behavior
 }
}}

3.1.3冲突问题

我们知道Java语言中一个类只能继承一个父类,但是一个类可以实现多个接口。随着默认方法在Java8中引入,有可能出现一个类继承了多个方法而它们使用的却是同样的函数签名。这种情况下,类会选择使用哪一个函数?在实际情况中,虽然这样的冲突很难发生,但是一旦发生,就必须要规定一套约定来处理这些冲突。这一节中,我们会介绍Java编译器如何解决这种潜在的冲突。

public interface A {
    default void hello(){
        System.out.println("i am A");
    }
}
interface B extends A{
    default void hello(){
        System.out.println("i am B");
    }
}
class C implements A,B{
    public static void main(String[] args) {
        new C().hello();
    }
}

上面的代码会输出i am B。为什么呢?我们下面有三个规则:

  1. 类中的方法优先级最高。类或父类中的声明的方法的优先级高于任何声明为默认方法的优先级。
  2. 如果无法依据第一条进行判断,那么子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口。如果B继承了A,那么B就比A的更具体。
  3. 最后,如果还是无法判断,继承了多个接口的类必须通过显示覆盖和调用期望的方法,显式地选择使用哪一个默认方法的实现。

接下来举几个例子

public interface A {
    default void hello(){
        System.out.println("i am A");
    }
}
interface B extends A{
    default void hello(){
        System.out.println("i am B");
    }
}
class D implements A{
    public void hello(){
        System.out.println("i am D");
    }
}
class C extends D implements A,B{
    public static void main(String[] args) {
        new C().hello();
    }
}

上面会输出D,遵循我们的第一条原则,类中的方法优先级最高。

public interface A {
    default void hello(){
        System.out.println("i am A");
    }
}
interface B {
    default void hello(){
        System.out.println("i am B");
    }
}

class C implements A,B{
    public static void main(String[] args) {
        new C().hello();
    }
}

上面代码会出现编译错误:Error:(19, 1) java: 类 java8.C从类型 java8.A 和
java8.B 中继承了hello()
的不相关默认值,这个时候必须利用第三条,显式得去调用父类的接口:

class C implements A,B{
    public void hello(){
        B.super.hello();
    }
    public static void main(String[] args) {
        new C().hello();
    }
}
interface SimpleInput {
  void foo();
  default void bar() {
    // some default behavior
  }
}

有了这个新接口后,用户得更新他的代码来使用这个默认方法,而不再是适配器类了。使用新接口而非适配器类的一大好处就是,用户可以去继承一个别的类而不是这个适配器类了。我们来动手实践一下,将MyInput类改造成使用默认方法。由于现在我们可以继承别的类了,我们再额外地扩展一个第三方的基类试试。这个基类具体是做什么的在这里并不重要,我们先假设一下这么做对我们这个用例来说是有意义的。

使用这个新的接口,用户可以更新他的代码,采用默认方法来代替原来的适配器类。通过使用接口代替适配器类的最好的结果是,该类可以继承(extend)其它的类,而不是特定的适配器。现在我们进行实践,移植MyInput类使其使用默认方法。因为我们现在能继承其它类了,所以我们继承一个第三方的基础类。我们这里不需要关心这个基础类的作用,我们可以假设这个对我们的功能是有意义的。

class MyInput extends ThirdPartyBaseClass implements SimpleInput {
 @Override
 public void foo() {
 // do something ...
 }
 @Override
 public void bar() {
 SimpleInput.super.foo();
 // do something additionally ... 
 }
}
class MyInput extends ThirdPartyBaseClass implements SimpleInput {
  @Override
  public void foo() {
    // do something ...
  }
  @Override
  public void bar() {
    SimpleInput.super.bar();
    // do something additionally ... 
  }
}

为了实现和原先那个类同样的功能,这里我们用到了Java
8的新语法来调用接口的默认方法。同样的,我们把myMethod的逻辑放到某个基类MyBase里面。可以捶捶肩膀放松下了。重构之后棒极了!

为了实现原始类相似的功能,我们使用Java
8的新的语法来调用指定接口的默认方法。同时,将我们方法中的一些逻辑移到基础类中去。此时,你可能拍着我的肩膀说,这是一次非常好的重构!

我们使用的这个库得到了很大的改进。然而,维护人员需要添加另一个接口来实现一些额外的功能。这个接口叫做CompexInput
,它继承了SimpleInput类,并增加了一个额外的方法。由于通常都认为默认方法是可以放心地添加的,因此维护人员重写了SimpleInput类的默认方法并添加了一些额外的动作来给用户提供一个更好的默认实现。毕竟使用适配器类的时候这个做法也十分常见:

我们相当成功的使用了该库。但是,维护人员需要增加另一个接口来提供更多的功能。该接口被
ComplexInput 接口所代替,这个接口继承自 SimpleInput
接口,并增加了新的方法。因为默认方法通常来说是可以很安全的添加的,因此,维护人员覆盖了
SimpleInput
的默认方法,提供了一个更好的默认方法。毕竟,这对于采用适配器类的方式来说是很平常的事情。

interface ComplexInput extends SimpleInput {
 void qux();
 @Override
 default void bar() {
 SimpleInput.super.bar(); 
 // so complex, we need to do more ...
 }
}
interface ComplexInput extends SimpleInput {
  void qux();
  @Override
  default void bar() {
    SimpleInput.super.bar(); 
    // so complex, we need to do more ...
  }
}

这个新特性看起来非常不错,因此ThirdPartyBaseClass类的维护人员也决定使用这个库了。为了实现这个,他将ThirdPartyBaseClass类实现了ComplexInput接口。

新的特性带来了非常好的效果以至于维护 ThirdPartyBaseClass
的人也决定依赖该库。为了完成这项工作,它在 ThirdPartyLibrary 中实现了
ComplexInput 接口。

但这样的话对MyInput类意味着什么?由于它继承了ThirdPartyBaseClass类,因此默认实现了ComplexInput接口,这样的话调用SimpleInput的默认方法就不合法了。结果就是,用户的代码最后无法通过编译。还有就是,现在已经彻底无法调用这个方法了,因为Java把这种调用间接父类的super-super方法认为是不合法的。你只能去调用ComplexInput接口的默认方法了。然而这首先需要你在MyInput类中显式的实现一下这个接口。对于这个库的用户而言,这些改动完全是意想不到的。

但是这对 MyInput 类来说意味着什么呢?为了隐式的实现 ComplexInput
接口,可继承 ThirdPartyBaseClass 类,但是调用 SimpleInput
的默认方法突然变成非法的了。结果,用户的代码不能通过编译。现在这种调用是被禁止的,因为Java认为这种在非直接子类中调用父类的父类的方法是非法的。你只能在
ComplexInput
中去调用该默认方法,但是,这要求你显示的在MyInput中实现该接口。对于库的用户来说,这种改变不是所预期的!

(注:简单点说其实就是:

更奇怪的是,Java运行时却不做这种限制。JVM的校验器是允许一个编译好的类去调用
SimpleInput::foo 方法的,即使该类是通过继承更新后的
ThirdPartyBaseClass,从而隐式的实现了ComplexClass。这种限制只存在于编译器中。

interface A {
 default void test() {
  
 }
}

interface B extends A {
 default void test() {
  
 }
}

public class Test implements B {
 public void test() {
  B.super.test();
  //A.super.test(); 错误
 }


}

我们从这里能学到什么东西呢?简单的说,确保不要在一个接口中覆盖另一个接口的默认方法,既不要用默认方法覆盖,也不要用抽象方法覆盖。总的来说,请谨慎使用默认方法。即使它使得Java的集合接口API轻易的发生了革命性的变化,但本质上讲,这种继承层级之间的方法调用,增加系统的复杂性。而在Java
7之前,你只需要沿着线性的类层级去查找真正调用的代码。只有当你觉得非常有必要的时候才去增加这种复杂性。

当然这么写的话是用户主动选择实现了B接口,而文中的例子由于引入了一个基类,因此由于库和基类中都进行了一个看似没有影响的改动,实际上却导致用户代码无法通过编译)

很奇怪的是,Java在运行时并没有对这个进行区分。JVM的校验器允许一个编译过的类进行SimpleInput::foo方法的调用,尽管加载的这个类继承了ThirdPartyBaseClass的更新版本后隐式地实现了ComplexInput接口。要怪只能怪编译器了。(注:编译器与运行时的行为不一致)

那我们从中学到了什么?简单地说,不要在另一个接口中重写原接口的默认方法。不要用另一个默认方法来重写它,也不要某个抽象方法来重写它。总而言之,使用默认方法时应当十分谨慎。虽然它们使得Java现有的集合库的接口更容易改进了,但它允许你在类的继承结构中进行方法调用,这本质上其实是增加了复杂性。在Java
7以前,你只需遍历线性的类层次结构看一下实际调用的代码就可以了。当你觉得的确需要的时候,再去使用默认方法。

以上就是针对为什么要慎用Java8的默认方法进行的详细解释,希望对大家的学习有所帮助。

You can leave a response, or trackback from your own site.

Leave a Reply

网站地图xml地图