Java 8 效率精进指南(6)接口默认函数、Optional 空安全

163 阅读10分钟

1965年,英国一位名为Tony Hoare的计算机科学家在设计ALGOL W语言时提出了 null 引用的想法。ALGOL W是第一批在堆上分配记录的类型语言之一。Hoare选择 null 引用这种方式, “只是因为这种方法实现起来非常容易”。虽然他的设计初衷就是要“通过编译器的自动检测机制,确保所有使用引用的地方都是绝对安全的”,他还是决定为 null 引用开个绿灯,因为他认为这是为“不存在的值”建模最容易的方式。很多年后,他开始为自己曾经做过这样的决定而后悔不迭,把它称为“我价值百万的重大失误”。

image.png

使用 Java 8 进行重构

重构时的考虑点

  • 改善代码的可读性,降低别人理解代码的难度
  • 将匿名类转换为 Lambda
  • 将 Lambda 提取为方法引用
  • 从命令式的数据处理切换到 Stream
  • 采用函数接口
  • 有条件的延迟执行

高级技巧:使用延迟执行,降低 Log 打印成本

在进行 Android 软件开发时,经常会遇到通过 Log 打印调试信息的场景,例如使用 Log.d(TAG, msg) 打印大量信息。此时如果在参数 msg 中进行了复杂计算,则会因为日志打印而产生性能损耗。

使用行为参数化,将 Log 接口转换为 Lambda 格式延迟执行,则能够降低上文中的性能消耗。

// 使用 Lambda 延迟执行日志信息生成
public void log(Level level, Supplier<String> msgSupplier) {
    if(logger.isLoggable(level)) {
        log(level, msgSupplier.get()); // ===> 只有 level 达标时,才执行 lambda 计算
    }
}

// 调用入口,延迟生成 Log 信息
logger.log(Level.DEBUG, () -> "Problem: " + generateDiagnostic());

使用 peek 打印调试信息

在使用流进行多项中间操作时,如果想要查看每一步的计算结果,可以使用 peek() 进行打印,它对于流的执行过程是透明的。

// 错误的调试方法,使用 forEach 打印最终结果
List<Integer> numbers = Arrays.asList(2, 3, 4, 5);
numbers.stream()
    .map(x -> x + 17)
    .filter(x -> x % 2 == 0)
    .limit(3)
    .forEach(System.out::println);
    
// 正确的调试方法,使用 peek 打印每一步中间值
List<Integer> result =
    numbers.stream()
        .peek(x -> System.out.println("from stream: " + x))
        .map(x -> x + 17)
        .peek(x -> System.out.println("after map: " + x))
        .filter(x -> x % 2 == 0)
        .peek(x -> System.out.println("after filter: " + x))
        .limit(3)
        .peek(x -> System.out.println("after limit: " + x))
        .collect(toList());

image.png

接口的默认函数

在传统 Java 中,类 C 如果要实现接口 I,必须对 I 中所声明的所有函数进行实现。在 Java 开发工程师看来,这简直就跟 1+1=2 一样天经地义。然而,这种思想却对为接口未来的扩展造成了巨大阻力。试想这样一种场景,随着业务需求的扩展,你发现已有的接口 InterfaceA 需要增加一些新的函数,以支持更多场景。然而,这意味着所有实现了 InterfaceA 的类,都需要进行修改,增加对这些新函数的支持,即使它们根本用不到这些新函数,也不得不增加空实现。

针对这种场景,Java 8 增加了 默认函数,通过 default 关键字修饰接口中的方法,可以为其定义默认的实现,子类无需实现该函数。

// Collections.sort 默认函数实现
default void sort(Comparator<? super E> c) {
    Collections.sort(this, c);
}

同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式,工具类定 义了与接口实例协作的很多静态方法。比如, Collections 就是处理 Collection 对象的辅 助类。

默认函数的常见使用场景

可选方法

接口可以声明一些方法的默认实现,用作模板代码,如果子类意外调用到了该方法,则抛出异常。

