iT邦幫忙

2021 iThome 鐵人賽

DAY 15
3
Software Development

你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)系列 第 15

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

上一篇
Day 14 「不殘而廢」單元測試、Code Smell 與重構 - Data Class 篇
下一篇
Day 16 「聽從你的蜥蜴腦」單元測試、Code Smell 與重構 - If 篇
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言