Day 13 「难兄难弟」 单元测试、Code Smell 与重构 - Data Clump 与 Primitive Obsession 篇


图片截自三立新闻

与笔者年纪相当的朋友,肯定还记得小时候有个非常红的电示节目叫「龙兄虎弟」吧。当时可谓万人空巷,红到整个节目被挖角到友台去变身「龙虎综艺王」,搞得原电视台不得不临时找来徐乃麟与黄安接棒主持,最後徐黄两人闹翻,至今老死不相往来…

今天要来聊两个很常见,很常一起出现,也能很快破坏程序可读性的坏味道:Data Clump 与 Primitive Obsession。当然,这两家伙就肯定不是龙兄虎弟了,顶多只能算是「难兄难弟」而已…

Primitive Obsession

在物件导向的程序里,我们喜欢把相关的值和行为「封装」在同一个物件里,让它们就可以自己拿自己身上的值,用「介面」来与外界互动,而不用倚赖他人帮忙。

现下程序语言大多都支援特定几种「基本型别」,它们没有物理意义、不具商业逻辑、没有行为,甚至不能修改。这些属性同时形成了它们的优点与缺点。然而在一些场合,需要表达较清楚的「物理意义」时,过度使用基本型别来表现,将使得程序不易阅读。好的命名可以稍稍缓解此现象,但终究不能取代「物件」能提供的行为与商业逻辑

举例,在 Java 的程序里拿 long 来表示一个时间点,是准确可靠的做法,却因此丧失了「时间」的观念,还得另外用一些运算来补足;拿 两个 double 来代表经纬度,一样能表现物体在地球上的位置,但是他们就变得老是得绑在一起,而且,也还是要另外找算式来运算他们,才能表现出「位置」这个物理意义。

因此,在程序里(尤其是在介面上),如果大量使用基本型别,将会使得程序丧失表达力,变得不好理解。

Data Clump

Refactoring 书中对 Data Clump 的定义是:「...就像小孩子,喜欢成群结队地待在一块儿」。这不能怪它们,它们就是得要待在一起才有意义。譬如,市内电话与区域号码、座标平面上的 X 与 Y、API 网址的 Domain Name 与 port,以及刚刚提到的经度与纬度等。

这些资料有个特性:它们「老是一起出现」。如果在程序里经常使用,或是程序的介面需要拿它们当参数,那它们就永远在强迫使用者老是写「重复的程序码」。这很正常,譬如,没有人做平面绘图只看 X 座标的。我要拿 X,我就不得不拿 Y,重复程序码就出现了,我还没得选!这也应验了我们先前说过的:「重复的程序码」乃程序的万恶之渊薮。

难兄难弟

经验上,这两个坏味道很常一起出现,互为因果。譬如刚刚提过的经度跟纬度,他们本身就是基本型别(double),这使得使用者在地图上的「位置」这个商业概念不见了,形成了 Primitive Obsession。

而功能还是得开发,为了表达「位置」这个商业概念,开发者就不得不老是让这两个数字一起出现,就形成了 Data Clump。更麻烦的是,当「位置」必须在物件之间传递时,又老是强占人家两个参数的位置。

Uncle Bob 曾说:「一个方法,最理想的参数个数,就是没有参数,其次是一个,以此类推,最多不要超过三个。」你一个经度纬度一出现,一下子就占掉人家两个参数的位置,人家当然不喜欢啦。

解决之道

当多个基本型别老是一起出现,才能代表一个商业概念,那我们就乾脆把它们送作堆,新创一个物件来装起来就得了。如果这个物件出现在介面上,我们就把它称为「Parameter Object」。

举例

我们来到教务处网站,学校现在想做一个「线上签到」的功能。当学生在课堂上打开网页或 App,点一下「签到」,系统就能自动为你签到,而不用摆签到簿或一一点名。为了防止学生人不在教室,却在家偷签,我们在学生签到时,检查他的地理位置是否离教室够近。够近才能成立。

public class DistanceChecker {

    // 中略

    public boolean checkDistance(long courseId, double longitude, double latitude) {

        Course course = courseRepository.find(courseId);

        ClassRoom classRoom = course.getClassRoom();

        double classRoomLongitude = classRoom.getLongitude();
        double classRoomLatitude = classRoom.getLatitude();

        double distance = distanceCalculator.calculate(longitude, latitude, classRoomLongitude, classRoomLatitude);

        return distance < 50D;
    }

}

简单来说,这段程序就是拿着 classId 找出 ClassRoom,跟 ClassRoom 要了教室的经纬度之後,再拿着学生打卡时的经纬度,丢给一个专门算距离的工具 DistanceCalculator 去算距离,最後判断学生是否在距教室 50 公尺以内的地方打卡,是就成功,不是就失败。

