こんにちは。コミュニケーションアプリ「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")
}
preparedValue = ... // execute `prepare` logic
}
fun play() {
val currentValue = preparedValue
if (currentValue == null) {
error("Not prepared yet")
}
// ... play `videoUri`.
}
}
このコードで改善できる点はありますか?
割れたコンストラクタを直す
この FooVideoPlayer
には、「準備ができていない」という状態を安全に扱えないという問題があります。FooVideoPlayer
のインスタンスが渡されたとき、 prepare
が呼ばれているかどうかがわからない上に、間違った関数を呼び出すと例外が投げられてしまいます。
このように、使い方に注意が必要なクラスや関数は、バグの原因になります。注意点をドキュメンテーションとして書くこともできますが、間違った使い方ができないようにする のが理想的です。今回の場合は、以下のような解決策があります。
- 初期化時に
prepare
を実行する - 初回の
play
呼び出し時にprepare
を実行する prepare
前にplay
を呼び出せないようにする
Option 1: 初期化時に prepare を実行する
最も単純な解決策は、コンストラクタやイニシャライザで prepare
に相当するロジックを実行することです。
class FooVideoPlayer(
private val videoUri: Uri,
... // other options
) {
private val preparedValue: PreparedValue
init {
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