如何在 Java 中安全地使用子类型

2019 Java 开发者跳槽指南.pdf (吐血整理)….>>>

点击上方“后端技术精选”,选择“置顶公众号”

技术文章第一时间送达!

来自:唐尤华,importNew

dzone.com/articles/how-to-safely-subtype-in-java

你可能还记得,Liskov 代换原则是关于承诺和契约的规则。但具体是怎样的承诺呢?为了确保 subtype(子类型)的安全性,意味着必须保证可以合理地从超类型推导出 subtype,而且这个过程具有传递关系。在数学中,对所有 a,b,c ∈ x,如果 aRb 并且 bRc,那么 aRc。在面向对象程序设计中,subclass 即对应 subtype,然而这不是正确的打开方式(这篇文章中 subclass 特指 subtype)。我们必须确保不会违反继承超类的承诺,我们不会依赖于一些无法控制的东西。如果它发生更改,则可以影响其他对象(这些是不可变对象)。实际上,subclass 甚至可能导致 bug。

译注:Liskov 于1987年提出了一个关于继承的原则:“继承必须确保超类所拥有的性质在子类中仍然成立”。也就是说,只有当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有 is-A 关系。

1. 为什么要安全地使用subtype(子类型)

实际上,subclass 是一种特殊的 subtype,它允许 subtype 重用 supertype 的实现(目的是防止因超类中的小改动导致重新实现)。我们可以认为 subclass 是 subtype,但不能说 subtype,但不能说 subtype 是 subclass。subclass 主要有两个工作:subtype(多态)和代码重用。subtype 的影响最大,父类中任何 public 或 protected 更改都将影响其子类。subetype 有时候是,但并不总是 Is-A 关系。实际上,subtype 是一种程序上的代码重用技术,也是一种实现动态多态性(dynamic polymorphism)的工具。

subclass 只关心实现的内容和方式,而非承诺的内容。如果违背了基类承诺会发生什么,如何保证它们之间能够兼容?即使编译器也无法理解这种错误,留给你的会是代码中的 bug,比如下面这个例子:

class DoubleEndedQueue {
    void insertFront(Node node) {
        // ...
        // 在队列的前面插入节点
    }
    void insertEnd(Node node) {
        // ...
        // 在队列末尾插入节点
    }
    void deleteFront(Node node) {
        // ...
        // 删除队列前面的节点
    }
    void deleteEnd(Node node) {
        // ...
        // 删除队列末尾的节点
    }
}
class Stack extends DoubleEndedQueue {
    // ...
}

如果 Stack 类希望使用 subtype 实现代码重用,那么它可能会继承一个违反自身原则的行为,比如 insertFront。让我们接着看另一个代码示例:

public class DataHashSet extends HashSet {
    private int addCount = 0;
    public function DataHashSet(Collection collection) {
        super(collection);
    }
    public function DataHashSet(int initCapacity, float loadFactor) {
        super(initCapacity, loadFactor);
    }
    public boolean function add(Object object) {
        addCount++;
        return super.add(object);
    }
    public boolean function addAll(Collection collection) {
        addCount += collection.size();
        return super.addAll(collection);
    }
    public int function getAddCount() {
        return addCount;
    }
}

上面的示例使用 DataHashSet 类重新实现 HashSet 跟踪插入操作。DataHashSet 继承 HashSet 并成为它的一个子类。我们可以在 Java 中传入 DataHashSet 对象替换 HashSet 对象。此外,我的确重写(override)了基类中的一些方法。这在 Liskov 代换原则中合法吗?由于没有对基类行为进行任何更改,只是加入跟踪插入操作代码,似乎完全合法。但我认为这显然是错误的 subtype 代码。

首先,应该看一下 add 方法到底能做什么。它把 addCount 属性加1,并调用父类方法。这段代码存在一个溜溜球问题。让为我们看看 addAll 方法。首先,它把 addCount 的值加上集合大小,然后调用父类的 addAll 方法。但是父类的 addAll 方法具体怎么执行呢?它将多次调用 add 方法(循环遍历集合)。问题来了,父类将调用哪个 add 方法?是当前子类的 add 还是父类中 add?答案是子类中的 add 方法。因此,count 大小增加两倍。调用 addAll 时,count 增加一次,当父类调用子类 add 方法时,count 会再增加一次。这就是为什么称之为悠悠球问题。

译注:yo-yo problem 溜溜球问题。在软件开发中,溜溜球问题是一种反模式(anti-pattern)。当阅读和理解一个继承层次非常长且复杂的程序时,程序员不得不在许多不同的类定义之间切换,以便遵循程序的控制流程。这种情况经常发生在面向对象程序设计。该术语来自比较弹跳注意的程序员的上下运动的玩具溜溜球。Ganti、Taenzer 和 Podar 解释为什么这么命名时说道:“当我们试图理解这些信息时,常常会有一种骑着溜溜球的感觉”。

这里还有一个例子证明 subtype 是有风险的,看下面这段代码:

class A {
    void foo(){
        ...
        this.bar();
        ...
    }
    void bar(){
        ...
    }
}
class B extends A {
    // 重写 bar
    void bar(){
        ...
    }
}
class C {
    void bazz(){
        B b = new B();
        // 这里会调用哪个 bar 函数?
        B.foo();
    }
}

调用 bazz 方法时,将调用哪个 bar 方法?当然是 B 类中的 bar 方法。这会带来什么问题?问题在于 A 类中的 foo 方法不知道被 B 类中 bar 方法重写,由于 A 中的 foo 方法认为调用的是自己定义的 bar 方法,从而导致类中的不变量和封装遭到破坏。这个问题也称为脆弱的基类问题(fragile base-class problem)。

