谈谈Java 8 interface 引入的default method

Demon.Lee 2020年12月19日 4,013次浏览

学习笔记:《Java实战(第2版)》第13章 默认方法

使用接口的困境:扩展

“兄弟,你的新版API有bug啊,我的代码都报错。”
“怎么可能,我这都编译运行正常,肯定是你自己的问题。”
“不信,你到我那去看。”
“看就看。”

原来,在某个产品中,你提供了一个计算几何图形周长和面积的类库给不同的平台使用。这两天,你根据需求的变化,更新了这个类库的API。接着就发生了上面的一幕,客户端的同事过来找你定位问题。

这个类库包含接口Shape(代码如下所示)和几个默认实现类:

public interface Shape {

    double getCircumference();

    double getArea();
}

代码运行的很好,过了一段时间,客户端的同事跟你说,我们现在除了计算平面几何的图形之外,有了三维立体图,需要能计算体积。你一听,“对啊,当初咋没想到这个呢,不过需求总是变化的,也很正常”,你心里安慰着自己。你想也没想,打开IDEA编辑器,在接口中增加了一个方法,同时对类库中实现了该接口的类增加了对应的默认实现,测试之后,很快就愉快的发布了:

public interface Shape {

    double getCircumference();

    double getArea();
    
    double getVolume();
}

你是不是也遇到过类似的情况:提供一个公共类库给不同的平台使用,这个类库中包含了一个接口和几个默认实现类,我们称这个版本为1.0。后面有了需求变化,需要给接口增加一个方法,我们将这个版本命名为1.1或2.0。但问题在于,不同平台的使用者,已经根据1.0版本的接口,在自己的代码中增加了特定的实现,比如:

public class Cube implements Shape {

    ...
    ...

    @Override
    public double getCircumference(){
        ...
        ...
    }

    @Override
    public double getArea(){
        ...
        ...
    }
}

当他们将引入新版本的API时,对应的代码就会提示编译错误:

Class ‘XXX’ must either be declared abstract or implement abstract method ‘testXXX()’ in ‘XXX’。

没错,你已经发现了:版本不能向后兼容,也是源代码级不兼容(见附录1)。

这个时候,你只能强颜欢笑的说:“我的锅,我的锅”,顿时没了脾气。

紧接着,你便思考该如何解决这个问题了:

  • 方案1:增加一个默认的抽象类。
    把所有接口方法都在这个抽象类中进行默认实现。但依然解决不了上面的问题,因为上一版中,客户端的使用者就已经直接针对接口进行实现了,而不是你现在写的抽象类。即使你现在写的就是第1版,也没法保证:客户端会继承抽象类,而不是直接实现对应的接口。
  • 方案2:客户端使用者区分版本。
    但这样无疑就将负担转移到了客户端的头上,维护不同的版本,实非良策。

好像不管哪种方案,客户端的使用者都会来找你麻烦,可能还会数落你一番:😭。

默认方法:兼容,平滑迭代

Java 8 针对前文中描述的API演进问题,在接口中引进了默认方法。

Default Methods enable you to add new functionality to the interfaces of your libraries and ensure binary compatibility with code written for older versions of those interfaces.

简单来说,默认方法就是在接口中可以对方法给出默认的实现,使用 default 关键字修饰。实现类中可以重写(@Override)该默认方法,也可以直接继承该默认实现。

到这里,前文提到的那个没有完美解决方案的问题也就迎刃而解了:

public interface Shape {

    double getCircumference();

    double getArea();
    
    default double getVolume() {
        throw new UnsupportedOperationException();
    }
}

我们将getVolume()用default修饰,然后添加上实现就结束了。这里我们直接抛出异常,因为我们不确定具体的Shape是二维平面的还是三维立体的。

接口 vs 抽象类

有没有一种感觉:当接口中有了默认方法之后,接口和抽象类越来越像了,接口中的默认方法就像把抽象类中的默认实现移出来了。那现在该如何定位两者呢,抽象类是不是可以越来越被淡化了呢?为了找寻答案,我们需要对二者进行一番比较:

