[Kotlin]ジェネリクスの「制約」と「in/out」キーワード

  • 共変 (covariant): 広い型(例:double)から狭い型(例:float)へ変換すること。
  • 反変 (contravariant) : 狭い型(例:float)から広い型(例:double)へ変換すること。
  • 不変 (invariant): 型を変換できないこと。

Wikipedia 共変性と反変性 (計算機科学)より

共変、反変、不変。これらは単体で理解しようとすると、余計に難しくなる厄介な用語です。
そこでこのページでは、ジェネリクスのキーワードと対応させて、こう呼びたいと思います。

  • 不変のデフォルト
  • 共変のout
  • 反変のin

今回はKotlinのジェネリクス第2回。「制約」と「in/outキーワード」について。

ジェネリック型の制約

最初にキーワードの前提となる「制約」を取り上げます。前回定義したBoxクラスを使って、ジェネリック型に制約を付け加えていきましょう。

class Box<T>(_contents: T){
    var contents = _contents

    fun take(): T{
        return contents
    }
    fun <R> take(predicate: (T)->R): R{
        return predicate(contents)
    }
}

今のところ、このBoxにはどんな型でも入ります。でもここに制限をかけたい場合もありますよね?

例えばこれが、女友達に渡すプレゼントを入れるBoxだとしましょう。
「心がこもってれば何でもいい」と言いつつ、何でもよくないのは中学生でも分かります。

いくらあなたがロマンを感じるからとって、食虫植物はNGです。


charm(チャーム) (食虫植物)ハエトリソウ 3号(1ポット)

Tに制限をかける

今回はその候補をスイーツ、ハンドクリーム、その他に分け、指定した型しかBox型に入らないようにしてみましょう。

open class Present(val price: Int)

class Sweets(price: Int, val flavor: String): Present(price)
class HandCream(price: Int): Present(price)

SweetsクラスとHandCreamクラスは、どちらもPresentクラスを継承しています。

これらのクラスのみをBoxコンストラクタで受け取れるようにするには、ジェネリック型パラメータを「Presentクラスを継承した型」に指定します。

class Box<T: Present>(_contents: T){...}

このように指定すると、BoxコンストラクタはPresent型か、その派生クラスのオブジェクトしか受け付けません。

この「受け取る型の限定」がジェネリクスの制約です。

fun main() {
    var candidate1: Box<Sweets> = Box(Sweets(2000, "ビターチョコ"))
    var candidate2: Box<Present> = Box(Present(3000))
}

これで「スイーツが入ったBox」であるcandidate1(候補1)と、「何らかのプレゼント候補が入ったBox」であるcandidate2(候補2)が生成できました。ここまでは理解しやすいところ。

不変(invariant)のデフォルト

違う型には入らない?

さてここからです。この変数candidate1を、より大きな型を持つcandidate2に代入することはできるでしょうか?

candidate2 = candidate1
//Type mismatch: inferred type is Box<Sweets> but Box<Present> was expected
型の不一致: 推論される型はBox<Sweets>型だが、Box<Present>型が求められる

結果はエラーになりますが、これは通常の型では全く不自然なエラーです。

val x: Int = 10
var y: Number = 1

//Number型にInt型を代入
y = x   //yはInt型に変化
println(y)   //10

NumberはIntやDoubleのスーパークラスです。そのためNumber型の変数にはInt型を代入することができます。Int型を代入されたyはスマートキャストにより、Int型に変化します。

普通に考えるとジェネリック型でも同じような結果になりそうなものですが、ジェネリック型デフォルトの状態ではこれが許されていません。

なぜダメなのか

この挙動の大きな理由は型安全の担保です。仮にBox<Present>にBox<Sweets>を代入することができるのであれば、以下のようなコードが実行可能になってしまいます。

