「班长:班长命令你实施敌火下作业,试问单兵该如何处置?」
『单兵:报告班长,请班长以火力掩护我,完成敌火下作业。』
「班长:好!我以火力掩护你。」
在写这篇时,笔者突然想到当年拿着枪与小抄,在成功岭光秃秃的山丘上滚来滚去,沾得全身是沙的往事(菸)
在上一篇透过测试,嗅到了「重复」程序码的坏味道以後呢,我们就要来藉着测试的保护,来重构逻辑了。理想状态下的重构应该是几乎不会动到测试的。我们待会进行时要以此为目标。
在开始重构之前,我们还是得先看一下这段程序到底做了什麽事情。在此之前,我们都尽量不在文章中完整显示这个方法,因为他实在是太长了,读起来相当辛苦,然而这里为了重构,以及方便各位读者比较重构前後的差异,只好「全文照登」。
来,别怕,我们一起来看一下,这个七十几行的方法到底在做啥,以及读起来有多辛苦:
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修课跟人家领什麽奖学金!
int total = courses.size();
int achieved = 0;
for (Course course : courses) {
if (course.getScore() >= 80) {
achieved++;
}
}
double rate = (double) achieved / total;
if (rate >= (double) 1 / 2) {
return 10_000;
} else if (rate >= (double) 1 / 3) {
return 5_000;
} else {
return 0;
}
}
if (programType.equals("Master")) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修课跟人家领什麽奖学金!
double totalCredit = 0.001D;
double totalWeightedScore = 0D;
for (Course course : courses) {
totalCredit += course.getCredit();
totalWeightedScore += course.getScore() * course.getCredit();
}
double weightedAverage = totalWeightedScore / totalCredit;
if (weightedAverage >= 90D) {
return 15_000;
} else if (weightedAverage >= 80D) {
return 7_500;
} else {
return 0;
}
}
if (programType.equals("PhD")) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修课跟人家领什麽奖学金!
for (Course course : courses) {
if (course.getScore() < 80) {
return 0;
}
if (course.getScore() < 90) {
return 20_000;
}
}
return 40_000;
}
throw new UnknownProgramTypeException(programType);
}
好,我们终於读完了。读到这一行的你,肯定已经忘记前面在做啥了吧?没关系,这很正常,这也就是我们为什麽必须重构的原因。其实说到底,整个 calculate 方法做了五件事:
我们已经提过很多次,一个方法只做一件事,这里很明显超过,怎麽办?没关系,在还没头绪的时候,先解决最困扰的:「这方法实在是太长了!」真的太长了,长到我每次读到後面,就忘了前面在讲什麽,一直在程序的细节实作与抽象逻辑里来回跳动思考,非常痛苦。於是我决定先处理太长这件事。
本篇将会使用 Martin Folwer 在重构一书中提到的一些手法。这里会先用「提取方法」,来隐藏一些细节,目的是要使高阶抽象逻辑暴露。我们把判断完三种学生後要做的复杂事情提取出方法来放在旁边,此时原方法就会变成这样:
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return calculateBachelor(transcript);
}
if (programType.equals("Master")) {
return calculateMaster(transcript);
}
if (programType.equals("PhD")) {
return calculatePhD(transcript);
}
throw new UnknownProgramTypeException(programType);
}
酷吧!我们只是简单地提取了三个方法,就让原本冗长难以阅读的方法,一下子就缩短到只剩不到 20 行。这下,我们就可以「一眼」把逻辑看完了!就算只做到这里,我也觉得很划算了!
题外话,这里,我要建议各位为了效率与正确性,请「一定不要自己写,也不要自己复制贴上」,请务必使用 IDE 的重构功能,像这样:
提取一个方法也许只能省下 10 秒,但是在重构的过程中,你会遇到非常多这种小规模机械式操作。一个动作省 10 秒,长久下来可以省去你非常多时间。很多人不重构,也就是因为他认为重构很花时间,但其实不是的,浪费时间的元凶其实是这些机械式动作。
请记住,「跑测试是不用钱的」。进行下一步前,别忘了再花 65 ms 跑一下测试,确定一下功能没被改坏:
现在我们能一眼看出方法意图,我们就再往下处理它「做太多事」这个问题。很明显地,这里不但管了「演算法的选择」,也管了「演算法的内容」。这时只要三种演算法的任何一种有变,你都必须来改这个类。这个类就变成一个修改热点。我们不喜欢,於是我们想要把计算「委托」给别的类来做。
如果读者对「Delegate」的概念还不熟悉,我们复习一下:Delegate 意指,把原本在 A 类实作的行为,搬移到 B 类别去做,让 A 去引用就好。一样地,这边也建议各位使用 IDE 提供的 Extract Delegate 重构功能。重构後程序码就会变成这样:
private final BachelorScholarshipCalculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final MasterScholarshipCalculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final PhDScholarshipCalculator phDScholarshipCalculator = new PhDScholarshipCalculator();
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return bachelorScholarshipCalculator.calculateBachelor(transcript);
}
if (programType.equals("Master")) {
return masterScholarshipCalculator.calculateMaster(transcript);
}
if (programType.equals("PhD")) {
return phDScholarshipCalculator.calculatePhD(transcript);
}
throw new UnknownProgramTypeException(programType);
}
各位可以看到,程序码异动不大,但是刚刚提到的「修改热点」问题已减缓许多,现在要修改各类奖学金的计算方式,只要动该类型的对应类就好,原本的 Service 是完全不用动的。
进行下一步前,不免俗地,还是花个 65 ms 跑一下测试,确认没有东西坏掉。
现在程序的样貌已经比一开始好很多了,但我们好还要更好。现在这个方法本身存在一个问题:「违反开放封闭原则」。如果我今天要加个「在职专班」,或是「夜间进修」的学生类别,我还是得进来改这个方法。这让我们想要用更抽象的方式来写这个方法。其实这个方法做的事,就是:
既然如此,那我们就把这个方法改写成上述的样貌就好。而要做到这一点,我们得先对计算机动手脚,让计算机的介面统一,如此一来,原方法就可以用一模一样的方式来操作不同计算机了。
有点难理解吗?没关系,先来抽看看就知道了。容笔者再提醒一次,这里的抽取介面,也请直接使用 IDE 提供的重构功能,以节省时间与避免打字错误:
private final Calculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final Calculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final Calculator phDScholarshipCalculator = new PhDScholarshipCalculator();
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return bachelorScholarshipCalculator.calculate(transcript);
}
if (programType.equals("Master")) {
return masterScholarshipCalculator.calculate(transcript);
}
if (programType.equals("PhD")) {
return phDScholarshipCalculator.calculate(transcript);
}
throw new UnknownProgramTypeException(programType);
}
眼尖的您应该能发现,这里除了抽了 Calculator 这个介面以外,其他步骤几乎没有变动。是的,我故意的,重构,就是每步都不能走太大步。我们现在已经知道这个程序是对的了,我们踏出的每一步都会让他「暂时」暴露在坏掉的风险之中。因此,我们要尽量让每一步都小小的,这样万一改坏了,或讲更实际的,万一发生什麽事情,我们不得不放弃重构,必须马上上线,我们也只要丢弃一点点东西,而不用整个重来。这点很重要。
言归正传,到这里为止,我们进行的都是简单的、IDE 能一件完成的操作。这里,我们就要稍微多写点 Code 了。我们要把 calculate 这个方法,正式改写成符合前述「依照学生类别,找到对应的计算机,再回传计算机处理的结果」的样貌。
记得先 Commit,这也很重要 x 3!
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
Calculator calculator = findCalculator(transcript.getProgramType());
return calculator.calculate(transcript);
}
private Calculator findCalculator(String programType) throws UnknownProgramTypeException {
switch (programType) {
case "Bachelor":
return new BachelorScholarshipCalculator();
case "Master":
return new MasterScholarshipCalculator();
case "PhD":
return new PhDScholarshipCalculator();
default:
throw new UnknownProgramTypeException(programType);
}
}
呼!打完收工!
图片截自网路
经过我们一番操作,现在的 calculate 方法只做一件事:找到正确的 calculator 後回传运算结果,也就是个管流程的。这也使得这个 Service 的设计,更为符合 Uncle Bob 在 Clean Architecture 一书中,对 Service 的定义,同时意外地,不小心套用了 Eric Evans 在 Domain-Driven Design 一书中,提到的「无副作用的函式」模式。
这两个观念不是本篇要讨论的重点,所以笔者就不详细论述了。重点是,现在不管你是要新增学生身份,修改任一奖学金的计算方式,还是要修改金额,都不用进来修改 calculate 方法了。而这方法只有一种原因会修改,就是当「流程有变」的时候。这就满足我们想要的,「一个方法只会因为一种原因而被修改」的原则了。
恭喜各位,在完整测试的保护下,完成了一次幅度不算小的重构。各位回头再比对一下重构之前的原始样貌,差距很大吧!
「还可以再往下重构吗?」
可以唷!其实这里虽然已经把「挑选计算机」的逻辑抽成方法,但还是留在同一个 class 里面。如果真要说,这个 findCalculator 方法也可以 delegate 出去给其他 class 做。不过这得看你觉不觉得困扰,而且主要是整理到这边我也累了(躺),所以我决定暂时就先这样,等哪天又看不顺眼时,再委托出去也行。反正有测试嘛,不怕!
说到测试,各位有发现吗?我们从头到尾,测试都没改唷!是的,因为我们没有改变 calculate 方法对外的表现。
谜之声:「这,才叫重构!」
ithelp2021
键盘钢琴 ( 音符动画 ) 教学原文参考:键盘钢琴 ( 音符动画 ) 这篇文章会介绍,如何在 Scr...
在SwiftUI 上设计多画面 NavigationView 这边来介绍一下 NavigationV...
前言 前篇讲解如何产二维条码 QR Code,这篇则是示范如何解析(解码) QR Code,类似工具...
DDD 学习资源 ddd-crew 里面有许多关於 DDD 各个面向的 repo,其中这个 repo...
KNN简单说明 为一种监督学习的方法,其原理就好像物以类聚一样,相同的东西会聚在一起 我们可以设定一...