Kotlin Collection: map、filterおよびgroupByを実例で学ぶ

1. 概要

ビジネスロジックのコードの大部分は、リストの変換です。数個のフィールドを抽出したり、条件でフィルタリングしたり、グループ化して合計を計算したりします。Kotlinの標準ライブラリには、このため既にmap、filter、groupByという拡張関数が用意されているため、追加のライブラリは不要です。

このガイドでは、各関数とその重要なバリエーションについて説明し、それらをパイプラインに組み合わせます。記事の最後にはテストの検証とよくある間違いのリストがあります。

2. サンプルデータ

すべての例では、共通の注文リストを使用します。

enum class Status { NEW, PAID, CANCELLED }

data class Order(
    val id: Int,
    val customer: String,
    val status: Status,
    val total: Int,          // 単位:千ドン
    val coupon: String? = null
)

val orders = listOf(
    Order(1, "An",   Status.PAID,      120, coupon = "SALE10"),
    Order(2, "Bình", Status.NEW,        80),
    Order(3, "An",   Status.CANCELLED,  50),
    Order(4, "Chi",  Status.PAID,      200, coupon = "VIP"),
    Order(5, "Bình", Status.PAID,       45),
)

ordersの型はList<Order>で、読み取り専用のリストです。以下の関数は、元のリストを変更しません。呼び出すたびに、新しいCollectionが返されます。

3. map: 各要素を変換する

3.1. 基本的なmap

mapはラムダ(T) -> Rを受け取り、各要素に適用してList<R>を返します。

val ids = orders.map { it.id }
val customers = orders.map { it.customer }

println(ids)
println(customers)
[1, 2, 3, 4, 5]
[An, Bình, An, Chi, Bình]

結果は常に元のリストと同じ要素数を持ち、順序も保持されます。重複を除いた値のみが必要な場合は、.distinct()を追加します。orders.map { it.customer }.distinct()は[An, Bình, Chi]を返します。

シンプルなプロパティの場合、ラムダの代わりにproperty referenceを使用できます:orders.map(Order::id)。

3.2. mapNotNull: 変換してnullを除外する

ラムダがnullを返す可能性がある場合は、mapNotNullを使って変換とnull値の除外を同時に行います。

val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]

戻り値の型はList<String>で、List<String?>ではありません。この方法はmap { it.coupon }.filterNotNull()よりも簡潔で型安全です。

3.3. mapIndexed: インデックスが必要な場合

val lines = orders.mapIndexed { index, order -> "${index + 1}. 注文 #${order.id} - ${order.total}k" }
lines.forEach(::println)
1. 注文 #1 - 120k
2. 注文 #2 - 80k
3. 注文 #3 - 50k
4. 注文 #4 - 200k
5. 注文 #5 - 45k

3.4. Mapに対するmap

Mapに対してmapを呼び出すとListが返されます。結果がMapのままであることが必要な場合は、mapValuesまたはmapKeysを使用します。

val prices = mapOf("cafe" to 30, "tra" to 25)

val labels = prices.map { (name, price) -> "$name: ${price}k" }   // List<String>
val withVat = prices.mapValues { (_, price) -> price * 110 / 100 } // Map<String, Int>

println(labels)
println(withVat)
[cafe: 30k, tra: 25k]
{cafe=33, tra=27}

4. filter: 条件を満たす要素を保持する

4.1. filterとfilterNot

filterはpredicate (T) -> Booleanを受け取り、predicateがtrueを返す要素を保持します。

val paid = orders.filter { it.status == Status.PAID }
val notCancelled = orders.filterNot { it.status == Status.CANCELLED }

println(paid.map { it.id })
println(notCancelled.map { it.id })
[1, 4, 5]
[2, 4, 5]

ちょっと待ってください、2行目が間違っています。注文1はキャンセルされていないため、含まれている必要があります。正しい結果は:

[1, 4, 5]
[1, 2, 4, 5]

filterNotはfilter { !(...) }を書くより読みやすい方法です。否定条件がコードを読みにくくする場合に使用します。

