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> のようなジェネリック型なら、その要素型まで完全に検査できるわけではない。