LINEヤフー Tech Blog

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

コード品質向上のテクニック: 第 17 回(砂上の楼閣)

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

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

砂上の楼閣

以下の UserProfileViewData は、「ユーザプロフィール」を表示するための UI モデルです。

class UserProfileViewData private constructor(
    val userName: String,
    val emailAddress: String,
    val profileImageUri: Uri?,
    val optionalStatusMessage: String?
)

このインスタンスを作るために、以下のようなビルダーが用意されたとします。

ここで、also は引数を呼び出したあとにレシーバを返す関数です。つまり also { userName = value } は、value  userName に代入したあとに this を返します。また checkNotNull は、引数が null のときに IllegalStateException を投げる関数です。null でないならば引数をそのまま戻り値として返します。

class UserProfileViewData private constructor(
    ...
) {
    class Builder {
        private var userName: String? = null
        private var emailAddress: String? = null
        private var profileImageUri: Uri? = null
        private var optionalStatusMessage: String? = null

        fun userName(value: String): Builder =
            also { userName = value }

        fun emailAddress(value: String): Builder =
            also { emailAddress = value }

        fun profileImageUri(value: Uri): Builder =
            also { profileImageUri = value }

        fun optionalStatusMessage(value: String): Builder =
            also { optionalStatusMessage = value }

        fun build(): UserProfileViewData {
            val nonNullUserName = checkNotNull(userName)
            val nonNullEmailAddress = checkNotNull(emailAddress)
            return UserProfileViewData(nonNullUserName, nonNullEmailAddress, profileImageUri, optionalStatusMessage)
        }
    }
}

このコードの問題点はなにかありますか?

堅牢な基礎の上に建てる

特別な理由がない限り、ビルダーパターンよりもコンストラクタやファクトリ関数のほうが好ましい ことが多いです。

コンストラクタやファクトリ関数を使うことで、必要なパラメータを渡し忘れるというバグを防ぎやすくなります。今回の場合は、UserProfileViewData のコンストラクタをそのまま使えば十分です。先程の Builder では userNameemailAddress が必須でしたが、それらを与え忘れてもコンパイル時のエラーにはならず、ランタイムでしかエラーになりません。ランタイムエラーよりもコンパイルエラーで検出できるようにしたほうが、より頑健性の高いコードになるでしょう。

しかし、ライブラリやプラットフォームの都合で、ビルダーパターンを使わざるを得ないこともあります (O/R mapper 等)。他にも以下の状況では、ビルダーパターンが必要になることもあります。

  1. 必須のパラメータが少なく、多くのパラメータにデフォルトの値が存在する場合。
  2. 「構築途中」の状態を他のクラスや関数に渡す必要がある場合。
  3. デコレータなどに対して「終端操作」を定義したい場合。

ただし 1 と 2 については、他の方法で代替できることもあります。

1: 多くのパラメータにデフォルト値が存在する場合

もし、多くのパラメータがオプショナルである場合、プログラミング言語によってはデフォルト引数を使うことができます。また、パラメータの与え方のパターンが限られる場合は、コンストラクタをオーバーロードするのも 1 つの手段になります。

class UserProfileViewData(
    val userName: String,
    val emailAddress: String,
    val profileImageUri: Uri? = null,
    val optionalStatusMessage: String? = null
)

デフォルト引数が使えないプログラミング言語では、ビルダーパターンも選択肢の一つになります。その場合、必須のパラメータをビルダーのコンストラクタに渡すことで、より頑健なコードにすることができます。以下の Builder では、必須である userNameemailAddressBuilderコンストラクタのパラメータとして受け取っています。

class Builder(
    private val userName: String,
    private val emailAddress: String
) {
    private var profileImageUri: Uri? = null
    private var optionalStatusMessage: String? = null

    ...

    fun build(): UserProfileViewData {
        return UserProfileViewData(nonNullUserName, nonNullEmailAddress, profileImageUri, optionalStatusMessage)
    }
}

2: 「構築途中」の状態を取り扱う場合

以下のように、「構築途中」の状態を他の関数などに渡したい状況も考えられます。

fun caller() {
    val builder = Builder()
        ...

    setStatusMessage(builder)

    val viewData = builder.build()
    ...
}

fun setStatusMessage(builder: Builder) {
    ...
    val statusMessage = ...

    builder.optionalStatusMessage(statusMessage)
}

しかし、この setStatusMessage の引数 builder は、アウトパラメータのように振る舞います。一般には、コードを読みやすくするためにも、アウトパラメータよりも戻り値を使う方が好ましいです。戻り値を使うことで、以下のようにビルダーパターンをコンストラクタやファクトリ関数に置き換えられることがあります。

fun caller() {
    val viewData = UserProfileViewData(
        ...,
        getStatusMessage(...)
    )
}

fun getStatusMessage(...): String {
    ...
    return statusMessage
}

もし、インスタンスを構築するロジックがパイプラインのようになっている場合は、構築中の状態ごとに別の型を定義することで、不正な状態を排除することができます。以下のコードでは、profileImageUri の付与前後で、型を UserAccountModel  UserProfileViewComponent で分けています。

class UserAccountModel(val userName: String, val emailAddress: String)
class UserProfileViewComponent(
    val accountModel: UserAccountModel,
    val profileImageUri: Uri?
)

fun toUserProfileViewComponent(
    accountModel: UserAccountModel
): UserProfileViewComponent { ... }

3: 「終端操作」を作る場合

以下の条件を満たすときは、ビルダーパターンに類似したものを使うとうまくいくことがあります。

  • 0 回以上の任意回数、任意順序で適用する「操作」がある
  • 「終端操作」以降は上記の操作が禁止される

典型的には、デコレータパターンに終端操作を加える場合が当てはまります。以下のコードは、画像の編集にこのパターンを利用した例です。

val profileImageBitmap = loadModifiableImage(uri)
    .crop(CropType.ROUND)
    .fitIn(hightPx, widthPx)
    .colorFilter(Filter.DARK)
    .createBitmap()

このようにすることで、以下のような純粋なデコレータパターンよりも可読性が高くなることがあります。

val profileImage = ColorFilter(
    FitIn(
        Crop(
            loadModifiableImage(uri),
            CropType.ROUND
        ),
        hightPx,
        widthPx
    ),
    Filter.DARK
).getBitmap()

一言まとめ: ビルダーパターンの代わりにコンストラクタやファクトリ関数を使うことを考慮する

キーワード: builder pattern, optional properties, immutability