こんにちは。コミュニケーションアプリ「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
、他の言語での Optional
や Maybe
といったものになります。エラーを区別する際に型を分けることは、動的型付けの言語でも効果があります。本来実行されるべきでないコードがそのまま実行されるよりも、ランタイムエラーを起こしたほうがバグに気が付きやすくなるためです。
Null Object パターンを使う場合は、以下のどちらかの状況に絞るとよいでしょう。
- エッジケース・エラーケースと通常のケースを区別する必要がない
- 「エラー」を示す値の候補が複数あり、かつ、そのうちどれかは静的に検証することができない
- 例:
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