LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

This post is also available in the following languages. English

コード品質向上のテクニック:第16回 火の null 所に煙は立た null

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 16 回です。Weekly Report については、第 1 回の記事を参照してください。

火の null 所に煙は立た null

Null Object パターンは、null/nil/undefined といった値の代わりに「空」や「無効」といった意味の持つオブジェクトを使うというデザインパターンの 1 つです。このパターンを使う理由はいくつかありますが、その典型的なもの 1 つに「エラーをフォールバック用の値に変換する」というものがあります。

TeamDao.selectMemberIds(TeamId): List<Int>? という関数があることを仮定します。この関数は、通常はチームメンバーの ID のリストを返しますが、存在しないチームの ID が与えられた場合は null を返すとします。以下の TeamModelRepository.getMemberIds(TeamId) では、TeamDao.selectMemberIds が null を返したとき、.orEmpty() を使って空のリストにフォールバックしています。

class TeamModelRepository(private val dao: TeamDao) {
    fun getMemberIds(teamId: TeamId): List<Int> =
        dao.selectMemberIds(teamId).orEmpty()
}

このパターンを適切に使うことにより、呼び出し元のコードを単純にすることができます。顕著な例としては、List といったコレクションを走査することが挙げられます。以下のコードは先程の TeamModelRepository.getMemberIds を呼び出しているのですが、null を取り扱う必要がないため、コードを単純にすることができています。

teamModelRepository.getMemberIds(teamId).asSequence()
    .map(userModelRepository::getUserModel)
    .map(UserModel::getName)
    .forEach { userName -> sendMessage("Hello, $userName") }

Null Object パターンは、List のような汎用的な型に対してだけではなく、アプリケーション固有のデータモデルに対しても使われることがあります。

以下の UserModel は、Null Object として INVALID というインスタンスが定義されています。

class UserModel(
    val id: Int,
    val accountName: String,
    val nickName: String,
    val emailAddress: String
) {
    val isInvalid: Boolean
        get() = this == INVALID

    companion object {
        val INVALID = UserModel(0, "", "", "")
    }
}

この UserModel は、以下のように使われます。

class UserModelRepository(private val dao: UserDao) {
    fun getUserModel(userId: Int): UserModel =
        dao.selectUser(userId) ?: INVALID
}

// On the caller side
fun caller(userId: Int) {
    val userModel = userModelRepository.getUserModel(userId)

    if (userModel.isInvalid) {
        ... // Unhappy-path logic such as showing a dialog
        return
    }

    ...
    // Happy-path logic, such as update profile UI
}

このコードに、何か問題点はあるでしょうか?

null に煙を立たせない

Null Object と通常のオブジェクトを区別する必要がある場合は、Null Object パターンを使わないほうが、コードがより頑健になる可能性があります。

これを理解するためには、まず Null Object パターンの利点の確認が必要です。Null Object パターンの利点の 1 つに、エッジケースやエラーケースのロジックを通常のケースに統合しやすくなることがあります。以下のコードがその例です。

class ProfileViewData(
    val userName: String,
    val profileImagePath: Path,
    ...
) {
    companion object {
        private val UNKNOWN = ProfileViewData("Unknown user", UNKNOWN_USER_IMAGE_PATH, ...)

        fun fromProfileModel(model: ProfileModel?) {
            if (model == null) {
                return UNKNOWN // Converts null to a null object
            }

            // Happy-path case
            ...
            return ProfileViewData(...)
        }
    }
}

// On the caller side
fun updateProfileView(val profileModel: ProfileModel?) {
    val viewData = ProfileViewData.fromProfileModel(profileModel)

    // Only happy-path case, no unhappy-path case
    nameTextView.text = viewData.userName
    profileImageView.image = loadImage(viewData.profileImagePath)
    ...
}

これとは対称的に、エッジケースやエラーケースのロジックを通常のケースから分ける必要がある場合は、Null Object パターンは不適当になることが多いです。UserModel のコードスニペットでは、userModel.isInvalid を確認する必要がありました。しかし、isInvalid のチェックを忘れてしまってもコンパイルは通ってしまい、実際に動作させないとバグに気がつくことができません。

エッジケース・エラーケースと通常のケースを明確に区別する必要がある場合は、静的に検証される型をつかうのがより好ましいです。その典型的なものが Kotlin の null、Swift の nil、他の言語での OptionalMaybe といったものになります。エラーを区別する際に型を分けることは、動的型付けの言語でも効果があります。本来実行されるべきでないコードがそのまま実行されるよりも、ランタイムエラーを起こしたほうがバグに気が付きやすくなるためです。

Null Object パターンを使う場合は、以下のどちらかの状況に絞るとよいでしょう。

  1. エッジケース・エラーケースと通常のケースを区別する必要がない
  2. 「エラー」を示す値の候補が複数あり、かつ、そのうちどれかは静的に検証することができない
    • 例: List<T>? における null と空リスト。この場合は List<T> に変え、エラーの値を空リストだけにすべきことが多い。

余談: “identity” と “equivalence”

“identity” (同一性) は「同じオブジェクトである」ことを意味する一方で、”equivalence” (等価性、時々「同値性」と訳されることも) は「(たとえオブジェクトとしては別であっても)値として同じである」であることを意味します。

Null Object パターンを使う場合は、この identity と equivalence の違いに気をつけなければなりません。UserModel.isInvalid... == INVALID という式で実装されており、UserModel は独自の equals 実装を持っていません。この状況では、UserModel(0, "", "", "").isInvalid の結果は false になるのですが、この動作が思わぬバグを作ることにもなり得ます。


一言まとめ

エラーの値を区別する必要がある場合は、Null Object パターンを使わないほうがよいことが多い。

キーワード: error value, type safeness, null object pattern

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