interface Iterator<T> {
    boolean hasNext();
    T next();
    default void remove() {
        throw new UnsupportedOperationException(); // ===> 子类未实现的前提下,发生调用则抛异常
    }
}

多继承

Java 原生是不支持多继承的,然而我们可以通过默认函数来曲线提供多继承能力。例如我们让类 SomeClass 实现接口 InterfaceAInterfaceBInterfaceC,这三个接口中分别提供了默认函数 funAfunBfunC。则 SomeClass 自然具备了 funAfunBfunC 三个函数的默认实现。

image.png

菱形继承:如果两个接口提供了同样签名函数的不同实现

随着默认方法的引入,有可能出现一个类实现了多个方法,而它们使用的是同样的函数签名。在设计良好的接口体系中,这种冲突虽然在现实中极少发生,但是一旦发生时,就必须有一套规则来确定,要按照什么样的约定,才能处理这种冲突。

在 C++ 中,这种典型问题被称为 菱形继承 问题。

image.png

典型问题场景:hello() from A and B

接口 A、B 分别提供了 hello() 函数的默认实现:

public interface A {
    default void hello() {
        System.out.println("Hello from A");
    }
}

public interface B {
    default void hello() {
        System.out.println("Hello from B");
    }
}

public Class implements B, A {
    public static void main(String... args) {
        new C().hello(); // ===> from A or B ???
    }
}

针对这种问题,Java 8 提供了解决的三条原则。如果一个类使用相同的函数签名从多个地方(比如另一个类或接口)继承了方法,通过三条规则可以进行判断。

  1. 类第一:类中的方法优先级最高。类或父类中声明的方法的优先级高于任何声明为默认方法的优先级。
  2. 具体优先:如果无法依据第一条进行判断,那么子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口,即如果 B 继承了 A ,那么 B 就比 A 更加具体。
  3. 显式覆盖:最后,如果还是无法判断,继承了多个接口的类必须通过显式覆盖和调用期望的方法,显式地选择使用哪一个默认方法的实现。

规则一 + 规则二 同时生效的场景

image.png

public class D implements A{ }
public class C extends D implements B, A {
    public static void main(String... args) {
        new C().hello();
    }
}

如上图,类 D 实现接口 A,但并未提供 hello() 函数的具体实现,因此 C 即使继承自 D,但在 D 这条分支上获取到的,仍然是接口 Ahello(),其优先级低于接口 B(B 是 A 的后代),因此 C.hello() 实际上指向的是 B.hello()

如果两个祖先是并列关系

此时规则一、规则二都无法判断函数优先级,在编译器看来,AB 两个接口的 hello() 是平等的,编译器无法决定调用哪一个默认实现。因此会抛出异常:Error: class C inherits unrelated defaults for hello() from types B and 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 { } // ===> 无法判断 hello() 来自于 A 还是 B

image.png

针对上述冲突,编译器无法提供更简化的方案,调用方必须通过 X.super.m(...) 显示决定,自身的 hello() 函数来自于哪一个祖先。

public class C implements B, A {
    void hello(){
        B.super.hello(); // ===> 显式调用
    }
}

Optional

null —— 万恶之源

如引文所言,null 的出现原本是为 不存在的值 所开的绿灯,它可以豁免于基本类型以外的一切类型检查。然而,这个 绿灯 却为后续开发者带来了数不胜数的麻烦。

NullPointerException

NPE 是 Java 开发者遇到最多的异常,为了实现 null-safe,我们不得不写大量丑陋的检查代码。

方案一:饱和式检查

也称为“深层质疑”,在一切可能为 null 的对象上进行判断,这会导致很深的括号嵌套,牺牲了代码可读性,这种方式也不具备扩展性。

// 获取车险名字
public String getCarInsuranceName(Person person) {
    if (person != null) {
        Car car = person.getCar();
        if (car != null) {
            Insurance insurance = car.getInsurance();
            if (insurance != null) {
                return insurance.getName();
            }
        }
    }
    return "Unknown";
}

