502 lines
28 KiB
Markdown
502 lines
28 KiB
Markdown
# 09 | 代码现代化:如何将一个300行的方法重构为3行?
|
||
|
||
你好,我是姚琪琳。
|
||
|
||
在上节课里,我们学习了如何对遗留代码做可测试化重构,并添加测试。有了测试的保障,接下来就可以大胆地开始重构“烂代码”了。
|
||
|
||
在重构了大量遗留代码后,我终于找到了两个最实用的方法,这节课我就带你认识这两种重构遗留代码的利器,我把它们称为“倚天剑”和“屠龙刀”,可以帮你劈开一团乱麻式的代码。
|
||
|
||
我曾经用这两种模式将一个300行代码重构为3行。是不是感觉很神奇?
|
||
|
||
## 基于坏味道的重构
|
||
|
||
在此之前,我先来简单絮叨两句我们重构代码的原则,就是**基于坏味道来重构**。也就是说,我们在重构时,要尽量先去识别《重构》中总结的二十几种坏味道,再用书中对应的重构手法去重构。
|
||
|
||
你可能会质疑,要不要这么教条啊?这其实并不是教条。Martin Fowler已经“阅码无数”,甚至可能比我吃的饭都多。他总结出来的坏味道已经足够典型,对应的重构手法也足够好用。我也承认我的智商远不如他,那为什么不能拿来主义呢?
|
||
|
||
和第六节课学习代码增量演进时一样,在重构代码之前,我还是先带你识别坏味道,然后再重构。遗留系统的代码,简直是最具代表性的“代码坏味道大观园”。
|
||
|
||
尽管重构起来挑战重重,但攻克它们又令人上瘾、着迷、欲罢不能。我这样安排,是为了授之以渔(即重构的方法),而不光是授之以鱼(即重构好的方法)。
|
||
|
||
准备好了吗?我们开始。
|
||
|
||
## 倚天剑:拆分阶段
|
||
|
||
我们先来见识见识重构遗留代码的倚天剑,**拆分阶段(Split Phase)**。这是Martin Fowler在《重构(第2版)》中新提出的一种重构手法。当我第一次看到这个手法的介绍时,简直茅塞顿开(当然,我第一次看到《重构》中的很多内容时,都是这个状态)。它解决了我在面临遗留代码时最头疼的问题。
|
||
|
||
![图片](https://static001.geekbang.org/resource/image/03/76/034d1bcaef664422de2971ed7af8a076.png?wh=1920x1088)
|
||
|
||
遗留代码最大的问题就是方法过长。方法长了之后,前后几代开发人员不停往里塞东西,有的加在这里,有的加在那里,导致最后谁都无法分清到底方法做了几件事情。我们都说,方法长了就不是**单一职责(SRP)**了,但更要命的是,由于代码过于混乱,你甚至说不清楚到底有哪些职责。
|
||
|
||
拆分阶段就像倚天剑一样,轻巧地把方法到底做了几件事情给拎清楚,把相关的代码组织到一起。
|
||
|
||
比如,大多数情况下,一个方法都在处理这样的三个阶段:第一,校验、转换传入的数据;第二,根据传入或转换后的数据,完成业务处理;第三,准备要返回的数据并返回。其中第二个阶段如果过于复杂,还可以拆分成更多的小步骤。
|
||
|
||
现在我们来举一个例子。以下代码来自一个拆分阶段的重构Kata,它出自《重构(第2版)》的第1章,作者对原来的代码做了一些简化,使之更适合拆分阶段的重构练习。你可以在[GitHub](https://github.com/gregorriegler/refactoring-split-phase)上找到完整的代码。
|
||
|
||
```java
|
||
public class TheatricalPlayers {
|
||
public String print(Invoice invoice) {
|
||
var totalAmount = 0;
|
||
var volumeCredits = 0;
|
||
var result = String.format("Statement for %s\n", invoice.customer);
|
||
|
||
NumberFormat format = NumberFormat.getCurrencyInstance(Locale.US);
|
||
|
||
for (var perf : invoice.performances) {
|
||
var play = perf.play;
|
||
var thisAmount = 40000;
|
||
if (perf.audience > 30) {
|
||
thisAmount += 1000 * (perf.audience - 30);
|
||
}
|
||
|
||
var thisCredits = Math.max(perf.audience - 30, 0);
|
||
if ("comedy".equals(play.type)) thisCredits += Math.floor((double) perf.audience / 5);
|
||
|
||
totalAmount += thisAmount;
|
||
volumeCredits += thisCredits;
|
||
}
|
||
|
||
result += String.format("Amount owed is %s\n", format.format(totalAmount / 100));
|
||
result += String.format("You earned %s credits\n", volumeCredits);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
我们一起来看看它有哪些坏味道。
|
||
|
||
它计算了演出的总费用以及观众量积分(volume credits,根据到场的观众数量来计算的积分,下次客户再请剧团演出的时候,可以用这个积分获得折扣),并且对这两项数据进行格式化并返回。
|
||
|
||
看到这样的描述,估计你的第一反应就是,它违反了**单一职责原则(SRP)**。没错,一个方法承担了计算总费用、计算观众量积分和格式化信息这三个职责。然而我更喜欢用《重构》中介绍的坏味道**发散式变化(divergent change)**来评价它。
|
||
|
||
**一个类或方法应该只有一个引起它变化的原因**,而对于这个方法来说,显然有三个。如果计算费用的逻辑发生变化,比如观众的基数从30改成了50;如果计算积分的逻辑发生变化,比如演出悲剧也有相应的积分;如果格式化的逻辑发生变化,比如用HTML来输出清单,你都需要修改这个方法。引起它变化的原因,不是一个,而是三个。
|
||
|
||
对于这个坏味道,《重构》的建议是,如果不同的变化方向形成了先后顺序,就用**拆分阶段**手法将它们分开。我们来看看该怎么操作。
|
||
|
||
首先,在开始正式重构之前,我建议你先运行一下所有的测试,确保通过。
|
||
|
||
再仔细观察这段代码,你会发现,很多变量的声明和使用的位置离得非常远。这是遗留代码的典型特点。一方面,一些古老的编程语言或编程风格,以及某些大学课程,要求你把方法内的所有变量都声明在方法开头。另一方面,由于代码经手的人太多,很多人会有意无意将自己的代码插入到变量的声明和使用之间。
|
||
|
||
但你应该清楚的是,我们更倾向于**给局部变量更小的作用域**,也就是在使用它之前再声明。
|
||
|
||
我们先把result和format两个变量的声明往下挪,挪到result使用之前。仅此一步,你其实已经完成了拆分阶段的部分内容,把格式化部分的逻辑择了出来。
|
||
|
||
别忘了运行测试。虽然只有这一步,你几乎可以100%确认没有问题,但你仍然需要运行一下测试,养成好习惯。
|
||
|
||
```java
|
||
public class TheatricalPlayers {
|
||
public String print(Invoice invoice) {
|
||
var totalAmount = 0;
|
||
var volumeCredits = 0;
|
||
|
||
for (var perf : invoice.performances) {
|
||
var play = perf.play;
|
||
var thisAmount = 40000;
|
||
if (perf.audience > 30) {
|
||
thisAmount += 1000 * (perf.audience - 30);
|
||
}
|
||
|
||
var thisCredits = Math.max(perf.audience - 30, 0);
|
||
if ("comedy".equals(play.type)) thisCredits += Math.floor((double) perf.audience / 5);
|
||
|
||
totalAmount += thisAmount;
|
||
volumeCredits += thisCredits;
|
||
}
|
||
|
||
var result = String.format("Statement for %s\n", invoice.customer);
|
||
NumberFormat format = NumberFormat.getCurrencyInstance(Locale.US);
|
||
result += String.format("Amount owed is %s\n", format.format(totalAmount / 100));
|
||
result += String.format("You earned %s credits\n", volumeCredits);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
第二步,来看看for循环里面吧。play变量也和使用它的地方差了6行的距离,你一定二话不说,也往下移。但是等等,再仔细观察一下,其实只有一个地方在使用这个play,干脆不用移动了,直接内联(inline)吧。
|
||
|
||
注意,我说的移动一行代码和内联,包括后面的提取方法和移动方法,在大多数IDE中都是有快捷键的。我强烈建议你**记住并熟练运用这些快捷键**,它们可以使你事半功倍。后面讲解“屠龙刀”时会专门说说快捷键。
|
||
|
||
第三步,计算thisAmount的代码已经集中在了一起,这也是拆分阶段的阶段性成果。不要迟疑,把它们提取成一个方法,彻底和下面的代码划清界限。我们来看看,现在的代码变成了什么样子:
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
var totalAmount = 0;
|
||
var volumeCredits = 0;
|
||
|
||
for (var perf : invoice.performances) {
|
||
int thisAmount = getThisAmount(perf);
|
||
|
||
var thisCredits = Math.max(perf.audience - 30, 0);
|
||
if ("comedy".equals(perf.play.type)) thisCredits += Math.floor((double) perf.audience / 5);
|
||
|
||
totalAmount += thisAmount;
|
||
volumeCredits += thisCredits;
|
||
}
|
||
|
||
// format代码
|
||
}
|
||
|
||
```
|
||
|
||
第四步,把totalAmount的累加代码上移,让使用thisAmount和声明thisAmount的代码挨在一起。这时你会发现,thisAmount也只有这一处调用,完全可以内联。你可能正发愁这个变量名不知道怎么改好,这样一内联,它就不见了,真是一了百了。
|
||
|
||
第五步,重复上面的第三、四步,把计算thisCredits的方法提取出来,并内联thisCredits。这时for循环内部,只剩短短的两行代码了。
|
||
|
||
```java
|
||
for (var perf : invoice.performances) {
|
||
totalAmount += getThisAmount(perf);
|
||
volumeCredits += getThisCredits(perf);
|
||
}
|
||
|
||
```
|
||
|
||
是不是已经很清爽了?但其实还有改进空间,不要停止脚步,我们继续。变量totalAmount和valumeCredits的声明和使用还是分离的,而且它们在循环内部赋值,在循环后面使用,这样的变量似乎只能在循环前面声明。
|
||
|
||
第六步,复制一下这个for循环,分别删掉两个for中的volumeCredits和totalAmount的赋值语句,用两个for循环分别计算totalAmount和volumeCredits。对这一步你可能有异议,本来一个循环变为了两个,性能变差了呀。的确,性能是变差了那么一点点,但这一点点性能损失与它所带来的可读性提升相比,根本不值一提。
|
||
|
||
第七步,把totalAmount和volumeCredits的声明和各自的for循环移到一起,形成下面这样的形式:
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
var totalAmount = 0;
|
||
for (var perf : invoice.performances) {
|
||
totalAmount += getThisAmount(perf);
|
||
}
|
||
|
||
var volumeCredits = 0;
|
||
for (var perf : invoice.performances) {
|
||
volumeCredits += getThisCredits(perf);
|
||
}
|
||
|
||
var result = String.format("Statement for %s\n", invoice.customer);
|
||
var format = NumberFormat.getCurrencyInstance(Locale.US);
|
||
result += String.format("Amount owed is %s\n", format.format(totalAmount / 100));
|
||
result += String.format("You earned %s credits\n", volumeCredits);
|
||
return result;
|
||
}
|
||
|
||
```
|
||
|
||
到这一步,我们基本完成了拆分阶段的重构。代码本来是一团乱麻,被倚天剑劈成了三段,分别负责计算totalAmount、计算volumeCredits和格式化输出结果。代码已经相当清爽了。
|
||
|
||
讲到这,其实你会发现,拆分阶段不过就是重新组织代码,把跟某个逻辑相关的语句,从原先分散的各处拎出来,统统合并在一起。你可以用空行隔开不同阶段,也可以抽取出方法,这样就能让原方法显得更简洁一些。
|
||
|
||
因此,第八步,把各个阶段提取成单独的方法,彻底完成重构。
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
int totalAmount = getTotalAmount(invoice);
|
||
int volumeCredits = getVolumeCredits(invoice);
|
||
return getResult(invoice, totalAmount, volumeCredits);
|
||
}
|
||
|
||
```
|
||
|
||
总结一下我们重构这段代码的八个步骤,如下图:
|
||
|
||
![](https://static001.geekbang.org/resource/image/ef/5a/efa27a11abf8f6112c5cfc130841765a.jpg?wh=6580x4220 "拆分阶段")
|
||
|
||
把一个长方法拆分成多个阶段,并抽取成小的方法,这样做不但能使代码异常整洁,而且在你需要修改的时候,只需找到相关的小方法,而完全不需要去关心其他小方法内的细节,从而降低了**认知负载**。
|
||
|
||
拆分阶段不仅适用于代码拆分,而且也可以用于存储过程和函数的拆分,我们后面在实战篇里还会看到这个模式。
|
||
|
||
展示了倚天剑,是时候掏出屠龙刀了,它可以将职责不相关的代码彻底斩断关系。
|
||
|
||
## 屠龙刀:方法对象
|
||
|
||
**方法对象(Method Object)**是极限编程和TDD之父Kent Beck在《实现模式》中提出的一种模式。Kent Beck甚至直言,这是他最喜爱的模式之一。
|
||
|
||
所谓方法对象,就是指**只包含一个方法的对象**,这个方法就是该对象主要的业务逻辑。如果你不知道如何隔离不同的职责,就可以“无脑”地使用方法对象模式,将不同职责都提取到不同的方法对象中。
|
||
|
||
我们仍然以上面介绍的代码为例来介绍方法对象。拆分阶段完成之后的完整代码如下:
|
||
|
||
```java
|
||
public class TheatricalPlayers {
|
||
public String print(Invoice invoice) {
|
||
int totalAmount = getTotalAmount(invoice);
|
||
int volumeCredits = getVolumeCredits(invoice);
|
||
return getResult(invoice, totalAmount, volumeCredits);
|
||
}
|
||
|
||
private int getTotalAmount(Invoice invoice) {
|
||
var totalAmount = 0;
|
||
for (var perf : invoice.performances) {
|
||
totalAmount += getThisAmount(perf);
|
||
}
|
||
return totalAmount;
|
||
}
|
||
|
||
private int getThisAmount(Performance perf) {
|
||
var thisAmount = 40000;
|
||
if (perf.audience > 30) {
|
||
thisAmount += 1000 * (perf.audience - 30);
|
||
}
|
||
return thisAmount;
|
||
}
|
||
private int getVolumeCredits(Invoice invoice) {
|
||
var volumeCredits = 0;
|
||
for (var perf : invoice.performances) {
|
||
volumeCredits += getThisCredits(perf);
|
||
}
|
||
return volumeCredits;
|
||
}
|
||
|
||
private int getThisCredits(Performance perf) {
|
||
var thisCredits = Math.max(perf.audience - 30, 0);
|
||
if ("comedy".equals(perf.play.type)) thisCredits += Math.floor((double) perf.audience / 5);
|
||
return thisCredits;
|
||
}
|
||
|
||
private String getResult(Invoice invoice, int totalAmount, int volumeCredits) {
|
||
var result = String.format("Statement for %s\n", invoice.customer);
|
||
var format = NumberFormat.getCurrencyInstance(Locale.US);
|
||
result += String.format("Amount owed is %s\n", format.format(totalAmount / 100));
|
||
result += String.format("You earned %s credits\n", volumeCredits);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
代码的坏味道仍然是**发散式变化**,只不过从方法级别变成了类级别,当三个阶段的任何一个逻辑发生变化的时候,你都需要修改这个类。
|
||
|
||
我们要做的就是把getTotalAmount、getVolumeCredits和getResult三个方法都移动到不同的方法对象中。
|
||
|
||
你可能会说,这有何难?我最擅长的就是Copy&Paste了。先别急着按Ctrl(Cmd)+ C,IDE普遍提供了强大的重构工具支持,如果不能物尽其用,简直就是暴殄天物了。你完全可以全都使用重构工具来自动完成这些重构,甚至都不需要碰鼠标。
|
||
|
||
我在这里就以Mac版的IntelliJ IDEA来演示一下,如何只用键盘就安全地实现移动方法的重构。仔细看好,不要眨眼。
|
||
|
||
我们先来移动getTotalAmount这个方法。由于它还调用了getThisAmount,所以必须连带着把它也移走。为了避免这个麻烦,你可以选择先把getThisAmount内联,这样就只需要移动一个方法了。
|
||
|
||
你可以把光标放到getThisAmount的方法定义处或者调用处,然后按Cmd+Opt+N,在弹出的对话框中选择“Inline all and remove the method”。
|
||
|
||
下一步,按下Opt+F1,唤出选择视图的菜单,再按回车选择第一个Project View,这时焦点正好在TheatricalPlayers类上。你可以按Cmd+N在相同的包内创建一个新类,名字就叫TotalAmountCalculator吧。
|
||
|
||
Kent Beck在书中介绍方法对象时说,**可以用方法名的变形作为类名**。如果方法名叫complexCalculation,那么类名就可以叫ComplexCalculator。我们这里的方法叫做getTotalAmount,按同样的思路应该叫TotalAmountGetter,但这个名字并不好,因为getTotalAmount这个名字本身就不好,其实应该叫calculateTotalAmount。
|
||
|
||
创建完类之后,IDE会帮我们打开这个类,然而我现在并不打算对这个类做修改。你可以按Ctrl+Tab跳回到TheatricalPlayers类中,把光标放到getTotalAmount方法签名上,按Cmd+F6来修改它的签名,将刚刚创建的TotalAmountCalculator作为方法的参数,在参数默认值的文本框中,可以填new TotalAmountCalculator()。
|
||
|
||
按下回车,方法的签名就改好了。你可能会问,为什么要把新建的类作为方法参数呢?方法内又没有用到,这不是多此一举吗?不用急,你很快就会发现原因了。
|
||
|
||
接下来就是见证奇迹的时刻。把光标放到getTotalAmount的方法名上,按下F6,会弹出移动方法的对话框,你要选择一个对象来移动你的方法。由于我们想把方法移动到新建的TotalAmountCalculator中,所以当然要选这个。按下回车,getTotalAmount方法就被神奇地移动到了TotalAmountCalculator中。
|
||
|
||
现在你应该明白了为什么要把TotalAmountCalculator放到方法参数中了吧?因为要移动方法时,需要选择一个位置,这个位置就是这个方法所依赖的类。放到方法参数中就相当于让它依赖了TotalAmountCalculator,这样你才能在后续移动方法时,选择TotalAmountCalculator作为移动的目标。
|
||
|
||
接下来,你可以用同样的方式来移动getVolumeCredits和getResult方法,这里就不一一演示了。完成之后的代码如下:
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
int totalAmount = new TotalAmountCalculator().getTotalAmount(invoice);
|
||
int volumeCredits = new VolumeCreditsCalculator().getVolumeCredits(invoice);
|
||
return new ResultFormatter().getResult(invoice, totalAmount, volumeCredits);
|
||
}
|
||
|
||
```
|
||
|
||
由于我们是在方法中直接构造的这些DOC,你可以把它们**提取成接缝**,通过构造函数进行注入。我们再做一些重命名,把看着不爽的方法名通通改掉。
|
||
|
||
```java
|
||
public class TheatricalPlayers {
|
||
private TotalAmountCalculator totalAmountCalculator;
|
||
private VolumeCreditsCalculator volumeCreditsCalculator;
|
||
private ResultFormatter resultFormatter;
|
||
|
||
public TheatricalPlayers(TotalAmountCalculator totalAmountCalculator, VolumeCreditsCalculator volumeCreditsCalculator, ResultFormatter resultFormatter) {
|
||
this.totalAmountCalculator = totalAmountCalculator;
|
||
this.volumeCreditsCalculator = volumeCreditsCalculator;
|
||
this.resultFormatter = resultFormatter;
|
||
}
|
||
|
||
public String print(Invoice invoice) {
|
||
int totalAmount = totalAmountCalculator.calculate(invoice);
|
||
int volumeCredits = volumeCreditsCalculator.calculate(invoice);
|
||
return resultFormatter.format(invoice, totalAmount, volumeCredits);
|
||
}
|
||
}
|
||
|
||
|
||
```
|
||
|
||
到这里,方法对象的重构就全部完成了。它就像屠龙刀一样,彻底劈开了不同职责之间的联系,让它们各自位于自己的方法对象里。
|
||
|
||
我在重构了大量遗留代码之后发现,虽然不同代码最终的样子不尽相同,但过程中似乎都包含了方法对象。有些可能会进一步重构成行为型的设计模式,有些就干脆以方法对象为终点。可以说,方法对象,是设计模式的中间步骤。
|
||
|
||
![](https://static001.geekbang.org/resource/image/1a/51/1a49223e13ac1e0b399216d7110d4451.jpg?wh=7369x4737 "方法对象")
|
||
|
||
我们用快捷键秀操作的过程就到此为止了。我强烈建议你跟着文稿,实际操练一遍,体会快捷键编程带来的快感。
|
||
|
||
![图片](https://static001.geekbang.org/resource/image/19/11/198f254a1bd8537bedf2f5347b0fb311.jpg?wh=1920x1178 "IntelliJ IDEA常用快捷键速记表")
|
||
|
||
我创建了两个关于快捷键的小测验,一个是[Mac版](https://jinshuju.net/f/RD0T8r),一个是[Windows版](https://jinshuju.net/f/KHJfng),你可以刻意练习一下。
|
||
|
||
这样做的好处是,每一个步骤都是IDE自动完成的,是比较安全的。即使在没有测试的情况下,也能相对安全地完成重构。当然,我这可不是鼓励你在没有测试的情况下就去重构,这只是万不得已的情况。
|
||
|
||
用快捷键来操作,也是IntelliJ IDEA的正确打开方式,它可以大大提高你的开发效率,让你的手速能够跟上你的思维。如果你熟练的话,整个重构过程不超过1分钟就能完成,省去了各种上下文切换的成本,比如键鼠切换、Tab页切换等。
|
||
|
||
## 重构结束了吗?
|
||
|
||
有人可能认为重构已经结束了,但如果你对代码有洁癖,就不能容忍坏味道的存在。我们虽然提取出了三个方法对象,但代码仍然有问题。
|
||
|
||
下面我再提供两种不同的重构方向。你可以来比较一下。
|
||
|
||
### 重构到策略模式
|
||
|
||
仔细观察TotalAmountCalculator和VolumeCreditsCalculator,你会发现,它们的方法签名非常类似,都是接受一个Invoice参数,返回一个int。这种坏味道叫做**异曲同工的类(Alternative Classes with Different Interfaces)**,我们可以提取接口,让这两个类实现同一个接口:
|
||
|
||
```java
|
||
public interface InvoiceCalculator {
|
||
int calculate(Invoice invoice);
|
||
}
|
||
|
||
```
|
||
|
||
TheatricalPlayers将变成:
|
||
|
||
```java
|
||
public class TheatricalPlayers {
|
||
private InvoiceCalculator totalAmountCalculator;
|
||
private InvoiceCalculator volumeCreditsCalculator;
|
||
private ResultFormatter resultFormatter;
|
||
|
||
public TheatricalPlayers(InvoiceCalculator totalAmountCalculator, InvoiceCalculator volumeCreditsCalculator, ResultFormatter resultFormatter) {
|
||
this.totalAmountCalculator = totalAmountCalculator;
|
||
this.volumeCreditsCalculator = volumeCreditsCalculator;
|
||
this.resultFormatter = resultFormatter;
|
||
}
|
||
|
||
// print方法
|
||
}
|
||
|
||
|
||
```
|
||
|
||
现在,不同的calculator都实现了同一个接口,我们貌似重构到了策略模式。看过《重构与模式》的人可能会暗喜,重构到设计模式可是重构的最高境界啊,我的代码貌似向着“整洁”的方向又迈进了一大步。然而真的是这样吗?我们先来看看另一种重构思路。
|
||
|
||
### 重构到领域模型
|
||
|
||
我们看看TotalAmountCalculator这个方法对象,它只依赖Invoice类,并且本身没有任何数据。这种大量依赖外部数据,而不依赖自己内部数据的坏味道,叫做**依恋情结(Feature Envy)**。我们可以直接将方法移动到Invoice内部。用上面学到的快捷键,按一下F6就可以搞定。
|
||
|
||
移动之后的Invoice类如下所示:
|
||
|
||
```java
|
||
public class Invoice {
|
||
// 其他代码
|
||
|
||
int calculate() {
|
||
var totalAmount = 0;
|
||
for (var perf : performances) {
|
||
var thisAmount = 40000;
|
||
if (perf.audience > 30) {
|
||
thisAmount += 1000 * (perf.audience - 30);
|
||
}
|
||
totalAmount += thisAmount;
|
||
}
|
||
return totalAmount;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
这时这个方法再叫calculate就不合适了,我们把它改回getTotalAmount,如果你不喜欢get为前缀的名字,也可以叫calculateTotalAmount。
|
||
|
||
你还会发现,移动到Invoice中来之后,这个方法就只依赖Performance了,Invoice不过是遍历了多个Performance而已。你可以提取计算单个Performance的Amount的方法,看看会发生什么。
|
||
|
||
```java
|
||
int calculateTotalAmount() {
|
||
var totalAmount = 0;
|
||
for (var perf : performances) {
|
||
int thisAmount = getThisAmount(perf);
|
||
totalAmount += thisAmount;
|
||
}
|
||
return totalAmount;
|
||
}
|
||
private int getThisAmount(Performance perf) {
|
||
var thisAmount = 40000;
|
||
if (perf.audience > 30) {
|
||
thisAmount += 1000 * (perf.audience - 30);
|
||
}
|
||
return thisAmount;
|
||
}
|
||
|
||
```
|
||
|
||
你会发现getThisAmount方法只依赖Performance,这又是**依恋情结**坏味道。同样的,可以把方法移动到Performance内来消除。移动完之后,calculateTotalAmount变为:
|
||
|
||
```java
|
||
int calculateTotalAmount() {
|
||
var totalAmount = 0;
|
||
for (var perf : performances) {
|
||
int thisAmount = perf.calculateAmount();
|
||
totalAmount += thisAmount;
|
||
}
|
||
return totalAmount;
|
||
}
|
||
|
||
```
|
||
|
||
这时你还可以充分发挥Java stream的语法特性,将for循环也消除掉:
|
||
|
||
```java
|
||
int calculateTotalAmount() {
|
||
return performances.stream().mapToInt(Performance::calculateAmount).sum();
|
||
}
|
||
|
||
```
|
||
|
||
同样的,VolumeCreditsCalculator方法也存在依恋情结坏味道,可以用同样的方式来重构。都完成后,TheatricalPlayers类的print方法将如下所示:
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
int totalAmount = invoice.calculateTotalAmount();
|
||
int volumeCredits = invoice.calculateVolumeCredits();
|
||
return resultFormatter.getResult(invoice, totalAmount, volumeCredits);
|
||
}
|
||
|
||
```
|
||
|
||
这时,你可以将totalAmount和volumeCredits内联,这样方法就剩下了一行。它的职责就只剩下了格式化结果,因为计算totalAmount和volumeCredits的逻辑已经被隔离在了Invoice中。那么当前方法和ResultFormatter的职责也重叠了,我们可以把这个getResult方法也内联掉。
|
||
|
||
```java
|
||
public String print(Invoice invoice) {
|
||
var format = NumberFormat.getCurrencyInstance(Locale.US);
|
||
var result = String.format("Statement for %s\n", invoice.customer);
|
||
result += String.format("Amount owed is %s\n", format.format(invoice.calculateTotalAmount() / 100));
|
||
result += String.format("You earned %s credits\n", invoice.calculateVolumeCredits());
|
||
return result;
|
||
}
|
||
|
||
```
|
||
|
||
我们把重构出来的三个方法对象,居然又全部消除掉了!
|
||
|
||
为什么产生了“消消乐”一样的效果呢?这是因为我们把计算的逻辑都放到了Invoice和Performance对象中,就没有必要引入其他的算法类(方法对象)了。这种**把数据行为都放在对象中**的模式,叫做**领域模型模式**。我们将在下节课详细介绍。
|
||
|
||
比较一下上面提到的两种重构方向,你觉得哪种更适合当前的代码呢?我的答案是第二种。
|
||
|
||
第一种重构虽然看上去像是“策略模式”,但实际上策略接口的两个实现类并不是相互替换的关系,而是“毫无关系”。所有行为型模式的共同特点是,不同的行为可以根据某些条件相互替换,直白点说就是,要有if/else,才能体现出这些替换。而代码中的TotalAmountCalculator和VolumeCreditsCalculator虽然都叫calculator,但没有if/else,它们在原方法中是顺序执行的,不能相互替换。
|
||
|
||
你必须对所有代码坏味道和模式非常熟悉,才能找到正确的重构方向。重构到设计模式固然美好,但并不一定就是最终目标,有时候你可能会用错设计模式,有时候会过度设计。重构到一个刚刚好的状态,没有明显的坏味道,就足够了。
|
||
|
||
## 小结
|
||
|
||
终于学完了今天的课程,希望你学完的感受是“大呼过瘾”。有时候重构的感觉就是这样,比实际写代码更让人身心愉悦。
|
||
|
||
我今天为你展示了重构遗留代码的倚天剑和屠龙刀,希望它们能助你在遗留系统的荆棘之中,杀出一条血路。其实重构手法和模式还有很多很多,我之所以认为这两个特别实用,是因为在重构了大量遗留代码后,我发现**拆分阶段**和**方法对象**是必不可少的中间步骤。
|
||
|
||
当你通过这两种方式完成了初步重构之后,还要审视一下代码,根据坏味道实现下一步的重构。
|
||
|
||
我还在介绍方法对象的时候,穿插了如何使用快捷键来完成重构。你可能会觉得记住额外的快捷键属于外在认知负载,其实不然。它们能够提高你的工作效率,而且一旦记住并且熟练掌握,就能一劳永逸。这种知识属于内在认知负载,是我们完成工作必须具备的技能。
|
||
|
||
重构手法也好,快捷键也罢,都不是什么奇技淫巧,而是像玄铁重剑一样,重剑无锋,大巧不工。它们应该融化在每一个开发人员的血液里。如果你还不熟悉,就抓紧练起来吧。
|
||
|
||
无他,唯手熟尔。
|
||
|
||
## 思考题
|
||
|
||
感谢你学完了今天的内容,希望你能通过书籍和博客去学习一下其他的重构手法和模式。今天的课后作业,还是请你来贴一段项目中的实际代码,我们一起来分析一下其中的坏味道,并通过坏味道来驱动我们重构。
|
||
|
||
如果你觉得今天的课程对你有帮助,请把它分享给你的同事和朋友,我们一起来重构吧。下节课,我们继续挑战代码的分层重构,敬请期待。
|
||
|