📚

ライブラリでしかお目にかかれない珍しい実装

2024/11/13に公開

11/11にGMOメイクアプリ・Sansan モバイル勉強会 in 福岡というイベントでLTをさせてもらったネタなんですが、Xで思いのほか反響があったので記事として書き起こそうとおもいました。

https://speakerdeck.com/mikanichinose/raiburaridesikaomu-nikakarenaizhen-siishi-zhuang

たまにComposecoroutineの実装を見にいくことがあるのですが、普段のアプリケーション開発ではあまりみない不思議な実装があったので、今回は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で実装して、必要があればhashCodecopyを自前実装します。

  • data classが生成するcopycomponentNメソッドにはABI互換性が考慮されていない
    • プロパティを追加したら、コンストラクタやcopyメソッドの互換性が壊れる
    • プロパティの順番を入れ替えたら、componentNメソッドの互換性が壊れる

Kotlinのライブラリ製作者向けドキュメントにも記載があります
https://kotlinlang.org/docs/api-guidelines-backward-compatibility.html#avoid-using-data-classes-in-your-api

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な実装です

このパターンはMutableStateFlowStateFlowにアップキャストするときに使う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にダウンキャストしようとしても、ReadonlyStateFlowMutableStateFlowは赤の他人なのでダウンしようがなく、ClassCastExceptionが吐かれてクラッシュするので、意図しない状態更新を防ぐことができます

class MainViewModel: ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()
}

    (viewModel.uiState as MutableStateFlow<MainUiModel>).update {
//  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//              throw ClassCastException
    }

以上の3つがライブラリでしかみられない珍しい実装でした
みなさんも普段利用しているライブラリの実装を見てみると新たな発見があるかもしれないので是非やってみてください!

GitHubで編集を提案

Discussion