传统上,Java
程序的接口是将相关方法按照约定组合到一起的方式。实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题。
现实情况是,现存的实体类往往不在接口设计者的控制范围之内,这些实体类为了适配新的接口约定也需要进行修改。由于Java 8
的API
在现存的接口上引入了非常多的新方法,这种变化带来的问题也愈加严重,一个例子就是之前使用过的List
接口上的sort
方法。想象一下其他备选集合框架的维护人员会多么抓狂吧,像Guava
和ApacheCommons
这样的框架现在都需要修改实现了List
接口的所有类,为其添加sort
方法的实现。
Java 8
中的接口可以通过默认方法和静态方法提供方法的代码实现。
Java 8
为了解决这一问题引入了一种新的机制。Java 8
中的接口现在支持在声明方法的同时提供实现,通过两种方式可以完成这种操作。其一,Java 8
允许在接口内声明静态方法。其二,Java 8
引入了一个新功能,叫默认方法,通过默认方法你可以指定接口方法的默认实现。换句话说,接口能提供方法的具体实现。因此,实现接口的类如果不显式地提供该方法的具体实现,就会自动继承默认的实现。这种机制可以使你平滑地进行接口的优化和演进。
默认方法的主要目标用户是类库的设计者。默认方法的引入就是为了以兼容的方式解决像Java API
这样的类库的演进问题的,如下图所示。
默认方法的出现能帮助库的设计者以后向兼容的方式演进API
。
默认方法是Java 8
中引入的一个新特性,希望能借此以兼容的方式改进API
。现在,接口包含的方法签名在它的实现类中也可以不提供实现。那么,谁来具体实现这些方法呢?实际上,缺失的方法实现会作为接口的一部分由实现类继承(所以命名为默认实现),而无需由实现类提供。
默认方法的开头以关键字default
修饰,方法体与常规的类方法相同。
默认方法由default
修饰符修饰,并像类中声明的其他方法一样包含方法体。比如,可以像下面这样在集合库中定义一个名为Sized
的接口,在其中定义一个抽象方法size
,以及一个默认方法isEmpty
:
public interface Sized {
int size();
default boolean isEmpty() { //默认方法
return size() == 0;
}
}
向发布的接口添加抽象方法不是源码兼容的。
这样任何一个实现了Sized
接口的类都会自动继承isEmpty
的实现。因此,向提供了默认实现的接口添加方法就不是源码兼容的。
函数式接口只包含一个抽象方法,默认方法是种非抽象方法。
一个类只能继承一个抽象类,但是一个类可以实现多个接口。
一个抽象类可以通过实例变量(字段)保存一个通用状态,而接口是不能有实例变量的。
默认方法的使用模式
默认方法可以用于创建可选方法和行为的多继承。
可选方法
有时,类实现了接口,不过却刻意地将一些方法的实现留白。以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
方法了,因为它现在已经有一个默认的实现。
行为的多继承
默认方法让之前无法想象的事儿以一种优雅的方式得以实现,即行为的多继承。这是一种让类从多个来源重用代码的能力,如下图所示。
Java的类只能继承单一的类,但是一个类可以实现多接口。要确认也很简单,下面是Java API
中对ArrayList
类的定义:
public class ArrayList<E> extends AbstractList<E> //继承唯一一个类
implements List<E>, RandomAccess, Cloneable,
Serializable, Iterable<E>, Collection<E> { //但是实现了六个接口
}
类型的多继承
这个例子中ArrayList
继承了一个类,实现了六个接口。因此ArrayList
实际是七个类型的直接子类,分别是:AbstractList
、List
、RandomAccess
、Cloneable
、Serializable
、Iterable
和Collection
。所以,在某种程度上,我们早就有了类型的多继承。
由于Java 8
中接口方法可以包含实现,类可以从多个接口中继承它们的行为(即实现的代码)。下面从一个例子入手,看看如何充分利用这种能力来为我们服务。保持接口的精致性和正交性能帮助你在现有的代码基上最大程度地实现代码复用和行为组合。
利用正交方法的精简接口
假设你需要为你正在创建的游戏定义多个具有不同特质的形状。有的形状需要调整大小,但是不需要有旋转的功能;有的需要能旋转和移动,但是不需要调整大小。这种情况下,你怎么设计才能尽可能地重用代码?
你可以定义一个单独的Rotatable
接口,并提供两个抽象方法setRotationAngle
和getRotationAngle
,如下所示:
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees){ //rotateBy方法的一个默认实现
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);
}
}
组合接口
通过组合这些接口,你现在可以为你的游戏创建不同的实体类。比如,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中继承而来的rotateBy
m.moveVertically(10); //调用由Moveable中继承而来的moveVertically
假设你现在需要声明另一个类,它要能移动和旋转,但是不能缩放,比如说Sun
。这时也无需复制粘贴代码,你可以像下面这样复用Moveable
和Rotatable
接口的默认实现。
public class Sun implements Moveable, Rotatable {
… //需要给出所有抽象方法的实现,但无需重复实现默认方法
}
下图是这一场景的UML
图表。
像你的游戏代码那样使用默认实现来定义简单的接口还有另一个好处。假设你需要修改moveVertically
的实现,让它更高效地运行。你可以在Moveable
接口内直接修改它的实现,所有实现该接口的类会自动继承新的代码(这里我们假设用户并未定义自己的方法实现)。
解决冲突的规则
Java
语言中一个类只能继承一个父类,但是一个类可以实现多个接口。随着默认方法在Java 8
中引入,有可能出现一个类继承了多个方法而它们使用的却是同样的函数签名。这种情况下,类会选择使用哪一个函数?在实际情况中,像这样的冲突可能极少发生,但是一旦发生这样的状况,必须要有一套规则来确定按照什么样的约定处理这些冲突。
解决问题的三条规则
如果一个类使用相同的函数签名从多个地方(比如另一个类或接口)继承了方法,通过三条规则可以进行判断。
- 类中的方法优先级最高。类或父类中声明的方法的优先级高于任何声明为默认方法的优先级。
- 如果无法依据第一条进行判断,那么子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口,即如果
B
继承了A
,那么B
就比A
更加具体。 - 最后,如果还是无法判断,继承了多个接口的类必须通过显式覆盖和调用期望的方法,显式地选择使用哪一个默认方法的实现。
选择提供了最具体实现的默认方法的接口
下面这个例子中C
类同时实现了B
接口和A
接口,而这两个接口恰巧又都定义了名为hello
的默认方法。另外,B
继承自A
。
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(); //打印输出的是什么?
}
}
下图是这个场景的UML
图。
编译器会使用声明的哪一个hello
方法呢?按照规则(2)
,应该选择的是提供了最具体实现的默认方法的接口。由于B
比A
更具体,所以应该选择B
的hello
方法。所以,程序会打印输出“Hello from B
”。
现在,我们看看如果C
像下面这样继承自D
,会发生什么情况:
public class D implements A{ }
public class C extends D implements B,A {
public static void main(String...args) {
new C().hello();
}
}
下图是这个场景的UML
图。
依据规则(1)
,类中声明的方法具有更高的优先级。D
并未覆盖hello
方法,可是它实现了接口A
。所以它就拥有了接口A
的默认方法。规则(2)
说如果类或者父类没有对应的方法,那么就应该选择提供了最具体实现的接口中的方法。因此,编译器会在接口A
和接口B
的hello
方法之间做选择。由于B
更加具体,所以程序会再次打印输出“Hello from B
”。
冲突及如何显式地消除歧义
到目前为止,这些例子都能够应用前两条判断规则解决。下面更进一步,假设B
不再继承A
:
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 { }
下图是这个场景的UML
图。
这时规则(2)
就无法进行判断了,因为从编译器的角度看没有哪一个接口的实现更加具体,两个都差不多。A
接口和B
接口的hello
方法都是有效的选项。所以,Java
编译器这时就会抛出一个编译错误,因为它无法判断哪一个方法更合适:“ Error: class C inherits unrelated defaults for hello() from types B and A.
”。
解决这种两个可能的有效方法之间的冲突,没有太多方案;你只能显式地决定你希望在C
中使用哪一个方法。为了达到这个这个目的,你可以覆盖类C
中的hello
方法,在它的方法体内显式地调用你希望调用的方法。Java 8
中引入了一种新的语法X.super.m(…)
,其中X
是你希望调用的m
方法所在的父接口。举例来说,如果你希望C
使用来自于B
的默认方法,它的调用方式看起来就如下所示:
public class C implements B, A {
void hello(){
B.super.hello(); //显式地选择调用接口B中的方法
}
}
菱形继承问题
现在考虑最后一种场景,它亦是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(); //打印输出的是什么?
}
}
下图以UML
图的方式描述了出现这种问题的场景。
这种问题叫“菱形问题”,因为类的继承关系图形状像菱形。这种情况下类D
中的默认方法到底继承自什么地方——源自B
的默认方法,还是源自C
的默认方法?实际上只有一个方法声明可以选择。只有A
声明了一个默认方法。由于这个接口是D
的父接口,代码会打印输出“ Hello from A
”。
现在,我们看看另一种情况,如果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
显式地添加实现,否则该程序无法通过编译。
如果一个类的默认方法使用相同的函数签名继承自多个接口,解决冲突的机制其实相当简单。只需要遵守下面这三条准则就能解决所有可能的冲突。
- 首先,类或父类中显式声明的方法,其优先级高于所有的默认方法。
- 如果用第一条无法判断,方法签名又没有区别,那么选择提供最具体实现的默认方法的接口。
- 最后,如果冲突依旧无法解决,你就只能在你的类中覆盖该默认方法,显式地指定在你的类中使用哪一个接口中的方法。