子类和父类的关系开始很简单,但是随着时间的推移有可能会变的越来越复杂。一个子类通常需要紧密的依赖其父类,但是有时会矫枉过正。
这就是继承的两面性,下面我们看看继承可能代码的Code Smell。
01 场景复现
需求描述
这是关于活动(Activity)和票(Ticket)的业务需求:
活动的主题(ActityType): session | workshop | read | TDD
活动(Activity)包含属性:日期、主题、基础价格
票有两种:普通票(Ticket)、VIP票(VIPTicket)
普通票(Ticket)的业务描述:
(1)是否有Session活动:如果主题是session且活动日期是工作日则返回true,否则返回false。
(2)获得票价:如果是如果周一到周四票价=原价,如果周五返回则票价=原价x2
(3)退款:活动开始前可以进行退款。
VIP票(VIPTicket)的业务需求:
(1)是否有Session活动:如果主题是session则返回true,否则返回false。
(2)获得票价:票价 = 如果是如果周一到周四票价=原价+100,如果周五返回则票价=原价x2+100
(3)是否有附加活动:如果活动主题为TDD或者制定了附加活动则返回true,否则返回false。
基于上面的业务需求,下面是一段具有“被拒绝的遗赠”Smell的代码,如下:
Activity.java
@Getter
public class Activity {
private final ActivityType type;
private final LocalDate date;
private final int price;
public Activity(ActivityType type, LocalDate date, int price) {
this.type = type;
this.date = date;
this.price = price;
}
public enum ActivityType {WORKSHOP, TDD, SESSION}
}
Ticket.java
package com.page.refactoring;
import java.time.DayOfWeek;
public class Ticket {
private final Activity activity;
public Ticket(Activity activity) {
this.activity = activity;
}
public boolean isSession() {
return Activity.ActivityType.SESSION.equals(activity.getType()) && isWorkday();
}
private boolean isWorkday() {
return !activity.getDate().getDayOfWeek().equals(DayOfWeek.SATURDAY)
&& !activity.getDate().getDayOfWeek().equals(DayOfWeek.SUNDAY);
}
public int getPrice() {
return DayOfWeek.FRIDAY.equals(activity.getDate().getDayOfWeek())
? activity.getPrice() * 2
: activity.getPrice();
}
public int refund() {
return getPrice();
}
}
VIPTicket.java
public class VIPTicket extends Ticket {
private final boolean supportExtensionalActivities;
public VIPTicket(Activity activity, boolean supportExtensionalActivities) {
super(activity);
this.supportExtensionalActivities = supportExtensionalActivities;
}
public boolean isSession() {
return Activity.ActivityType.SESSION.equals(activity.getType());
}
public int getPrice() {
return super.getPrice() + 100;
}
public boolean hasExtensionalActivities() {
return Activity.ActivityType.TDD.equals(activity.getType()) || supportExtensionalActivities;
}
}
“被拒绝的遗赠”Code Smell代码地址:
https://gitlab.com/tengbai/refactoring/tree/21-refused-bequest
02 上面代码中的问题
上面的代码中Ticket和VIPTicket使用了继承。首先继承是一种有价值的机制,将公共的数据和行为放置在父类中,每个子类根据需要覆写部分特性。大部分时候能达到期望的效果,不会带来问题。但是上面的代码在使用继承时存在如下几个问题
VIPTicket继承了Ticket,虽然VIPTicket复用了Ticket的属性和部分方法,但是却使代码出现了下面的问题:
getPrice()方法不但覆写父类的方法并且并且还还调用了父类的getPrice()方法。虽然当前的结果复用的getPrice()方法没有什么问题,但是当当Ticket类上getPrice()的内部逻辑变化时会影响到VIPTicket子类。
VIPTicket提供了hasExtensionalActivities()方法,但是父类并没有该方法
Ticket提供了refund()退款功能,而VIPTicket业务中并不需要该功能,但是由于VIPTicket继承了Ticket,所以也拥有了refund()方法。这使得代码并没有按照本意来揭示业务意图。
很显然违反了LSP(里氏替换原则)。在我们经常使用的SOLID的原则中,LSP(里氏替换原则):子类必须能够替换掉他们的父类。即父类出现的地方就可以使用子类来代替,而且不会出现任何错误或者异常。
除了上面代码,继承还经常出现的问题有:
- 一个子类继承了父类但是子类中的某个方法抛出了异常,而父类中该方法并没有抛出异常。
- 一个子类继承了父类,但是子类修改了某个方法的内部行为。
- 调用者只能通过子类而不能通过父类来访问类。
- 无意义的继承,子类并不是父类的一个实例。
03 对“被拒绝的遗赠”可采取的措施和收益
首先重构上面这段代码的目的是:1,代码能够揭示业务意图;2,改善可测试性(同样的方法无需担心上下文的不同)。
1. 重新整理继承关系。
如下图,创建一个父类BasicTicket,它提供了公共的属性和方法,Ticket和VIPTicket成为兄弟子类,他们提供各自需要的方法。
重构后的代码: https://gitlab.com/tengbai/refactoring/tree/21-refused-bequest-rebuild-mapping
2. 组合优于继承
在很多次的讨论中,都会提到使用接口组合来代替继承。下面的图显示使用接口组合来解决上面的遇到的“被拒绝的遗赠”的问题。
重构后的代码: https://gitlab.com/tengbai/refactoring/tree/21-refused-bequest-refactoring-with-interface
3. 使用代理取代继承
将不同的变化原因委托给不同的类。委托是类之间的常规关系,使用委托接口更加清晰,耦合度更低。
上面的例子中使用委托来代替继承是最简单的一个修改方式。如下图:
重构后的代码: https://gitlab.com/tengbai/refactoring/tree/21-refused-bequest-refactoring-with-delegation
04 “被拒绝的遗赠”碰到就需要重构吗?
并不是。是否重构掉“被拒绝的遗赠”的代码取决于受益的多少。
1,有的时候后“被拒绝的遗赠”并不会创建一些新的类型,而这些类型有时并不是业务中描述的,而是纯粹技术上的实现。例如上面的使用接口组合代替继承。直白的表达意图要比高度抽象的表达代码容易理解。
2,如果重构掉“被决绝的遗赠”问题会带啦大量的重复类,那么想象新的重构手法。
3,在阅读源码的时候,有时候也会发现源码中有“被拒绝的遗赠”Smell的代码,作者之所以保留,很可能是因为重构掉它会带来大量的修改,投入产出并不高。在《重构》中作者也会经常使用继承,大部分时间都能达到期望的效果,如果稍后修改,就会重构掉这种继承关系。时刻保持重构,保持代码的Simple Design。
05 继承有可能造成的问题
1,子类只能继承一个父类。导致行为的原因可能用多种,但是继承只能处理一个方向上的变化。
2,继承给类之间引入了非常紧密的关系。在父类上做任何修改,都有可能会影响子类的行为。所以在处理有积继承关系的代码的时候,要充分理解父类和子类的关系。
拒收的遗赠就是继承是容易出现的Code Smell。关于继承经常出现的Smell包括:
- 被拒绝的遗赠
- 不当的紧密性
- 慵懒类
本文将专注在被拒绝的遗赠问题上,对于不当的紧密性和慵懒类将在后续的文章中介绍清楚。
文章并没有按照《重构》中Smell的顺序整理,直接上来就是“Refused-Bequest”。后面会陆续整理一些其他Smell的代码和内容。
参考
01《重构》第一版
02《重构》第二版
03《重构手册》