コレクションとジェネリクス
注文には複数の注文明細があり、商品の一覧も在庫の一覧も複数の値の集まりです。この章では、値の集まりを扱う配列とコレクションを見ていきます。前半では、読み取り専用のコレクションと可変のコレクションの違いを押さえ、注文明細の合計金額や商品ごとの数量を map、filter、groupBy、associate、fold などで集計します。続いて、中間のリストを作らずに要素を 1 つずつ処理する Sequence を扱います。後半はジェネリクスです。型パラメータを持つ関数とクラスの書き方、List<String> を List<Any> として渡せる理由 (変位)、実行時に型引数を使える reified を順に見ます。読み終えると、注文明細のリストから集計の結果を作る処理と、どの型の値にも使える小さな関数やクラスを書けるようになります。
題材のデータ
この章の例の多くは、次の商品と注文明細を使います。OrderLine の subtotal は、商品の価格に数量を掛けた小計です。data class とカスタムアクセサは クラスとオブジェクト で扱いました。
data class Product(val id: String, val name: String, val price: Int)
data class OrderLine(val product: Product, val quantity: Int) {
val subtotal: Int
get() = product.price * quantity
}
data class Order(val id: String, val lines: List<OrderLine>)
data class Inventory(val productId: String, val quantity: Int)
val notebook = Product("P1", "ノート", 200)
val pen = Product("P2", "ペン", 150)
val eraser = Product("P3", "消しゴム", 80)
val lines = listOf(
OrderLine(notebook, 3),
OrderLine(pen, 2),
OrderLine(notebook, 1),
OrderLine(eraser, 5),
)
val inventories = listOf(
Inventory("P1", 10),
Inventory("P2", 1),
Inventory("P3", 5),
)
試すときは、この宣言を main の外 (ファイルの一番外側) に書いてください。この後の例のうち lines や notebook を使うものは、同じファイルの main の中に書けば動きます。ノートの明細を複数の行に分けてあるのは、同じ商品の明細をまとめる集計を後で見るためです。
配列
配列は、作るときに要素の数が決まり、後から要素を足したり消したりできない値の並びです。要素を別の値に置き換えることはできます。
val quantities = intArrayOf(4, 2, 1)
quantities[1] = 5
println(quantities.size)
println(quantities[0])
println(quantities.contentToString())
println(quantities.sum())
val names: Array<String> = arrayOf("ノート", "ペン")
println(names.joinToString())
val squares = IntArray(4) { it * it }
println(squares.contentToString())
val other = intArrayOf(4, 5, 1)
println(quantities == other)
println(quantities.contentEquals(other))
3
4
[4, 5, 1]
10
ノート, ペン
[0, 1, 4, 9]
false
true
配列の型には 2 種類あります。Array<String> のように型引数で要素の型を指定する Array<T> と、IntArray、LongArray、DoubleArray、BooleanArray などの数値や真偽値の専用の型です。Array<Int> は要素を 1 つずつ Int のオブジェクトとして持ちますが、IntArray は数値をそのまま並べて持つので、数値の配列には専用の型を使ってください。作り方は、要素を並べる arrayOf と intArrayOf か、要素の数と各位置の値を返すラムダを渡す IntArray(4) { ... } の形です。ラムダの it には位置の番号が入ります。
要素は [] で読み書きし、数は size で取ります。配列を println に渡すと中身でなくオブジェクトの識別子が出るので、中身を見るときは contentToString を使います。
最後の 2 行は、配列の比べ方の注意点です。配列の == は中身を比べず、2 つが同じ配列のオブジェクトかどうかを比べます (Kotlin の基本 の === と同じ判定です)。中身を比べるときは contentEquals を使います。
配列は、要素の数を変えられないうえに == の扱いも違うので、アプリケーションのコードではほとんど使いません。値の集まりには、次の節のコレクションを使います。配列が出てくるのは、可変長引数 (関数とラムダ) を受け取ったときや、数値を大量に扱う処理です。
読み取り専用のコレクションと可変のコレクション
Kotlin の標準ライブラリには、3 種類のコレクションがあります。
| 種類 | 性質 | 読み取り専用の型 | 可変の型 |
|---|---|---|---|
| List | 順番があり、同じ値を重ねて持てる | List<E> | MutableList<E> |
| Set | 同じ値を 1 つしか持たない | Set<E> | MutableSet<E> |
| Map | キーと値の組を持ち、キーは重ならない | Map<K, V> | MutableMap<K, V> |
どの種類にも、読み取り専用の型と可変の型が 1 つずつあります。読み取り専用の型には要素を読む操作だけがあり、可変の型はそれに要素を足す操作と消す操作を加えたものです。List と Set は Collection を継承し、Collection は for で回せる値を表す Iterable を継承しています。Map はこの階層の外にあります。
コレクションを作る
読み取り専用のコレクションは listOf、setOf、mapOf で作ります。
val productNames = listOf("ノート", "ペン", "ノート")
val uniqueNames = setOf("ノート", "ペン", "ノート")
val prices = mapOf("ノート" to 200, "ペン" to 150)
println(productNames)
println(uniqueNames)
println(prices)
println(productNames[1])
println(productNames.getOrNull(5))
println("ペン" in uniqueNames)
println(prices["ペン"])
println(prices["消しゴム"])
[ノート, ペン, ノート]
[ノート, ペン]
{ノート=200, ペン=150}
ペン
null
true
150
null
mapOf に渡す "ノート" to 200 は、infix 関数 to でキーと値の組 (Pair) を作る式です。setOf で作った Set は、同じ値を 2 つ目以降から捨てます。setOf と mapOf で作ったコレクションは、要素を入れた順番を保ちます。
List の要素は [] に位置の番号を渡して取り出します。範囲の外の番号を渡すと例外が投げられるので、無いかもしれない位置を読むときは、無ければ null を返す getOrNull を使います。in は、要素がコレクションに含まれるかを調べる演算子です。
Map の [] にはキーを渡します。キーが無いときは null が返るので、prices["ペン"] の型は Int? です。null 安全の扱い方は Kotlin の基本 で見たとおりです。
要素の無いコレクションは emptyList<String>() や listOf<String>() で作ります。要素から型を推論できないので、型引数を書きます。
要素を変えられるコレクション
可変のコレクションは mutableListOf、mutableSetOf、mutableMapOf で作ります。
val cart = mutableListOf("ノート")
cart.add("ペン")
cart += "消しゴム"
cart.remove("ノート")
println(cart)
val reserved = mutableMapOf<String, Int>()
reserved["P1"] = 3
reserved["P1"] = (reserved["P1"] ?: 0) + 2
reserved.getOrPut("P2") { 1 }
println(reserved)
val names = buildList {
add("ノート")
addAll(cart)
}
println(names)
var history = listOf("ノート")
val before = history
history += "ペン"
println(before)
println(history)
[ペン, 消しゴム]
{P1=5, P2=1}
[ノート, ペン, 消しゴム]
[ノート]
[ノート, ペン]
MutableList には add と remove があり、+= でも要素を足せます。MutableMap は [] への代入でキーの値を書き込みます。すでにあるキーに代入すると、値は置き換わります。getOrPut は、キーがあればその値を返し、無ければラムダの値を書き込んでから返します。
buildList は、ラムダの中だけ可変の List として要素を足し、できあがったものを読み取り専用の List として返します。buildSet と buildMap もあります。足す処理に if やループが要るときは、mutableListOf で作って返すより、この形のほうが外へ可変の型を出しません。
最後の例は、+= の意味が変数によって変わることを示しています。history は var で、型は読み取り専用の List<String> です。読み取り専用の List には要素を足す操作が無いので、history += "ペン" は history = history + "ペン" として扱われます。+ は要素を足した新しい List を返すので、before が指している元の List は変わりません。val の MutableList に対する += は、同じ List に要素を足す操作 (plusAssign) です。
読み取り専用の型の変数から要素を足そうとすると、コンパイルが通りません。
fun addPen(names: List<String>) {
names.add("ペン")
}
Unresolved reference 'add'.
List の型には add という関数そのものがありません。関数の引数を List にしておけば、渡した List をその関数が書き換えないことを型で示せます。
読み取り専用は不変ではない
読み取り専用の型は「この変数からは書き換えられない」ことを表すだけで、中身が変わらないことは保証しません。
val cart = mutableListOf("ノート")
val view: List<String> = cart
val copy = cart.toList()
cart.add("ペン")
println(view)
println(copy)
[ノート, ペン]
[ノート]
MutableList<String> は List<String> を継承しているので、view に代入できます。このとき view と cart は同じ List を指しているので、cart から足した要素は view からも見えます。toList は要素を写した別の List を作るので、copy は後の変更を受けません。
関数から可変のコレクションを読み取り専用の型で返すと、受け取った側は書き換えられませんが、返した側が後で書き換えれば中身は変わります。受け取った側に変更を見せたくないときは、toList で写してから返してください。
コレクションの操作
コレクションには、要素を変換したり絞り込んだりまとめたりする関数が揃っています。どの関数も元のコレクションを書き換えず、結果を新しいコレクションか値として返します。ここからは、題材のデータの lines で注文明細を集計します。ラムダの書き方と it は 関数とラムダ で扱いました。
map と filter で変換して絞り込む
val subtotals = lines.map { it.subtotal }
println(subtotals)
val bulkLines = lines.filter { it.quantity >= 3 }
println(bulkLines.map { it.product.name })
println(lines.sumOf { it.subtotal })
println(lines.mapNotNull { if (it.quantity >= 3) it.product.id else null })
[600, 300, 200, 400]
[ノート, 消しゴム]
1500
[P1, P3]
map は、各要素にラムダを当てた結果を並べた List を返します。filter は、ラムダが true を返した要素だけを残します。sumOf はラムダが返した値の合計で、実行結果の 3 行目が注文全体の合計金額です。
mapNotNull は、map の結果から null を除きます。変換と絞り込みを 1 回で書けるので、「条件に合うものだけを変換する」処理によく使います。
条件で調べる
println(lines.any { it.quantity > 4 })
println(lines.all { it.quantity > 0 })
println(lines.none { it.product == pen })
println(lines.count { it.product == notebook })
println(lines.find { it.product == eraser }?.quantity)
println(lines.firstOrNull { it.product.price > 1000 })
true
true
false
2
5
null
any は条件に合う要素が 1 つでもあるか、all はすべての要素が条件に合うか、none は 1 つも合わないかを返します。count は条件に合う要素の数です。
find と firstOrNull は同じ動きをする関数で、条件に合う最初の要素を返し、無ければ null を返します。戻り値の型が null 許容なので、続けてプロパティを読むときは ?. を使います。条件を渡さない first() は、空のコレクションに対して例外を投げます。空でありうるときは firstOrNull() を使ってください。
並べ替える
println(lines.sortedBy { it.quantity }.map { it.quantity })
println(lines.sortedByDescending { it.subtotal }.map { it.subtotal })
val byIdThenQuantity = lines.sortedWith(
compareBy<OrderLine> { it.product.id }.thenBy { it.quantity },
)
println(byIdThenQuantity.map { "${it.product.id}:${it.quantity}" })
println(lines.maxBy { it.subtotal }.product.name)
[1, 2, 3, 5]
[600, 400, 300, 200]
[P1:1, P1:3, P2:2, P3:5]
ノート
sortedBy はラムダが返した値の小さい順に、sortedByDescending は大きい順に並べた List を返します。複数の基準で並べるときは、compareBy で比べ方 (Comparator) を作り、thenBy で 2 つ目の基準を足して sortedWith に渡します。実行結果の 3 行目では、商品 ID が同じ明細が数量の順に並んでいます。
maxBy は、ラムダの値が最も大きい要素を返します。空のコレクションでは例外を投げるので、空でありうるときは null を返す maxByOrNull を使います。最も小さい要素は minBy で取ります。
groupBy でまとめる
groupBy は、ラムダが返した値をキーにして、要素を Map にまとめます。
val linesByName = lines.groupBy { it.product.name }
println(linesByName.keys)
println(linesByName.getValue("ノート").map { it.quantity })
val quantityByName = lines
.groupBy({ it.product.name }, { it.quantity })
.mapValues { (_, quantities) -> quantities.sum() }
println(quantityByName)
[ノート, ペン, 消しゴム]
[3, 1]
{ノート=4, ペン=2, 消しゴム=5}
linesByName の型は Map<String, List<OrderLine>> で、値は同じ商品名の明細の List です。キーは、そのキーを持つ要素が最初に現れた順に並びます。getValue は、キーが無いときに null を返さず例外を投げる取り出し方です。キーがあると分かっているときに使います。
2 つ目の例のように、groupBy に 2 つ目のラムダを渡すと、Map の値に入れる前に要素を変換できます。続く mapValues は、Map の値だけを変換した新しい Map を返します。ラムダの引数 (_, quantities) は、Map の要素 (キーと値の組) を分解宣言で受け取る書き方で、使わないキーを _ にしています。実行結果の 3 行目が、商品ごとの数量の合計です。
associate で Map を作る
groupBy が 1 つのキーに複数の要素をまとめるのに対して、associate の仲間は 1 つの要素から Map の要素を 1 つ作ります。
val products = listOf(notebook, pen, eraser)
val productById = products.associateBy { it.id }
println(productById.getValue("P2").name)
val priceByName = products.associate { it.name to it.price }
println(priceByName)
val lineCount = products.associateWith { product -> lines.count { it.product == product } }
println(lineCount.mapKeys { it.key.name })
val inventoryById = inventories.associateBy { it.productId }
val shortLines = lines.filter { it.quantity > inventoryById.getValue(it.product.id).quantity }
println(shortLines.map { it.product.name })
ペン
{ノート=200, ペン=150, 消しゴム=80}
{ノート=2, ペン=1, 消しゴム=1}
[ペン]
3 つの関数は、ラムダが返すものが違います。associateBy はラムダの値をキーにして要素を値にし、associateWith は要素をキーにしてラムダの値を値にします。associate は、ラムダが返した Pair をそのままキーと値にします。ラムダが同じキーを 2 回返したときは、後の要素が前の要素を上書きします。キーが重なりうるなら groupBy を使ってください。
最後の例は、在庫を商品 ID で引ける Map にしてから、注文明細の数量が在庫を超える明細を探しています。明細ごとに inventories の List を先頭から探すと、明細と在庫の数を掛けた回数だけ比べることになります。先に Map にしておけば、明細ごとにキーで 1 回引くだけで済みます。ただし、明細を 1 行ずつ在庫と比べるこの書き方には見落としがあります。直し方は練習問題で扱います。
fold と reduce で 1 つの値にする
sumOf や count で書けない集計は、fold で書きます。
val total = lines.fold(0) { acc, line -> acc + line.subtotal }
println(total)
val quantityById = lines
.groupingBy { it.product.id }
.fold(0) { acc, line -> acc + line.quantity }
println(quantityById)
println(lines.groupingBy { it.product.id }.eachCount())
val largest = lines.map { it.subtotal }.reduce { acc, subtotal -> maxOf(acc, subtotal) }
println(largest)
println(emptyList<Int>().reduceOrNull { acc, n -> acc + n })
1500
{P1=4, P2=2, P3=5}
{P1=2, P2=1, P3=1}
600
null
fold は、最初の引数を初期値にして、要素を 1 つずつラムダに渡します。ラムダの 1 つ目の引数 acc はそこまでの集計の値で、ラムダが返した値が次の要素での acc になります。最後の要素を処理した後の値が fold の戻り値です。
groupingBy は、groupBy のように要素をキーで分けますが、すぐには Map を作りません。続けて fold や eachCount を呼ぶと、キーごとに集計した値の Map を返します。groupBy で List の Map を作ってから mapValues で集計する形と同じ結果を、途中の List を作らずに得られます。
reduce は初期値を取らず、最初の要素を初期値にします。空のコレクションでは最初の要素が無いので、reduce は例外を投げ、reduceOrNull は null を返します。初期値を決められる集計なら、fold を使ってください。
そのほかのよく使う操作
val orders = listOf(
Order("O1", listOf(OrderLine(notebook, 3), OrderLine(pen, 2))),
Order("O2", listOf(OrderLine(notebook, 1), OrderLine(eraser, 5))),
)
println(orders.flatMap { it.lines }.map { it.product.id })
println(orders.map { order -> order.id to order.lines.sumOf { it.subtotal } })
val (bulk, small) = lines.partition { it.quantity >= 3 }
println(bulk.map { it.quantity })
println(small.map { it.quantity })
println(lines.map { it.product.id }.distinct())
println(lines.take(2).map { it.product.id })
[P1, P2, P1, P3]
[(O1, 900), (O2, 600)]
[3, 5]
[2, 1]
[P1, P2, P3]
[P1, P2]
| 関数 | 返すもの |
|---|---|
flatMap | 各要素から List を作り、それらを 1 つにつなげた List |
partition | 条件に合う要素の List と合わない要素の List の Pair |
distinct | 重なる値を除いた List (最初に現れた順) |
take | 先頭から指定した数の要素の List。末尾からは takeLast |
flatMap は、入れ子のコレクションを平らにする処理に使います。実行結果の 2 行目は、注文 ID と合計金額の Pair の List で、Pair の toString は丸括弧で 2 つの値を囲みます。partition の戻り値は Pair なので、分解宣言で 2 つの変数に受け取れます。
Set の操作
val orderedIds = setOf("P1", "P2", "P3")
val discontinuedIds = setOf("P3", "P9")
println(orderedIds intersect discontinuedIds)
println(orderedIds subtract discontinuedIds)
println(orderedIds union setOf("P4"))
println(orderedIds - "P1")
println(listOf("P1", "P2", "P1").toSet())
[P3]
[P1, P2]
[P1, P2, P3, P4]
[P2, P3]
[P1, P2]
intersect は両方にある要素、subtract は左にあって右に無い要素、union はどちらかにある要素の Set を返します。どれも infix 関数なので、演算子のように間に置いて書けます。実行結果の 1 行目は、注文された商品のうち販売を止めた商品の ID です。
- は要素を除いた新しい Set を返し、+ は要素を足した新しい Set を返します。List を toSet で Set にすると、重なる要素が除かれます。
Map の操作
val inventory = mapOf("P1" to 10, "P2" to 1, "P3" to 5)
for ((productId, quantity) in inventory) {
println("$productId: $quantity")
}
println(inventory.getOrElse("P9") { 0 })
println(inventory.filterValues { it < 5 })
println(inventory.mapValues { (_, quantity) -> quantity * 2 })
println(inventory + ("P4" to 3))
println(inventory - "P1")
P1: 10
P2: 1
P3: 5
0
{P2=1}
{P1=20, P2=2, P3=10}
{P1=10, P2=1, P3=5, P4=3}
{P2=1, P3=5}
Map を for で回すと、キーと値の組 (Map.Entry) が 1 つずつ取り出されます。(productId, quantity) のように分解宣言で受け取ると、キーと値を別々の変数で使えます。
getOrElse は、キーが無いときにラムダの値を返します。filterValues は値で、filterKeys はキーで要素を絞り込みます。mapValues は値を変換し、mapKeys はキーを変換した Map を返します。+ に Pair を渡すと組を足した Map を、- にキーを渡すとその組を除いた Map を返します。どれも元の Map は変えません。
Sequence
前の節の操作は、呼ぶたびに結果の List を作ります。lines.filter { ... }.map { ... } と書くと、filter の結果の List を作ってから、それを map に渡します。Sequence は、この途中の List を作らずに、要素を 1 つずつ最後の操作まで通す仕組みです。
List の操作は 1 段ずつ全要素を処理する
次の例は、小計を求め、300 未満の最初の小計を取り出します。どの要素でラムダが呼ばれたかを println で出しています。
val firstSmall = lines
.map {
println("map: ${it.product.id}")
it.subtotal
}
.filter {
println("filter: $it")
it < 300
}
.first()
println(firstSmall)
map: P1
map: P2
map: P1
map: P3
filter: 600
filter: 300
filter: 200
filter: 400
200
map はすべての明細の小計を求めて List を作り、filter はその List のすべての要素を調べて、また List を作ります。first が受け取るのは、絞り込みが終わった List です。条件に合う要素が先頭の近くにあっても、map と filter は最後の要素まで処理します。
Sequence は要素ごとに処理する
同じ処理を、asSequence で Sequence にしてから書きます。
val firstSmall = lines.asSequence()
.map {
println("map: ${it.product.id}")
it.subtotal
}
.filter {
println("filter: $it")
it < 300
}
.first()
println(firstSmall)
map: P1
filter: 600
map: P2
filter: 300
map: P1
filter: 200
200
Sequence では、1 つの要素を map から filter まで通してから、次の要素に進みます。first は条件に合う要素を受け取った時点で値を返し、残りの要素を求めません。2 つの実行結果を比べると、ラムダが呼ばれる順番と回数の違いが分かります。
終端の操作まで何も動かない
Sequence の map や filter は、処理の手順を積み重ねた新しい Sequence を返すだけで、その時点ではラムダを呼びません。first、toList、sum、count のように Sequence 以外の値を返す操作を終端の操作といい、これを呼んだときに初めて要素が処理されます。このように、値が要るときまで計算を遅らせることを遅延評価といいます。
val subtotals = lines.asSequence().map {
println("map: ${it.product.id}")
it.subtotal
}
println("toList の前")
println(subtotals.toList())
val orderIds = generateSequence(1) { it + 1 }.map { "O$it" }
println(orderIds.take(3).toList())
toList の前
map: P1
map: P2
map: P1
map: P3
[600, 300, 200, 400]
[O1, O2, O3]
実行結果の 1 行目は、map を呼んだ後に出した行です。map のラムダの行は、その後の toList を呼んだときに出ています。
要素を求めるのが終端の操作のときだけなので、Sequence は終わりの無い並びも表せます。generateSequence(1) { it + 1 } は、初期値から始めて、前の値にラムダを当てた値を次々に返す Sequence です。take(3) で先頭の 3 つに限ってから toList を呼ぶので、処理は終わります。take を付けずに toList を呼ぶと、要素を求め続けて止まりません。
Sequence にすれば常に速くなるわけではありません。要素ごとに手順を進める仕組みの分の手間があるので、要素が少なく操作が 1 つか 2 つなら、List のままのほうが簡単で十分です。Sequence を選ぶのは、要素が多くて途中の List が大きくなるとき、first や take で途中で止められるとき、終わりの無い並びを扱うときです。asSequence で作った Sequence は、終端の操作を呼ぶたびに最初の要素から処理し直すので、何度も読む結果は toList で List にしておきます。
ジェネリクス
List<String> の <String> のように、型を引数として受け取る仕組みをジェネリクスといいます。List の宣言は List<E> で、E を型パラメータ、使うときに渡す String を型引数と呼びます。ここまで使ってきたコレクションの関数も、ジェネリクスで書かれています。
ジェネリックな関数
関数に型パラメータを付けるときは、fun の直後に <T> を書きます。
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
fun <T : Comparable<T>> larger(a: T, b: T): T = if (a >= b) a else b
fun <T> smallestNonBlank(items: List<T>): T?
where T : CharSequence, T : Comparable<T> =
items.sorted().firstOrNull { it.isNotBlank() }
fun main() {
println(listOf("ノート", "ペン").secondOrNull())
println(listOf(1).secondOrNull())
println(larger(3, 7))
println(larger("P1", "P2"))
println(smallestNonBlank(listOf("ペン", " ", "ノート")))
}
ペン
null
7
P2
ノート
secondOrNull は、どの型の List にも使える拡張関数です。呼び出し側では型引数を書いていませんが、コンパイラが受け取った List の型から T を推論します。listOf("ノート", "ペン").secondOrNull() の戻り値の型は String? になります。
larger の <T : Comparable<T>> は、T に渡せる型を Comparable<T> を実装した型に限る書き方で、この制限を上限境界といいます。上限境界があるので、関数の中で a >= b と比べられます。Int と String はどちらも Comparable を実装しているので渡せます。上限境界を書かない型パラメータの上限は Any? で、null 許容の型も渡せます。null を渡せないようにするには <T : Any> と書きます。
上限境界を 2 つ以上付けるときは、smallestNonBlank のように、戻り値の型の後に where を書いて並べます。T は文字列のように扱えて、しかも比べられる型でなければなりません。sorted は Comparable を、isNotBlank は CharSequence を要求するので、両方の上限境界が要ります。
ジェネリックなクラス
クラスに型パラメータを付けるときは、クラス名の直後に書きます。一覧の API が返す「1 ページ分の要素と全体の件数」を表すクラスを作ってみます。
data class Page<T>(val items: List<T>, val totalCount: Int) {
fun <R> map(transform: (T) -> R): Page<R> = Page(items.map(transform), totalCount)
}
fun main() {
val products = Page(listOf(notebook, pen), totalCount = 3)
val names: Page<String> = products.map { it.name }
println(names)
println(products.items.first().price)
}
Page(items=[ノート, ペン], totalCount=3)
200
Page(listOf(notebook, pen), totalCount = 3) では、コンストラクタの引数から T が Product と推論され、products の型は Page<Product> になります。products.items.first() の型も Product なので、型変換をせずに price を読めます。
map は、クラスの型パラメータ T とは別に、自分の型パラメータ R を持つ関数です。要素を変換する関数を受け取り、Page<R> を返します。件数はそのまま引き継ぎます。
変位
型 A の値をいつでも型 B の値として扱えるとき、A を B の部分型といいます。String は Any の部分型なので、String の値は Any の変数に代入できます。では、List<String> は List<Any> の変数に代入できるでしょうか。型引数どうしの関係から、ジェネリックな型どうしの関係がどう決まるかを変位といいます。
List は out で宣言されている
List は List<out E> と宣言されていて、この out が、型引数の部分型の関係をそのまま List にも当てはめることを表します。String が Any の部分型なので、List<String> も List<Any> の部分型になります。この性質を共変といいます。
一方、MutableList<String> を MutableList<Any> の変数には代入できません。もし代入できると、MutableList<Any> の変数から Int を足せてしまい、String だけのはずの List に Int が入ります。MutableList<E> の E には out も in も付いていないので、型引数が違えば代入できません。この性質を不変といいます。MutableList<String> を読み取り専用の List<Any> の変数に代入するのは、List<String> を経由した代入なのでできます。配列の Array<T> も不変です。
共変の代入と、最後に述べた List<Any> への代入は、次のように書けます。
val names: List<String> = listOf("ノート", "ペン")
val values: List<Any> = names
println(values)
val cart: MutableList<String> = mutableListOf("ノート")
val readOnly: List<Any> = cart
println(readOnly)
[ノート, ペン]
[ノート]
names の型は List<String> と決まっていて、それを List<Any> の変数 values に代入しています。
宣言側の変位: out と in
自分で書く interface やクラスにも、型パラメータに out と in を付けられます。
interface Catalog<out T> {
fun all(): List<T>
}
fun interface Validator<in T> {
fun validate(value: T): Boolean
}
class ProductCatalog : Catalog<Product> {
override fun all(): List<Product> = listOf(notebook, pen)
}
fun countAll(catalog: Catalog<Any>): Int = catalog.all().size
fun main() {
println(countAll(ProductCatalog()))
val notBlank: Validator<CharSequence> = Validator { it.isNotBlank() }
val nameValidator: Validator<String> = notBlank
println(nameValidator.validate("ノート"))
println(nameValidator.validate(" "))
}
2
true
false
Catalog<out T> は T の値を返すだけの interface です。out を付けたので、Catalog<Product> を Catalog<Any> を受け取る countAll に渡せます。商品を返すカタログは、「何かの値を返すカタログ」として使っても困りません。
Validator<in T> は逆に、T の値を受け取るだけの interface です。in を付けると、部分型の関係が逆向きになります。CharSequence を検証できる Validator<CharSequence> は、String も検証できるので、Validator<String> として使えます。この性質を反変といいます。
コンパイラは、out を付けた型パラメータが値を受け取る位置 (関数の引数の型) に現れないことを確かめます。
interface Catalog<out T> {
fun all(): List<T>
fun add(item: T)
}
Type parameter 'T' is declared as 'out' but occurs in 'in' position in type 'T (of interface Catalog<out T>)'.
エラーの 'in' position は、値を受け取る位置を指します。add を許すと、Catalog<Any> として渡された Catalog<Product> に、Product でない値を足せてしまいます。同じ理由で、in を付けた型パラメータは戻り値の型に使えません。覚え方は名前のとおりで、外へ出す (out) だけなら共変に、中へ入れる (in) だけなら反変にできます。
使用側の変位
MutableList のように、値を返す関数と受け取る関数の両方を持つ型は、宣言に out も in も付けられません。そうした型でも、使う場所で out か in を付けると、その場所での使い方を絞る代わりに代入できる型を広げられます。
fun copyNames(from: MutableList<out CharSequence>, to: MutableList<in String>) {
for (name in from) {
to.add(name.toString())
}
}
fun main() {
val source: MutableList<String> = mutableListOf("ノート", "ペン")
val target: MutableList<Any> = mutableListOf()
copyNames(source, target)
println(target)
}
[ノート, ペン]
from の型 MutableList<out CharSequence> には、MutableList<String> も MutableList<StringBuilder> も渡せます。その代わり、from からは要素を CharSequence として読むことしかできず、add は呼べません。to の型 MutableList<in String> には、String を入れられる MutableList<Any> も渡せます。to には String を足せますが、読み出した要素の型は Any? になります。
型引数に何が来ても構わないときは、List<*> のように * を書きます。この書き方をスター投影といい、要素を Any? として読むことだけができます。次の節の例で使います。
reified
ジェネリクスの型引数はコンパイルの時点の確認に使われ、実行時には残りません。これを型消去といいます。実行時には List<String> も List<Int> も同じ List なので、型パラメータ T を使って「値が T かどうか」を調べることはできません。
fun <T : Any> typeNameOf(): String = T::class.simpleName ?: "名前なし"
Cannot use 'T' as reified type parameter. Use a class instead.
T::class は T の実際のクラスを要求しますが、実行時の関数には T が何の型かが渡されません。
inline 関数 (関数とラムダ) の型パラメータに reified を付けると、この制限が外れます。inline 関数は呼び出した場所に本体が展開されるので、コンパイラはその場所の型引数を本体の T に当てはめられます。
inline fun <reified T> List<*>.countOf(): Int = count { it is T }
inline fun <reified T : Any> typeName(): String = T::class.simpleName ?: "名前なし"
fun main() {
val records: List<Any> = listOf(notebook, Inventory("P1", 10), pen)
println(records.countOf<Product>())
println(records.countOf<Inventory>())
println(records.filterIsInstance<Inventory>())
println(typeName<Product>())
}
2
1
[Inventory(productId=P1, quantity=10)]
Product
countOf の中の it is T は、呼び出しごとに it is Product や it is Inventory としてコンパイルされます。reified の型パラメータは、is、as、T::class で実際の型として使えます。
標準ライブラリの filterIsInstance も reified の関数です。指定した型の要素だけを残し、結果の型を List<Inventory> にします。filter { it is Inventory } では結果の型が List<Any> のままなので、要素を Inventory として使うには型変換が要ります。
reified を付けられるのは inline 関数の型パラメータだけで、クラスの型パラメータには付けられません。
まとめ
- 配列は要素の数が決まった並びです。数値には
IntArrayなどの専用の型を使い、中身はcontentEqualsで比べます - コレクションには読み取り専用の型 (
List、Set、Map) と可変の型 (MutableListなど) があります。読み取り専用は「その変数から書き換えられない」ことで、中身が変わらないことは保証しません - 変換は
map、絞り込みはfilter、キーでまとめるのはgroupByかgroupingBy、Map を作るのはassociateの仲間、1 つの値への集計はsumOfかfoldを使います - 空でありうるコレクションには、例外を投げる
firstやmaxByでなく、null を返すfirstOrNullやmaxByOrNullを使います - Sequence は要素を 1 つずつ最後の操作まで通し、終端の操作を呼ぶまで処理を始めません。要素が多いときや途中で止められるときに使います
- 型パラメータには上限境界を付けられます。2 つ以上なら
whereで並べます - 値を返すだけの型パラメータには
out、受け取るだけならinを付けると、代入できる型が広がります。宣言に付けられない型は、使う場所で付けます - 型引数は実行時に残りません。inline 関数の
reifiedの型パラメータだけが、isやT::classで使えます
次に読む
前の章は クラスとオブジェクト です。
次は 例外と失敗の表し方 です。この章では、first や getValue が要素の無いときに例外を投げることと、null を返す firstOrNull や getOrNull があることを見ました。次の章では、自分で書く関数が失敗をどう表すかを、例外と null と sealed のそれぞれで扱います。
練習問題
associate の節の最後の例は、注文明細を 1 行ずつ在庫と比べています。消しゴム (在庫 5 個) の明細が 3 個と 4 個の 2 行に分かれた注文では、どちらの行も在庫以下なので、在庫の不足を見落とします。商品ごとの数量の合計で在庫と比べ、在庫が足りない商品の ID を求めるように書き直してください
解答例
val orderLines = listOf(OrderLine(eraser, 3), OrderLine(eraser, 4), OrderLine(pen, 1))
val inventoryById = inventories.associateBy { it.productId }
val shortIds = orderLines
.groupingBy { it.product.id }
.fold(0) { acc, line -> acc + line.quantity }
.filter { (productId, quantity) -> quantity > inventoryById.getValue(productId).quantity }
.keys
println(shortIds)
[P3]
在庫から引き当てる数は、同じ商品の明細の数量を足した値です。そこで、比べる前に groupingBy と fold で商品 ID ごとの数量の合計を作ります。groupBy { it.product.id } で明細の List の Map を作り、mapValues で合計しても同じ結果になります。
合計の Map を filter で絞り込むと、在庫を超えた商品だけが残ります。Map の filter のラムダは Map.Entry を受け取るので、分解宣言でキーと値に分けています。最後の keys で、残った商品の ID の Set を取り出します。
List<*> から、指定した型の要素のうち最初のものを返し、無ければ null を返す関数 firstOfType を書いてください。records.firstOfType<Product>() のように呼べるようにします
解答例
inline fun <reified T> List<*>.firstOfType(): T? = firstOrNull { it is T } as T?
fun main() {
val records: List<Any> = listOf(Inventory("P1", 10), pen, notebook)
println(records.firstOfType<Product>())
println(records.firstOfType<String>())
}
Product(id=P2, name=ペン, price=150)
null
関数の中で it is T と書くので、T には reified が要ります。reified は inline 関数にしか付けられないので、inline fun で宣言します。
firstOrNull の結果の型は、List<*> の要素の型の Any? です。条件で型を確かめていても、コンパイラは戻り値の型を T? と判断しないので、as T? で型を変えています。T が reified なので、この as は実行時にも型を確かめます。
標準ライブラリを組み合わせて、filterIsInstance<T>().firstOrNull() と書くこともできます。この形は型変換を書かずに済みますが、条件に合う要素をすべて集めた List を作ってから先頭を取ります。最初の 1 つで止めたいなら、解答例の形か、asSequence().filterIsInstance<T>().firstOrNull() を使います。