问题产生的原因
之前在学习函数式编程时,已经接触到了默认方法。最近在看别的书时,对默认方法有了更多的认知,抽空记录下总结与示例。
传统上来说,实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题,因为打破了二进制的兼容性。Java 8中对API最大的改变在于集合类。比如Collection
接口中增加了新的stream
方法,此时如何能让一些第三方类库在不知道该方法的情况下通过编译?
Java 8为了解决这一问题引入了一种新的机制。Java 8中的接口支持在声明方法的同时提供实现,接口中这样的方法叫作默认方法,在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。默认方法非常好区分,使用default
修饰的方法就是默认方法,方法体与常规的类方法相同。
JDK中forEach
的实现方式
default void forEach(Consumer<? super T> action) {
for (T t : this) {
action.accept(t);
}
}
不同类型的兼容性
变更对Java程序的影响大体可以分成三种类型的兼容性,分别是:二进制级的兼容、源代码级的兼容,以及函数行为的兼容。
二进制级的兼容性表示现有的二进制执行文件能无缝持续链接(包括验证、准备和解析)和运行。比如,为接口添加一个方法就是二进制级的兼容,这种方式下,如果新添加的方法不被调用,接口已经实现的方法可以继续运行,不会出现错误。
简单地说,源代码级的兼容性表示引入变化之后,现有的程序依然能成功编译通过。比如,向接口添加新的方法就不是源码级的兼容,因为遗留代码并没有实现新引入的方法,所以它们无法顺利通过编译。
最后,函数行为的兼容性表示变更发生之后,程序接受同样的输入能得到同样的结果。比如,为接口添加新的方法就是函数行为兼容的,因为新添加的方法在程序中并未被调用(抑或该接口在实现中被覆盖了)。
默认方法
默认方法在继承规则上和普通方法也略有区别。
最简单的情况(没有重写)
public interface Parent {
void message(String msg);
default String welcome() {
message("Parent welcome");
return "Parent welcome";
}
}
public class ParentImpl implements Parent {
@Override
public void message(String msg) {
System.out.println("ParentImpl => "+msg);
}
}
@Test
public void test0() {
Parent parent = new ParentImpl();
assertEquals("Parent welcome", parent.welcome());
}
断言通过,开始没有重写,ParentImpl
类没有实现welcome
方法,因此它自然继承了该默认方法。
现在新建一个接口Child
,继承自Parent
接口,同时实现了自己的默认welcome
方法。
public interface Child extends Parent {
@Override
default String welcome() {
message("Child welcome");
return "Child welcome";
}
}
public class ChildImpl implements Child {
@Override
public void message(String msg) {
System.out.println("ChildImpl => " + msg);
}
}
@Test
public void test1() {
Parent parent = new ParentImpl();
assertEquals("Parent welcome", parent.welcome());
Child child = new ChildImpl();
assertEquals("Child welcome", child.welcome());
}
该方法重写了Parent
的方法。同样在这个例子中,ChildImpl
类不会实现welcome
方法,因此它自然也继承了接口的默认方法。
此时的继承结构。
现在默认方法成了虚方法。
重写的情况
重写welcome默认实现的父类。
public class OverridingParent extends ParentImpl {
@Override
public String welcome() {
message("OverridingParent welcome");
return "OverridingParent welcome";
}
}
@Test
public void test2() {
Parent overridingParent = new OverridingParent();
assertEquals("OverridingParent welcome", overridingParent.welcome());
}
断言通过,调用的是类中的具体方法,而不是默认方法。
此时的继承结构。
子接口重写了父接口中的默认方法。
public class OverridingChild extends OverridingParent implements Child {
}
@Test
public void test3() {
Parent overridingChild = new OverridingChild();
assertEquals("OverridingParent welcome", overridingChild.welcome());
}
断言通过,类中重写的方法优先级高于接口中定义的默认方法。
此时的继承结构。
这样的设计主要是由增加默认方法的目的决定的,增加默认方法主要是为了在接口上向后兼容。让类中重写方法的优先级高于默认方法能简化很多继承问题。如果类中重写的方法没有默认方法的优先级高,那么就会破坏已有的实现。
多重继承
接口允许多重继承,因此有可能碰到两个接口包含签名相同的默认方法的情况。
public interface A {
default String say() {
return "Hello A";
}
}
public interface B {
default String say() {
return "Hello B";
}
}
public class C implements A, B {
}
此时,javac并不明确应该继承哪个接口中的方法,因此编译器会报错:com.example.C inherits unrelated defaults for say() from types com.example.A and com.example.B
在类中实现say()
方法就可以解决这个问题。
public class C implements A, B {
@Override
public String say() {
// or B.super.say()
return A.super.say();
}
}
该例中使用了Java 8中引入的一种新的语法,增强的super
语法,用来指明使用接口A
中定义的默认方法。此前,使用super
关键字是指向父类,现在使用类似InterfaceName.super
这样的语法指的是继承自父接口的方法。
菱形继承
public interface A {
default void hello() {
return "Hello from A";
}
}
public interface B extends A {
}
public interface C extends A {
}
public class D implements B, C {
}
@Test
public void test4() {
D d = new D();
assertEquals("Hello from A", d.hello());
}
只有A声明了一个默认方法。由于这个接口是D的父接口,所以断言通过。
如果B中也提供了一个默认的hello方法,并且函数签名跟A中的方法也完全一致,编译器会选择提供了更具体实现的B接口中的方法。
如果B和C都使用相同的函数签名声明了hello方法,就会出现冲突,正如上面的多重继承,你需要显式地指定使用哪个方法。
总结
类胜于接口。
如果在继承链中有方法体或抽象的方法声明,那么就可以忽略接口中定义的方法。子类胜于父类。
如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。如果上面两条规则不适用,子类要么需要实现该方法,要么将该方法声明为抽象方法。
或者说是显式地指定在你的类中使用哪一个接口中的方法。
类或者父类中声明的方法的优先级高于任何默认方法。如果无法解决冲突,那就选择同函数签名的方法中实现得最具体的那个接口的方法。两个默认方法都同样具体时,你需要在类中覆盖该方法,显式地选择使用哪个接口中提供的默认方法。
接口中的静态方法
Java 8允许在接口内声明静态方法。比如Stream
是个接口,Stream.of
是接口中的静态方法。
同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式,工具类定义了与接口实例协作的很多静态方法。比如,Collections就是处理Collection对象的辅助类。由于静态方法可以存在于接口内部,你代码中的这些辅助类就没有了存在的必要,你可以把这些静态方法转移到接口内部。为了保持后向的兼容性,这些类依然会存在于Java应用程序的接口之中。
如果一个方法有充分的语义原因和某个概念相关,那么就应该将该方法和相关的类或接口放在一起,而不是放到另一个工具类中。这有助于更好地组织代码,阅读代码的人也更容易找到相关方法。
权衡
现在的接口提供了某种形式上的多重继承功能,然而多重继承在以前饱受诟病,Java 因此舍弃了该语言特性,这也正是Java在易用性方面优于C++的原因之一。C++中的菱形问题要比上面的例子复杂得多。首先,C++允许类的多继承。默认情况下,如果类D继承了类B和类C,而类B和类C又都继承自类A,类D实际直接访问的是B对象和C对象的副本。最后的结果是,要使用A中的方法必须显式地声明:这些方法来自于B接口,还是来自于C接口。此外,类也有状态,所以修改B的成员变量不会在C对象的副本中反映出来。
接口和抽象类之间还是存在明显的区别。接口允许多重继承,却没有成员变量;抽象类可以继承成员变量,却不能多重继承。在对问题域建模时,需要根据具体情况进行权衡,而在以前的Java中可能并不需要这样。
继承不应该成为你一谈到代码复用就试图倚靠的万精油。比如,从一个拥有100个方法及字段的类进行继承就不是个好主意,因为这其实会引入不必要的复杂性。你完全可以使用代理有效地规避这种窘境,即创建一个方法通过该类的成员变量直接调用该类的方法。这就是为什么有的时候我们发现有些类被刻意地声明为final类型:声明为final的类不能被其他的类继承,避免发生这样的反模式,防止核心代码的功能被污染。注意,有的时候声明为final的类都会有其不同的原因,比如,String类被声明为final,因为我们不希望有人对这样的核心功能产生干扰。
这种思想同样也适用于使用默认方法的接口。通过精简的接口,你能获得最有效的组合,因为你可以只选择你需要的实现。