Kotlin ジェネリクス(Generic) - 共変(covariant)と不変(invariant)の理解
共変(covariant)とは何か?
IntはAnyとして使用できる(AnyはJavaではObjectに相当する)。
では、
List<Int>はList<Any>として扱えるだろうか?MutableList<Int>はMutableList<Any>として扱えるだろうか?
この答えは、使用中のGenericクラスが型パラメータに対して共変(covariant)なのか、不変(invariant)なのかによって決まる。
List<Int>はList<Any>として使用できる(Listは共変である)。MutableList<Int>はMutableList<Any>として使用できない(MutableListは共変ではなく、不変である)。
「Genericクラスが共変である」とは、型引数として指定した型の親子関係が、そのGenericクラスによって生成された型の親子関係と同じになることを意味する。たとえば、List<E>は型パラメータEに対して共変なので、List<Int>をList<Any>として扱える(List<Int>はList<Any>のサブタイプと見なされる)。
List<Int>はList<Any>として扱える
fun showElems(list: List<Any>) {
for (e in list) {
println(e)
}
}
fun main() {
val a = listOf(0, 1, 2) // List<Int>
showElems(a) // OK!
}
一方、MutableList<E>は型パラメータEに対して共変ではないため、MutableList<Int>はMutableList<Any>として扱えない。まったく互換性のない型として扱われる。
なぜこの違いが発生するのだろうか。次のようなコードを考えると理解しやすい。
MutableList<Int>はMutableList<Any>として扱えない
fun addElems(list: MutableList<Any>) {
list.add("Hello")
list.add("World")
}
fun main() {
val a = mutableListOf(0, 1, 2) // MutableList<Int>
addElems(a) // Compile Error!
}
もし上のようなコードが可能であれば、MutableList<Int>型のオブジェクトにInt以外の任意の要素を追加できてしまう。そのため、MutableList<E>は型パラメータEに対して共変ではないと定義されており、上のコードはコンパイルエラーになる。
List<Int>の要素が、より汎用的なAnyとしてのみ使用されるなら問題ないが、MutableList<Int>がより汎用的な型であるAnyを受け入れるのは問題があるという意味である。
少し難しいが、共変(covariant)と不変(invariant)はKotlinのジェネリクスを扱ううえで非常に重要な概念なので、もう一度整理する。
AがBのサブタイプであるとき、List<A>はList<B>のサブタイプになる。このようなGenericクラスを共変(covariant)という。AがBのサブタイプであっても、MutableList<A>はMutableList<B>のサブタイプになれない。このようなGenericクラスを不変(invariant)という。つまり共変ではない。
共変Genericクラス(covariant class)の定義
ある型パラメータに対して共変なGenericクラスを定義するには、型パラメータにoutキーワードを付けて定義する。逆に、何の修飾子も付けずに型パラメータを定義すると、そのGenericクラスは基本的に不変(invariant)になる。
class Holder<out T>(val elem: T) {
fun get(): T = elem
}
型パラメータにoutを付けることは、そのGenericクラスの実装で、その型パラメータを出力用としてのみ使用するという宣言でもある。出力用として使用するとは、戻り値の位置でのみその型パラメータを使用するという意味である。そのため、このようなクラスをproducerと呼ぶこともあり、サンプルコードのクラス名としてProducerが使われることもある。個人的には、この名前はサンプルコードでは理解しにくいと思うため、ここではHolderというクラス名を使用した。
専門用語では、getterなどの出力位置で使用することをout positionで使用するといい、setterなどの入力位置で使用することをin positionで使用するという。型パラメータにoutを付けられるのは、その型パラメータをout positionでのみ使用する場合、正確にはin positionで使用しない場合に限られる。
たとえば、上の例のコードでは、型パラメータTをgetterの戻り値(out position)の型としてのみ使用している。コンストラクタパラメータとしても使用しているが、コンストラクタのvalパラメータは本質的にgetterを定義するため、out positionと見なされる。in positionでは型パラメータTをまったく使用していないため、out修飾子を追加でき、共変(covariant)なGenericクラスにできる。
もし次のように、outを付けた型パラメータをin positionで使用すると、
class Holder<out T>(val elem: T) {
fun dump(t: T) { ... } // ERROR
}
Type parameter T is declared as 'out' but occurs in 'in' position in type T
このようなコンパイルエラーが発生する。なぜなら、この使い方を許可すると、先ほど説明したMutableListの例のように型安全性を維持できなくなる場合があるためである。
ここで、次のように継承関係のあるAnimalクラスとBirdクラスがあると仮定する。BirdはAnimalのサブタイプである。
open class Animal {
fun eat() { println("EAT") }
}
class Bird : Animal() {
fun fly() { println("FLY") }
}
そして、Holder<Animal>をパラメータとして受け取るdoEat関数を次のように定義したとする。
fun doEat(holder: Holder<Animal>) {
val animal = holder.get()
animal.eat()
}
Holderクラスは共変ジェネリッククラスとして定義されている(型パラメータTにoutを付けて定義されている)ため、Holder<Bird>オブジェクトをHolder<Animal>オブジェクトの代わりに使用できる。したがって、次のようにdoEat関数にHolder<Bird>オブジェクトを渡せる。
val holder: Holder<Bird> = Holder(Bird())
doEat(holder)
これがclass Holder<out T>のように型パラメータにoutを付けて定義した効果である。もしoutを付けずにclass Holder<T>のような不変(invariant)クラスとして定義していた場合、doEat(holder)の呼び出しは次のようなエラーになる。
Type mismatch: inferred type is Holder<Bird> but Holder<Animal> was expected
これは、Holderが共変ではないため、Holder<Bird>とHolder<Animal>の間に互換性がないからである。ただし、ここには少し抜け道があり、ジェネリッククラスを使用する側、ここではdoEat関数を定義する場所で、次のようにoutを付けて「共変(covariant)な型パラメータとして使用する」と宣言する方法がある。
fun doEat(holder: Holder<out Animal>) {
val animal = holder.get()
animal.eat()
}
これは、Holderクラス自体は共変として定義していないが、少なくともこの関数の実装では共変クラスとして使用することを示す(Holder<Animal>の代わりにHolder<Bird>を渡してもよい)。このようにジェネリッククラスを使用する場所で型引数にout修飾子を付ける方法を**use-site variance(使用位置変性)**という。詳しい内容は後で説明する。
Kotlin ListおよびMutableListの定義
では、KotlinのListインターフェースとMutableListインターフェースの定義を見てみる。
interface List<out E> : Collection<E> { ... }
interface MutableList<E> : List<E>, MutableCollection<E> { ... }
Listインターフェースの型パラメータEにはoutが付いており、MutableListインターフェースには付いていない。したがって、Listは型パラメータEに対して共変(covariant)なジェネリッククラスであり、MutableListは不変(invariant)なジェネリッククラスである。
型パラメータのout修飾子は、主にファクトリ系のクラスや不変(immutable)なデータホルダー系のクラスでよく使用される。ただし、clear()のようにパラメータをまったく受け取らずにオブジェクトの内容を変更する関数もあり得るため、不変(immutable)クラスだけが共変(covariant)クラスになれるわけではない。
反変Genericクラス(contravariant class)の定義
共変(covariant)に近い概念として、その反対の性質を持つ反変(contravariant)がある。共変(covariant)なジェネリッククラスから生成された型は、型引数として指定した型の親子関係と同じになるが、反変(contravariant)なジェネリッククラスから生成された型では、この親子関係が逆転する。文章だけでは理解しにくいため、コードで見てみる。
まず共変(covariant)の復習から始める。共変な型パラメータはoutで修飾する。
interface List<out E>
List<Int>はList<Any>のサブタイプである。
次に、反変(contravariant)の例である。反変インターフェースの代表的な例としてComparatorインターフェースがある。反変な型パラメータはinで修飾する。
interface Comparator<in T> {
fun compare(a: T, b: T): Int
}
Comparator<Int>はComparator<Any>のスーパータイプになる。つまり、Comparator<Any>がComparator<Int>のサブタイプになる。この部分の親子関係が共変(covariant)のときと反対になっているため、反変(contravariant)と呼ぶ。
なぜこのように親子関係が逆転するのだろうか。Comparator<Any>とComparator<Int>の関係を考えてみる。Comparator<Int>をパラメータとして受け取る関数には、Comparator<Any>オブジェクトを渡せる。
val anyComp = Comparator<Any> { a, b ->
a.hashCode() - b.hashCode()
}
val intList = mutableListOf(3, 1, 5)
intList.sortWith(anyComp)
なぜなら、Comparator<Any>の実装ではAnyインターフェースだけを参照するため、Intオブジェクト同士の比較にその実装を使用しても何も問題がないからである。
in修飾子を付けられる型パラメータは、in positionでのみ使用されるものに制限される。正確には、public関数の戻り値のようなout positionではまったく使用されないものに限られる。そのジェネリッククラス内で型パラメータを受け入れる、つまり消費する用途でのみ使用するため、そのジェネリッククラスをconsumerと呼ぶこともある。そのため、サンプルコードでは反変(contravariant)ジェネリッククラスの名前がConsumerになっていることもある。
KotlinのContinuationインターフェースも、反変ジェネリックインターフェースの1つである。
interface Continuation<in T> {
val context: CoroutineContext
fun resume(value: T)
fun resumeWithException(exception: Throwable)
}
declaration-site variance(宣言位置変性)およびuse-site variance(使用位置変性)
前述のHolderクラスの説明でも少し触れたが、ジェネリッククラスの型パラメータにoutやinのような変性修飾子(variance modifier)を付ける場合、Kotlinではその追加時点が2つのパターンに分かれる。
- declaration-site variance(宣言位置変性)
- クラスやインターフェースを宣言するとき、型パラメータを
outやinで修飾する。
- クラスやインターフェースを宣言するとき、型パラメータを
- use-site variance(使用位置変性)
- すでに定義されたジェネリッククラスを使用するとき、型引数に
outやinを付ける。
- すでに定義されたジェネリッククラスを使用するとき、型引数に
declaration-site variance(宣言位置変性)
次は、Listインターフェースのように、インターフェースを宣言するときに型パラメータへoutやin修飾子を付ける方法である。
interface List<out E> : Collection<E> { ... }
この場合、このListインターフェースを使用する場所では基本的に共変(covariant)として扱われる。たとえば、List<Number>を受け取る関数には、List<Int>やList<Double>を渡せる。
fun showNumbers(nums: List<Number>) {
println(nums)
}
fun main() {
showNumbers(listOf(1, 2, 3)) // List<Int>を渡せる。
showNumbers(listOf(0.1, 0.2, 0.3)) // List<Double>も渡せる。
}
より簡単に言えば、Listインターフェースでは次のような代入が可能ということである。
val nums: List<Number> = listOf<Int>(1, 2, 3) // OK
逆に、MutableListインターフェースは共変(covariant)として定義されていないため(=不変(invariant))、次のような代入は不可能である。
val nums: MutableList<Number> = mutableListOf<Int>(1, 2, 3) // NG
use-site variance(使用位置変性)
ジェネリッククラスを使用するとき、その型引数にinやoutキーワードを付ける方法である。
たとえば、KotlinのMutableListは不変(invariant)なジェネリッククラスなので、次のようにMutableList<Int>をMutableList<Number>として扱うことはできない。
val list: MutableList<Number> = mutableListOf<Int>(1, 2, 3) // ERROR
このような不変(invariant)クラスでも、使用する場所にoutキーワードを付けると、その部分だけ共変として扱える。その副作用として、in positionで要素を扱えなくなるため、add()関数などを呼び出せなくなる。
val list: MutableList<out Number> = mutableListOf<Int>(1, 2, 3)
list.add(1.5) // ERROR
このように型引数を使用することを専門用語で**型投影(type projection)**という。この場合はoutキーワードを付けているため、out-projectedされたという。
より実用的な例として、次のように配列(Array)の内容をコピーする関数を考える。
fun <T> copyArray(src: Array<T>, dst: Array<T>) {
assert(src.size == dst.size)
for (i in src.indices) {
dst[i] = src[i]
}
}
KotlinのArrayクラスは、次のように型パラメータにvariance modifier(out、in)が付いていないため、MutableListと同じく不変(invariant)なジェネリッククラスである。
class Array<T>
したがって、Array<Int>とArray<Number>には互換性がなく、次のようなコードはエラーになる。
val arr1 = arrayOf<Int>(1, 2, 3)
val arr2 = arrayOfNulls<Number>(3)
copyArray(arr1, arr2) // ERROR
そこで、使用位置変性(use-site variance)の仕組みを利用し、dstパラメータの型パラメータにinキーワードを付けて反変(contravariant)にする。inキーワードを付けることは、このdstオブジェクトがT型の要素だけを受け取る(consume)という宣言になる。
fun <T> copyArray2(src: Array<T>, dst: Array<in T>) {
assert(src.size == dst.size)
for (i in src.indices) {
dst[i] = src[i]
}
}
すると、この関数のsrcパラメータにArray<Int>を渡した場合、dstパラメータにはArray<Number>などを上位型として渡せるようになる。
val arr1 = arrayOf<Int>(1, 2, 3)
val arr2 = arrayOfNulls<Number>(3)
copyArray2(arr1, arr2) // OK
これは、Arrayクラスは本来不変(invariant)クラスとして定義されているが、この関数だけは反変(contravariant)として使用できるようになったという意味である。
参考までに、Javaにはこのuse-site varianceだけが存在し、<? extends Hoge>や<? super Hoge>のような上限境界、下限境界を指定する方法が使われていた。
in positionとout position
型パラメータを共変(covariant)にするにはout修飾子を、反変(contravariant)にするにはin修飾子を付ける必要がある。これらの修飾子を付けるときの制約条件は次のとおりである。
out: in positionで使用する型パラメータには付けられない。in: out positionで使用する型パラメータには付けられない。
ここでは、どの位置で型パラメータを参照することがin positionとout positionのどちらで使用するものと見なされるのかを整理する。
- in position
- publicな関数のパラメータ
- publicなプロパティのsetter(コンストラクタパラメータで
varが付いたもの。内部的にsetterが定義されるため、in positionと見なされる)
- out position
- public関数の戻り値
- publicなプロパティのgetter(コンストラクタパラメータで
valまたはvarが付いたもの。内部的にgetterが定義されるため、out positionと見なされる)
- in positionでもout positionでもない(共変も反変も可能)
- privateな関数やプロパティのパラメータおよび戻り値(privateな関数では型の誤用の恐れがないため)
- コンストラクタパラメータで
valもvarも付いていないもの(初期化時にのみ呼び出され、型パラメータ誤用の危険が少ないため、in positionでもout positionでもないと見なされる)
Kotlinの配列は不変、Javaの配列は共変
KotlinのArrayクラスは不変(invariant)なジェネリッククラスとして定義されている。したがって基本的に、Arrayクラスから生成される異なる型の間に親子関係は発生しない(前述の型投影(use-site variance)の仕組みを使用する場合は例外である)。
val arr: Array<Any> = arrayOf<Int>(1, 2, 3) // ERROR
一方、Javaの配列は共変(covariant)として定義されている。そのため、次のような代入が可能だった。
Integer[] nums = {1, 2, 3};
Object[] objs = nums;
objs[0] = "ABC"; // Runtime ERROR
この仕様の問題点として、上のようにInteger[]配列にStringオブジェクトを保存するコードがコンパイルできてしまう点がある。この問題を解決するために、KotlinではArray<E>クラスを、原始的な要素を保持するIntArrayやCharArrayと同じように不変(invariant)にした。Array<Int>とArray<Any>の間には互換性がないため、上のJavaの例のように予期しない型の要素が保存される心配がない。
List<Any?>とList<*>(star projection)の違い
ジェネリック型のオブジェクトを受け取る関数を定義するとき、要素の型情報を特に意識しない場合は、型パラメータの代わりに、より簡潔なスター投影(star projection)構文を使用できる。
fun dump(list: List<*>) {
for ((index, elem) in list.withIndex()) {
println("$index: $elem")
}
}
fun main() {
val list = listOf("AAA", "BBB", "CCC")
dump(list)
}
上のdump関数を次のように型パラメータを使用して定義しても同じように動作するが、関数定義で型情報を使用しないため、スター投影構文を使う方が簡単に書ける。
fun <T> dump(list: List<T>)
型パラメータの位置に<*>を指定することと<Any?>を指定することは似ているように感じられるが、次のように明確な違いがある。
MutableList<*>: 特定の型の要素が入っているリストMutableList<Any?>: 何でも入れられるリスト
MutableList<*>として参照しているリストの実体はMutableList<Int>である可能性があるが、MutableList<Any?>として参照しているリストは必ずMutableList<Any?>である。
MutableList<*>を受け取る関数は具体的な型を気にしないが、呼び出し側で具体的な型引数を指定して作成したリストが渡されることを想定している。つまり、次のようなコードは型安全ではないためコンパイルエラーになる。
fun addSomething(list: MutableList<*>) {
list.add("Hello") // ERROR: 勝手にStringを入れてはいけない。
}
fun main() {
var intList = mutableListOf(1, 2, 3)
addSomething(intList)
}
一方、MutableList<Any?>は何でも保存できるリストであることを示す。
fun addSomething(list: MutableList<Any?>) {
list.add("Hello")
}
fun main() {
var list = mutableListOf<Any?>(1, "A", null)
addSomething(list)
}
MutableListは不変(invariant)なクラスなので、MutableList<Any?>を受け取る関数にはMutableList<Any?>オブジェクトだけを渡せる。
スター投影には、要素を読み取り専用にする性質がある。
val list1 = mutableListOf(1, 2, 3)
list1.add(100) // OK
val list2: MutableList<*> = list1
list2.add(100) // ERROR
参考までに、Javaではワイルドカード文字として*ではなく?を使用するが、Kotlinと同じようにコレクションクラスを読み取り専用にする作用がある。
List<String> strList = new ArrayList<>();
strList.add("Hello"); // OK
List<?> roList = strList;
roList.add("World"); // ERROR