たくさんの自由帳

Kotlin の out T とか Nothing とかを理解したい

投稿日 : | 0 日前

文字数(だいたい) : 11107

どうもこんばんわ
11 月ってこんな暑かったっけ?

本題

Jetpack Composeを使うときによく、以下のような画面の値を持っておくステートクラスみたいなのを作ると思うんですけど
多分、参考:https://github.com/android/nowinandroid/blob/main/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt

// HomeScreen の ステート
sealed interface HomeScreenUiState {
    // ロード
    object Loading : HomeScreenUiState
    // 成功
    data class Successful(val data: String) : HomeScreenUiState
    // 失敗
    data class Error(val throwable: Throwable) : HomeScreenUiState
}

private suspend fun getData(): String {
    delay(500)
    return "Hello World"
}

@Composable
fun HomeScreen() {
    val uiState = remember { mutableStateOf<HomeScreenUiState>(HomeScreenUiState.Loading) }

    LaunchedEffect(key1 = Unit) {
        // 取得処理
        val response = getData()
        uiState.value = HomeScreenUiState.Successful(response)
    }

    Box {
        when (val state = uiState.value) {
            is HomeScreenUiState.Error -> Text(text = "失敗した!")
            HomeScreenUiState.Loading -> Text(text = "ロードなう")
            is HomeScreenUiState.Successful -> Text(text = "成功 = ${state.data}")
        }
    }
}

一画面だけなら問題ないと思いますが、似たような画面で使いたい場合に、HomeScreenUiStateの名前変えただけバージョンがコピーされてしまうのは避けたいなと思ったわけで、
解決するにはジェネリクス<T>みたいなのを使えばいいですね!!

というわけでこれ。

sealed interface BaseUiState<out T> {
    object Loading : BaseUiState<Nothing>
    data class Successful<T>(val data: T) : BaseUiState<T>
    data class Error(val throwable: Throwable) : BaseUiState<Nothing>
}

参考:https://stackoverflow.com/questions/44243763/how-to-make-sealed-classes-generic-in-kotlin

<out T>outって何・・・
あと<Nothing>って何なの?

確かに動くのですが、謎ばっかりだったので調べてみた

sealed interface BaseUiState<out T> {
    object Loading : BaseUiState<Nothing>
    data class Successful<T>(val data: T) : BaseUiState<T>
    data class Error(val throwable: Throwable) : BaseUiState<Nothing>
}

private suspend fun getData(): String {
    delay(1000)
    return "Hello World"
}

@Composable
fun HomeScreen() {

    val uiState = remember { mutableStateOf<BaseUiState<String>>(BaseUiState.Loading) }

    LaunchedEffect(key1 = Unit) {
        // 取得処理
        val response = getData()
        uiState.value = BaseUiState.Successful(response)
    }

    Box {
        when (val state = uiState.value) {
            is BaseUiState.Error -> Text(text = "失敗した!")
            BaseUiState.Loading -> Text(text = "ロードなう")
            is BaseUiState.Successful -> Text(text = "成功 = ${state.data}")
        }
    }
}

sealed class / sealed interface

シールクラスって言うとかなんとか
TypeScriptだとUnionが一番近いかな?

このクラスの特徴は継承する際に制限があり(同じパッケージ内で継承して宣言する必要がある?)、その代わり継承しているクラスが分かるという特徴があります。

https://kotlinlang.org/docs/sealed-classes.html#location-of-direct-subclasses

基本的には同じファイル内にすべて継承するクラスを定義するはず。

package io.github.takusan23.uistategenerics

sealed class Setting()
class WifiSetting : Setting()
class BluetoothSetting : Setting()
class DisplaySetting : Setting()

例えばパッケージが違うとエラー

package io.github.takusan23.uistategenerics.test

import io.github.takusan23.uistategenerics.Setting

class DeveloperSetting : Setting()  // エラー
// Inheritor of sealed class or interface declared in package io.github.takusan23.uistategenerics.test but it must be in package io.github.takusan23.uistategenerics where base class is declared

制限がある代わりに、継承しているクラスが全て分かるため、when等でinstanceofする際に継承しているクラスが既にわかっているため、全て網羅すればelseを追加する必要がないという点です。
これはenumとかでも言えることですね

sealed class Setting()
class WifiSetting : Setting()
class BluetoothSetting : Setting()
class DisplaySetting : Setting()

fun example(setting: Setting) {
    // Setting クラスは以下のクラス以外は継承していないので、else を書く必要がない
    when (setting) {
        is BluetoothSetting -> {

        }

        is DisplaySetting -> {

        }

        is WifiSetting -> {

        }
    }
}

実際どこで使うの