var candidate1 = Box(Sweets(2000, "ビターチョコ")

//Present型を内包するBox型に、Box<Sweets>を代入
var xBox: Box<Present> = candidate1

//Box<Sweet>をBox<HandCream>に書き換えられる
xBox.contents = HandCream(2000)

これは問題です。変数xBoxに代入を許してしまったばかりに、xBox側でBoxの中身を別の型に入れ替えてしまうと、変数candidate1の中身まで違うものに変更されてしまいます。


Box<Sweets>型の変数にBox<HandCream>型は入りません。結局はここでエラー。

こういったことを防ぐためにデフォルトの状態では、ジェネリック型に指定した型「T」の継承関係は無視され、そのスーパークラスであろうが無かろうが、違う型に代入し直すことができないという制限があります。

この状態を「不変」(invariant)であるといいますが、分かりにくいのでこう捉えましょう。

Kotlin: 「あなたどうせTの型変えるつもりでしょ!そんなのあり得ない!」

共変(covariant)のoutキーワード

コンパイラを満足させる約束

不変であるということは型安全を担保するのにはいいことです。しかしこうも言えます。

「型が変わらなければいいんだよね?」

Kotlinがヒステリックな原因はジェネリック型である「T」が変更されてしまうという不安からです。ならその不安を取り除けば、ギャーギャーわめかれることも無くなります。

「T」は一度決定すれば二度と変更されないと約束して、Kotlinを安心させてあげましょう。最初のBoxクラスはこんな定義でした。

class Box<T: Present>(_contents: T){
    var contents = _contents
//以下は省略
}

それを少しだけ変更します。

class Box<out T: Present>(_contents: T){  //Tの前にoutを追加
    val contents = _contents   //contentsをvalに変更
//以下は省略
}

「T」はこのクラスの内部でcontentsという名前のプロパティになります。それをvalプロパティにすることで、後から変更不可なプロパティとして定義し直しています。

これが約束です。そして約束をしてしまえば、ジェネリック型に「out」を付け加えることができます。

outキーワードを付け加える

outキーワードが付いたジェネリック型「T」は継承関係を維持します。
つまりNumberはIntのスーパークラスであり、PresentはSweetsのスーパークラスであるという概念をそのまま利用できるということになります。

fun main() {
    val candidate1 = Box(Sweets(2000, "ビターチョコ"))
    var candidate2 = Box(Present(3000))

    candidate2 = candidate1
    println(candidate2.contents.price)   //2000
}

今度はエラーは起こりません。一度Box<Sweets>としてインスタンス化されたBox型のT(Sweets)が変更されることが無いと約束されているからです。

スマートキャストが有効になり、Box<Present>という広い型からBox<Sweets>という狭い型への変換が行われています。この状態が「共変」 (covariant)です。


Kotlin: 「ホントに? T変えない? …ならいいわよ。許してあげる」

反変(contravariant)のinキーワード

inキーワードが意味するところはoutと全く逆です。つまり「変更は可能だが読み込みはできない」状態にすることで、狭い型から広い型への変換が可能になります。

class Box<in T: Present>(_contents: T){  //Tの前に「in」を追加

    //privateなvarプロパティにセットすることはできる
    private var contents = _contents

    //他のTを引数に取り、プロパティのTを書き換える「だけの」メソッド
    //内部のT(contentsプロパティ)を参照してはいけない
    fun changeContents(other: T){
        this.contents = other
        println(contents.price)
    }

    //値を参照するような処理は不可
//    fun <R> take(predicate: (T)->R): R{
//        return predicate(contents)
//    }
}

inキーワードが付いたTは変更可能であり、その代わり読み込み不可です。そのためTを参照するような処理はできません。ただしprivateで、かつvarなプロパティとして初期化することは許されます。

fun main() {
    var candidate1 = Box(Sweets(2000, "ビターチョコ"))
    val candidate2 = Box(Present(3000))

    //広い型を狭い型へ代入する = 狭い型を広い型に変換することが可能
    candidate1 = candidate2

    candidate1.changeContents(Sweets(5000, "抹茶チョコ"))
}
//5000

main関数6行目ではoutのときとは真逆の代入が行われています。今度は中身の型「T」にアクセスしないという約束を取り付けることで、Box型の中身(contentsプロパティ)であるTを入れ替えることを可能にし、通常は許されない「狭い型に広い型を代入する」コードが成立します。


この状態を「反変」(contravariant)といいます。

Kotlin: 「呼び出さないならエラーも起こらないから…まぁ、好きにすれば?」

まとめ

Box<T: Some>はT(Someを継承した何らかの型)を利用するクラス。

  1. Box<T>というデフォルトの状態では、Tは参照も変更も可能である。そのため継承関係があったとしても、別の型に入れ替えることができない(不変)
  2. Box<out T>のとき、プロパティTは変更不可であり、その代わりにより大きな型に入れ替えることができる(共変)
  3. Box<in T>のとき、プロパティTは参照不可であり、その代わりにより小さな型に入れ替えることができる(反変)
タイトルとURLをコピーしました