ライブラリでしかお目にかかれない珍しい実装
11/11にGMOメイクアプリ・Sansan モバイル勉強会 in 福岡というイベントでLTをさせてもらったネタなんですが、Xで思いのほか反響があったので記事として書き起こそうとおもいました。
たまにComposeやcoroutineの実装を見にいくことがあるのですが、普段のアプリケーション開発ではあまりみない不思議な実装があったので、今回は3つ紹介したいとおもいます
Avoid using data class
1つ目はdata classを避けるということです
What
ライブラリが公開しているAPIを見てみると、一見data class
で実装してもよさそうなのに、普通にclassで実装していることがあります
// 例: composeのボタンの色を定義しているクラスの実装の一部
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Button.kt;l=1217-1279
@Immutable
class ButtonColors
constructor(
val containerColor: Color,
val contentColor: Color,
val disabledContainerColor: Color,
val disabledContentColor: Color,
) {
// data classだと自動生成されるcopyをわざわざ自前実装している!
fun copy(
containerColor: Color = this.containerColor,
contentColor: Color = this.contentColor,
disabledContainerColor: Color = this.disabledContainerColor,
disabledContentColor: Color = this.disabledContentColor
) =
ButtonColors(
containerColor.takeOrElse { this.containerColor },
contentColor.takeOrElse { this.contentColor },
disabledContainerColor.takeOrElse { this.disabledContainerColor },
disabledContentColor.takeOrElse { this.disabledContentColor },
)
// ...
// hashCodeも上書きしている!
override fun hashCode(): Int {
var result = containerColor.hashCode()
result = 31 * result + contentColor.hashCode()
result = 31 * result + disabledContainerColor.hashCode()
result = 31 * result + disabledContentColor.hashCode()
return result
}
}
Why
なぜこのようなことをしているかというと、一言でいえば、バイナリ互換性を維持するためです。
data class
をつかうと、copy
メソッドや、destructuringのためのcomponentN
メソッドが自動生成されて便利ですが、これらのメソッドにはバイナリ互換性が考慮されていません。
たとえば、プロパティを1件追加したら、コンストラクタとcopy
メソッドの互換性が壊れますし、プロパティの順番を変えるだけでも、conponentN
メソッドの互換性が壊れてしまいます。
このような不安定な実装は公開APIには使わず、普通にclass
で実装して、必要があればhashCode
やcopy
を自前実装します。
-
data class
が生成するcopy
やcomponentN
メソッドにはABI互換性が考慮されていない- プロパティを追加したら、コンストラクタや
copy
メソッドの互換性が壊れる - プロパティの順番を入れ替えたら、
componentN
メソッドの互換性が壊れる
- プロパティを追加したら、コンストラクタや
Kotlinのライブラリ製作者向けドキュメントにも記載があります
Fake constructor
2つ目はFake constructorという実装パターンです
ViewModelの中で状態を管理するために、StateFlowをつかうことがあるとおもいます。MutableStateFlow()
とよびだして、内部で可変なプロパティを用意しますが、このときに使用したMutableStateFlow
は実はコンストラクタではありません。
private val _counter = MutableStateFlow(0)
MutableStateFlow
の実装を見てみると、これはインターフェースとして定義されています。
インターフェースということはコンストラクタをもつこともありません。
public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {
// ...
}
どうなっているかというと、MutableStateFlow
という名前の関数が用意されています。
この関数を実行すると、MutableStateFlow
を実装しているStateFlowImpl
というオブジェクトのコンストラクタを呼び出してインスタンスを作ります。
public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> =
StateFlowImpl(value ?: NULL)
このように関数名をインターフェースと同じにして、あたかもコンストラクタかのように見せかけるのがFake Constructorという実装パターンです
Why
なぜこのような実装が必要なのかと言えば、アプリケーションを実装する環境との違いを考えればよく、通常、インターフェースを使った実装はhilt
などのDIコンテナフレームワークを使い、インターフェースを実体にマッピングして注入することが多いと思いますが、ライブラリではそのようなツールは使えません。
ライブラリの利用者はhiltを使っているかもしれませんし、koinを使っているかもしれませんし、なにも使わずにPure DIをしているかもしれないからです。
そのような制約のなかで、インターフェースだけ公開して、実装クラスを隠すための工夫としてFake Constructorが利用されているんだとおもいます。
Readonly implementation
3つ目はReadonlyな実装です
このパターンはMutableStateFlow
をStateFlow
にアップキャストするときに使うasStateFlow
メソッドで利用されています。
asStateFlow
を実行するとReadonlyStateFlow
という聞き馴染みのないオブジェクトのインスタンスが作られます。
public fun <T> MutableStateFlow<T>.asStateFlow(): StateFlow<T> =
ReadonlyStateFlow(this, null)
ReadonlyStateFlow
の実装は下記のようになっていて、StateFlow
の実装の1つです。
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private class ReadonlyStateFlow<T>(
flow: StateFlow<T>,
@Suppress("unused")
private val job: Job? // keeps a strong reference to the job (if present)
) : StateFlow<T> by flow, CancellableFlow<T>, FusibleFlow<T> {
override fun fuse(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow) =
fuseStateFlow(context, capacity, onBufferOverflow)
}
Why
ReadonlyStateFlow
があるとなにが嬉しいかというと、たとえば、LiveData
の頃のように、公開するプロパティの型をStateFlow
と明示して、アップキャストしてしまうと、それを利用しているコードでMutableStateFlow
にダウンキャストできてしまいます。
これだと、状態の更新がViewModelに閉じなくなってしまうので管理がしづらくなってしまいます。
class MainViewModel: ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState
}
(viewModel.uiState as MutableStateFlow<MainUiModel>).update {
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// down cast to MutableStateFlow
}
asStateFlow
を使って公開すると、MutableStateFlow
にダウンキャストしようとしても、ReadonlyStateFlow
とMutableStateFlow
は赤の他人なのでダウンしようがなく、ClassCastException
が吐かれてクラッシュするので、意図しない状態更新を防ぐことができます
class MainViewModel: ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState = _uiState.asStateFlow()
}
(viewModel.uiState as MutableStateFlow<MainUiModel>).update {
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// throw ClassCastException
}
以上の3つがライブラリでしかみられない珍しい実装でした
みなさんも普段利用しているライブラリの実装を見てみると新たな発見があるかもしれないので是非やってみてください!
Discussion