同: 都可以定义默认实现(非抽象方法),也都可以定义抽象方法。
异: 1)一个类只能继承一个抽象类,但可以实现多个接口(从设计来说,扩展性更好);2)抽象类可以定义实例变量(属性)保存中间状态,而接口中没有实例变量(接口中的变量都是final的)。

由此可以看出,当进行代码复用时,如果功能复杂,并且存在很多中间状态时,更倾向于使用抽象类。但我们应该尽可能多的使用接口,因为接口代表了行为,实现了多个接口,也就相当于将多个行为进行组合。在此基础上,如果业务复杂,我们可以再定义一个公共的抽象类,将中间状态提取出来,用一句话总结就是:接口为主,抽象类为辅。

组合接口

多个行为的组合,在Java中体现的就是:类可以实现多个接口,接口也继承多个接口,所以也称为:行为的多继承。为了避免因为类的多继承而引入的复杂性,Java中的类只有单继承,而没有C++中的多继承。

举个例子,不同业务的报表不尽相同,获取数据,生成格式都有所区别,也就是说每一块业务都要写自己的代码逻辑。除了报表生成之外,还有一些类似的需求,比如有的报表需要发送给上级主管,有的报表还需要对敏感数据过滤等。这个时候,我们将不同的行为通过不同的接口进行封装。业务报表需要哪些功能,实现对应的接口就好了,伪代码如下:

public interface Sendable {
    
    void send(List<String> contacts, String content);
}

public interface Desensitizeable {
    
    String desensitize(String content);
}

public class ReportA implements Sendable {
    ...
    ...
}

public class ReportB implements Sendable, Desensitizeable {
    ...
    ...
}

挑战

想必你已经想到了,如果继承或实现的多个接口中有相同的方法(方法签名相同),我怎么知道调用哪个接口中的方法呢?别急,JDK开发人员早已为你想到了。

解决冲突的三原则

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

“纸上得来终觉浅”,我们上手试试。

  • 样例1:
public interface A {

    default void hello() {
        System.out.println("Hello from A...");
    }

}

public interface B extends A {

    @Override
    default void hello() {
        System.out.println("Hello from B...");
    }
}

public class D implements B {

    @Override
    public void hello() {
        System.out.println("Hello from D...");
    }
}

public class C extends D implements A, B {
}

public class InterfaceDefaultMethodTest {

    @Test
    public void testDefaultMethodUsage() {
        C c = new C();
        // 这里输出啥?D类中重写了hello()方法,如何没有重写,又如何输出
        c.hello();
    }
}

输出:

// D 中重写 hello(),根据规则1,输出为:
Hello from D...

// D 中没有重写 hello(),根据规则2,输出为:
Hello from B...
  • 样例2:
public interface A1 {

    // void hello();

    default void hello() {
        System.out.println("Hello from A1...");
    }
}

public interface B1 {

    default void hello() {
        System.out.println("Hello from B1...");
    }
}

public class C1 implements A1, B1 {
    /* 实现相同的默认方法,如果不显示覆盖,则编译器提示报错:
      com.practice.learn.moderninjava.chapter13.interfaces.C1 inherits unrelated defaults for hello() from types
     *com.practice.learn.moderninjava.chapter13.interfaces.A1 and
     com.practice.learn.moderninjava.chapter13.interfaces.B1 */

    /**
     * 可以通过B1.super.xxx() 的新语法进行显示调用
     */
    @Override
    public void hello() {
        B1.super.hello();
        System.out.println("Hello C1...");
    }
    
    @Test
    public void testDefaultMethodConflict() {
        // 规则3
        C1 c1 = new C1();
        c1.hello();
    }
}

上面代码中的注释也说明了要点,因为同时继承了相同的方法,所以必须由实现类显示说明如何调用,否则编译器提示错误,输出:

// 根据规则3,打印输出为:
Hello from B1...
Hello C1...

如果我们把A1中的hello()从默认方法改为抽象方法(即注释掉的代码),那又会发生变化?答案是没变化,C1依然需要显示覆盖,否则不知道调用哪个hello()。

