Day 17 「提枪上阵」在测试保护下重构出 State 设计模式


报告班长,图片截自网路

大家有听过「报告班长」吗?这部 1987 年的电影,当年推出後一炮而红,带领一阵中华民国军教片的风潮,由庾澄庆演唱的同名片尾曲也为这位歌手打响名号。但你有发现,有句歌词唱错了吗?

歌词里有句唱着:「出枪快、转枪慢」,但是根据中华民国单兵作战准则,标准用枪技巧,为了使单兵记得出枪动作不要太大惊动敌人,且准备好後立即进入备射姿势,其实应该要是「出枪慢、转枪面快」才对。

你想,要是在战场上,阿兵哥想起歌词,先快速出枪吸引敌人注意,再慢慢准备射击动作,那还怎麽活命啊?幸好大家都没有很认真在背歌词,不然我们要怎麽反共救国呢?

好,总之我们要来提枪上阵了。本文将实际找一个案例,在程序与测试都具备的情况下,试着透过一些重构手法,将原程序调整成可读性与易修改性都比较好的样貌,同时不破坏原逻辑。最後验证当新需求来临时,如此重构後的新设计,是否真能限缩修改范围,以更快速地因应。

Console Interaction

Console Interaction 是笔者在网路上找到的一个 TDD 练习题,这里我们还没有要聊 TDD 的部份,所以我们假设已经有了可运行的程序,并且有测试保护。接下来我们会先了解题目内容,再进行下一步:

大家可能像笔者年纪够大,有玩过像 Mud 这种「线上文字游戏」,如果没有,那应该也使用过坊间一些使用 CLI 介面来互动的电脑软件。这种介面没有视窗介面那麽美观亲切,但在某些场合还蛮能快速解决问题的,譬如安装系统应用程序。


图片截自 Wikipedia

Console Interaction 的题目要求我们为一个「计算周长与面积」的应用程序编写 CLI 程序。此程序先问使用者要计算矩型(Rectangle)还是圆型(Circle),再依使用者输入,决定下一个问题,直到问完必须的问题,就会秀出该图型的周长与面积。

分析:隐藏的陷阱

这个题目要完成不难,但要写得好测不容易,关键在开发者有没有发现陷阱,把「使用者介面」与「主逻辑」分开。什麽意思?其实题目虽名为 Console Interaction,但其实使用者介面应该要是可以随时抽换的。意即,你就算把介面换成 GUI,甚至是 Web,都不该影响核心的「状态」与「资料」。

开工:先用丑方法完成功能

看穿了这一点,我们发现我们其实可以只管「主逻辑」的程序与测试就好。上文有提到这篇文章着重在「重构」,於是我们假设有个小天使为我们完成了初版的程序如下:

public class Module {
    private final String RECTANGLE_B_SELECTED = "RectangleBSelected";
    private final String RECTANGLE_A_SELECTED = "RectangleASelected";
    private final String RECTANGLE_SELECTED = "RectangleSelected";
    private final String INITIAL = "Initial";


    private String status = INITIAL;
    private int a;
    private int b;

    public String print() {
        if (this.status.equals(RECTANGLE_SELECTED)) {
            return "Rectangle side A length?";
        }

        if (this.status.equals(RECTANGLE_A_SELECTED)) {
            return "Rectangle side B length?";
        }

        if (this.status.equals(RECTANGLE_B_SELECTED)) {
            return "Area=" + (a * b) + ", Circumference=" + (2 * (a + b));
        }
        return "Shape: (C)ircle or (R)ectangle?";
    }

    public void input(String answer) {
        if (this.status.equals(INITIAL) && answer.equals("R")) {
            this.status = RECTANGLE_SELECTED;
        } else if (this.status.equals(RECTANGLE_SELECTED)) {


            try {
                Integer answerInt = Integer.valueOf(answer);
                this.a = answerInt;
                this.status = RECTANGLE_A_SELECTED;
            } catch (NumberFormatException e) {
                return;
            }

        } else if (this.status.equals(RECTANGLE_A_SELECTED)) {

            try {
                Integer answerInt = Integer.valueOf(answer);
                this.b = answerInt;
                this.status = RECTANGLE_B_SELECTED;
            } catch (NumberFormatException e) {
                return;
            }
        }

    }
}

上面的程序,把主逻辑命名为 Module,并且定义 Module 的状态,每当 User Interface 呼叫 Module 的 print 或 input 指令, Module 就先判断自己现在处於什麽状态,并进行对应的行为。

程序完成了,测试当然也不能少,於是补上测试,确保功能正常。这里碍於篇幅,只列出大约 1/3 的测项,但应能足够看出测试的设计与意图:

