Day 11 「我以火力掩护你」在测试的保护下重构:消除重复

「班长:班长命令你实施敌火下作业,试问单兵该如何处置?」
『单兵:报告班长,请班长以火力掩护我,完成敌火下作业。』
「班长:好!我以火力掩护你。」
在写这篇时,笔者突然想到当年拿着枪与小抄,在成功岭光秃秃的山丘上滚来滚去,沾得全身是沙的往事(菸)

在上一篇透过测试,嗅到了「重复」程序码的坏味道以後呢,我们就要来藉着测试的保护,来重构逻辑了。理想状态下的重构应该是几乎不会动到测试的。我们待会进行时要以此为目标。

事前分析

在开始重构之前,我们还是得先看一下这段程序到底做了什麽事情。在此之前,我们都尽量不在文章中完整显示这个方法,因为他实在是太长了,读起来相当辛苦,然而这里为了重构,以及方便各位读者比较重构前後的差异,只好「全文照登」。

来,别怕,我们一起来看一下,这个七十几行的方法到底在做啥,以及读起来有多辛苦:

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 方法做了五件事:

  1. 从成绩单中得到学生的身份
  2. 如果学生是大学生,就用大学生的公式算奖学金。
  3. 如果学生是硕士生,就用硕士生的公式算奖学金。
  4. 如果学生是博士生,就用博士生的公式算奖学金。
  5. 如果都不是,就丢错误。

我们已经提过很多次,一个方法只做一件事,这里很明显超过,怎麽办?没关系,在还没头绪的时候,先解决最困扰的:「这方法实在是太长了!」真的太长了,长到我每次读到後面,就忘了前面在讲什麽,一直在程序的细节实作与抽象逻辑里来回跳动思考,非常痛苦。於是我决定先处理太长这件事。

提取方法

本篇将会使用 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 的重构功能

题外话,这里,我要建议各位为了效率与正确性,请「一定不要自己写,也不要自己复制贴上」,请务必使用 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 跑一下测试,确认没有东西坏掉。

抽取介面

现在程序的样貌已经比一开始好很多了,但我们好还要更好。现在这个方法本身存在一个问题:「违反开放封闭原则」。如果我今天要加个「在职专班」,或是「夜间进修」的学生类别,我还是得进来改这个方法。这让我们想要用更抽象的方式来写这个方法。其实这个方法做的事,就是:

  1. 依照学生类别,找到对应的计算机
  2. 回传计算机处理的结果

既然如此,那我们就把这个方法改写成上述的样貌就好。而要做到这一点,我们得先对计算机动手脚,让计算机的介面统一,如此一来,原方法就可以用一模一样的方式来操作不同计算机了。

有点难理解吗?没关系,先来抽看看就知道了。容笔者再提醒一次,这里的抽取介面,也请直接使用 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 方法对外的表现

谜之声:「这,才叫重构!」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. 开放封闭原则:https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle
  3. The Clean Architecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  4. Eric Evans, Domain-Driven Design : Tackling Complexity in the Heart of Software, Addison-WesleyProfessional, 2003
tags: ithelp2021

<<:  【Day11】忙得团团转的回圈

>>:  身为与会者,控场的重要性

Day 14 ( 中级 ) 键盘钢琴 ( 音符动画 )

键盘钢琴 ( 音符动画 ) 教学原文参考:键盘钢琴 ( 音符动画 ) 这篇文章会介绍,如何在 Scr...

Day11 SwiftUI 04 - 在SwiftUI 上设计多画面

在SwiftUI 上设计多画面 NavigationView 这边来介绍一下 NavigationV...

Day12 - 解析图片中的 QR Code 资料

前言 前篇讲解如何产二维条码 QR Code,这篇则是示范如何解析(解码) QR Code,类似工具...

[DAY30] DDD学习资源与完赛感言

DDD 学习资源 ddd-crew 里面有许多关於 DDD 各个面向的 repo,其中这个 repo...

Day28-机器学习(2) KNN

KNN简单说明 为一种监督学习的方法,其原理就好像物以类聚一样,相同的东西会聚在一起 我们可以设定一...