LINEヤフー Tech Blog

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

コード品質向上のテクニック: 第 21 回(コンストラクタを叩いて渡る)

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

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

コンストラクタを叩いて渡る

以下の FooVideoPlayer は、動画を再生するためのクラスです。FooVideoPlayer.play を呼び出すことで動画を再生できるのですが、その前に prepare を呼んで preparedValue の値を決めておく必要があります。もし、prepare を呼ばずに play を呼び出した場合は、error によって例外が投げられます。

/**
 * A video player for a file specified by [videoUri]. 
 * 
 * Call [play] to play the video, but [prepare] must be called before calling `play`.
 * Otherwise `play` will throw [IllegalStateException].
 */
class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private var preparedValue: PreparedValue? = null

    fun prepare() {
        if (preparedValue != null) {
            error("Already prepared")
        }

        val preparedValue = ... // execute `prepare` logic
    }

    fun play() {
        val currentValue = preparedValue
        if (currentValue == null) {
            error("Not prepared yet")
        }

        // ... play `videoUri`.
    }
}

このコードで改善できる点はありますか?

割れたコンストラクタを直す

この FooVideoPlayer には、「準備ができていない」という状態を安全に扱えないという問題がありますFooVideoPlayer のインスタンスが渡されたとき、 prepare が呼ばれているかどうかがわからない上に、間違った関数を呼び出すと例外が投げられてしまいます。

このように、使い方に注意が必要なクラスや関数は、バグの原因になります。注意点をドキュメンテーションとして書くこともできますが、間違った使い方ができないようにする のが理想的です。今回の場合は、以下のような解決策があります。

  1. 初期化時に prepare を実行する
  2. 初回の play 呼び出し時に prepare を実行する
  3. prepare 前に play を呼び出せないようにする

Option 1: 初期化時に prepare を実行する

最も単純な解決策は、コンストラクタやイニシャライザで prepare に相当するロジックを実行することです。

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private val preparedValue: PreparedValue

    init {
        val preparedValue = ... // execute `prepare` logic
    }

    fun play() {
        // ... play `videoUri`.
    }
}

この方法の利点は、多くのプロパティを読み取り専用にできることですprepare 時点で初めて値が決まるプロパティについても、 val を使うことができます。

しかしこの書き方は、安全でないコードになる可能性があります。イニシャライザ内で呼び出した関数が、未初期化のプロパティを読み出してしまうといったバグを引き起こしかねません。Swift といった一部の言語では、イニシャライズ完了前のプロパティや関数の呼び出しに制約があるので、このようなバグを引き起こせないようになっています。ただしその場合は、prepare に相当するロジックをイニシャライザ内に書けない可能性があります。コンストラクタの制約は他にも様々なケースがあり、例えば Kotlin では、コンストラクタ自体を suspend にはできません。

このように、コンストラクタやイニシャライザで複雑なロジックや大きな副作用があるロジックを書くと、問題になる可能性があります。そこで、コンストラクタを private にして別途ファクトリ関数を定義し、その関数内で prepare に相当するロジックを書くことも選択肢に入れましょう。以下のコードでは、companion object 内に createInstance というファクトリ関数を定義しています。(companion object 内で定義した関数は、Java で言うところの static メソッドに相当します。)

class FooVideoPlayer private constructor(
    private val videoUri: Uri,
    ..., // other options
    private val preparedValue: PreparedValue
) {
    fun play() {
        // ... play `videoUri`.
    }

    companion object {
        fun createInstance(videoUri: Uri, ...): FooVideoPlayer {
            val preparedValue = ... // execute `prepare` logic

            return FooVideoPlayer(
                videoUri,
                ...,
                preparedValue
            )
        }
    }
}

Option 2: 初回の play 呼び出し時に prepare を実行する

インスタンスが作られたときに prepare に相当するロジックを実行するのではなく、初回の play 呼び出し時に prepare を実行する方法もあります。

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private var preparedValue: PreparedValue? = null

    fun play() {
        val preparedValue = prepare()

        // ... play `videoUri`.
    }

    private fun prepare(): PreparedValue {
        val existingValue = preparedValue
        if (existingValue != null) {
            return existingValue
        }

        val newValue = ... // preparation logic
        preparedValue = newValue
        return newValue
    }
}

この方法では、「インスタンスが作られても play が呼び出されない可能性が高く、かつ、prepare のコストが高い」場合に有効です。しかし一方で、prepare で確定するプロパティを可変 (var) にしなければならないという欠点もあります。この問題は、Kotlin の lazy のように、初回アクセス時にロジックを実行する仕組みを作る・使うことで軽減できます。

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private val preparedValue: PreparedValue by lazy {
        ... // preparation logic
    }

    fun play() {
        // ... play `videoUri` by using `preparedValue`
    }
}

Option 3: prepare 前に play を呼び出せないようにする

静的型付けの言語ならば、prepare 前と prepare 後で型を分けてしまい、prepare 後にのみ play を定義する方法もあります。使い方を間違えた時は、そもそもコンパイル不可能にしようというアイディアです。この方法は特に、「prepare の実行コストが高い」などの理由で、prepare の実行タイミングを呼び出し元で制御したい場合に有効です。別の視点で見ると、これは Option 1 のファクトリ関数をクラス化したものとみなせます。Option 1 と比べてこの方法独自の利点としては、prepare 済みのインスタンスを FooVideoPlayer のキャッシュとして持てることや、「段階的な初期化状態」を管理可能になることが挙げられます。

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    fun prepare(): PreparedFooVideoPlayer {
        val preparedValue = ... // execute `prepare` logic

        return PreparedFooVideoPlayer(
            videoUri,
            ...,
            preparedValue
        )
    }
}

class PreparedFooVideoPlayer(
    private val videoUri: Uri,
    ..., // other options
    private val preparedValue: PreparedValue
) {
    fun play() {
        // ... play `videoUri`.
    }
}

一言まとめ: 準備できていないインスタンスは使えないようにする。

キーワード: initialization, constructor, factory