第18条:复合优先于继承
在包的内部使用继承是安全的,因为属于同一个包下的类,通常都由同一批人控制,设计和实现上都比较统一。但是,跨越包边界(即继承普通的具体类)使用继承是危险的。复合(Composition) 提供了一种更安全、更灵活的方式来重用现有类的功能,避免了实现继承的固有缺陷。
日常开发中,一般,我们在实体类属性拓展以及子类真正是父类的子类型,复合is-a关系时使用继承,在功能方法拓展时使用复合。
为什么不推荐实现继承?
- 破坏封装性
子类的正确性依赖于父类的实现细节。父类的实现可能在版本之间发生变化,即使父类的规范没有改变,也可能导致子类崩溃。
如果你想创建一个记录插入元素次数的InstrumentedHashSet,如果通过继承实现,你需要重写add和addAll方法。但HashSet的addAll方法内部调用了add方法,导致你的计数器被重复计算。你不得不依赖并了解父类的这种实现细节,这非常脆弱,破坏了封装性。
错误方式:
import java.util.*;
// 通过继承HashSet实现 - 这种方式有问题!
public class InstrumentedHashSet<E> extends HashSet<E> {
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;
}
}
public class InheritanceProblemDemo {
public static void main(String[] args) {
InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
set.addAll(Arrays.asList("A", "B", "C"));
System.out.println("期望计数: 3");
System.out.println("实际计数: " + set.getAddCount()); // 输出: 6 而不是 3!
}
}
正确方式(使用复合):
import java.util.*;
import java.util.Collection;
// 使用复合而不是继承
public class InstrumentedSet<E> implements Set<E> {
private final Set<E> set; // 复合:持有Set实例的引用
private int addCount = 0;
// 构造函数:包装一个现有的Set
public InstrumentedSet(Set<E> set) {
this.set = set;
}
// 我们只增强需要的方法
@Override
public boolean add(E e) {
addCount++;
return set.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}
// 以下都是转发方法 - 委托给被包装的set,已省略
.......
}
- 破坏子类
如果父类后续版本添加不符合子类预期的新方法或者添加子类已有方法,就会对子类产生破坏。
- “is-a”关系并不总是成立
继承应该只用于真正的“是一个(is-a)”关系。如果你只是想复用另一个类的代码,而不是在概念上成为其一个子类型,那么继承就是错误的工具。
什么是复合?如何操作?
指在新类中创建一个现有类的私有实例(即“有一个”关系),然后通过这个私有实例来调用其方法,从而转发请求。新类对外暴露的API可以只包含所需的方法。
其实就是包装模型。
- 不扩展原有类,而是让新类实现与原有类相同的接口。
- 在新类中定义一个私有字段,引用原有类的一个实例。这个实例被称为“后备实例”(backing instance)。
- 在新类中实现每个方法,将调用“转发”给后备实例的对应方法。这些方法被称为“转发方法”。
- 在新类中添加你需要的任何额外功能。
复合的缺点
- 当被包装对象需要将自我引用(this)传递给外部系统时,包装器会"丢失身份",外部系统只能看到被包装的原始对象,而看不到包装器的增强功能。
被包装类:
// 第三方库中的类 - 我们无法修改其源码
public class Button {
private OnClickListener listener;
public void setOnClickListener(OnClickListener listener) {
this.listener = listener;
}
// 当按钮被点击时,会调用这个方法
public void click() {
if (listener != null) {
// 关键点:这里会将 this(按钮自己)传递给回调方法
listener.onClick(this);
}
}
public interface OnClickListener {
void onClick(Button button);
}
// 按钮的其他方法
public String getName() {
return "Original Button";
}
}
包装类:
我们想创建一个 LoggingButton,在点击时记录日志:
// 错误的尝试:使用包装器模式
public class LoggingButton implements Button.OnClickListener {
private final Button button; // 复合:持有被包装的按钮
public LoggingButton(Button button) {
this.button = button;
// 问题所在:我们将自己(LoggingButton)注册为监听器
this.button.setOnClickListener(this);
}
// 转发方法
public void setOnClickListener(Button.OnClickListener listener) {
button.setOnClickListener(listener);
}
public void click() {
button.click();
}
public String getName() {
return "Logging: " + button.getName();
}
// 实现点击监听接口
@Override
public void onClick(Button button) {
// 这里我们期望记录日志
System.out.println("Button was clicked: " + button.getName());
// 问题:参数 button 是被包装的原始按钮,不是我们的 LoggingButton!
// 我们无法访问 LoggingButton 的增强功能
}
}
在 LoggingButton 构造函数中,我们将 this(即 LoggingButton 实例)注册为监听器,但当原始按钮被点击时,它调用 listener.onClick(this),这里的 this 是原始 Button 对象,所以在回调中,我们只能访问到原始按钮,无法访问包装器。
- 性能开销:方法转发会带来轻微的性能损失,但在绝大多数场景下可以忽略不计。
- 代码编写稍显繁琐:需要编写大量的转发方法。不过,在IDE的代码生成功能帮助下,这通常不是大问题。
总结
在决定使用继承而不是复合之前,问自己最后一个问题:对于你正试图扩展的类,它的API是否有缺陷?如果有,你是否愿意将这些缺陷传播到你的类的API中?继承会传播任何父类的缺陷,而复合允许你设计一个新的、隐藏了这些缺陷的API。除非子类真正是父类的子类型,否则复合优先于继承。
942

被折叠的 条评论
为什么被折叠?



