Day 12「可恶想要」单元测试、Code Smell 与重构 - Feature Envy 篇


图片来源:https://disp.cc/b/115-9Z5x

从这一篇起,我们会一连进行几篇跟「重构与坏味道」有关的讨论。其中会列出几个在工作中非常容易遇到的坏味道。我们将用一些实际案例,来显示这些坏味道是在什麽场景下较常发生,他的影响会是什麽,以及该如何重构他。当然,是在「单元测试」的保护下进行。

Feature Envy(依恋情结)

关於 Feature Envy 的定义,Martin Fowler 在 Refactoring 书中指出:「函式对於某个 class 的兴趣高过对自己所处之 host class 的兴趣。」部落格「搞笑谈软工」作者 Teddy Chen 则延伸解释道:「这个函数一天到晚跟『隔壁老王』或『小三』在那里『line来line去』,对於「家务事」却兴趣缺缺。」

Martin Fowler 直指,这种迷恋最常发生在「资料」上。今天如果 A 类里的某个方法,老是喜欢存取 B 类的资料来运算,这会导致 B 的细节一旦有变,A 就不得不跟着变。或是每当 B 想要改变自己身上资料的存取方式时,还得看 A 的脸色。这就造成了 A 与 B 紧密耦合,而我们并不乐见此事。

解决之道

正常来说,我们喜欢将「总是一起变化的东西」放在一块儿。於是当发现 A 的方法对 B 的 Feature 有异常的 Envy,那就乾脆放他自由,把此方法移到 B 身上得了。

举例

我们拿先前教务处网站後台,算奖学金的例子来看看,随便找个「算硕士生奖学金」的方法来看看吧。

@Override
public int calculate(Transcript transcript) {

    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;
    }
}

这里有两个运算,分别为「是否有修课」,以及「计算加权平均分」。在这里我们可以看到,MasterScholarshipCalculator 身上 calculate 的方法,花了一半以上的篇幅在跟 Transcript 的资料沟通,而对自己身上的逻辑,只有在最後取几个数字而已。套句小马哥说的:「如果这不是 Feature Envy,那什麽才是 Feature Envy?」


图片截自 YouTube

要对付 Feature Envy,起手式就是要提取方法(Extrace Method)。我们先把跟别人搞七捻三的逻辑区段用方法将其隔离开来,使高阶业务逻辑浮现,晚点要搬移再来搬。容我再提醒各位一次:这一步骤,请务必使用 IDE 的重构工具来进行。结束後也别忘记花个 65 ms 跑个测试:

    @Override
    public int calculate(Transcript transcript) {

        List<Course> courses = transcript.getCourses();

        if (hasNoCourses(courses)) return 0; // 不修课跟人家领什麽奖学金!

        double weightedAverage = calculateWeightedAverage(courses);

        if (weightedAverage >= 90D) {
            return 15_000;
        } else if (weightedAverage >= 80D) {
            return 7_500;
        } else {
            return 0;
        }
    }

    private double calculateWeightedAverage(List<Course> courses) {
        double totalCredit = 0.001D;
        double totalWeightedScore = 0D;

        for (Course course : courses) {
            totalCredit += course.getCredit();
            totalWeightedScore += course.getScore() * course.getCredit();
        }

        double weightedAverage = totalWeightedScore / totalCredit;
        return weightedAverage;
    }

    private boolean hasNoCourses(List<Course> courses) {
        return courses.isEmpty();
    }

抽出方法後,Feature Envy 的味道更明显了!提取出来的两个方法,根本就从头到尾都在跟 Transcript 身上的 courses 这个 List 互动嘛!既然如此,我们就成全他们,用 Move Method 手法,把它们搬到 Transcript 身上去吧!

@Override
public int calculate(Transcript transcript) {

    if (transcript.hasNoCourses()) return 0; // 不修课跟人家领什麽奖学金!

    double weightedAverage = transcript.calculateWeightedAverage();

    if (weightedAverage >= 90D) {
        return 15_000;
    } else if (weightedAverage >= 80D) {
        return 7_500;
    } else {
        return 0;
    }
}

呼,这下相爱的人终於光明正大在一起,不用在那边「偷来暗去」了。Service 则是眼不见为净,只要跟 Transcript 要最终运算结果,过程跟资料细节他就不用管了,皆大欢喜。

眼尖的读者会发现,这里其实还存在者 if 这个坏味道,怎麽办?再往下重构啊!反正有测试保护,怕什麽。「重构」本来就不该是一次性的大功能,他是一步一步,分阶段慢慢进行的。不过,这不是本篇要讲的内容,我就暂时就此打住了,各位可以下载下来自己继续往下重构看看。

谜之音:「最後的疼爱是手放开。」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. 搞笑谈软工部落格:https://teddy-chen-tw.blogspot.com/
  3. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

<<:  OpenStack Neutron 介绍 3

>>:  [Day12] CH08:积沙成塔——Array & ArrayList(中)

企划实现(30)

止损 止损顾名思义就是停止损失,今天在做企划的同时,世界并不会停下来等你发展,所以如果在做企划的同时...

Day6-Java反编译工具:javap

javap介绍 javap是jdk工具中自带的反编译工具,它是根据class位元组码档案,反解析出当...

Day 10 打包 python 程序-2

打包 python 程序是个大坑,现在没有一个 python 打包器能完美打包所有 python 程...

[Day27]ISO 27001 附录 A.15 供应者关系

规划 组织要有委外管理的政策,在规划时应考量安全 合约 相关责任是否都纳入合约,包含分包、转包 执...

Python 入门笔记

前言 : Python 是一门相对比较好上手的程序语言,简洁的表述与直觉的语句使许多人易於上手;笔者...