程序码不长,但可读性略差(至少在笔者任职的单位,这种程序码 Code Review 是不会过的)。其实有些读者已经看出三、四个坏味道以上了,但是没关系,我们一个一个来,要重构,先加测试。

class DistanceCheckerTest {
    @Test
    void closed_enough() {

        DistanceChecker distanceChecker = new DistanceChecker(
                dummy_repository(9527L, 0D, 0D),
                dummy_calculator(1D, 1D, 0D, 0D, 49D));

        assertTrue(distanceChecker.checkDistance(9527L, 1D, 1D));

    }


    @Test
    void too_far() {

        DistanceChecker distanceChecker = new DistanceChecker(
                dummy_repository(9527L, 0D, 0D),
                dummy_calculator(99D, 99D, 0D, 0D, 51D));

        assertFalse(distanceChecker.checkDistance(9527L, 99D, 99D));

    }
    
    // ...後略
}

这里为了方便示范,我们分别测成功(49 公尺)与失败(51 公尺)的案例各一就好,,其他什麽找不到教室,经纬度超出范围的情况,我们暂时先不管。各位哪天真的在工作上遇上了,还是得测嘿!

各位看看这个测试,有够烦的啦,数字有够多!这是好事,因为你才刚写完就发现它很难用,而不是等到用户来跟你抱怨难用。而且,你程序才刚写完印象还很新,又有了测试保护,这是重构最佳时机啊!怎麽做?在 Refactoring 书中,作者建议的手法之一就是「提取参数物件」(Extract Parameter Object),让经度跟纬度两个数字,变身成具物理意义的物件,我们就命名为「Position」吧!

注意,注意,注意!这里还是要不厌其烦地提醒各位,尽量使用 IDE 提供的重构功能来做,才能节省时间、避免出错!


善用 IDE 重构功能示意图

我们直接来看看重构完变成什麽样子,记得先跑测试:

public class DistanceChecker {

    // 中略
    
    public boolean checkDistance(long courseId, Position studentPosition) {

        Position classroomPosition = courseRepository.find(courseId).getClassRoom().getPosition();

        double distance = distanceCalculator.calculate(studentPosition, classroomPosition);

        return distance < 50D;
    }

}

果真简洁很多。

出现了!其他坏味道!

在消除完 Data Clump 与 Primitive Obsession 两个 Code Smell 之後,程序介面的「意图表达力」就变好了,程序码也变简洁了。这时,我们可以选择 push 然後下班,也可以选择继续重构。

「还要重构什麽?剩三行的 code 是能有什麽坏味道?」当然还有!随便喵两眼就看到两个坏味道:「Message Chains」与「Feature Envy」。

Message Chains 指的是使用者跟 A 物件要完 B 物件,再跟 B 物件要 C 物件,再向 C 物件要 D 物件...以此类推,这明显地违反了「迪米特法则」,你可以在取得 classroomPosition 时看到。而 Feature Envy 的定义我们上一篇讲过了,它就藏在计算距离的逻辑中。 我们可以看到这个 DistanceCalculator 从头到尾只做跟 Position 有关的事,这是很明显的 Feature Envy。距离应该要叫 Position 自己算就好。

从这里我们不难看出,真的要找坏味道,短短几行就可以找得到,只是要不要改,得看你觉不觉得困扰。以这段程序来说,笔者其实觉得,重构到这里,如果你不觉得困扰,也可以不改,或是等哪天真的受不了再回头改也行,反正有测试保护嘛!

谜之声:「坏味道是改不完的,大家找个觉得舒服的平衡点就好,不然你想几点下班啊?」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  3. 搞笑谈软工部落格:https://teddy-chen-tw.blogspot.com/
  4. 迪米特法则:https://en.wikipedia.org/wiki/Law_of_Demeter
  5. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

<<:  DAY 1『 Xcode 如何建专案 』

>>:  Day6-AI Performance

Day19 Gin with Swagger

Background 在前後端分离的专案维护一份完整且及时更新的api文件会极大的提高我们的工作效率...

EP17 - 容器化你的 Django 专案

昨天我们简单介绍什麽是容器, 今天我们要开始实战, 将你的 Django Portal, 逐步包装成...

[2020铁人赛] Day28 - 用CsvHelper读写csv档案

公司最近有个需求要处理csv档案,必须要将资料库资料转成csv汇出,而且希望每个资料都有双引号,并以...

Day 29 (Jq)

1.empty、remove、detach比较 (1)empty vs remove empty()...