Day 15 「一切皆空」单元测试、Code Smell 与重构 - Null 篇


一切皆空,影片来源:YouTube

一般人以为佛教说的空,,等於什麽都没有,是消极并悲观的,其实不是。世上宗教追溯到最後,大多都来自对眼前事物起源的探讨,佛教也是。佛教认为一切事物都是其他事物种的因,在我们眼前结的果,就像照镜子,镜子里的成像虽然近在眼前看似真实,但是其实是镜子角度、光线,加上我们站的位置与看的方式而来,而这些因又是其他更远的因而结的果。因为凡事都有其因,不是无故出现,所以一切皆「空」。

所以,佛教讲的是「因果」,是逻辑的探讨,不是一般人以为的消极或逃避现实。

碎碎念至此,终於来到笔者从业生涯最最最…讨厌的坏味道了:null。

Null 并不是 Martin Fowler 在 Refactoring 书中明白条列出来的坏味道,但笔者还是要花一整篇的工夫来讨论它,因为它实在是太常见,也太讨厌了。所以,对,这篇是带着个人情绪来写的,读者如不像笔者这麽讨厌 Null,可以不用太认真,抱着「看好戏」的心态,轻松阅读本篇即可 XD

常见的 Null 发生场景

Null 会出现的地方,不外乎就是方法内部、接口 Input、或是接口的 Output。看似废话,但这里特别条列出来,就是因为它们虽然都很讨厌,但「讨厌程度」并不相同:

Null 如果出现在一个方法的内部,那是你自己设计的问题,反正在物件导向程序中,外部的人只会看你的接口,你内部自己确保自己的正确性就好。所以其实还好,不太会影响到别人。事实上,Null 如果会造成什麽问题,十有八九都出现在接口,也可以理解为签名处。

当 Null 出现在一个方法的 Input,那就代表「使用的人传了一个 Null 进来」。这时,我要嘛有心理准备,已经在里面为其写好应对逻辑,要嘛就是会事先检查,以防万一。最糟的情况,也就是我方法内部出了错,导致结果不正确,或是会丢一个例外出去,但这种情形,也就是显示使用者没有好好照约定使用我定义的介面。所以错误虽是我发的,但不是我造成的,那是使用者该解决的事,还好。

Output 就最讨厌了。一个方法如果有机会「回传 Null」,会让使用者在不知情的情况下,意外的在运行时出错,而这出错的原因不是使用方的用法有问题,而是说好的介面上你应该回传个物件给我,结果我兴高采烈地要来使用这物件的方法时,机器却发现这个物件「根本就不存在」,因此导致使用方的运行出错。这就完全是方法本身的问题了。也就是身为方法撰写者的你的问题。

为什麽 Null 是个坏味道?

以下我们来看,为什麽「回传 Null」是个坏味道?笔者根据自身经验,自行归纳两大理由:

  1. 语意不明
  2. 重复代码

首先,当一个方法回传 Null,他可能代表几种意义:他可能是要的东西找不到,可能是使用者输入有错,可能是运算过程中出错了,也可能是我要找的东西本身就是 Null,...,族繁不及备载。甚至上面讲的「出错」,也都看不出是可修复错误,还是不可修复错误。Null 可能表示的意思太多了。

於是乎收到回传的使用者就完全不知道这个 Null 到底是什麽意思。他就必须去翻文件、问原作者,甚至是去读原始码,才能决定应该怎麽处理这个「什麽都没有」的情况。

天啊,我有听错吗?你程序的使用者收到一个物件後,还得回头去阅读你的原始码才能知道这是什麽意思?你在跟我开玩笑吗?这在现代软件开发观念来说,算是非常浪费时间、非常不合理、非常不负责任的设计。

好,大局为重,查就查吧。我们就姑且假设使用者去查了文件,确定这个 Null 是指某个特殊意义。不论是上述的哪一种,他都得在呼叫完你的方法後,加一行类似 if ___ == null 的判断式,来决定 在 null 与非 null 的情况下,他各别应该怎麽做。你的方法被呼叫几次,这个 if 判断就会被写几次。他们被逼得不得不「老是一起出现」。简单来说,你的使用在者写重复程序码,而且是被你逼的!


图片截自华视

「重复程序码乃万恶之渊薮」,相信还言犹在耳吧?别这麽做。


图片截自网路

解决之道

随着语言与框架不同,解决 Null 的可能做法也不一样。当你在写一个方法,而你发现这里可能需要回传一个 Null 时,你可能有以下做法:

Optional:以 Java 8+ 为例,Optional 放在 output 的签名上,代表的是「我告诉你这里有可能是空值,你要用我就要先想好万一是 Null 应该怎麽办。」笔者自己蛮喜欢这个 Solution 的,因为我认为这算是一个「先君子後小人」的设计。大家先讲好,出了事你可不能怪我。而换个角度看,对使用者来说更棒的是,有了 Optional 後,其他在签名上没有 Optional 的方法就不会回传 Null,因此使用者可以完全不考虑 Null 的 Case,也就可以少写些重复代码了,多好!

List:如果这是一个「查询」的场景,譬如「寻找班上『C++』分数最高的同学」,这时大部分情况会返回一人,极少数情况因为同分,会有两人以上,而还有一种特殊情况是「没人修『C++』」,这时,返回空 List,会比返回 Null 来得合理得多。

