Effective Java 3rd 条目20 接口优于抽象类

Java有两个机制定义允许多个实现的类型:接口和抽象类。因为在Java8中引入了接口的默认方法(default method)[JLS 9.4.3],所以两个机制都让你可以为一些实例方法提供实现。主要的区别在于,为了实现由抽象类定义的类型,一个类必须是这个抽象类的子类。因为Java仅仅允许单继承,抽象类的限制严重约束了作为类型定义的使用。定义所有必需方法和遵从通用规范的任何类,允许实现一个接口,而不管这个类在类层级的所处位置。

现存类可以容易地更新为实现一个新接口。你要做的所有事情是,如果必需的方法还不存在时添加它们,而且添加implements子句到类声明中。例如,当许多存在类添加到平台时,它们更新为实现Comparable、Iterable和Autocloseable接口。通常,现存类不能更新为扩展一个新抽象类。如果你想让两个类扩展同一个抽象类,你必须把它放到类层级的更高位置,这个位置是这两个类的祖先。不幸的是,这可能造成类层次的间接伤害,即迫使新抽象类的所有子类,不管这是否合适,必须子类化它。

接口对于定义混入很理想的。大致地讲,混入(mixin)是一个,除了它的“主要类型”,可实现类型,声明它提供了可选类型。比如,Comparable是一个混入接口,让一个类声明,相应于其他可比较的可变对象,它的实例是有序的。这样的接口叫做混入,因为它允许可选功能“混入”到类型的主要功能。由于同样的原因,抽象类不能使用于定义混入:它们不能更新到现存类中,一个类不能有多于一个祖先,而且在类层级中没有合理的位置插入一个混入。

接口为构建非层级类型框架留出余地。类型层级对于组织一些事情是很棒的,但是其他事情不能整洁地放入到严格的层级中。例如,假设我们有代表歌手的接口和代表歌曲作家的接口:

public interface Singer { 
    AudioClip sing(Song s); 
} 

public interface Songwriter { 
    Song compose(int chartPosition); 
}

在实际中,一些歌手也是歌曲作家。因为我们使用了接口而不是抽象类定义这些类型,对于单个类型,同时实现Singer和Songwriter是完全允许的。事实上,我们可以定义第三个接口,同时扩展了Singer和Songwriter,而且添加适合于组合的新方法:

public interface SingerSongwriter extends Singer, Songwriter { 
    AudioClip strum(); 
    void actSensitive(); 
}

你不总是需要这个灵活级别,但是当你需要的时候,接口是救命稻草。替代方法是一个膨胀的类层级,为每个支持的属性组合提供一个单独的类。如果这个类型系统中有n个属性,那么你可能不得不支持的 2 n 个可能的组合。这个叫组合爆炸。膨胀类层级可能导致膨胀类,膨胀类有仅仅不同于它们参数类型的许多方法,因为在类层级中没有类型获取通用行为。

通过包装类习惯用法(条目18),接口使得增强安全强大的功能变得可能。如果你使用抽象类定义类型,那么你让想添加功能的程序员没有选择,只有使用继承。最终的类是不那么强大了而且比包装类更加脆弱。

当依据其他接口方法,有一个接口方法的明显实现,那么考虑为程序员以默认方法的形式提供实现协助。作为这个技巧的例子,参考104页的removeIf方法。如果你提供默认方法,确保为它们的继承使用@implSpec Javadoc标签文档化(条目19)。

你可以为默认方法提供多少实现协助,是有限制的。虽然许多接口指定Object方法的行为,比如equals和hashCode,但是你不允许为它们提供默认方法。而且,接口不允许包含实例域或者非公开静态成员(除了私有静态方法)。最终,你不能添加默认方法到你不能控制的接口。

然而,你可以结合接口和抽象类的优点,通过提供一个和接口匹配的抽象骨架实现(skeletal implementation)类。接口定义了这个类型,或许提供了一些默认方法,而骨架实现类,在基元接口方法上,实现了剩下的非基元接口方法。扩展骨架实现把实现接口的大部分工作拿出来了。这叫模板方法(Template Method)模式[Gamma95]。

按照惯例,骨架实现叫做AbstractInterface,这里面Interface是它们实现接口的名字。例如,Collections框架提供了一个骨架实现,支持每个主要集合接口:AbstractCollection、AbstractSet、AbstractList和 AbstractMap。有争议地,它们叫SkeletalCollection、SkeletalSet、SkeletalList和SkeletalMap是说的通的,但是Abstract惯例现在牢固地建立了。当你合理设计时,骨架实现(不管单独抽象类,还是仅仅包含接口的默认方法)可以让程序员容易地提供他们自己的接口实现。例如,以下是一个静态工厂方法,在AbstractList之上包含了一个完整和完全功能List实现:

