Kotlinのジェネリクス構文は型についての別々の問いに答える

Kotlin のジェネリクスには out、in、where、星投影、reified という似た位置に現れる構文がある。 これらを一つの規則として覚えようとすると混乱する。 構文ごとに答えている問いが違うからである。

flowchart TD
    Q["ジェネリクスで何を知りたいか"]
    Q --> R["型同士をどちら向きに代入できるか"]
    R --> R1["宣言時の out / in"]
    Q --> O["この変数からどの操作を許すか"]
    O --> O1["型投影の out / in / *"]
    Q --> B["T にどの型を指定できるか"]
    B --> B1["上界の : / where"]
    Q --> E["実行時に T を調べられるか"]
    E --> E1["inline reified"]

宣言時のoutとinは代入の向きを決める

クラスやインターフェースを宣言するときの out と in は、そのジェネリック型全体の部分型関係を決める。

Producer は T を返すだけなので、T を out にできる。 Cat が Animal の部分型なら、Producer<Cat> も Producer<Animal> の部分型として扱える。 型引数とジェネリック型の矢印が同じ向きになるので、これは共変である。

interface Producer<out T> {
    fun produce(): T
}

val cats: Producer<Cat> = CatProducer()
val animals: Producer<Animal> = cats

Consumer は T を受け取るだけなので、T を in にできる。 Animal を受け取れる Consumer は Cat も受け取れるため、Consumer<Animal> を Consumer<Cat> として扱える。 型引数とジェネリック型の矢印が逆向きになるので、これは反変である。

interface Consumer<in T> {
    fun consume(value: T)
}

val animals: Consumer<Animal> = AnimalConsumer()
val cats: Consumer<Cat> = animals

T を返す操作と受け取る操作の両方がある Box<T> は、どちらの指定もできず不変になる。 Box<Cat> を Box<Animal> として扱えるなら、猫専用の箱へ犬を入れられてしまうからである。

型投影は使う場所だけ操作を制限する

Array<T> は読み書きの両方ができるため不変である。 しかし、ある関数が配列を読むだけなら、その引数から書き込み操作を隠せば Array<Int> を Array<Any> 相当の入力として安全に使える。

使用場所に書く Array<out T> が型投影である。 これは元の配列を読み取り専用に変える構文ではない。 その変数を通して許す操作を、T を取り出す操作だけに制限する構文である。

反対に Array<in T> では T を書き込めるが、取り出した値の具体的な型は分からないので Any? としてしか読めない。

fun <T> copy(from: Array<out T>, to: Array<in T>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

宣言時の out と型投影の out は、どちらも「T を安全に取り出せる」という原理を使う。 違いは、前者がジェネリック型の全用途に適用され、後者が一つの使用場所にだけ適用される点にある。

星投影は型が不明なときの安全な見方を作る

MutableList<*> の星は「何でも入る」という意味ではない。 「型引数が何か分からない」という意味である。

実体が MutableList<String> かもしれず MutableList<Int> かもしれないため、要素は Any? として読めるが、新しい値は安全に追加できない。 星投影は型情報を捨てて危険な操作を許す生の型ではなく、分からない範囲の操作を禁止する型安全な表現である。

fun inspect(values: MutableList<*>) {
    val first: Any? = values.firstOrNull()
    values.add("hello") // コンパイルエラー
}

型投影を使ったコードで CapturedType(out X) というエラーが出ることがある。 これは、コンパイラが「正確な型は不明だが、X の部分型であることは分かっている型」を内部で扱っていることを示す。

上界とwhereはTの候補を制限する

T : Animal は T に Animal またはその部分型だけを指定できるという上界である。 何も指定しない型パラメータの上界は Any? になる。

fun <T : Animal> feed(animal: T) {
    // T は Animal の部分型
}

一つの型パラメータへ複数の上界を課すときは where を使う。 次の T は CharSequence と Comparable<T> の両方を満たす必要がある。

fun <T> copyWhenGreater(values: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return values.filter { it > threshold }.map { it.toString() }
}

上界は Producer<Cat> と Producer<Animal> の代入方向を決めない。 T の候補として何を認めるかだけを決める。

reifiedは実行時にTを参照できるようにする

JVM では通常、ジェネリック型の実引数は実行時に消去されるため、関数本体で value is T のような検査はできない。 inline 関数の型パラメータを reified にすると、呼び出し場所の具体的な型が展開された関数へ埋め込まれ、is T や T::class を使える。

inline fun <reified T> hasType(value: Any): Boolean {
    return value is T
}

hasType<String>("hello")

reified は共変、反変、上界を変える仕組みではない。 型消去がある実行環境で、型パラメータを実行時の検査に使えるようにする仕組みである。 ただし、T 自体が List<String> のようなジェネリック型なら、その要素型まで完全に検査できるわけではない。

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

#kotlin #generics #type-system