subtype 会引发更关键的耦合问题:程序中的一部分对另一部分产生非预期得依赖(紧耦合)。强耦合的经典案例就是全局变量。例如,如果修改全局变量的类型,那么使用该变量(即与变量耦合)的所有函数都可能受到影响,因此必须检查、修改和重新测试所有代码。

此外,所有使用全局变量的函数都因为它彼此耦合。也就是说,如果变量的值在某个不恰当的时间更改,那么一个函数可能错误地影响另一个函数的行为。在多线程程序中,这种问题尤其可怕。

2. 如何安全地 subclass

subclass 最安全的方法是避免 subtype。如果类设计时并不希望支持 subclass,那么可以把构造函数设为 private 或在类的声明中加 final 关键字防止 subclass。如果希望支持 subclasss,那么可以新建一个包装类(wrapper class)实现代码重用。

这时,我们需要对代码重用进行模块化推理,即在无需了解实现细节的情况下重用代码的能力。有几种方法可以做到这一点,这里介绍其中的一些方法。一种方法是将可重写(overridable)的功能限制在少数 protected 方法中,避免自我调用可重写方法。

例如,通过语言自身机制或规范来防止重写其他方法。在 DataHashSet 类中,避免 addAll 调用 add 方法。另外,避免在类中调用可重写方法减少重写对其他函数的直接影响。让我们用前面的例子继续说明:

class A {
    void foo(){
        ...
        this.insideBar();
        ...
    }
    void insideBar(){
        ...
    }
    void bar(){
        this.insideBar();
    }
}
class B extends A {
    // 重写 bar
    void bar(){
        ...
    }
}
class C {
    void bazz(){
        B b = new B();
        B.foo();
    }
}

在上面的代码中,仅仅添加了 insideBar 方法,防止重写导致不必要的更改,就可以解决问题。大多数情况下,创建包装类是降低 subtype 风险的好方法。相比 subtype 我更喜欢组合(composition)或委托(delegation)。

有些时候必须不惜一切代价避免 subtype。如果有不止一种方法实现 subtype,那么最好使用委托。或者父类中包含一些没有调用的方法时,意味着不需要使用继承(Liskov 代换原则)。同样的规则对 class 也适用。我的意思是不应该在启用共享类(shared class)的时候对重用该类。

译注:shared class 共享类技术。Java5 平台的 IBM 实现中新的共享类特性提供了一种完全透明和动态的方法,可以共享已经装载的所有类,而不会对共享类数据的 JVM 施加限制。这个特性为减少虚拟内存占用和改进启动时间提供了一个简单且灵活的解决方案,大多数应用程序都能够因此受益。

3. subtype 委托

subtype 模式可以把类看做模板,它定义了所有实例的结构。每个实例都具备类属性与行为,但不包含属性值。因为类的所有实例(包括子类的实例)都使用类定义的属性,所以对类属性的任何更改都将影响到所有实例。

一个实例包含了父类(superclass)和子类所有信息的组合。subtype 呈一种线性的上下关系(Java 与 C++ 不同,不能有多个子类)。值存储在实例中,而不是类中,并且不支持共享。在子类中,实例之间互相独立,更改一个实例的状态值不会影响任何其他实例,而且每个实例都有自己的父对象。

委托表示使用另一个对象进行方法调用。在委托实例中,不通过类共享属性或行为,通常称之为无类实例。要重用某个类,可以使用它的一个实例。假设有一个面积计算器类,能够接受不同形状并返回其计算的面积。只要创建一个面积计算器对象,调用不同的面积计算类。在子类中,针对每种类型的面积计算,必须创建一个带有父类型的独立对象。

如果计算器对象将一个方法或变量委托给一个原型,那么修改任何属性或值都将同时影响对象和原型。使用这种方式,委托关系中的对象会互相依赖。在委托实现中,需要启动多个对象。与 subtype 相反,对象可以是不同类型。此外,还需要用正确的方式组合实例,以满足类的需要。

由于没有父类,因此不能直接使用对象属性。在 subtype 中,子类可以使用父类中的属性或方法;在委托中,必须先定义才能访问。

在委托中,只需要建立同这些类的连接,一个重用类(reuse class)可以重复使用多个重用类,这些类都包含在同一个实例中。但在 subtype 中,重用类必须是其他重用类的子类(具备继承关系)。

让我们用委托来解决 DataHashSet 中的问题:

public class DataHashSet implements Set {
    private int addCount = 0;
    private Set set;
    public
        function DataHashSet(Set set
{
        This.set = set;
    }
    public boolean
    function add(Object object
{
        addCount++;
        return This.set.add(object);
    }
    public boolean
    function addAll(Collection collection
{
        addCount += collection.size();
        return This.set.addAll(collection);
    }
    public int
    function getAddCount(
{
        return addCount;
    }
}

4. 如何使用 Skeletal 模式?

Skeletal 模式既不损失灵活性,又能享受 subtype 的优点。它为每个接口提供一个实现该接口的抽象类,不指定基础方法(primitive method)。这意味着将方法设为 abstract 由子类实现,同时它还定义了非基础方法。然后,由使用该接口的开发者实现接口,负责框架实现。它不如包装类灵活,比如组合或委托。为了增加其灵活性,可以使用包装类将调用委托给框架实现的匿名子类对象。

译注:有关 Skeletal 模式实例,可以参阅 Favor Skeletal Implementation in Java
https://dzone.com/articles/favour-skeletal-interface-in-java

推荐阅读(点击即可跳转阅读)

1. SpringBoot内容聚合

2. 面试题内容聚合

3. 设计模式内容聚合

4. Mybatis内容聚合

5. 多线程内容聚合

如何在 Java 中安全地使用子类型

原文始发于微信公众号(后端技术精选):如何在 Java 中安全地使用子类型