本章内容
什么是默认方法
如何以一种兼容的方式改进API
默认方法的使用模式
解析规则
传统上,Java程序的接口是将相关方法按照约定组合到一起的方式。实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题。现实情况是,现存的实体类往往不在接口设计者的控制范围之内,这些实体类为了适配新的接口约定也需要进行修改。由于Java 8的API在现存的接口上引入了非常多的新方法,这种变化带来的问题也愈加严重,一个例子就是前几章中使用过的List
接口上的sort
方法。想象一下其他备选集合框架的维护人员会多么抓狂吧,像Guava和Apache Commons这样的框架现在都需要修改实现了List
接口的所有类,为其添加sort
方法的实现。
且慢,其实你不必惊慌。Java 8为了解决这一问题引入了一种新的机制。Java 8中的接口现在支持在声明方法的同时提供实现,这听起来让人惊讶!通过两种方式可以完成这种操作。其一,Java 8允许在接口内声明静态方法。其二,Java 8引入了一个新功能,叫默认方法,通过默认方法你可以指定接口方法的默认实现。换句话说,接口能提供方法的具体实现。因此,实现接口的类如果不显式地提供该方法的具体实现,就会自动继承默认的实现。这种机制可以使你平滑地进行接口的优化和演进。实际上,到目前为止你已经使用了多个默认方法。两个例子就是你前面已经见过的List
接口中的sort
,以及Collection
接口中的stream
。
第1章中我们看到的List
接口中的sort
方法是Java 8中全新的方法,它的定义如下:
default void sort(Comparator<? super E> c){ Collections.sort(this, c);}
请注意返回类型之前的新default
修饰符。通过它,我们能够知道一个方法是否为默认方法。这里sort
方法调用了Collections.sort
方法进行排序操作。由于有了这个新的方法,我们现在可以直接通过调用sort
,对列表中的元素进行排序。
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);numbers.sort(Comparator.naturalOrder); ←─sort是List接口的默认方法
不过除此之外,这段代码中还有些其他的新东西。注意到了吗,我们调用了Comparator.naturalOrder
方法。这是Comparator
接口的一个全新的静态方法,它返回一个Comparator
对象,并按自然序列对其中的元素进行排序(即标准的字母数字方式排序)。
第4章中你看到的Collection
中的stream
方法的定义如下:
default Stream<E> stream { return StreamSupport.stream(spliterator, false);}
我们在之前的几章中大量使用了该方法来处理集合,这里stream
方法中调用了SteamSupport.stream
方法来返回一个流。你注意到stream
方法的主体是如何调用spliterator
方法的了吗?它也是Collection
接口的一个默认方法。
喔噢!这些接口现在看起来像抽象类了吧?是,也不是。它们有一些本质的区别,我们在这一章中会针对性地进行讨论。但更重要的是,你为什么要在乎默认方法?默认方法的主要目标用户是类库的设计者啊。正如我们后面所解释的,默认方法的引入就是为了以兼容的方式解决像Java API这样的类库的演进问题的,如图9-1所示。
图 9-1 向接口添加方法
简而言之,向接口添加方法是诸多问题的罪恶之源;一旦接口发生变化,实现这些接口的类往往也需要更新,提供新添方法的实现才能适配接口的变化。如果你对接口以及它所有相关的实现有完全的控制,这可能不是个大问题。但是这种情况是极少的。这就是引入默认方法的目的:它让类可以自动地继承接口的一个默认实现。
因此,如果你是个类库的设计者,这一章的内容对你而言会十分重要,因为默认方法为接口的演进提供了一种平滑的方式,你的改动将不会导致已有代码的修改。此外,正如我们后文会介绍的,默认方法为方法的多继承提供了一种更灵活的机制,可以帮助你更好地规划你的代码结构:类可以从多个接口继承默认方法。因此,即使你并非类库的设计者,也能在其中发现感兴趣的东西。
静态方法及接口
同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式,工具类定义了与接口实例协作的很多静态方法。比如,
Collections
就是处理Collection
对象的辅助类。由于静态方法可以存在于接口内部,你代码中的这些辅助类就没有了存在的必要,你可以把这些静态方法转移到接口内部。为了保持后向的兼容性,这些类依然会存在于Java应用程序的接口之中。
本章的结构如下。首先,我们会跟你一起剖析一个API演化的用例,探讨由此引发的各种问题。紧接着我们会解释什么是默认方法,以及它们在这个用例中如何解决相应的问题。之后,我们会展示如何创建自己的默认方法,构造Java语言中的多继承。最后,我们会讨论一个类在使用一个签名同时继承多个默认方法时,Java编译器是如何解决可能的二义性(模糊性)问题的。
9.1 不断演进的API
为了理解为什么一旦API发布之后,它的演进就变得非常困难,我们假设你是一个流行Java绘图库的设计者(为了说明本节的内容,我们做了这样的假想)。你的库中包含了一个Resizable
接口,它定义了一个简单的可缩放形状必须支持的很多方法, 比如:setHeight
、setWidth
、getHeight
、getWidth
以及setAbsoluteSize
。此外,你还提供了几个额外的实现(out-of-box implementation),如正方形、长方形。由于你的库非常流行,你的一些用户使用Resizable
接口创建了他们自己感兴趣的实现,比如椭圆。
发布API几个月之后,你突然意识到Resizable
接口遗漏了一些功能。比如,如果接口提供一个setRelativeSize
方法,可以接受参数实现对形状的大小进行调整,那么接口的易用性会更好。你会说这看起来很容易啊:为Resizable
接口添加setRelativeSize
方法,再更新Square
和Rectangle
的实现就好了。不过,事情并非如此简单!你要考虑已经使用了你接口的用户,他们已经按照自身的需求实现了Resizable
接口,他们该如何应对这样的变更呢?非常不幸,你无法访问,也无法改动他们实现了Resizable
接口的类。这也是Java库的设计者需要改进Java API时面对的问题。让我们以一个具体的实例为例,深入探讨修改一个已发布接口的种种后果。
9.1.1 初始版本的API
Resizable
接口的最初版本提供了下面这些方法:
public interface Resizable extends Drawable{ 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 { …}
他实现了一个处理各种Resizable
形状(包括Ellipse
)的游戏:
public class Game{ public static void main(String...args){ List<Resizable> resizableShapes = Arrays.asList(new Square, new Rectangle, new Ellipse); ←─可以调整大小的形状列表 Utils.paint(resizableShapes); }}public class Utils{ public static void paint(List<Resizable> l){ l.forEach(r -> { r.setAbsoluteSize(42, 42); ←─调用每个形状自己的setAbsoluteSize方法 r.draw; }); }}
9.1.2 第二版API
库上线使用几个月之后,你收到很多请求,要求你更新Resizable
的实现,让Square
、Rectangle
以及其他的形状都能支持setRelativeSize
方法。为了满足这些新的需求,你发布了第二版API,具体如图9-2所示。
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 添加了一个新方法}
图 9-2 为Resizable
接口添加新方法改进API。再次编译应用时会遭遇错误,因为它依赖的Resizable
接口发生了变化
用户面临的窘境
对Resizable
接口的更新导致了一系列的问题。首先,接口现在要求它所有的实现类添加setRelativeSize
方法的实现。但是用户最初实现的Ellipse
类并未包含setRelativeSize
方法。向接口添加新方法是二进制兼容的,这意味着如果不重新编译该类,即使不实现新的方法,现有类的实现依旧可以运行。不过,用户可能修改他的游戏,在他的Utils.paint
方法中调用setRelativeSize
方法,因为paint
方法接受一个Resizable
对象列表作为参数。如果传递的是一个Ellipse
对象,程序就会抛出一个运行时错误,因为它并未实现setRelativeSize
方法:
Exception in thread "main" java.lang.AbstractMethodError: lambdasinaction.chap9.Ellipse.setRelativeSize(II)V
其次,如果用户试图重新编译整个应用(包括Ellipse
类),他会遭遇下面的编译错误:
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does not override abstract method setRelativeSize(int,int) in Resizable
最后,更新已发布API会导致后向兼容性问题。这就是为什么对现存API的演进,比如官方发布的Java Collection API,会给用户带来麻烦。当然,还有其他方式能够实现对API的改进,但是都不是明智的选择。比如,你可以为你的API创建不同的发布版本,同时维护老版本和新版本,但这是非常费时费力的,原因如下。其一,这增加了你作为类库的设计者维护类库的复杂度。其次,类库的用户不得不同时使用一套代码的两个版本,而这会增大内存的消耗,延长程序的载入时间,因为这种方式下项目使用的类文件数量更多了。
这就是默认方法试图解决的问题。它让类库的设计者放心地改进应用程序接口,无需担忧对遗留代码的影响,这是因为实现更新接口的类现在会自动继承一个默认的方法实现。
不同类型的兼容性:二进制、源代码和函数行为
变更对Java程序的影响大体可以分成三种类型的兼容性,分别是:二进制级的兼容、源代码级的兼容,以及函数行为的兼容。1刚才我们看到,向接口添加新方法是二进制级的兼容,但最终编译实现接口的类时却会发生编译错误。了解不同类型兼容性的特性是非常有益的,下面我们会深入介绍这部分的内容。
二进制级的兼容性表示现有的二进制执行文件能无缝持续链接(包括验证、准备和解析)和运行。比如,为接口添加一个方法就是二进制级的兼容,这种方式下,如果新添加的方法不被调用,接口已经实现的方法可以继续运行,不会出现错误。
简单地说,源代码级的兼容性表示引入变化之后,现有的程序依然能成功编译通过。比如,向接口添加新的方法就不是源码级的兼容,因为遗留代码并没有实现新引入的方法,所以它们无法顺利通过编译。
最后,函数行为的兼容性表示变更发生之后,程序接受同样的输入能得到同样的结果。比如,为接口添加新的方法就是函数行为兼容的,因为新添加的方法在程序中并未被调用(抑或该接口在实现中被覆盖了)。
1参见https://blogs.oracle.com/darcy/entry/kinds_of_compatibility。
9.2 概述默认方法
经过前述的介绍,我们已经了解了向已发布的API添加方法,对现存代码实现会造成多大的损害。默认方法是Java 8中引入的一个新特性,希望能借此以兼容的方式改进API。现在,接口包含的方法签名在它的实现类中也可以不提供实现。那么,谁来具体实现这些方法呢?实际上,缺失的方法实现会作为接口的一部分由实现类继承(所以命名为默认实现),而无需由实现类提供。
那么,我们该如何辨识哪些是默认方法呢?其实非常简单。默认方法由default
修饰符修饰,并像类中声明的其他方法一样包含方法体。比如,你可以像下面这样在集合库中定义一个名为Sized
的接口,在其中定义一个抽象方法size
,以及一个默认方法 isEmpty
:
public interface Sized { int size; default boolean isEmpty { ←─默认方法 return size == 0; }}
这样任何一个实现了Sized
接口的类都会自动继承isEmpty
的实现。因此,向提供了默认实现的接口添加方法就不是源码兼容的。
现在,我们回顾一下最初的例子,那个Java画图类库和你的游戏程序。具体来说,为了以兼容的方式改进这个库(即使用该库的用户不需要修改他们实现了Resizable
的类),可以使用默认方法,提供setRelativeSize
的默认实现:
default void setRelativeSize(int wFactor, int hFactor){ setAbsoluteSize(getWidth / wFactor, getHeight / hFactor);}
由于接口现在可以提供带实现的方法,是否这意味着Java已经在某种程度上实现了多继承?如果实现类也实现了同样的方法,这时会发生什么情况?默认方法会被覆盖吗?现在暂时无需担心这些,Java 8中已经定义了一些规则和机制来处理这些问题。详细的内容,我们会在9.5节进行介绍。
你可能已经猜到,默认方法在Java 8的API中已经大量地使用了。本章已经介绍过我们前一章中大量使用的Collection
接口的stream
方法就是默认方法。List
接口的sort
方法也是默认方法。第3章介绍的很多函数式接口,比如Predicate
、Function
以及Comparator
也引入了新的默认方法,比如Predicate.and
或者Function.andThen
(记住,函数式接口只包含一个抽象方法,默认方法是种非抽象方法)。
Java 8中的抽象类和抽象接口
那么抽象类和抽象接口之间的区别是什么呢?它们不都能包含抽象方法和包含方法体的实现吗?
首先,一个类只能继承一个抽象类,但是一个类可以实现多个接口。
其次,一个抽象类可以通过实例变量(字段)保存一个通用状态,而接口是不能有实例变量的。
请应用你掌握的默认方法的知识,回答一下测验9.1的问题。
测验9.1:
removeIf
这个测验里,假设你是Java语言和API的一个负责人。你收到了关于
removeIf
方法的很多请求,希望能为ArrayList
、TreeSet
、LinkedList
以及其他集合类型添加removeIf
方法。removeIf
方法的功能是删除满足给定谓词的所有元素。你的任务是找到添加这个新方法、优化Collection API的最佳途径。答案:改进Collection API破坏性最大的方式是什么?你可以把
removeIf
的实现直接复制到Collection API的每个实体类中,但这种做法实际是在对Java界的犯罪。还有其他的方式吗?你知道吗,所有的Collection
类都实现了一个名为java.util.Collection
的接口。太好了,那么我们可以在这里添加一个方法?是的!你只需要牢记,默认方法是一种以源码兼容方式向接口内添加实现的方法。这样实现Collction
的所有类(包括并不隶属Collection API的用户扩展类)都能使用removeIf
的默认实现。removeIf
的代码实现如下(它实际就是Java 8 Collection API的实现)。它是Collection
接口的一个默认方法:default boolean removeIf(Predicate<? super E> filter) { boolean removed = false; Iterator<E> each = iterator; while(each.hasNext) { if(filter.test(each.next)) { each.remove; removed = true; } } return removed;}
9.3 默认方法的使用模式
现在你已经了解了默认方法怎样以兼容的方式演进库函数了。除了这种用例,还有其他场景也能利用这个新特性吗?当然有,你可以创建自己的接口,并为其提供默认方法。这一节中,我们会介绍使用默认方法的两种用例:可选方法和行为的多继承。
9.3.1 可选方法
你很可能也碰到过这种情况,类实现了接口,不过却刻意地将一些方法的实现留白。我们以Iterator
接口为例来说。Iterator
接口定义了hasNext
、next
,还定义了remove
方法。Java 8之前,由于用户通常不会使用该方法,remove
方法常被忽略。因此,实现Interator
接口的类通常会为remove
方法放置一个空的实现,这些都是些毫无用处的模板代码。
采用默认方法之后,你可以为这种类型的方法提供一个默认的实现,这样实体类就无需在自己的实现中显式地提供一个空方法。比如,在Java 8中,Iterator
接口就为remove
方法提供了一个默认实现,如下所示:
interface Iterator<T> { boolean hasNext; T next; default void remove { throw new UnsupportedOperationException; }}
通过这种方式,你可以减少无效的模板代码。实现Iterator
接口的每一个类都不需要再声明一个空的remove
方法了,因为它现在已经有一个默认的实现。
9.3.2 行为的多继承
默认方法让之前无法想象的事儿以一种优雅的方式得以实现,即行为的多继承。这是一种让类从多个来源重用代码的能力,如图9-3所示。
图 9-3 单继承和多继承的比较
Java的类只能继承单一的类,但是一个类可以实现多接口。要确认也很简单,下面是Java API中对ArrayList
类的定义:
public class ArrayList<E> extends AbstractList<E> ←─继承唯一一个类 implements List<E>, RandomAccess, Cloneable, Serializable, Iterable<E>, Collection<E> { ←─但是实现了六个接口}
1. 类型的多继承
这个例子中ArrayList
继承了一个类,实现了六个接口。因此ArrayList
实际是七个类型的直接子类,分别是:AbstractList
、List
、RandomAccess
、Cloneable
、Serializable
、Iterable
和Collection
。所以,在某种程度上,我们早就有了类型的多继承。
由于Java 8中接口方法可以包含实现,类可以从多个接口中继承它们的行为(即实现的代码)。让我们从一个例子入手,看看如何充分利用这种能力来为我们服务。保持接口的精致性和正交性能帮助你在现有的代码基上最大程度地实现代码复用和行为组合。
2. 利用正交方法的精简接口
假设你需要为你正在创建的游戏定义多个具有不同特质的形状。有的形状需要调整大小,但是不需要有旋转的功能;有的需要能旋转和移动,但是不需要调整大小。这种情况下,你怎么设计才能尽可能地重用代码?
你可以定义一个单独的Rotatable
接口,并提供两个抽象方法setRotationAngle
和getRotationAngle
,如下所示:
public interface Rotatable { void setRotationAngle(int angleInDegrees); int getRotationAngle; default void rotateBy(int angleInDegrees){ ←─retateBy方法的一个默认实现 setRotationAngle((getRotationAngle + angle) % 360); }}
这种方式和模板设计模式有些相似,都是以其他方法需要实现的方法定义好框架算法。
现在,实现了Rotatable
的所有类都需要提供setRotationAngle
和getRotationAngle
的实现,但与此同时它们也会天然地继承rotateBy
的默认实现。
类似地,你可以定义之前看到的两个接口Moveable
和Resizable
。它们都包含了默认实现。下面是Moveable
的代码:
public interface Moveable { int getX; int getY; void setX(int x); void setY(int y); default void moveHorizontally(int distance){ setX(getX + distance); } default void moveVertically(int distance){ setY(getY + distance); }}
下面是Resizable
的代码:
public interface Resizable { int getWidth; int getHeight; void setWidth(int width); void setHeight(int height); void setAbsoluteSize(int width, int height); default void setRelativeSize(int wFactor, int hFactor){ setAbsoluteSize(getWidth / wFactor, getHeight / hFactor); }}
3. 组合接口
通过组合这些接口,你现在可以为你的游戏创建不同的实体类。比如,Monster
可以移动、旋转和缩放。
public class Monster implements Rotatable, Moveable, Resizable {… ←─需要给出所有抽象方法的实现,但无需重复实现默认方法}
Monster
类会自动继承Rotatable
、Moveable
和Resizable
接口的默认方法。这个例子中,Monster
继承了rotateBy
、moveHorizontally
、moveVertically
和setRelativeSize
的实现。
你现在可以直接调用不同的方法:
Monster m = new Monster; ←─构造函数会设置Monster的坐标、高度、宽度及默认仰角m.rotateBy(180); ←─调用由Rotatable 中继承而来的rotateBym.moveVertically(10); ←─调用由Moveable中继承而来的moveVertically
假设你现在需要声明另一个类,它要能移动和旋转,但是不能缩放,比如说Sun。这时也无需复制粘贴代码,你可以像下面这样复用Moveable
和Rotatable
接口的默认实现。图9-4是这一场景的UML图表。
public class Sun implements Moveable, Rotatable {… ←─需要给出所有抽象方法的实现,但无需重复实现默认方法}
图 9-4 多种行为的组合
像你的游戏代码那样使用默认实现来定义简单的接口还有另一个好处。假设你需要修改moveVertically
的实现,让它更高效地运行。你可以在Moveable
接口内直接修改它的实现,所有实现该接口的类会自动继承新的代码(这里我们假设用户并未定义自己的方法实现)。
关于继承的一些错误观点
继承不应该成为你一谈到代码复用就试图倚靠的万精油。比如,从一个拥有100个方法及字段的类进行继承就不是个好主意,因为这其实会引入不必要的复杂性。你完全可以使用代理有效地规避这种窘境,即创建一个方法通过该类的成员变量直接调用该类的方法。这就是为什么有的时候我们发现有些类被刻意地声明为
final
类型:声明为final
的类不能被其他的类继承,避免发生这样的反模式,防止核心代码的功能被污染。注意,有的时候声明为final
的类都会有其不同的原因,比如,String
类被声明为final
,因为我们不希望有人对这样的核心功能产生干扰。这种思想同样也适用于使用默认方法的接口。通过精简的接口,你能获得最有效的组合,因为你可以只选择你需要的实现。
通过前面的介绍,你已经了解了默认方法多种强大的使用模式。不过也可能还有一些疑惑:如果一个类同时实现了两个接口,这两个接口恰巧又提供了同样的默认方法签名,这时会发生什么情况?类会选择使用哪一个方法?这些问题,我们会在接下来的一节进行讨论。
9.4 解决冲突的规则
我们知道Java语言中一个类只能继承一个父类,但是一个类可以实现多个接口。随着默认方法在Java 8中引入,有可能出现一个类继承了多个方法而它们使用的却是同样的函数签名。这种情况下,类会选择使用哪一个函数?在实际情况中,像这样的冲突可能极少发生,但是一旦发生这样的状况,必须要有一套规则来确定按照什么样的约定处理这些冲突。这一节中,我们会介绍Java编译器如何解决这种潜在的冲突。我们试图回答像“接下来的代码中,哪一个hello
方法是被C
类调用的”这样的问题。注意,接下来的例子主要用于说明容易出问题的场景,并不表示这些场景在实际开发过程中会经常发生。
public interface A { default void hello { System.out.println("Hello from A"); }}public interface B extends A { default void hello { System.out.println("Hello from B"); }}public class C implements B, A { public static void main(String... args) { new C.hello; ←─猜猜打印输出的是什么? }}
此外,你可能早就对C++语言中著名的菱形继承问题有所了解,菱形继承问题中一个类同时继承了具有相同函数签名的两个方法。到底该选择哪一个实现呢? Java 8也提供了解决这个问题的方案。请接着阅读下面的内容。
9.4.1 解决问题的三条规则
如果一个类使用相同的函数签名从多个地方(比如另一个类或接口)继承了方法,通过三条规则可以进行判断。
(1) 类中的方法优先级最高。类或父类中声明的方法的优先级高于任何声明为默认方法的优先级。
(2) 如果无法依据第一条进行判断,那么子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口,即如果B
继承了A
,那么B
就比A
更加具体。
(3) 最后,如果还是无法判断,继承了多个接口的类必须通过显式覆盖和调用期望的方法,显式地选择使用哪一个默认方法的实现。
我们保证,这些就是你需要知道的全部!让我们一起看几个例子。
9.4.2 选择提供了最具体实现的默认方法的接口
让我们回顾一下本节开头的例子,这个例子中C
类同时实现了B
接口和A
接口,而这两个接口恰巧又都定义了名为hello
的默认方法。另外,B
继承自A
。图9-5是这个场景的UML图。
图 9-5 提供最具体的默认方法实现的接口,其优先级更高
编译器会使用声明的哪一个hello
方法呢?按照规则(2),应该选择的是提供了最具体实现的默认方法的接口。由于B
比A
更具体,所以应该选择B
的hello
方法。所以,程序会打印输出“Hello from B”。
现在,我们看看如果C
像下面这样(如图9-6所示)继承自D
,会发生什么情况:
public class D implements A{ }public class C extends D implements B, A { public static void main(String... args) { new C.hello; }}
图 9-6 继承一个类,实现两个接口的情况
依据规则(1),类中声明的方法具有更高的优先级。D
并未覆盖hello
方法,可是它实现了接口A
。所以它就拥有了接口A
的默认方法。规则(2)说如果类或者父类没有对应的方法,那么就应该选择提供了最具体实现的接口中的方法。因此,编译器会在接口A
和接口B
的hello
方法之间做选择。由于B
更加具体,所以程序会再次打印输出“Hello from B”。你可以继续尝试测验9.2,考察一下你对这些规则的理解。
测验9.2:牢记这些判断的规则
我们在这个测验中继续复用之前的例子,唯一的不同在于
D
现在显式地覆盖了从A
接口中继承的hello
方法。你认为现在的输出会是什么呢?public class D implements A{ void hello{ System.out.println("Hello from D"); }} public class C extends D implements B, A { public static void main(String... args) { new C.hello; }}
答案:由于依据规则(1),父类中声明的方法具有更高的优先级,所以程序会打印输出“Hello from D”。
注意,
D
的声明如下:public abstract class D implements A { public abstract void hello;}
这样的结果是,虽然在结构上,其他的地方已经声明了默认方法的实现,
C
还是必须提供自己的hello
方法。
9.4.3 冲突及如何显式地消除歧义
到目前为止,你看到的这些例子都能够应用前两条判断规则解决。让我们更进一步,假设B
不再继承A
(如图9-7所示):
public interface A { void hello { System.out.println("Hello from A"); }}public interface B { void hello { System.out.println("Hello from B"); }}public class C implements B, A { }
这时规则(2)就无法进行判断了,因为从编译器的角度看没有哪一个接口的实现更加具体,两个都差不多。A
接口和B
接口的hello
方法都是有效的选项。所以,Java编译器这时就会抛出一个编译错误,因为它无法判断哪一个方法更合适:“Error: class C
inherits unrelated defaults for hello
from types B
and A
.”
图 9-7 同时实现具有相同函数声明的两个接口
冲突的解决
解决这种两个可能的有效方法之间的冲突,没有太多方案;你只能显式地决定你希望在C
中使用哪一个方法。为了达到这个目的,你可以覆盖类C
中的hello
方法,在它的方法体内显式地调用你希望调用的方法。Java 8中引入了一种新的语法X.super.m(…)
,其中X
是你希望调用的m
方法所在的父接口。举例来说,如果你希望C
使用来自于B
的默认方法,它的调用方式看起来就如下所示:
public class C implements B, A { void hello{ B.super.hello; ←─显式地选择调用接口B中的方法 }}
让我们继续看看测验9.3,这是一个相关但更加复杂的例子。
测验9.3:几乎完全一样的函数签名
这个测试中,我们假设接口
A
和B
的声明如下所示:public interface A{ default Number getNumber{ return 10; }}public interface B{ default Integer getNumber{ return 42; }}
类
C
的声明如下:public class C implements B, A { public static void main(String... args) { System.out.println(new C.getNumber); }}
这个程序的会打印输出什么呢?
答案:类
C
无法判断A
或者B
到底哪一个更加具体。这就是类C
无法通过编译的原因。
9.4.4 菱形继承问题
让我们考虑最后一种场景,它亦是C++里中最令人头痛的难题。
public interface A{ default void hello{ System.out.println("Hello from A"); }}public interface B extends A { }public interface C extends A { }public class D implements B, C { public static void main(String... args) { new D.hello; ←─猜猜打印输出的是什么? }}
图9-8以UML图的方式描述了出现这种问题的场景。这种问题叫“菱形问题”,因为类的继承关系图形状像菱形。这种情况下类D
中的默认方法到底继承自什么地方 ——源自B
的默认方法,还是源自C
的默认方法?实际上只有一个方法声明可以选择。只有A
声明了一个默认方法。由于这个接口是D
的父接口,代码会打印输出“Hello from A”。
图 9-8 菱形问题
现在,我们看看另一种情况,如果B
中也提供了一个默认的hello
方法,并且函数签名跟A
中的方法也完全一致,这时会发生什么情况呢?根据规则(2),编译器会选择提供了更具体实现的接口中的方法。由于B
比A
更加具体,所以编译器会选择B
中声明的默认方法。如果B
和C
都使用相同的函数签名声明了hello
方法,就会出现冲突,正如我们之前所介绍的,你需要显式地指定使用哪个方法。
顺便提一句,如果你在C
接口中添加一个抽象的hello
方法(这次添加的不是一个默认方法),会发生什么情况呢?你可能也想知道答案。
public interface C extends A { void hello;}
这个新添加到C
接口中的抽象方法hello
比由接口A
继承而来的hello
方法拥有更高的优先级,因为C
接口更加具体。因此,类D
现在需要为hello
显式地添加实现,否则该程序无法通过编译。
C++语言中的菱形问题
C++语言中的菱形问题要复杂得多。首先,C++允许类的多继承。默认情况下,如果类
D
继承了类B
和类C
,而类B
和类C
又都继承自类A
,类D
实际直接访问的是B
对象和C
对象的副本。最后的结果是,要使用A
中的方法必须显式地声明:这些方法来自于B
接口,还是来自于C
接口。此外,类也有状态,所以修改B
的成员变量不会在C
对象的副本中反映出来。
现在你应该已经了解了,如果一个类的默认方法使用相同的函数签名继承自多个接口,解决冲突的机制其实相当简单。你只需要遵守下面这三条准则就能解决所有可能的冲突。
首先,类或父类中显式声明的方法,其优先级高于所有的默认方法。
如果用第一条无法判断,方法签名又没有区别,那么选择提供最具体实现的默认方法的接口。
最后,如果冲突依旧无法解决,你就只能在你的类中覆盖该默认方法,显式地指定在你的类中使用哪一个接口中的方法。
9.5 小结
下面是本章你应该掌握的关键概念。
Java 8中的接口可以通过默认方法和静态方法提供方法的代码实现。
默认方法的开头以关键字
default
修饰,方法体与常规的类方法相同。向发布的接口添加抽象方法不是源码兼容的。
默认方法的出现能帮助库的设计者以后向兼容的方式演进API。
默认方法可以用于创建可选方法和行为的多继承。
我们有办法解决由于一个类从多个接口中继承了拥有相同函数签名的方法而导致的冲突。
类或者父类中声明的方法的优先级高于任何默认方法。如果前一条无法解决冲突,那就选择同函数签名的方法中实现得最具体的那个接口的方法。
两个默认方法都同样具体时,你需要在类中覆盖该方法,显式地选择使用哪个接口中提供的默认方法。