1. 继承与组合的本质区别
在面向对象编程中,继承(Inheritance)和组合(Composition)是两种最基础的代码复用机制。理解它们的本质区别是掌握面向对象设计的关键。
继承建立的是"is-a"关系,即子类是父类的一种特例。例如,Dog继承自Animal,表示"狗是一种动物"。这种关系具有以下特点:
- 子类自动获得父类的属性和方法
- 子类可以扩展或重写父类的功能
- 父类的修改会直接影响所有子类
组合建立的是"has-a"关系,即一个类包含另一个类的实例作为其组成部分。例如,Car包含Engine,表示"汽车有一个发动机"。这种关系的特点是:
- 通过持有其他对象的引用来实现功能复用
- 对象之间的关系更加松散
- 内部对象的实现细节对外部不可见
关键区别:继承是编译时静态绑定的垂直关系,组合是运行时动态绑定的水平关系。
2. 继承的缺陷与陷阱
虽然继承看似直观,但在实际开发中却存在诸多问题,这也是"组合优于继承"原则提出的根本原因。
2.1 脆弱的基类问题
基类的修改会不可预测地影响派生类。例如,当基类的方法实现改变时,即使派生类没有做任何修改,其行为也可能发生变化。这种隐式的依赖关系会导致:
- 难以维护的代码:随着继承层次加深,修改影响范围难以评估
- 意外的行为变化:父类方法的内部实现细节会影响子类
- 测试困难:需要测试整个继承链而不仅是当前类
2.2 方法重写的陷阱
重写方法时容易违反里氏替换原则(LSP)。典型问题包括:
- 无意中改变了父类方法的契约
- 对父类方法实现细节的隐性依赖
- 重写方法时破坏了父类的不变量
// 典型的问题示例 class CountingList extends ArrayList { private int count = 0; @Override public boolean add(Object o) { count++; return super.add(o); } @Override public boolean addAll(Collection c) { count += c.size(); return super.addAll(c); } }这个实现看起来合理,但实际上会因为ArrayList.addAll()内部调用add()而导致重复计数。
3. 组合的优势与实践
组合通过委托(delegation)实现代码复用,提供了更灵活的设计方式。
3.1 转发类模式
转发类(Forwarding class)是实现组合的典型方式:
public class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } // 所有方法都转发给内部Set实例 public int size() { return s.size(); } public boolean isEmpty() { return s.isEmpty(); } // ...其他Set方法 }3.2 包装类模式
包装类(Wrapper class)在转发类基础上添加新功能:
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; } }这种设计解决了继承方式下的计数问题,因为:
- 不依赖具体Set实现的内部逻辑
- 可以安全地与其他Set实现配合使用
- 修改Set实现不会影响计数功能
4. 何时使用继承
虽然组合通常更优,但在以下情况继承仍是合适的选择:
- 真正的子类型关系:当子类确实是父类的特殊类型时
- 框架设计:需要定义扩展点的抽象基类
- 接口与实现分离:通过抽象类提供部分实现
使用继承的准则:
- 确保符合里氏替换原则
- 避免重写非抽象方法
- 将可继承的类设计为专门用于继承
5. 设计模式中的组合应用
许多经典设计模式都体现了组合思想:
- 策略模式:通过组合不同的算法实现
- 装饰器模式:动态添加功能
- 适配器模式:接口转换
- 桥接模式:抽象与实现分离
例如,Java I/O库就是组合模式的典型应用:
// 组合多个流处理器 InputStream in = new BufferedInputStream( new FileInputStream("file.txt"));6. 实际项目中的选择建议
在工程实践中,建议采用以下决策流程:
- 首先考虑组合是否可行
- 检查是否真正存在"is-a"关系
- 评估未来可能的扩展需求
- 考虑团队成员的理解成本
经验法则:当你犹豫该用继承还是组合时,选择组合通常更安全。
常见误区避免:
- 不要为了代码复用而滥用继承
- 避免过深的继承层次(建议不超过3层)
- 警惕对框架基类的盲目继承
7. 现代语言的发展趋势
新语言和框架越来越倾向于组合:
- Go语言:通过接口和嵌入实现组合
- Kotlin:委托作为语言特性
- React/Vue:组件化开发思想
- 微服务架构:服务组合而非继承
这种转变反映了软件工程对松耦合、高内聚的持续追求。