Osmanthus

空想具現化


  • 首页
  • 归档
  • 分类
  • 标签
  • 关于
  •   

© 2024 Homurax

UV: | PV:

Theme Typography by Makito

Proudly published with Hexo

Java8中的默认方法

发布于 2018-08-23 Java  java8 默认方法 

问题产生的原因

之前在学习函数式编程时,已经接触到了默认方法。最近在看别的书时,对默认方法有了更多的认知,抽空记录下总结与示例。

传统上来说,实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题,因为打破了二进制的兼容性。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方法,就会出现冲突,正如上面的多重继承,你需要显式地指定使用哪个方法。

总结

  1. 类胜于接口。
    如果在继承链中有方法体或抽象的方法声明,那么就可以忽略接口中定义的方法。

  2. 子类胜于父类。
    如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。

  3. 如果上面两条规则不适用,子类要么需要实现该方法,要么将该方法声明为抽象方法。
    或者说是显式地指定在你的类中使用哪一个接口中的方法。

类或者父类中声明的方法的优先级高于任何默认方法。如果无法解决冲突,那就选择同函数签名的方法中实现得最具体的那个接口的方法。两个默认方法都同样具体时,你需要在类中覆盖该方法,显式地选择使用哪个接口中提供的默认方法。

接口中的静态方法

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,因为我们不希望有人对这样的核心功能产生干扰。

这种思想同样也适用于使用默认方法的接口。通过精简的接口,你能获得最有效的组合,因为你可以只选择你需要的实现。

 上一篇: Java NIO 下一篇: 工厂模式 

© 2024 Homurax

UV: | PV:

Theme Typography by Makito

Proudly published with Hexo