例えばUIになにかアラートか何かを出すため、データを入れておくクラスをつくろうと思います。
アラートはこの三種類、どれも Android 端末を触ったことがあれば出会ったことがあると思う。

汎用的にこんな感じかな

class NotifyData(
    val type: Type,
    // メッセージ
    val message: String
) {

    // Toast / Snackbar / ダイアログ のどれか
    enum class Type {
        TOAST,
        SNACKBAR,
        DIALOG
    }

}

あ!SnackbarDialogはボタンがあるので、ボタンのテキストも設定できるようにしたいですね。
うーん雲行きが怪しく

class NotifyData(
    val type: Type,
    val message: String,
    // TOAST 以外
    val actionText: String
) {
    enum class Type {
        TOAST,
        SNACKBAR,
        DIALOG
    }
}

あとダイアログは外側を押したらキャンセル出来るかのフラグも欲しいかも

class NotifyData(
    val type: Type,
    val message: String,
    // TOAST 以外
    val actionText: String,
    // DIALOG のみ
    val isCancellable: Boolean
) {
    enum class Type {
        TOAST,
        SNACKBAR,
        DIALOG
    }
}

きつくなってきた。
アラートの種類はenumで表現できるのに、種類ごとに必要なデータが違うから使えない・・・!でも継承可だとなんか違う!
ってときに使うといいと思います。

sealed interface Notify

data class Toast(val message: String) : Notify

data class Snackbar(
    val message: String,
    val actionText: String? = null
) : Notify

data class Dialog(
    val message: String,
    val positiveText: String,
    val isCancellable: Boolean
) : Notify

fun notifyToUi(notify: Notify) {
    when (notify) {
        is Toast -> {
            // Toast を出す処理
        }

        is Snackbar -> {
            // Snackbar を出す処理
        }

        is Dialog -> {
            // Dialog を出す処理
        }
    }
}

これをうまく使ったのが、冒頭のUiStateですね
whenの使いやすさと相まっていいと思う

sealed interface HomeScreenUiState {
    object Loading : HomeScreenUiState
    data class Successful(val data: String) : HomeScreenUiState
    data class Error(val throwable: Throwable) : HomeScreenUiState
}

以上!sealed class

Nothing / Any

ようやく本題、はい。

Any

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-any/

全てのクラスのスーパークラス
全てのクラスはこのAnyを継承しているってことらしい。

toStringとかはいつ見てもあると思いますがこれは根っこの部分Anyで用意されているからなんですねえ

試しにさっき作ったクラスをインスタンス化しis Anyしてみますがもちろんtrueになります

Imgur

Nothing

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-nothing.html

これは逆に全てのクラスを継承したクラスです。
Anyが根っこならNothingはてっぺんです←?

全てのクラスを継承しているとかいう意味不明なクラスなので、インスタンス化することができません。

存在しない値を表現する際に使うらしい。例えばthrowした場合Nothingが返される。
もしNothingを返す関数があればそれは例外を投げるか、無限ループになって呼び出し元に戻らない?になるらしいです。

val error: Nothing = throw RuntimeException("") // throw したら続行できないので、error に値が入ることはない...

イマイチ使い方が思いつかないですが、もう一個、多分こっちが本題!、
インスタンス化して使うことはできませんが、全てのクラスを継承した型として使うことができます。

全てのクラスを継承しているクラス(インスタンス化できないので建前でしか無いですが)はアップキャストと組み合わせることで効果を発揮します。

アップキャストはまぁ調べれば出ると思いますが、子クラスを親クラスにキャストするやつです。あの安全な方のキャスト
AndroidだとImageView / TextView (子)からView (子が継承している親のクラス)にキャストするみたいな感じです。

話を戻して、アップキャストと組み合わせることでこんな事ができます。いや難しいなこれ

// むずかしい...

// 基本的にはアップキャストされるので、View と ImageView だと親クラスの View の配列になる
val list1: List<View> = listOf<ImageView>() + listOf<View>()

// Nothing 型は全てのクラスの子なので、アップキャストして安全を取ったとしても String になる
// Nothing 型のインスタンスなんて無いので、実質 String です
val list1: List<String> = listOf<Nothing>() + listOf<String>()

// Any 型は全てのクラスの親なので、アップキャストすると安全を取って Any 型になる
val list2: List<Any> = listOf<Any>() + listOf<String>()

てかこれ考えたん賢くない???頭めっちゃいいと思う
で、これを次の<out T>と組み合わせることで、Jetpack ComposeUiStateクラスが共通化できちゃうわけ

ジェネリクス

ジェネリックなのかジェネリクスなのか、どっちなんだろう

https://kotlinlang.org/docs/generics.html