// 建立于骨架实现上的具体实现
static List<Integer> intArrayAsList(int[] a) {
     Objects.requireNonNull(a);

    // 菱形操作子只有在在Java9和之后是合法的
    // 如果你使用之前的版本,指定<Integer>
    return new AbstractList<>() {
        @Override public Integer get(int i) { 
            return a[i]; // 自动装箱(条目6) 
        }

        @Override public Integer set(int i, Integer val) { 
            int oldVal = a[i]; 
            a[i] = val; // 自动拆箱 
            return oldVal; // 自动装箱
        }

        @Override public int size() { 
            return a.length; 
        }
    };
}

当你考虑到List实现为你所做的所有事情,这个例子是一个骨架实现能力的令人印象深刻的展示。顺便说一下,这个例子是一个Adapter[Gamma95],它允许int队列看成是Integer实例的列表。因为int值和Integer实例(装箱和拆箱)之间来回转换,它的性能不是很好。注意,实现以匿名类的方式出现(条目24)。

骨架实现类的漂亮之处在于,它们提供了抽象类的所有实现协助,当抽象类作为类型定义时没有由它们强加的严重约束。对于有骨架实现类的大多数接口实现者,扩展这个类是一个明显优势的选择,但是严格来讲它是可选的。如果一个类不能够扩展骨架类,这个类总能直接实现这个接口。这个类仍旧从接口自身中的任何默认方法中获益。此外,骨架实现仍旧辅助实现者的任务。实现这个接口的类可以转发接口方法调用到一个私有内部类的包含实例,这个内部类扩展了骨架实现。这个技巧,叫做模拟多继承(simulated multiple inheritance),是和条目18中讨论的包装类习惯用法密切相关。它提供了多继承的许多好处,而且避免了缺陷。

编写骨架实现是一个相当简单的过程,虽然有点单调。首先,研究这个接口,然后决定哪些方法是基元,依据这些,可以实现其他方法。接下来,为直接可以在基元上实现的所有方法,提供接口默认方法,但是回忆到,你可以不提供Object的默认方法,比如equals和hashCode。如果基元和默认方法包括了这个接口,你就完成了,而且对于骨架实现类来说没必要。这个类可能包含了任意一个非公开域和适合这个任务的方法。

作为一个简单的类子,考虑Map.Entry接口。显而易见的基元是getKey、getValue和setValue(可选)。这个接口指定了equals和hashCode的行为,有一个依据基元的toString明显实现。因为你不允许为Object方法提供默认实现,所以所有实现放置在骨架实现类中:

// 骨架实现类 
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> { 
    // 可更改映射中的实体必须覆写这个方法
    @Override public V setValue(V value) {
        throw new UnsupportedOperationException(); 
    } 
    // 实现Map.Entry.equals的通用协定
    @Override public boolean equals(Object o) {
        if (o == this) return true; 
        if (!(o instanceof Map.Entry)) return false; 
        Map.Entry<?,?> e = (Map.Entry) o; 
        return Objects.equals(e.getKey(), getKey())
            && Objects.equals(e.getValue(), getValue());
    }

    // 实现Map.Entry.hashCode的通用协定
    @Override public int hashCode() { 
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); 
    }

    @Override public String toString() { 
        return getKey() + "=" + getValue(); 
    }
}

注意到,这个骨架实现不能在Map.Entry接口中实现,或者作为一个子接口,因为默认方法不允许覆写Object方法,比如equals、hashCode和toString。

因为骨架实现是为继承设计的,你应该遵从条目19中的所有设计和文档规范。为简略起见,前面例子中省略了文档注释,但是在一个骨架实现中好的文档是决定必要的,不管接口中还是单独抽象类中它包含了默认方法。

接口实现的一个小变体是由AbstractMap.SimpleEntry例证的简单实现。一个简单实现像一个骨架实现,它实现一个接口而且为继承设计,但是不同之处在于它不是抽象的:它是最简单的可运作的实现。你可以按照这种样子使用它,或者环境允许时子类化它。

总而言之,一个接口通常是定义允许多实现的一个类型的最好方法。如果你导出一个非显然的接口,那么你应该仔细考虑为它匹配一个骨架实现。在可能的范围内,你应该通过接口默认方法提供骨架实现,以便这个接口的所有实现可以使用它。也就是说,接口限制通常要求骨架实现以抽象类的方式出现。

猜你喜欢

转载自blog.csdn.net/tigershin/article/details/79222965