class ConsoleInteractionTest {
    @Test
    void initial_and_print() {


        Module module = new Module();

        String printed = module.print();

        Assertions.assertEquals("Shape: (C)ircle or (R)ectangle?", printed);


    }

    @Test
    void initial_and_R() {

        Module module = new Module();

        module.input("R");

        String printed = module.print();

        Assertions.assertEquals("Rectangle side A length?", printed);


    }


    @Test
    void initial_and_R_5() {

        Module module = new Module();

        module.input("R");

        module.input("5");

        String printed = module.print();

        Assertions.assertEquals("Rectangle side B length?", printed);


    }
    
    // ... 後略

}

分析:暂停,闻闻坏味道

至此,我停下来了。读者应该有发现,我只完成一半工作,Circle 的逻辑还没做。为什麽不继续?因为这里已经有非常明显的坏味道了,如果硬着头皮继续,晚点重构成本会太高。因此,我决定在此暂停一下,闻闻坏味道,重构一下,然後再继续。

首先,If 太多了!每次 User Interface 与 Module 互动,Module 就要经过一连串的 If 判断,才能找到对应的行为。这里还没把 Circle 相关的状态们放进来,print 与 input 就要承担这麽多责任,很明显违反了「单一职责原则」,使得这两个方法同时变成了修改的热点,需要改善。

另外,在 input 的字串处理的逻辑也重复了,重复永远是我们要极力避免的坏味道,这点也需要改善。

重构:决定策略

其实说到底,这个 Module 的「资料」与「状态」被绑在一起了,但「状态」与「状态对应的处理逻辑」又被分开了。换句话说,在 Module 的方法被呼叫时,「现在该怎麽处理」应该要与「现在是什麽状态」应该要被独立抽到不同类别去才对,而 Module 只要负责「放一个状态在身上」就好,其他该做什麽事、什麽时候该跳状态,让状态他们自己去协调就好。

聪明如你,可能已经想到了,这不就是「状态模式」的场景吗?没错,你果真有读书!既然如此我们就来试着重构成状态模式吧!决定了重构的方向,又有测试保护,那就直接开工吧!因为重构步骤繁多(我们没有要「重写」),用文字不容易表达,这里用一段影片,来示范重构细节:

重构:来动手吧!


小步骤重构,参考影片

现在我们再来试着加入刚刚未完成的 Circle 逻辑,看看已经重构成「状态模式」的这段程序,在需要加入新状态时好不好加。都已经使用了状态模式,理应符合「开放封闭原则」,这时应该只需新增类,不需修改旧类别才对。试了就知道:


重构完後,接着完成剩余功能

结论

今天我们藉由一个程序题,走了一遍分析、写程序、写测试、发现有 Code Smell、停下来再分析、接着再重构的流程。在日常工作中,不一定会经常遇到这麽复杂的场景,但这只是个练习,主要是希望读者们参考一下这样的工作方式,未来在工作上可以也试着照这样的流程,一小步一小步慢慢前进,而使每一步都走得稳健而安全,才能做自己老是在「安全的环境」中进行每天的工作。最後,惟有看出设计模式的必要性,才改写成设计模式。

谜之声:「设计模式是重构出来的,不是规划出来的。」

Reference

  1. Console Interaction:https://sites.google.com/site/tddproblems/all-problems-1/Console-interaction
  2. CLI:https://en.wikipedia.org/wiki/Command-line_interface
  3. 状态模式:https://en.wikipedia.org/wiki/State_pattern
  4. 本文范例程序:https://github.com/bearhsu2/design_patterns/tree/master/src/main/java/com/kuma/playground/console_interaction
tags: ithelp2021

<<:  [D02] 数位影像的基本介绍(2)

>>:  DAY 5 Big Data 5Vs – Volume(容量) - RedShift

[Day18] Flutter - Environment (part2)

前言 Hi, 我是鱼板伯爵今天要教大家如何初始化 Environment,在未来的开发的过程中可以快...

Day 16-infrastructure 也可以 for each 之三: Count meta-argument

infrastructure 也可以 for each 之三 课程内容与代码会放在 Github 上...

Day20-部署篇(二)SSH 连线与 PHP、Composer、Nginx、MySQL 安装

大家好~ 今天来把我们 Server 环境处理好吧~ SSH 先将我们昨天下载的金钥修改一下权限。 ...

菜鸡的机器学习入门

Day2 环境架设 前言 一般来说,蛮多人会用Jupyter notebook来进行Python的入...

【Day27】音乐情绪与乐理

简述音乐情绪模型 看完了昨天的介绍之後,我们知道音乐跟情绪是有相关且可以被分类的,而把这其中关系模型...