4.2. partition: 1回のトラバーサルで2つに分割する

満たすグループと満たさないグループの両方が必要な場合は、filterを2回呼び出さないでください。partitionは2つのリストを含むPairを返します。

val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }

println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]

分割代入val (a, b)は、各半分に明確な名前を付けるのに役立ちます。

4.3. filterIsInstance: 型でフィルタリングする

混合型のリストを扱う場合、filterIsInstance<T>()はフィルタリングとスマートキャストを同時に行います。

sealed interface Event
data class OrderPlaced(val orderId: Int) : Event
data class OrderShipped(val orderId: Int, val carrier: String) : Event

val events: List<Event> = listOf(OrderPlaced(1), OrderShipped(1, "GHN"), OrderPlaced(2))

val shipped: List<OrderShipped> = events.filterIsInstance<OrderShipped>()
println(shipped.map { it.carrier })
[GHN]

手動でキャストする必要はありません。結果は既にList<OrderShipped>型になっています。

5. groupBy: キーでグループ化する

5.1. 基本的なgroupBy

groupByはキー選択関数を受け取り、Map<K, List<T>>を返します。

val byCustomer: Map<String, List<Order>> = orders.groupBy { it.customer }

println(byCustomer.mapValues { (_, list) -> list.map { it.id } })
{An=[1, 3], Bình=[2, 5], Chi=[4]}

返されるMapはLinkedHashMapです。キーの順序は元のリストでの最初の出現順であり、各グループ内の要素の順序も保持されます。

5.2. valueTransform付きのgroupBy

2番目のオーバーロードはグループ化時に値を変換できるため、別にmapValuesを呼び出す必要がありません。

val totalsByCustomer: Map<String, List<Int>> =
    orders.groupBy(keySelector = { it.customer }, valueTransform = { it.total })

println(totalsByCustomer)
{An=[120, 50], Bình=[80, 45], Chi=[200]}

5.3. グループ化後の集計

最も一般的なパターンは、グループ化してから各グループの単一値を計算することです。

val revenueByStatus: Map<Status, Int> = orders
    .groupBy { it.status }
    .mapValues { (_, list) -> list.sumOf { it.total } }

println(revenueByStatus)
{PAID=365, NEW=80, CANCELLED=50}

5.4. groupingBy: 中間リストを作成しない集計

groupByは各グループのListを常に作成します。カウントか累積合計のみが必要な場合、groupingByは中間リストを作成しないため、より効率的です。

val orderCount: Map<String, Int> = orders.groupingBy { it.customer }.eachCount()

val spentByCustomer: Map<String, Int> = orders
    .groupingBy { it.customer }
    .fold(0) { acc, order -> acc + order.total }

println(orderCount)
println(spentByCustomer)
{An=2, Bình=2, Chi=1}
{An=170, Bình=125, Chi=200}

groupingByはGroupingオブジェクトを返し、まだ計算していません。集計はeachCount、fold、reduce、aggregateを呼び出すときにのみ実行されます。

5.5. groupByとassociateByの違い

これら2つの関数はよく混同されます。

val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}

associateByはMap<K, T>を作成します。つまり、各キーは単一の要素に対応します。キーが重複している場合、後の要素が前の要素を上書きします。したがって、associateByはキーが一意な場合にのみ使用します(例:associateBy { it.id })。キーが重複する可能性がある場合は、groupByを使用します。

6. パイプラインへの組み合わせ

上記の関数は相互に接続できます。次の例では、各顧客の支払い済みオーダーの収益を計算し、降順にソートします。

fun paidRevenueRanking(orders: List<Order>): List<Pair<String, Int>> =
    orders
        .filter { it.status == Status.PAID }
        .groupBy { it.customer }
        .mapValues { (_, list) -> list.sumOf { it.total } }
        .toList()
        .sortedByDescending { it.second }

println(paidRevenueRanking(orders))
[(Chi, 200), (An, 120), (Bình, 45)]

