组合比继承更适合表达“has-a”关系,因其语义准确、封装性强、支持运行时替换与测试;应将复用功能抽为接口或类,通过构造注入实现松耦合;需防范空指针与循环依赖,Spring中优先使用构造函数注入。

为什么组合比继承更适合表达“has-a”关系
继承表达的是“is-a”语义(比如 Dog is-a Animal),而现实中大量需求其实是“has-a”——例如 Car has-a Engine,Order has-a PaymentProcessor。强行用继承会破坏封装,导致子类被迫暴露父类实现细节,还容易引发脆弱基类问题(修改父类行为意外影响所有子类)。
组合通过持有其他类的实例来复用行为,天然支持运行时替换、更易测试、更少耦合。关键不是“能不能用继承”,而是“语义是否匹配”。一旦发现子类只为了复用代码而非表达类型层级,就该立刻转向组合。
如何用组合替代继承并保持可扩展性
核心是把原继承链中被复用的部分抽成接口或具体类,再通过字段注入。重点不是删掉 extends,而是重构职责边界。
- 将原父类中可复用的功能提取为独立类(如把
FileLogger从BaseService中拆出) - 定义清晰接口(如
Logger),让组合对象依赖接口而非实现 - 通过构造函数或 setter 注入依赖,避免硬编码 new 实例
- 必要时用策略模式支持运行时切换(如不同
PaymentStrategy实现)
public class OrderService {
private final PaymentProcessor paymentProcessor;
private final Logger logger;
public OrderService(PaymentProcessor processor, Logger logger) {
this.paymentProcessor = processor;
this.logger = logger;
}
public void placeOrder(Order order) {
logger.info("Placing order: " + order.getId());
paymentProcessor.process(order.getPayment());
}
}
组合中常见的生命周期与空指针陷阱
组合对象的生命周期由宿主类管理,但 Java 不自动处理 null 安全。如果依赖未初始化或被设为 null,运行时抛 NullPointerException 是高频错误。
立即学习“Java免费学习笔记(深入)”;
- 构造函数强制传入非 null 依赖(配合
@NonNull注解或断言) - 避免在 setter 中允许 null 值,或明确文档化“null 表示禁用某功能”
- 使用
Optional包装可能缺失的可选组件(如Optional) - 注意循环依赖:A 组合 B,B 又组合 A → 启动失败或 StackOverflowError
Spring 环境下组合的典型实践方式
Spring 的依赖注入天然契合组合思想,但新手常误用 @Autowired 字段注入导致测试困难或隐藏依赖。
- 优先使用构造函数注入(final 字段 + 显式依赖声明)
- 避免
@Resource或@Autowired字段注入,它绕过构造逻辑且难 mock - 组合对象本身也应是 Spring Bean(加
@Service/@Component),便于统一管理作用域和代理 - 若需动态获取不同实现(如按地区选汇率服务),用
ObjectProvider替代直接注入
组合不是“不用继承”,而是把继承留给真正需要多态类型系统的场景;其余复用逻辑,交给字段+接口+DI 来承担。最容易被忽略的一点:组合后,每个类的单一职责是否更清晰了?如果一个类同时持有 5 个不同语义的组件,那很可能它自己已经违反了 SRP,该继续拆分。