方案二:提前返回

在多处检查条件若不符合则直接 return,为了避免深层质疑导致的 递归 if 语句,这会导致代码中出现多处退出点,同样增加代码维护的成本。

public String getCarInsuranceName(Person person) {
    if (person == null) {
        return "Unknown";
    }
    Car car = person.getCar();
    if (car == null) {
        return "Unknown";
    }
    Insurance insurance = car.getInsurance();
    if (insurance == null) {
        return "Unknown";
    }
    return insurance.getName();
}

Kotlin 中的解决方案

在 Kotlin 语言中,参考 Groovy,提供了名为 安全导航操作符 Safe Navigation Operator 的符号 ?,可以安全访问 Nullable 变量。

val carInsuranceName = person?.car?.insurance?.name

Java 8 的解决方案 —— Optional

对此,Java 8 尝试从语义上进行区分,提供了 Optional<T> 类型,表明一个变量“可能为空”。当对象不存在时,返回 Optional.empty(),它是静态工厂函数,返回 Optional 类的特定单一实例。

Optional 与 null 的核心区别在于,它从语义上区分出“一定有某个成员”和“可能有某个成员”。例如,对于 Person 类而言,Optional<Car> 表示这个人可能有一辆车,也可能没有一辆车。而 Car 则意味着这个人一定有一辆车,如果没有则异常。

创建 Optional 对象

在了解 Optional 的定义以后,为了在实际场景中应用它,首先应当掌握 Optionl 对象的创建方法,一共有三种。

1. 通过静态工厂方法 Optional.empty 创建一个空的 Optional 对象

Optional<Car> optCar = Optional.empty();

2. 依据非空值,通过 Optional.of 创建

Optional<Car> optCar = Optional.of(someCar);

3. 可接受 null 的Optional,通过静态工厂函数 Optional.ofNullable 创建

Optional<Car> optCar = Optional.ofNullable(car);

从 Optional 对象中提取和转换值

Optional 让我们免于显式 null-check,而是通过 map 函数,获得其成员变量的 Optional 态对象。

例子:汽车 Car 不一定购买保险 Insurance,但保险 Insurance 一定具有保险公司名字 name

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

Optional 的串联

当需要对 Optional 进行链式调用时,必须用 flatMap 代替 map,因为后者返回的是一个 Optional 对象,对它再次调用 map 会导致 Optional<Optional<>> 嵌套产生,因此需要 flatMap 进行摊平,脱去外层 Optional 的壳。

例子:使用 Optional 获取 Car 的保险公司名称,其中 Person 不一定有 Car,Car 也不一定有保险,但保险一定有保险公司名字。

public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar)
        .flatMap(Car::getInsurance)
        .map(Insurance::getName)
        .orElse("Unknown");

从 Optional 对象中取值

Java 8 提供了多种方法,用于从 Optional 对象中取值、执行函数、抛异常。

1. 不推荐:直接 get()

如果不存在就会抛出 NoSuchElementException,这种方法相对于原始的 null 并没有任何进步,不要使用。

2. 提供备选值 orElse(T other)

当对象不存在时使用默认值,类似 Kotlin 的 ?:

3. 延迟提供备选值 orElseGet(Supplier<? extends T> other)

只有当创建默认值是一件耗费性能的操作,才考虑使用这种延迟调用版本。否则徒增代码复杂度。

4. 定制异常 orElseThrow(Supplier<? extends X> exceptionSupplier)

自行定义异常,使用场景较少。

5. 值存在时对其执行方法 ifPresent(Consumer<? super T>)

将存在的值作为参数,执行一个消费它的函数。

使用 filter 函数,简化 if-else 判断

filter 函数可以进行条件判断,并在符合条件时,返回 Optional 对象,继续执行后续链式调用。

例子:当用户选择的是剑桥保险公司时,打印 OK

Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance ->
        "CambridgeInsurance".equals(insurance.getName()))
    .ifPresent(x -> System.out.println("ok"));