重构实战:如何识别并修复‘被拒绝的遗赠’代码异味

张开发
2026/4/11 17:53:38 15 分钟阅读

分享文章

重构实战:如何识别并修复‘被拒绝的遗赠’代码异味
1. 什么是被拒绝的遗赠代码异味我第一次在项目中遇到被拒绝的遗赠Refused Bequest这个问题时完全没意识到这是个设计缺陷。当时只觉得代码能用功能正常直到后来维护时才发现问题所在。简单来说当子类继承了父类的方法或属性但却不使用它们或者需要大量修改父类行为时就出现了这种代码异味。想象一下你继承了一笔遗产但发现大部分东西都用不上还得花钱维护它们。这就是子类的处境——被迫接受不需要的遗赠。在票务系统的例子中VIP票继承了普通票的退款方法但实际上VIP票并不需要退款功能。这种设计不仅让代码变得臃肿还可能导致意料之外的行为。这种异味最直接的危害是违反了里氏替换原则LSP。按照这个原则子类应该能够完全替代父类而不影响程序正确性。但在我们的案例中VIP票虽然继承了Ticket却不能完全替代它——因为VIP票根本不需要退款功能。这就埋下了隐患如果有人用父类引用操作子类对象调用refund()方法时虽然代码能运行但业务逻辑上是不合理的。2. 如何识别被拒绝的遗赠识别这种代码异味有几个明显的信号。首先是子类重写父类方法时方法体直接抛出UnsupportedOperationException这几乎就是明晃晃地在说我不想要这个继承。其次是子类虽然调用了父类方法但只是为了获取部分计算结果然后完全改变业务逻辑——就像VIP票的getPrice()方法那样。在实际项目中我常用这几个检查点子类是否有很多父类方法的空实现子类是否需要修改父类方法的返回值类型或异常声明子类是否只使用了父类的部分功能是否经常需要向下转型来使用子类特有的方法以票务系统为例VIPTicket类暴露了所有这些问题它覆写了isSession()简化逻辑修改了getPrice()的计算方式完全用不到refund()方法还新增了父类没有的hasExtensionalActivities()方法。这种挑三拣四的继承关系就是典型的被拒绝的遗赠。3. 重构策略一调整继承关系第一种重构方法是为这两个类找一个更合适的父类。在票务系统中我们可以创建一个BasicTicket抽象类包含两者真正共享的属性和方法然后让Ticket和VIPTicket都继承它。具体操作步骤新建BasicTicket类包含activity字段和基础的getPrice()逻辑将Ticket中原有的getPrice()移到BasicTicket让Ticket继承BasicTicket保留自己特有的refund()方法让VIPTicket也继承BasicTicket实现自己的价格计算和附加活动检查public abstract class BasicTicket { protected final Activity activity; public BasicTicket(Activity activity) { this.activity activity; } public abstract int getPrice(); public boolean isSession() { return Activity.ActivityType.SESSION.equals(activity.getType()); } }这种重构的优点是保持了继承的清晰性每个类只包含自己真正需要的方法。我在一个电商平台的优惠券系统重构中就用了这种方法把各种优惠券的共同逻辑提取到基类使代码量减少了30%。4. 重构策略二用组合替代继承第二种更彻底的重构是用组合代替继承。这是《设计模式》中经常提倡的做法能更灵活地应对变化。具体到票务系统我们可以定义Ticket接口然后让普通票和VIP票分别实现这个接口通过组合Activity对象来共享数据。实现步骤创建Ticket接口定义isSession()和getPrice()方法让OriginalTicket和VIPTicket都实现这个接口在实现类内部组合Activity实例把各自特有的方法直接实现在对应类中public interface Ticket { boolean isSession(); int getPrice(); } public class OriginalTicket implements Ticket { private final Activity activity; public OriginalTicket(Activity activity) { this.activity activity; } Override public int getPrice() { // 原有逻辑 } public int refund() { return getPrice(); } }这种方式的优势在于完全解耦了票类型之间的关系。后来当我们需要增加一种团体票时只需新建一个GroupTicket类实现Ticket接口完全不用修改现有代码。我在一个物流系统中用这种方案处理不同类型的运输单系统扩展性得到了明显提升。5. 重构策略三代理模式第三种重构方案是使用代理模式特别适合子类需要扩展父类功能的情况。在票务系统中我们可以创建一个TicketProxy类内部持有一个Ticket实例然后VIP票可以继承这个代理类。具体实现创建TicketProxy类实现与Ticket相同的接口在代理类中持有Ticket实例并委托基本调用让VIPTicket继承TicketProxy添加特有功能public class TicketProxy implements Ticket { protected final Ticket ticket; public TicketProxy(Ticket ticket) { this.ticket ticket; } Override public boolean isSession() { return ticket.isSession(); } Override public int getPrice() { return ticket.getPrice(); } } public class VIPTicket extends TicketProxy { public VIPTicket(Ticket ticket) { super(ticket); } Override public int getPrice() { return super.getPrice() 100; } public boolean hasExtensionalActivities() { // VIP特有逻辑 } }这种方案在需要层层增强功能的场景下特别有用。我在一个游戏装备系统中就用类似设计处理装备的镶嵌和强化每个装饰器只关心自己的功能增强使系统非常容易维护。6. 何时应该重构被拒绝的遗赠不是所有被拒绝的遗赠都需要立即重构。在我参与过的一个历史悠久的金融系统中就存在大量这样的代码但考虑到修改风险和收益我们决定暂时保留。判断是否重构要考虑几个因素首先是修改的影响范围。如果这个继承关系被大量代码依赖重构可能引发连锁反应。其次是业务变化的频率。如果相关业务很稳定重构的紧迫性就不高。最后是测试覆盖率足够的测试能降低重构风险。我的经验法则是当发现自己在子类中频繁地抵制父类行为或者添加很多与父类无关的方法时就该考虑重构了。但如果是临时性的特殊处理或者修改成本过高也可以选择暂时容忍做好文档标注。7. 继承与组合的权衡选择在实际项目中我经常面临继承和组合的选择。继承的优势在于代码复用直观IDE支持好但缺点是高耦合。组合更灵活但需要写更多样板代码。我的选择标准是如果是is-a关系且子类确实是父类的特殊化用继承如果需要共享实现但关系不是严格的is-a用组合如果未来可能有多种变化维度优先选择组合如果性能是关键因素继承通常更快在票务系统的案例中由于普通票和VIP票虽然都是票但行为差异较大组合可能是更好的选择。但如果是不同类型的VIP票如金卡VIP、银卡VIP继承可能更合适。8. 实际项目中的重构经验在真正动手重构前有几个实用建议确保有完善的测试覆盖这是安全重构的前提使用IDE的重构工具它们能自动处理很多引用更新小步前进每次提交只做一个小的重构记录下每次重构前后的类图方便回溯我曾经在一个订单系统重构时吃过亏因为没有充分测试就大规模修改继承关系导致线上问题。后来我养成了先写测试再重构的习惯特别是对于复杂的继承体系会专门为父类行为编写测试用例。另一个教训是关于文档的。重构后一定要及时更新设计文档特别是类关系的变更。有次我重构了一个核心类但忘了更新文档导致后续团队花了大量时间理解新的设计。

更多文章