そこらの言語と同じ奴。

val list: List<String>
val list2: List<Int>

ただ、アップキャストの話をちょっと↑でしましたが、ジェネリクスだとうまく行かないことがあるんですね。(てなことをドキュメントに書いてる)
たとえばこんなコード

data class CommonData<T>(val data: T)

fun main() {

    var commonData1: CommonData<Any> = CommonData(Any())
    var commonData2: CommonData<String> = CommonData("Hello World")

    // CommonData<Any> に CommonData<String> を入れようとするとエラー
    commonData1 = commonData2

}

StringAnyを継承しているので、アップキャストにより入れられるはず・・・が入れようとするとエラーになる。継承してるのになぜ?

Imgur

これは訳あってそうしているらしく、以下のようにダウンキャストの可能性が捨てきれないんですね。(詳しくは Java ジェネリクス 共変とかで調べてみてください)
参考: https://kotlinlang.org/docs/generics.html#variance

(なので以下のコードは動きません)

data class CommonData<T>(var data: T) // 意図的に var にしました

fun main() {
    // String の箱
    var commonData1: CommonData<String> = CommonData("Hello World")

    // Any の箱、String は Any を継承しているので入るはずだが、エラーになる
    // もし仮にエラーにならないとする
    var commonData2: CommonData<Any> = commonData1

    // Any の箱に Any を入れる
    commonData2.data = Any()

    // さて、これは?
    // Any を String にしようとしている...!
    val string: String = commonData1.data
}

話を戻して、で、これを解決する方法があります。
Javaでも出来るらしいですが、Kotlinだとoutを利用することでこの問題を解決できます。

data class CommonData<out T>(val data: T) // <out T> にする 

fun main() {

    var commonData1: CommonData<Any> = CommonData(Any())
    var commonData2: CommonData<String> = CommonData("Hello World")

    // CommonData<Any> に CommonData<String> が入るようになった
    commonData1 = commonData2

}

これだとなんで通るようになるのかですが、<out T>だとTは返り値としてしか使うことができず、settervar等の値を変更する箇所でTを使おうとすると怒られるわけです。

上記の問題はsetterを使われてしまった場合に値が変わってしまう可能性があるために出来ないようにしていた、、、
が、変化しないと<out T>で宣言することでこの問題が解決!ついでに変化しないので継承している子クラスも入れられるようになりました。

data class CommonData<out T>(val data: T)

fun main() {
    // String の箱
    var commonData1: CommonData<String> = CommonData("Hello World")

    // out T なら出来る
    var commonData2: CommonData<Any> = commonData1

    // out T なので getter / val しか作ることが出来ず、結果的に後から値を変えることはできなくなるため、安全になる
    commonData2.data = Any() // エラー!

    // 書き換わらないので安全!
    val string: String = commonData1.data
}

これらを組み合わせると

こういうステートが作れます。 一体それぞれが何の役割をしているか、わかった気がしませんか?

sealed interface BaseUiState<out T> {
    object Loading : BaseUiState<Nothing>
    data class Successful<T>(val data: T) : BaseUiState<T>
    data class Error(val throwable: Throwable) : BaseUiState<Nothing>
}
  • sealed interface
    • 継承しているクラスをLoading/Successful/Error の3つに制限
    • wheninでクラスを比較する際はこの3つを見ればおっけー
  • object Loading
    • 値を持たない場合はobjectでいいらしい
    • もちろんsealed interfaceを継承してね
  • data class Successful<T>
    • 成功時の状態と型
    • 型をパラメータとして取ることで共通化
  • data class Error
    • エラー状態
  • ジェネリクスに入れているNothing
    • 全てのクラスを継承している型 Nothing
    • 成功時以外は型をNothingにすることで、アップキャストが働きNothing以外の型を探そうとし、結果成功時の型が使われるようになる
      • Successful<T>の型になる!
  • <out T>
    • ステート自体を共通化したいのでジェネリクスしたい!
      • Loading/Errorの型をどうすればいいの?
        • 全てのクラスを継承した型 Nothing を使うとアップキャストが効いていい感じ
    • <T>outをつけることで、親クラスを指定した際に子クラスも型パラメータに入れることを許容するように
      • 先述の通りNothingという特殊な型により、自動的に成功時の型が使われるようになる

・・・ってことで合ってる?

おわりに

間違ってたらすいません

参考にしました

難しい

https://stackoverflow.com/questions/44243763/how-to-make-sealed-classes-generic-in-kotlin https://phoneappli.hatenablog.com/entry/2021/03/30/180749 https://stackoverflow.com/questions/55953052/kotlin-void-vs-unit-vs-nothing