パイプラインは次のステップを通じて実行されます。

  • filterは注文1、4、5を保持します。
  • groupBy + mapValuesは{An=120, Chi=200, Bình=45}を生成します。
  • toList()はMapをList<Pair<String, Int>>に変換してソート可能にします。
  • sortedByDescendingは収益でソートします。

注意: List上の各ステップはeagerです。全データをトラバーサルして新しいリストを作成します。数千個の要素では無視できます。非常に大量のデータを扱う場合、または最初の数個の要素のみが必要な場合(take、first)、asSequence()に切り替えて、各要素を遅延処理してください。

val firstTwoBigPaid = orders.asSequence()
    .filter { it.status == Status.PAID }
    .map { it.id }
    .take(2)
    .toList()

println(firstTwoBigPaid)
[1, 4]

Sequenceを使用すると、2つの要素を取得した後、処理チェーンは停止します。注文5は考慮されません。

7. パイプラインのテストを書く

pom.xmlにJUnit 5とAssertJを追加します。KotlinプロジェクトにはすでにKotlin Maven Pluginが必要です。

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class OrderStatsTest {

    @Test
    fun `支払い済みオーダーのみの収益ランキング`() {
        val result = paidRevenueRanking(orders)

        assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
    }

    @Test
    fun `groupingBy eachCountで各顧客のオーダー数を正確にカウント`() {
        val count = orders.groupingBy { it.customer }.eachCount()

        assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
    }

    @Test
    fun `filterは元のリストを変更しない`() {
        orders.filter { it.total > 100 }

        assertThat(orders).hasSize(5)
    }
}

containsExactlyはコンテンツと順序の両方をチェックします。これは重要です。ランキング結果は順序に依存するからです。containsExactlyEntriesOfもエントリの順序を比較し、LinkedHashMapの順序保持特性を確認します。

8. よくある間違い

  • 逆の条件でfilterを2回使用する。 このコードはリストを2回トラバーサルし、条件が異なる可能性があります。partitionを使用してください。
  • キーが重複する可能性がある場合にassociateByを使用する。 データは上書きされますが、エラーは報告されません。これは非常に発見が難しいバグです。
  • カウントと累積合計のみが必要な場合にgroupByを使用する。 この方法は不要な中間リストを作成します。groupingBy { }.eachCount()または.fold()はより簡潔で軽量です。
  • map内でサイドエフェクトを発生させる。 例えばmap内でpaymentService.charge(it)を呼び出す。mapはデータを変換するためにあります。各要素でアクションを実行する必要がある場合、意図を明確にするためforEachを使用してください。
  • map { }.filterNotNull()を使用する。 mapNotNullに置き換えてください。
  • すべてのケースでSequenceを使用する。 小さなリストの場合、Sequenceはiteratorとラムダの作成オーバーヘッドのため、通常遅くなります。データが大きい場合、パイプラインが長い場合、または早期停止操作がある場合にのみSequenceに切り替えてください。
  • ループ内で同じデータに対してgroupByを複数回呼び出す。 1回計算して、Mapを変数に保存して参照してください。

注意: データがデータベースから取得される場合、フィルタリングとグループ化は、すべてをメモリにロードしてfilter/groupByを呼び出すのではなく、SQL(WHERE、GROUP BY)を使用して行う必要があります。これらのCollection関数は、既にメモリ内にあり、適切なサイズのデータに適しています。

9. 結論

mapは各要素を変換し、filterは条件を満たす要素を保持し、groupByはキーによって要素をMapにグループ化します。各関数には特化したバリエーションがあります。

  • 変換用:mapNotNull、mapIndexed、mapValues
  • フィルタリング用:filterNot、partition、filterIsInstance
  • グループ化とMap作成用:groupingBy、associateBy

正しいバリエーションを選択することで、コードが短くなり、バグを回避できます。パイプラインに組み合わせる場合は、AssertJでテストを書き、コンテンツと結果の順序の両方をロックします。データが大きい場合または早期停止が必要な場合は、Sequenceを使用することを検討してください。

kotlincollectionmapfiltergroupBy