pursue wind pursue wind
首页
Java
Python
数据库
框架
Linux
中间件
前端
计算机基础
DevOps
项目
面试
书
关于
归档
MacOS🤣 (opens new window)
GitHub (opens new window)
首页
Java
Python
数据库
框架
Linux
中间件
前端
计算机基础
DevOps
项目
面试
书
关于
归档
MacOS🤣 (opens new window)
GitHub (opens new window)
  • EffectiveJava

    • 考虑使用静态工厂方法替代构造方法
    • 当构造方法参数过多时使用builder模式
    • 使用私有构造方法或枚类实现Singleton属性
    • 使用私有构造方法执行非实例化
    • 依赖注入优于硬连接资源(hardwiring resources)
    • 避免创建不必要的对象
    • 消除过期的对象引用
    • 避免使用Finalizer和Cleaner机制
    • 使用try-with-resources语句替代try-finally语句
    • 重写equals方法时遵守通用约定
    • 重写equals方法时同时也要重写hashcode方法
    • 始终重写 toString 方法
    • 谨慎地重写 clone 方法
    • 考虑实现Comparable接口
    • 使类和成员的可访问性最小化
    • 在公共类中使用访问方法而不是公共属性
    • 最小化可变性
    • 组合优于继承
    • 要么设计继承并提供文档说明,要么禁用继承
    • 接口优于抽象类
    • 为后代设计接口
    • 接口仅用来定义类型
    • 类层次结构优于标签类
    • 支持使用静态成员类而不是非静态类
    • 将源文件限制为单个顶级类
    • 不要使用原始类型
    • 消除非检查警告
    • 列表优于数组
    • 优先考虑泛型
    • 优先使用泛型方法
    • 使用限定通配符来增加API的灵活性
    • 合理地结合泛型和可变参数
    • 优先考虑类型安全的异构容器
    • 使用枚举类型替代整型常量
    • 使用实例属性替代序数
    • 使用EnumSet替代位属性
    • 使用EnumMap替代序数索引
    • 使用接口模拟可扩展的枚举
    • 注解优于命名模式
    • 始终使用Override注解
    • 使用标记接口定义类型
    • lambda表达式优于匿名类
    • 方法引用优于lambda表达式
    • 优先使用标准的函数式接口
    • 明智审慎地使用Stream
    • 优先考虑流中无副作用的函数
    • 优先使用Collection而不是Stream来作为方法的返回类型
    • 谨慎使用流并行
    • 检查参数有效性
    • 必要时进行防御性拷贝
    • 仔细设计方法签名
    • 明智审慎地使用重载
    • 明智审慎地使用可变参数
    • 返回空的数组或集合,不要返回 null
    • 明智审慎地返回 Optional
    • 为所有已公开的 API 元素编写文档注释
    • 最小化局部变量的作用域
    • for-each 循环优于传统 for 循环
    • 了解并使用库
    • 若需要精确答案就应避免使用 float 和 double 类型
    • 基本数据类型优于包装类
    • 当使用其他类型更合适时应避免使用字符串
    • 当心字符串连接引起的性能问题
    • 通过接口引用对象
    • 接口优于反射
    • 明智审慎地本地方法
    • 明智审慎地进行优化
    • 遵守被广泛认可的命名约定
    • 只针对异常的情况下才使用异常
    • 对可恢复的情况使用受检异常,对编程错误使用运行时异常
    • 避免不必要的使用受检异常
    • 优先使用标准的异常
    • 抛出与抽象对应的异常
    • 每个方法抛出的异常都需要创建文档
    • 在细节消息中包含失败一捕获信息
    • 保持失败原子性
    • 不要忽略异常
    • 同步访问共享的可变数据
    • 避免过度同步
    • executor 、task 和 stream 优先于线程
    • 并发工具优于 wait 和 notify
    • 文档应包含线程安全属性
    • 明智审慎的使用延迟初始化
    • 不要依赖线程调度器
    • 优先选择 Java 序列化的替代方案
    • 非常谨慎地实现 Serializable
    • 考虑使用自定义的序列化形式
    • 保护性的编写 readObject 方法
    • 对于实例控制,枚举类型优于 readResolve
    • 考虑用序列化代理代替序列化实例
  • On Java 8

  • 书
  • EffectiveJava
pursuewind
2020-11-22

组合优于继承

# 18. 组合优于继承

继承是实现代码重用的有效方式,但并不总是最好的工具。使用不当,会导致脆弱的软件。 在包中使用继承是安全的,其中子类和父类的实现都在同一个程序员的控制之下。对应专门为了继承而设计的,并且有文档说明的类来说(详见第 19 条),使用继承也是安全的。 然而,从普通的具体类跨越包级边界继承,是危险的。 提醒一下,本书使用「继承」一词,其含义是实现继承(当一个类扩展另一个类时)。 在本条目中讨论的问题不适用于接口继承(当类实现接口,或者当接口继承另一个接口时)。

与方法调用不同,继承打破了封装[Snyder86]。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。

