学习笔记:《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条无法判断,则子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口,即如果接口B继承了接口A,那么B就比A更具体。
- 若前两条还是无法判断,则实现类必须通过显示覆盖和调用期望的方法,显示的调用哪一个接口默认方法的实现,语法:接口名.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:
- Java 9开始,接口中可以定义private方法了。
- 静态方法可以在接口中定义:如果接口中多个默认方法,使用了一个公共的静态方法,可以不用单独写工具类,直接在接口中实现,可读性、可维护性等都更好。
- 接口中的静态方法,直接使用:接口名.静态方法() 就可以调用,而不是通过实现类去调用。
- 接口中的默认方法,是非抽象方法,因为给出实现了,未给出实现的则是抽象方法。
- 使用@FunctionalInterface修饰的函数式接口,只有一个抽象方法,如果有其他方法则是:默认方法或静态方法。
附录
附录1: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 >
可以看到,运行正常,但此时编辑器在代码层面提示错误,如下图所示。