识别代码中的坏味道(二)
在上一篇文章中,介绍了通过名字就能理解的 8 个坏味道,感兴趣可以查看识别代码中的坏味道(一)。本篇文章将识别代码中的另外 10 个代码坏味道:10个晦涩但是通过简单的即可识别的坏味道。
如上图,这 10 个代码坏味道是:
- 发散式变化
- 霰弹式修改
- 依恋情结
- 数据泥球
- 基本类型偏执
- 平行继承体系
- 冗赘类
- 过度耦合信息链
- 异曲同工的类
- 纯数据类
01 发散式变化
简而言之就是一个类总是因为不同类型的原因发生变化。例如:需要修改数据源时要修改该类,需要修改缓存时还需要修改这个类,甚至当修改某个策略的计算公式时还会牵连到这个类。这种总是/经常因为不同类型原因导致一个类发生变化的代码就是指的发散式变化。
为什么发散式变化是代码坏味道?
由于总是不同的原因导致一个类发生变化,意味着一个类中存在多种类型的行为(例如即操作订单,又操作合同,还操作零件信息等),大而全的类会导致下面两方面的问题:
- 降低了代码可读性,存在不同上下问题的切换;
- 很可能导致无法快速响应变化。大而复杂类,在修改和维护的时候,并不容易做出决策,同时单个原因的修改很可能导致一个原因修改导致和非相关的业务代码发生变动。
- 随着代码的增加,代码的复杂性肯定是增加的,而发散式变化如果不被关注,很容易导致后续代码修改时类变成难以修改的大泥球。
发散式变化很容易导致另外一个坏味道出现,就是“过大的类”。
如何解决发散式变化这种坏味道?
单一职责原则可以用来解决发散式变化、过大的类的坏味道的指导原则:一个类只有一个引起其变化的原因。既然由于一个类存在过类行为,可以通过 Extract Class 来将不同的方法提炼到不同职责的类中。
发散式变化虽然很简单,但是却是很容易遇到的一种坏味道。因为刚开始添加的代码的很可能体会不到一个存在多类行为的坏处。只有当类发生变化或者修改的时候才会逐渐这种大而全的实现的缺点。
02 霰弹式修改
当一个类进行了修改会导致很多其他类也需要相应进行修改,我们称为“霰弹式修改”。
为什么霰弹式修改是一种坏味道?
- 当出现霰弹式修改的时候,容易造成修改上的遗漏,因此需要多次编译、运行测试、测试功能才有可能完全修改,虽然有的问题编译的时候就可以发现已经很快了,但是反复的编译本来也是不断花费时间的,久而久之也是一种重复低效的。
- 不难发现一个类的变化导致其他类相应的变化,这是一种强耦合的表现。
如何解决霰弹式修改这种坏味道?
既然霰弹式修改是一种耦合性的表现,我们可以将相关的代码通过 Move Field (移动属性)和 Move Method (移动方法)两种重构手段将代码移动到一个类中。这样做的好处是让变化的内容聚集到了,有助于简化后续的修改。
如果因为上面的操作类中添加了某些方法导致一个类有了多个职责,那么可以在进一步通过 Extract Method(提炼函数)来拆分职责。
也可以创建代理类或者方法重载来来解决特定的霰弹式修改导致的问题。
03 平行继承体系(Parallel Inheritance Hierarchies)
平行继承体系指:当一个类增加 1 个子类的时候,另外一个类也需要增加*增加一个子类。
例如:
当添加 XXXVIPTaskService 的时候就会需要新增出新的 XXXVIPScoreService 。
为什么平行继承体系是一种代码坏味道?
-
显而易见虽然没有直接关联,但是两者是同时产生并存的,但是两者的关联性并不显性的呈现,而是在 GradeService 中才体现出来。
-
这样的实现容易导致在 GradeService 中 Switch 语句的产生,switch 语句本身就是一种重复的体现。关于Switch 语句的问题可以参考:识别代码中的坏味道(一)
如何解决平行继承体系这种代码坏味道?
围绕上面说的原因可以做出如下两步重构:
- 建立直接引用。即 SVIPTaskService 直接引用 SVIPScoreService。
- 参考《Java有限状态机的4种实现对比》 消除继承体系,这里过程可以使用Move Field 和 Move Method 等重构手法。
通过上面的重构,隐形的关联变成直接引用。另外避免了 Switch 语句的问题。
04 依恋情结
刚开始接触代码中的坏味道时,乍一看你可能会觉得有些费解。其实它描述的问题却是很简单的,就是:一个类多次调用另外一个类的方法来获取最终的结果。如下
public class OrderService {
public List<Order> findAllOrders() {
...
}
public Order findLatestOrder(List<Order> orders) {
...
}
public Order addProduct(Order order, Product product) {
...
}
}
public class CartService {
...
public void addProduct(Product product) {
...
List<Orders> orders = orderService.findAllOrders();
Order order = orderService.findLatestOrder(orders);
order = orderService.addProduct(product);
...
}
}
再是不用考虑上面这段的代码业务上的合理性。代码中 CartService 中多次调用 OrderService 的方法,其目的就是执行最后的 addProduct() 方法,这就是一种依恋情结的代码。
为什么依恋情结是代码坏味道?
- 仔细观察 CartService.addProduct() 方法不难发现那三行的代码的意图就是将 product 添加到最新的 order 中,如何实现将 product 添加到 product 这个目的,上面带代码显然展示了一种策略的具体实现。显然这种实现使得方法的职责不再单一。
- 另外一个问题是,当 OrderService中的 findAllOrders()、findLatestOrder()、addProduct() 方法因为需求发生变动的时候,都有可能会牵连到 CartService 中的代码发生变化。因此上面中代码通过强耦合性虽然实现了功能,但是应对变化的能力也随之降低。代码是不断演进的,忽略了这种坏味道,会导致后续变化付出相应的代价。
如何解决依恋情结这种代码坏味道?
如果你看过上一篇内容或者看过上面前两个坏味道,那么应该也有一些思路了,如果一类在一个方法中多次依赖另外一个类,我们可以立即为有可能是职责没有划分划分明确的原因,可以通过一下手段进行重构:
- 将多次产生调用的几行代码使用 Extract Method(提炼函数)提炼为一个新的函数,并通过名称来解释这几个行代码所要表达的意思。
- 接下来可以使用 Move Method (搬移函数)将刚刚提炼的函数放置到一个更合适的类中,可以是刚刚被调用的类中,也可以创建新的类。
通过上面简单两步,我们可以将后续变化影响的范围变小,OrderService 内的变化将不再容易牵连到 CartService。
05 数据泥球
数据泥球指的是:多个类/方法参数中都有相同的属性,且这些相同的属性的业务意义也是相同的。
为什么数据泥球是代码坏味道?
很显然这是一种重复的表现。数据泥球容易造成如下问题:
- 涉及到属性的调整,容易造成遗漏,需要多次调整。
- 降低阅读代码的效率,因为每次都需要从类中识别出有几个属性是相关的在表达一个意思。
- 随着代码的增加容易导致多大的类、长函数等多种坏味道。
如何解决数据泥球这种代码坏味道?
- 如果类中的字段出现了数据泥球,对于这些重复的字段可以使用 Extract Class( 提炼类) 将关联几个属性提炼到一个类中,赋予它一个业务的概念。
- 如果是多个方法参数中出现了多个重复的多个参数,可以通过 Introduce Parameter Object(引入参数对象)将多个参数使用对象来代替,从而有效的减少重复和参数个数。
- 其中 2 的另外一种情况,如何调用者先通过一些逻辑生成几个变量,再将这几个变量通过参数传递给调用的方法,那么可以使用 Presere Whole Object(保持对象完整),将变量生成提炼到一个函数中,并并取消参数的传递,而是在被调用的方法中直接调用原本要传递的参数。
06 基本类型偏执
描述的是这样一种代码实现方式:经常使用基本数据类型,而不愿意使用对象将这些基本数据类型和其行为进行封装。
为什么基本类型偏执是代码坏味道?
首先基本类型有其作用。问题出现在不做场景区分场景,所有场景都是用基本数据类型去搭建业务逻辑。
问题往往出现在这种场景:
几个基本数据类型共同表达意思概念,但是实现方式却是像搭积木一样,将逻辑一步步的拼接搭建起来,最终得到期望的结果。
这种实现的方式的问题就在于日后阅读代码的时候每次阅读都需要从头到位梳理一遍,才能清楚的其表达的意思,时间消耗有的是几秒钟,有的是几分钟,但是堆积读几次将会累积消耗更多的阅读时间。问题就出现在不够直白的揭示意图。
使用几个基本数据类型表示不同的类型,即所谓的 Type Code。
这种代码也是存在可读性的问题,而且非常容易导致 switch 语句的坏味道。
因此,并不是不能使用基本数据类型,而是应该在揭示某个业务意图的时候适当的使用封装,将多个基本数据类型封装到一个类中。从而通过对象直白的表达意图。
如何解决基本类型偏执这种代码坏味道?
- 通过 Extract Method (提炼函数)将几个基本数据类型拼接的逻辑提炼为一个方法,比通过方法名来解释意图。
- 如果按照1做了,发现类中出现不应该出现的职责,那么就可以将几个相关的基本数据类型通过 Extract Class(提炼类)将几个基本数据类型提炼为一个类来表达一个概念,然后通过 Move Method 来讲相关的操作挪动到该类中。
- 如果使用基本数据类型来表示状态,可以选择使用 Replace Type Code with Class(以类取代类型码),并将相关的操作移动到类中,避免 Switch 语句。场景可以参考《Java有限状态机的4种实现对比》
07 冗赘类(Lacy Class)
这是单一职责的一个极端表现,即拆分了很多类,每个类的职责过度单一。
为什么冗赘类是一种代码坏味道?
因为每个类都是有阅读成本低的,职责拆分的过细,意味着多个关联性强的职责也被拆分了,因此阅读代码来成本不一定提升,反而因为过分的分散而导致理解起来需要会非常费劲。
如何解决冗赘类这种代码坏味道?
这个坏味道也给开发者一个提醒,极端的追求某些原则同样会导致不必要的麻烦,因此需要通过不断的练习和思考来获取平衡的这种点。
代码中一旦遇到职责过度拆分的情况就可以通过 Inline Class 或者 Collapse Hierarchy 来删除一些类,将概念合并到一个类中。
当代码更多的是处理业务逻辑的时候,那么其中的类应该像领域语言靠近,尽量避免凭空制造一些概念,拆分职责的时候和业务相结合更有利于我们将代码写的简单易读。
08 过度耦合的消息链
这种代码味道值得是不断从获取到的对象的子对象,导致很长的调用链。
例如
public class User {
...
private Address address;
...
}
public class Address {
...
private City city;
...
}
public class City {
...
private PostCode postCode;
...
}
public class PostCode {
...
private String code;
...
}
多度耦合的消息链代码如下
String postCode = user.getAddress()
.getCity()
.getPostCode()
.getCode();
为什么过度耦合的消息链是一种代码坏味道?
- 上面的实现虽然能够正常运行,但是会导致类之间的耦合,即 User 类的调用者需要在自己的内部来获得没有直接练习的 postCode 的实现;
- 降低了可读性。将整个消息链读完之后才能知道得到了什么,而这个过程的很多很多消息链中的信息是我们并不需要知道的。
如何解决过度耦合的消息链这种代码坏味道?
可以通过 Extract Method 来提炼函数,然后 通过 Move Method 来将提炼的方法移动到合适的位置。
如果读过《重构》还会提到 Hide Delegate(隐藏代理关系)的重构手法。不过不推荐使用,因为它引入多个 Middle Man 这种实现,当消息链过长的时候,这是一个有工作量且重复的工作,另外增加了很多很多耦合性的方法。
因此可以有限照顾可读性,通过 Extract Method 和 Move Method 来进行重构,从而获取实现和维护性上的平衡。
09 异曲同工的类
即两个类做的同一件事或者同一类事。这种代码很常见,比如两个开发者同时执行自己的开发工作,创建了功能类似但是方法不同的类,Code Review 的时候很容易发现这种代码。
为什么异曲同工的类是一种代码坏味道?
按照上面的描述,如果保留两个职责类似的类会有什么不好?
- 后续调用实现类时会导致选择上的疑虑,两个类应该选择用哪个,而疑虑之下就是时间的浪费。
- 添加代码的时候,只向其中一个类中添加了逻辑,后续调用时 就会困扰调用者,而且容易导致两个类中容易出现重复的代码。
异曲同工的类是后续很多坏味道的开始。
如何解决异曲同工的类这种代码坏味道?
-
一般情况,如果两个类是一般的工具类,可以选择使用Renove Method 和 Move Method 将类的职责描述清楚,并将相关的代码移动到一个类中,完成两个类的合并。
-
如果两个类存并非普通的工具类而是存在一定的继承关系,可以采用 Extract SuperClass (提炼超类)。
当遇到代码中的坏味道的时候,请避免延迟决策和延迟解决,因为它很可能后续导致其他的坏味道。及时个人意识到可以延迟决策但是放在团队中会可能在这个地方重复遇到问题,导致后续坏味道不断被扩散。一次一旦遇到类似的坏味道可以遵守“童子军军规”:让营地比你来的时候更干净!
10 纯数据类
纯数据类指的是:一个类中只有属性和这些属性所涉及到的 getter、setter。
为什么纯数据类是一种代码坏味道?
纯数据类有其使用场景,比如 DTO 经常这种贫血模型。但是如果结合业务到的纯数据类频繁出现,那可不是什么好的事情,因为操作这个类中属性的方法将会散落在各个类中,即存在者多处强耦合。
如何解决纯数据类这种代码坏味道?
建议使用充血模型,一个类中除了拥有属性也应该包含具有一定业务逻辑的行为。那么可以选择
- Extract Method 将部分调用逻辑进行提炼,提炼成一定的方法;
- 再使用 Move Method 将方法移动到类中,
- 最后 Hide Method 删除纯出局类中的 getter 和 setter。
纯数据类有其使用场景,但是应该时刻注意到哪些场景下数据类会引入坏味道,一旦发现尽早解决。
参考
《重构》