為什麼花一整章談 Null#

Null 並不是 Martin Fowler 在 Refactoring 中明白條列的程式碼異味(Code Smell),但因為它太常見、太討厭,值得單獨討論。

常見的 Null 出現場景#

Null 通常出現在三個地方,但討厭程度不一樣:

  • 方法內部:屬於設計者自家的事,外部看不到,影響有限
  • 方法 Input:呼叫者傳入了 Null,方法可以選擇預先檢查或假設遵守約定。錯誤雖由方法發出,但成因在呼叫者
  • 方法 Output:最討厭。當一個方法回傳 Null,呼叫者在不知情的情況下取用,會在執行期出錯。錯誤完全是方法本身造成的

為什麼回傳 Null 是壞味道#

回傳 Null 至少有兩個問題:

  • 語意不明
  • 製造重複程式碼

語意不明#

當一個方法回傳 Null,可能代表:

  • 找不到要的東西
  • 使用者輸入有誤
  • 運算過程出錯(可修復或不可修復)
  • 要找的東西本身就是 Null

呼叫者不知道哪一種,必須翻文件、問作者、甚至讀原始碼才能決定怎麼處理。這在現代軟體開發中是非常浪費時間、不負責任的設計。

重複程式碼#

即便查清楚了,呼叫者也得在每次呼叫之後加一行 if (___ == null) 判斷。方法被呼叫幾次,這個 If 就被寫幾次。重複永遠是萬惡之源。

解決之道#

依語言與框架不同,可選方案有:

  • Optional:Java 8+ 的 Optional 放在簽名上,等於宣告「這裡可能空值,請事先處理」。簽名上沒有 Optional 的方法就保證不會回傳 Null
  • 空集合(List):查詢場景下「找不到」就回傳空 List,比回傳 Null 自然,呼叫者用 for-loop 直接跑就好
  • Null Object Pattern:適用於回傳多型物件的場景。實作端發現對應物件不存在時,回傳一個「代表不存在」的特殊實作,呼叫者可以照常操作而不出錯
  • Exception:若方法本身就能判定「不存在」屬於錯誤情況,直接丟例外讓呼叫者決定如何處理

範例:用 List 取代 Null#

要找某學期某課程分數最高的同學並寄獎學金通知信。可能有「一人最高」、「同分多人」、「沒人修課」三種情況。如果 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 transcripts = repository.findHighestScore(semester, courseId);

        for (Transcript transcript : transcripts) {

            long studentId = transcript.getStudentId();

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

無論回傳一筆、多筆、還是空集合,for-loop 都能正確運行,不需任何特別判斷。

對應的測試#

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

透過適當提取方法(Extract Method),測試本身能清楚揭露使用場景與意圖。

輸入 Null 整自己,輸出 Null 害別人。盡量在介面層級就消除 Null。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10264214