Null Object:这个 Solution 比较常出现在返回物件为「多型」的场景。假设今天我们把学生的类别抽象化,在查询与操作时介面返回这个抽象介面。此时收到的人只知道他是学生,并不知道他到底是博士生、硕士生、还是大学生的哪一种。他也不需要知道,因为我们有严守「DIP:高低阶元件不彼此依赖,而是共同依赖於一个抽象介面」。这时如果低阶实作发现这个学生不存在,或是不属於任何一类已定义类别,它可以回传一种「代表不存在的特殊实作」,而使用者可以照样操作这个物件,而不会出错。这对使用者来说,是非常方便的做法。

Exception:如果这个被呼叫的方法,光凭自己所掌握的讯息,就可以判断,当某个物件不存在时,是一种「错误」,这时其实也可以考虑乾脆「直接丢个 Exception 出去」。呼叫方收到这个 Exception,再自己决定要怎麽处理就好。至於要丢 Checked Exception 还是 Unchecked Exception,可以参考前面关於「例外处理」的文章。

举例

我们就来示范 List 的做法吧。

假设今天就是要找 2021 年第一学期,某班上『C++』分数最高的同学,并且发出通知信请他们来领奖学金,那麽,我们来看看如果 Repository 使用 List 当回传的话,身为呼叫方的 Service 会有多好用。

public class FindTopAndNotifyService {

    private TranscriptRepository repository;
    private SendResultEmailService emailService;

    public FindTopAndNotifyService(TranscriptRepository repository, SendResultEmailService emailService) {
        this.repository = repository;
        this.emailService = emailService;
    }

    public void execute(String semester, long courseId) {

        List<Transcript> transcripts = repository.findHighestScore(semester, courseId);

        for (Transcript transcript : transcripts) {

            long studentId = transcript.getStudentId();

            this.emailService.send(studentId, "Congratulations! You've got Scholarship");

        }

    }
}

上面 Repository 提供找全班最高分同学的介面,会回传一个 List,里面放的是成绩单 Transcript 物件。我们来看看刚刚讲的三种情形,Service 要怎麽应付:

  1. 只有一位最高分:发信给这一位同学
  2. 有两位以上同分:发信给所有同学
  3. 没有人修这门课:完全不发信

以上三种情况,都在一个 for-loop 里面可以跑完,因此,不管 Repository 回传什麽,Service 都不用做特别的逻辑处理,非常方便。

光说不练是假把戏,我们还是得写个测试,看看这样写是不是对的:

class FindTopAndNotifyServiceTest {

    private final TranscriptRepository repository = Mockito.mock(TranscriptRepository.class);
    private final SendResultEmailService emailService = Mockito.mock(SendResultEmailService.class);
    private final FindTopAndNotifyService service = new FindTopAndNotifyService(repository, emailService);

    @Test
    void one_student() {

        given_highest_score_students("2021-fall", 9527L, transcript(55688L));

        when_execute_service("2021-fall", 9527L);

        then_send_email_like(55688L, 1);

    }

    @Test
    void many_students() {

        given_highest_score_students("2021-fall", 9527L,
                transcript(55688L), transcript(3345678L));

        when_execute_service("2021-fall", 9527L);

        then_send_email_like(55688L, 1);
        then_send_email_like(3345678L, 1);

    }

    @Test
    void NO_students() {

        given_highest_score_students("2021-fall", 9527L);

        when_execute_service("2021-fall", 9527L);

        then_NEVER_send_emails();


    }

    private void then_NEVER_send_emails() {
        Mockito.verify(emailService, Mockito.times(0))
                .send(anyLong(), eq("Congratulations! You've got Scholarship"));
    }


    private void then_send_email_like(long studentId, int invokes) {
        Mockito.verify(emailService, Mockito.times(invokes))
                .send(studentId, "Congratulations! You've got Scholarship");
    }

    private void when_execute_service(String semester, long courseId) {
        service.execute(semester, courseId);
    }

    private Transcript transcript(long studentId) {
        return new Transcript(studentId);
    }

    private void given_highest_score_students(String semester, long courseId, Transcript... transcripts) {
        Mockito.when(repository.findHighestScore(semester, courseId))
                .thenReturn(Arrays.asList(
                        transcripts
                ));
    }
}

这里刻意把整个测试类别都附上,主要是想让各位读者参考看看,透过适当的抽取方法,是可以让测试本身暴露程序的使用场景与测试意图的。如果懒得看完的同学可以参考三个标有 @Test 的方法内容就好。

谜之声:「输入 Null 整自己,输出 Null 害别人。」

Reference

  1. DIP:https://en.wikipedia.org/wiki/Dependency_inversion_principle
  2. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

<<:  [DAY1]什麽是聊天机器人?

>>:  【Day 01】 前言 - 大家好 & 目录

[区块链&DAPP介绍 Day14] Solidity 教学 - interfaces

昨天聊过 abstract constract,今天来聊聊 interfaces。 在 OOP 语言...

D5-用 Swift 和公开资讯,打造投资理财的 Apps { 实作 上市/上柜/兴柜 所有资料的列表 }

写到第五天,开始写 UI 罗~~ 前面都是在做资料处理,所以只有程序码,没有 UI 画面,谢谢看到今...

【Day 17】分散式资料库 High Availability 初探

对於分散式资料库的高可用性, 在前面【Day 3】分散式系统模型、容错、高可用的後段已经提过衡量的标...

【Day 10】While 回圈

前言 Python 里有两大回圈,分别是 while 和 for 回圈,今天要来介绍 while 回...

[早餐吃到饱-5] 浮云客栈 #开幕至今仅两年的机器人智能旅店

智能旅店,白话一点就是:「无人柜台」(浮云的Google Maps由此进) 疫情刚降温时,旅宿业者多...