为了具体说明,假设有一个使用 HashSet 的程序。 为了调整程序的性能,需要查询 HashSet ,从创建它之后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时数量也会下降)。 为了提供这个功能,编写了一个 HashSet 变体,它保留了尝试元素插入的数量,并导出了这个插入数量的一个访问方法。 HashSet 类包含两个添加元素的方法,分别是 add 和 addAll,所以我们重写这两个方法:

// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
    // The number of attempted element insertions
    private int addCount = 0;

    public InstrumentedHashSet() {
    }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
    public int getAddCount() {
        return addCount;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这个类看起来很合理,但是不能正常工作。 假设创建一个实例并使用 addAll 方法添加三个元素。 顺便提一句,请注意,下面代码使用在 Java 9 中添加的静态工厂方法 List.of 来创建一个列表;如果使用的是早期版本,请改为使用 Arrays.asList:

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
1
2

我们期望 getAddCount 方法返回的结果是 3,但实际上返回了 6。哪里出来问题?在 HashSet 内部,addAll 方法是基于它的 add 方法来实现的,即使 HashSet 文档中没有指名其实现细节,倒也是合理的。InstrumentedHashSet 中的 addAll 方法首先给 addCount 属性设置为 3,然后使用 super.addAll 方法调用了 HashSet 的 addAll 实现。然后反过来又调用在 InstrumentedHashSet 类中重写的 add 方法,每个元素调用一次。这三次调用又分别给 addCount 加 1,所以,一共增加了 6:通过 addAll 方法每个增加的元素都被计算了两次。

我们可以通过消除 addAll 方法的重写来“修复”子类。 尽管生成的类可以正常工作,但是它依赖于它的正确方法,因为 HashSet 的 addAll 方法是在其 add 方法之上实现的。 这个“自我使用(self-use)”是一个实现细节,并不保证在 Java 平台的所有实现中都可以适用,并且可以随发布版本而变化。 因此,产生的 InstrumentedHashSet 类是脆弱的。

稍微好一点的做法是,重写 addAll 方法遍历指定集合,为每个元素调用 add 方法一次。 不管 HashSet 的 addAll 方法是否在其 add 方法上实现,都会保证正确的结果,因为 HashSet 的 addAll 实现将不再被调用。然而,这种技术并不能解决所有的问题。 这相当于重新实现了父类方法,这样的方法可能不能确定是否是自用(self-use)的,实现起来也是困难的,耗时的,容易出错的,并且可能会降低性能。 此外,这种方式并不能总是奏效,因为子类无法访问一些私有属性,所以有些方法就无法实现。

导致子类脆弱的一个相关原因是,它们的父类在后续的发布版本中可以添加新的方法。假设一个程序的安全性依赖于这样一个事实:所有被插入到集中的元素都满足一个先决条件。可以通过对集合进行子类化,然后并重写所有添加元素的方法,以确保在添加每个元素之前满足这个先决条件,来确保这一问题。如果在后续的版本中,父类没有新增添加元素的方法,那么这样做没有问题。但是,一旦父类增加了这样的新方法,则很有可能由于调用了未被重写的新方法,将非法的元素添加到子类的实例中。这不是个纯粹的理论问题。在把 Hashtable 和 Vector 类加入到 Collections 框架中的时候,就修复了几个类似性质的安全漏洞。

这两个问题都源于重写方法。 如果仅仅添加新的方法并且不要重写现有的方法,可能会认为继承一个类是安全的。 虽然这种扩展更为安全,但这并非没有风险。 如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同签名和不同返回类型的方法,那么你的子类编译失败[JLS,8.4.8.3]。 如果已经为子类提供了一个与新的父类方法具有相同签名和返回类型的方法,那么你现在正在重写它,因此将遇到前面所述的问题。 此外,你的方法是否会履行新的父类方法的约定,这是值得怀疑的,因为在你编写子类方法时,这个约定还没有写出来。

幸运的是,有一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是 现有类的实例引用,这种设计被称为组合(composition),因为现有的类成为新类的组成部分。新类中的每个实例方法调用现有类的包含实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法被称为转发方法。由此产生的类将坚如磐石,不依赖于现有类的实现细节。即使将新的方法添加到现有的类中,也不会对新类产生影响。为了具体说用,下面代码使用组合和转发方法替代 InstrumentedHashSet 类。请注意,实现分为两部分,类本身和一个可重用的转发类,其中包含所有的转发方法,没有别的方法:

// Reusable forwarding class
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> s) {
        this.s = s;
    }

    public void clear() {
        s.clear();
    }

    public boolean contains(Object o) {
        return s.contains(o);
    }

    public boolean isEmpty() {
        return s.isEmpty();
    }

    public int size() {
        return s.size();
    }

    public Iterator<E> iterator() {
        return s.iterator();
    }

    public boolean add(E e) {
        return s.add(e);
    }

    public boolean remove(Object o) {
        return s.remove(o);
    }

    public boolean containsAll(Collection<?> c) {
        return s.containsAll(c);
    }

    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }

    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

    public boolean retainAll(Collection<?> c) {
        return s.retainAll(c);
    }

    public Object[] toArray() {
        return s.toArray();
    }

    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

    @Override
    public boolean equals(Object o) {
        return s.equals(o);
    }

    @Override
    public int hashCode() {
        return s.hashCode();
    }

    @Override
    public String toString() {
        return s.toString();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// Wrapper class - uses composition in place of inheritance
import java.util.Collection;
import java.util.Set;

public class InstrumentedSet<E> extends ForwardingSet<E> {

    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }
    
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

InstrumentedSet 类的设计是通过存在的 Set 接口来实现的,该接口包含 HashSet 类的功能特性。除了功能强大,这个设计是非常灵活的。InstrumentedSet 类实现了 Set 接口,并有一个构造方法,其参数也是 Set 类型的。本质上,这个类把 Set 转换为另一个类型 Set, 同时添加了计数的功能。与基于继承的方法不同,该方法仅适用于单个具体类,并且父类中每个需要支持构造方法,提供单独的构造方法,所以可以使用包装类来包装任何 Set 实现,并且可以与任何预先存在的构造方法结合使用:

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
1
2

InstrumentedSet 类甚至可以用于临时替换没有计数功能下使用的集合实例:

static void walk(Set<Dog> dogs) {
    InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... // Within this method use iDogs instead of dogs
}
1
2
3
4

InstrumentedSet 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。 这也被称为装饰器模式[Gamma95],因为 InstrumentedSet 类通过添加计数功能来“装饰”一个集合。 有时组合和转发的结合被不精确地地称为委托(delegation)。 从技术上讲,除非包装对象把自身传递给被包装对象,否则不是委托[Lieberman86; Gamma95]。

包装类的缺点很少。 一个警告是包装类不适合在回调框架(callback frameworks)中使用,其中对象将自我引用传递给其他对象以用于后续调用(「回调」)。 因为一个被包装的对象不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。 这被称为 SELF 问题[Lieberman86]。 有些人担心转发方法调用的性能影响,以及包装对象对内存占用。 两者在实践中都没有太大的影响。 编写转发方法有些繁琐,但是只需为每个接口编写一次可重用的转发类,并且提供转发类。 例如,Guava 为所有的 Collection 接口提供转发类[Guava]。

只有在子类真的是父类的子类型的情况下,继承才是合适的。 换句话说,只有在两个类之间存在「is-a」关系的情况下,B 类才能继承 A 类。 如果你试图让 B 类继承 A 类时,问自己这个问题:每个 B 都是 A 吗? 如果你不能明确的以“是的”来回答这个问题,那么 B 就不应该继承 A。如果答案是否定的,那么 B 通常包含一个 A 的私有实例,并且暴露一个不同的 API :A 不是 B 的重要部分 ,只是其实现细节。

在 Java 平台类库中有一些明显的违反这个原则的情况。 例如,stacks 实例并不是 vector 实例,所以 Stack 类不应该继承 Vector 类。 同样,一个属性列表不是一个哈希表,所以 Properties 不应该继承 Hashtable 类。 在这两种情况下,组合方式更可取。

如果在合适组合的地方使用继承,则会不必要地公开实现细节。由此产生的 API 将与原始实现联系在一起,永远限制类的性能。更严重的是,通过暴露其内部,客户端可以直接访问它们。至少,它可能导致混淆语义。例如,属性 p 指向 Properties 实例,那么 p.getProperty(key) 和 p.get(key) 就有可能返回不同的结果:前者考虑了默认的属性表,而后者是继承 Hashtable 的,它则没有考虑默认属性列表。最严重的是,客户端可以通过直接修改超父类来破坏子类的不变性。在 Properties 类,设计者希望只有字符串被允许作为键和值,但直接访问底层的 Hashtable 允许违反这个不变性。一旦违反,就不能再使用属性 API 的其他部分(load 和 store 方法)。在发现这个问题的时候,纠正这个问题为时已晚,因为客户端依赖于使用非字符串键和值了。

在决定使用继承来代替组合之前,你应该问自己最后一组问题。对于试图继承的类,它的 API 有没有缺陷呢? 如果有,你是否愿意将这些缺陷传播到你的类的 API 中?继承传播父类的 API 中的任何缺陷,而组合可以让你设计一个隐藏这些缺陷的新 API。

总之,继承是强大的,但它是有问题的,因为它违反封装。 只有在子类和父类之间存在真正的子类型关系时才适用。 即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承可能会导致脆弱性。 为了避免这种脆弱性,使用组合和转发代替继承,特别是如果存在一个合适的接口来实现包装类。 包装类不仅比子类更健壮,而且更强大。

Last Updated: 2023/01/30, 11:01:00
最小化可变性
要么设计继承并提供文档说明,要么禁用继承

← 最小化可变性 要么设计继承并提供文档说明,要么禁用继承→

Theme by Vdoing | Copyright © 2019-2023 pursue-wind | 粤ICP备2022093130号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
  • 飙升榜
  • 新歌榜
  • 云音乐民谣榜
  • 美国Billboard榜
  • UK排行榜周榜
  • 网络DJ