谈谈Java 8 interface 引入的default method

Demon.Lee 2020年12月19日 2,480次浏览

学习笔记:《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