菱形继承

  • 样例3:
public interface A2 {

    default void hello() {
        System.out.println("Hello from A2...");
    }
}

public interface B2 extends A2 {
}

public interface C2 extends A2 {
}

public class D2 implements B2, C2 {

    @Test
    public void testDefaultMethodConflict() {
        D2 d2 = new D2();
        d2.hello();
    }
}

输出为:

Hello from A2...

这个样例就是所谓的菱形继承,即D2实现了B2和C2两个接口,而B2和C2同时继承自A2,所以具有相同的hello()方法。

C++ 中由于类可以多继承,所以菱形继承问题更复杂。Java这里,我们只有接口会存在类似问题,但跟C++ 相比,还是要简单些,解决歧义还是依赖上面的三条原则。

比如,如果B2中给出了默认方法实现,则优先启用;如果B2和C2都给出了默认方法实现,则D2中需要强制覆盖该方法,显示的调用哪个。

Tips:

  1. Java 9开始,接口中可以定义private方法了。
  2. 静态方法可以在接口中定义:如果接口中多个默认方法,使用了一个公共的静态方法,可以不用单独写工具类,直接在接口中实现,可读性、可维护性等都更好。
  3. 接口中的静态方法,直接使用:接口名.静态方法() 就可以调用,而不是通过实现类去调用。
  4. 接口中的默认方法,是非抽象方法,因为给出实现了,未给出实现的则是抽象方法。
  5. 使用@FunctionalInterface修饰的函数式接口,只有一个抽象方法,如果有其他方法则是:默认方法或静态方法。

附录

附录1:Java代码兼容性级别

Java代码兼容性类型

这里通过一个实例来演示一下接口新增方法的二进制级兼容特性:我们新建两个module(一个shape-api, 一个shape-geometry),在shape-geometry中实现了shape-api中Shape接口,并编译了两个jar包(shape-api.jar, shape-geometry.jar)。然后在shape-api增加了方法,重新编译shape-api.jar,看shape-geometry.jar中的程序还能否正常运行(shape-geometry.jar不变)。

// shape-api中没有添加新方法,运行正常
➜  target >ll
total 16
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 classes
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 generated-sources
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 maven-archiver
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 maven-status
-rw-r--r--  1 leostudio  admin  2768 Dec 19 16:19 shape-api-1.0-SNAPSHOT.jar
-rw-r--r--  1 leostudio  admin  3168 Dec 19 16:18 shape-geometry-1.0-SNAPSHOT.jar
➜  target >java -jar shape-geometry-1.0-SNAPSHOT.jar shape-api-1.0-SNAPSHOT.jar 
cube.width: 9.0
cube.area: 486.0
cube.circumference: 108.0
➜  target >

// shape-api调整,增加了一个方法,重新编译,并测试,依然正常运行
➜  target >cd ../../shape-api
➜  target >mvn clean package
...
...
➜  target > cd ../../shape-geometry
➜  target >cp ../../shape-api/target/shape-api-1.0-SNAPSHOT.jar ./
➜  target >ll
total 16
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 classes
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 generated-sources
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 maven-archiver
drwxr-xr-x  3 leostudio  admin    96 Dec 19 16:18 maven-status
-rw-r--r--  1 leostudio  admin  2842 Dec 19 16:21 shape-api-1.0-SNAPSHOT.jar
-rw-r--r--  1 leostudio  admin  3168 Dec 19 16:18 shape-geometry-1.0-SNAPSHOT.jar
➜  target >java -jar shape-geometry-1.0-SNAPSHOT.jar shape-api-1.0-SNAPSHOT.jar
cube.width: 9.0
cube.area: 486.0
cube.circumference: 108.0
➜  target >

可以看到,运行正常,但此时编辑器在代码层面提示错误,如下图所示。

参考资料

  1. [英] 拉乌尔-加布里埃尔 • 乌尔玛 / [意] 马里奥 • 富斯科 / [英] 艾伦 • 米克罗夫特.陆明刚 / 劳佳 译. Java实战(第2版). 人民邮电出版社. 2019-11