ジェネリック(generic)とは「汎用的な」という意味の単語、ジェネリクス(generics)とはその汎用的な型を扱う「機構(システム)」のことを指します。
今回は「はじめてのジェネリクス」と題し、ジェネリクス機構を用いたクラスや関数を題材に、
- 汎用的な型「Any」の問題点
- ジェネリクスを使う利点
- ジェネリック型を扱うクラスや関数の読み方、作り方
についてお話していきます。
ジェネリクスとは何か
汎用的な型として真っ先に思い浮かぶのはAny型ですが、Any型とジェネリック型とは何が違うのでしょうか?
Any型を受け取るクラスとジェネリック型を受け取るクラスを比較して、ジェネリクスがなぜ必要なのか見ていくことにします。
Any型を受け取るクラス
こちらはAny型を受け取るクラス宣言です。
class Box(val contents: Any)
Boxクラスのコンストラクタはnull以外なら何でも受け取ることができ、「何か中身(contents)が入った箱」を表現します。
fun main() {
val x = Box("a")
val y = Box(0)
val z = Box(listOf(1,2,3))
}
便利ですね(棒読み)。ではこの箱の中身を+1してみましょう。
println(y.contents + 1)
//Unresolved reference.
//None of the following candidates is applicable because of receiver type mismatch:
//...
(以下の行では様々なクラスに定義されたplusメソッドが列挙される)
ま、そうなりますわな…。
Any型の問題点
Box型の変数yの中身は「0」という数値のはずです。しかしこれに+1することは許されません。なぜならこの「0」は、Boxに入った瞬間「Any型」としか認識されないからです。
Anyにはplusメソッドが定義されていないため、+1することは当然不可能となります。これを成立させるためにはas
を使った強制的な型キャストが必要です。
println(y.contents as Int + 1) //1
ジェネリック型を受け取るクラス
しかしいちいち型キャストするのは面倒だし、なによりas
は安全ではありません。そこでこのクラスのコンストラクタ引数を、ジェネリクスを使った表記に切り替えてみます。
class Box<T>(val contents: T)
このBoxクラスのコンストラクタを呼び出すと、
Box<T>がどんな型でも受け取ることに変わりはありません。しかしジェネリクスを使うことによって、実際のインスタンスはAnyではなく「Stringが入った」Boxや「Intが入った」Boxであると認識されます。
fun main() {
val x = Box("a")
val y = Box(0)
val z = Box(listOf(1,2,3))
println(x.contents + "bc") //abc
println(y.contents + 1) //1
z.contents.forEach { print(it) } //123
}
新しいBoxクラスのコンストラクタは、クラス定義の時点では「何でもいい」と言いつつ、実際にそれを使うときには(型推論によって)きちんと型指定していることに注目してください。
これは本当に何でもいい =「Anyとして扱う」わけではなく、型の決定を実際に使うときまで先延ばししているに過ぎません。しかし、だからこそBox<String>の中身は文字列を足し合わせる事ができ、Box<List<Int>>の中身はループ処理に対応できます。
- Any型→「何でも受け取るが、それら全てをAny型として処理する」
- ジェネリック型→「宣言の時点では決めないけれど、何かの型を受け取る」
ジェネリクスの表記
クラス
それでは表記を確認していきましょう。最初にクラス宣言です。
class Box<T>(val _contents: T)
//内部で初期化する場合、初期化部分の「: T」は省略可
class Box<T>(_contents: T){
val contents = _contents
}
この中で<T>はジェネリック型パラメータ、または単に型パラメータと呼ばれます。
これによってこのクラスは、「ジェネリック型T(ここでは限定しない型)を含む」という宣言がされています。
つまりこのクラスをインスタンス化する際にはBox<Int>
などのように、何らかの型が与えられなければなりません。
そしてコンストラクタの仮型引数にも同じ「T」が使われています。
このアルファベット自体に制限はありません。XやZZでもかまいませんが、含むものが「型」であることを分かりやすくするために「Type」のTが一般的に使われます。
ジェネリック型を返すメソッド
次にこのクラスに、ジェネリック型を返すメソッドを追加します。
class Box<T>(val contents: T){
fun take(): T{
return contents
}
}
これは単純です。通常のメソッド定義の戻り値をTとするだけ。この場合、Tはクラス宣言の型パラメータと結びついているため、それと同じ文字を使用してください。
ジェネリック型を受け取り、ラムダの結果を返すメソッド
少し複雑になります。今度は「関数を受け取り、ラムダの結果を返す」メソッドを定義します。
今回はメソッド名を上と同じtakeメソッドとして、オーバーロードで作ってみましょう。
class Box<T>(val contents: T){
fun take(): T{...} //引数無しtakeメソッドは上と同じ
fun <R> take(predicate: (T)->R): R{
return predicate(contents)
}
}
新しいtakeメソッドが受け取るものは「predicate」という名前の関数(ラムダ式)です。
ご存知の通り、引数に取る関数名も(メソッドの命名規則に則っていれば)自由に決められますが、KotlinのAPIリファレンスではよくこの単語が使われています。
断言する、…に基づかせる、述語/述部
このpredicateはTを受け取り、Rを返します。Rは「(関数の)Return」の頭文字。これもこの時点では型が決定していないジェネリック型です。
一度実際に使って動作を確かめてみましょう。
fun main() {
val x = Box("a")
println(x.take{it+"bc"}) //abc
}
predicate
の引数x.contents
に「bc」を加えたものが出力されました。x.contents
が文字列であることで、ラムダ式内の「it」は文字列として使用することができます。
関数
最後にクラスに紐付いていない関数です。こちらも記述方法としてはメソッドと同じ。
fun <E> extract(list: List<E>): E{
return list[0]
}
ここで使用しているジェネリック型はListの要素E(Element)です。
関数extractはList<E>(何らかの型が入ったList)を受け取り、その最初の要素である(何らかの型)Eを返します。
他の例と同じように、この時点では戻り値Eが実際は何の型なのか、はっきり指定していません。このEが決定するのは、何度も言いますが「この関数が実際に使用されるとき」です。
fun main() {
val list = listOf(5,6,7) //List<Int>であることから、EはIntに決定
println(extract(list)) //5
}
まとめ
今回の超要約。
- ジェネリクスは宣言時に型を限定せず、呼び出し時に委ねるシステム
- ジェネリクスで用いられるのがジェネリック(汎用的な)型
- ジェネリック型パラメータは、クラス宣言の場合
クラス名 <T>
、関数やメソッドの場合fun <T> 関数名
の順で表記する
ジェネリック型の表記 | 意味 |
---|---|
<T> | 型(Type)の略 |
<R> | 結果(Return)の略 |
<E> | 要素(Element)の略 |
<K> | キー(Key)の略 |
<V> | バリュー(Value)の略 |