Kotlin Collection: map, filter und groupBy mit praktischen Beispielen

1. Übersicht

Der Großteil des Business-Codes transformiert Listen: Es werden ein paar Felder herausgezogen, nach Bedingungen gefiltert, oder Elemente gruppiert, um Summen zu berechnen. Kotlin bietet in der Standard Library bereits die Erweiterungsfunktionen map, filter und groupBy dafür, sodass du keine zusätzliche Bibliothek benötigst.

Dieser Artikel geht durch jede Funktion und ihre wichtigsten Varianten, und kombiniert sie zu einer vollständigen Pipeline. Am Ende sind Tests zum Verifizieren und eine Liste häufiger Fehler enthalten.

2. Beispieldaten

Alle Beispiele in diesem Artikel verwenden eine gemeinsame Liste von Bestellungen:

enum class Status { NEW, PAID, CANCELLED }

data class Order(
    val id: Int,
    val customer: String,
    val status: Status,
    val total: Int,          // Einheit: Tausend Dong
    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 hat den Typ List<Order>, also eine unveränderbare Liste. Die Funktionen unten ändern die Original-Liste nicht. Jeder Aufruf gibt eine neue Sammlung zurück.

3. map: Transformiere jedes Element

3.1. map Grundlagen

map nimmt einen Lambda (T) -> R, wendet ihn auf jedes Element an und gibt List<R> zurück:

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]

Das Ergebnis hat gleich viele Elemente wie die Original-Liste und behält die Reihenfolge bei. Wenn du nur eindeutige Werte möchtest, verkette es mit .distinct(): orders.map { it.customer }.distinct() gibt [An, Bình, Chi] zurück.

Für eine einfache Eigenschaft kannst du statt eines Lambdas einen Property Reference schreiben: orders.map(Order::id).

3.2. mapNotNull: Transformiere und verwerfe null

Wenn der Lambda null zurückgeben kann, verwende mapNotNull, um gleichzeitig zu transformieren und null-Werte zu entfernen:

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

Der Rückgabetyp ist List<String>, nicht List<String?>. Diese Methode ist sauberer und typsicherer als map { it.coupon }.filterNotNull().

3.3. mapIndexed: Wenn die Position benötigt wird

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

3.4. map auf Map

Bei einer Map gibt map eine List zurück. Wenn du möchtest, dass das Ergebnis immer noch eine Map ist, verwende mapValues oder 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: Behalte nur Elemente, die eine Bedingung erfüllen

4.1. filter und filterNot

filter nimmt ein Prädikat (T) -> Boolean und behält nur die Elemente, bei denen das Prädikat true zurückgibt:

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]

Moment, die zweite Zeile war falsch. Bestellung 1 ist nicht storniert, also muss sie vorhanden sein. Das richtige Ergebnis ist:

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

filterNot ist nur eine lesbarere Schreibweise für filter { !(...) }. Verwende es, wenn eine negierte Bedingung den Code schwer lesbar macht.

4.2. partition: Teile in zwei Teile in einem Durchlauf auf

Wenn du beide Gruppen brauchst – die, die die Bedingung erfüllen, und die, die sie nicht erfüllen – rufe filter nicht zweimal auf. partition gibt ein Pair zurück, das zwei Listen enthält:

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

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

Destructuring val (a, b) hilft, jede Hälfte klar zu benennen.

4.3. filterIsInstance: Filtere nach Typ

Wenn man mit heterogenen Listen arbeitet, filtert filterIsInstance<T>() und macht gleichzeitig einen Smart Cast:

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]

Es ist kein manueller Cast mit as nötig. Das Ergebnis hat bereits den Typ List<OrderShipped>.

5. groupBy: Gruppiere nach Schlüssel

5.1. groupBy Grundlagen

groupBy nimmt eine Funktion zum Auswählen des Schlüssels und gibt Map<K, List<T>> zurück:

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]}

Die zurückgegebene Map ist eine LinkedHashMap. Die Reihenfolge der Schlüssel folgt dem ersten Auftreten in der Original-Liste, und die Reihenfolge der Elemente in jeder Gruppe bleibt auch erhalten.

5.2. groupBy mit valueTransform

Eine zweite Überladung ermöglicht es, die Werte während des Gruppierens zu transformieren, sodass du mapValues separat nicht brauchst:

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. Aggregation nach dem Gruppieren

Das häufigste Muster ist, zu gruppieren und dann einen Wert für jede Gruppe zu berechnen:

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: Aggregation ohne Zwischenlisten

groupBy erstellt immer eine vollständige List für jede Gruppe. Wenn du nur zählen oder aufsummieren möchtest, ist groupingBy effizienter, da es direkt akkumuliert:

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 gibt ein Grouping-Objekt zurück, das noch nichts berechnet hat. Die Aggregation läuft nur, wenn du eachCount, fold, reduce oder aggregate aufrufst.

5.5. Wie unterscheiden sich groupBy und associateBy

Diese zwei Funktionen werden oft verwechselt:

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

associateBy erstellt eine Map<K, T>, wobei jeder Schlüssel einem Element entspricht. Wenn Schlüssel dupliziert werden, überschreibt das spätere Element das frühere. Verwende daher associateBy nur, wenn der Schlüssel eindeutig ist, z.B. associateBy { it.id }. Wenn der Schlüssel dupliziert sein kann, verwende groupBy.

6. In einer Pipeline kombinieren

Die obigen Funktionen lassen sich verkettet verwenden. Das folgende Beispiel berechnet den Umsatz bezahlter Bestellungen nach jedem Kunden und ordnet sie absteigend:

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)]

Die Pipeline läuft durch diese Schritte:

  • filter behält Bestellung 1, 4 und 5.
  • groupBy + mapValues ergeben {An=120, Chi=200, Bình=45}.
  • toList() konvertiert die Map in List<Pair<String, Int>>, um sortiert zu werden.
  • sortedByDescending sortiert nach Umsatz.

Hinweis: Jeder Schritt auf einer List ist eager. Er durchsucht alle Daten und erstellt eine neue Liste. Mit ein paar tausend Elementen ist das vernachlässigbar. Mit sehr großen Datenmengen, oder wenn du nur ein paar Elemente brauchst (take, first), wechsle zu asSequence(), um lazy jedes Element zu verarbeiten:

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

println(firstTwoBigPaid)
[1, 4]

Mit Sequence stoppt die Pipeline, sobald 2 Elemente gefunden sind. Bestellung 5 wird gar nicht betrachtet.

7. Tests für die Pipeline schreiben

Füge JUnit 5 und AssertJ in deine pom.xml ein. Dein Kotlin-Projekt sollte bereits kotlin-maven-plugin haben:

<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 `rangiere umsatz nur für bezahlte bestellungen`() {
        val result = paidRevenueRanking(orders)

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

    @Test
    fun `groupingBy eachCount zählt richtig`() {
        val count = orders.groupingBy { it.customer }.eachCount()

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

    @Test
    fun `filter ändert nicht die original liste`() {
        orders.filter { it.total > 100 }

        assertThat(orders).hasSize(5)
    }
}

containsExactly prüft Inhalt und Reihenfolge. Das ist wichtig, weil die Ranking-Reihenfolge davon abhängt. containsExactlyEntriesOf vergleicht auch die Reihenfolge der Einträge, sodass es bestätigt, dass LinkedHashMap die Reihenfolge beibehält.

8. Häufige Fehler

  • filter zweimal mit umgekehrten Bedingungen aufrufen. Der Code durchläuft die Liste zweimal und Bedingungen können leicht abweichen. Verwende stattdessen partition.
  • associateBy für Schlüssel verwenden, die dupliziert sein können. Daten werden überschrieben, ohne dass ein Fehler auftritt. Das ist ein sehr schwer zu findender Bug.
  • groupBy nur zum Zählen oder Aufsummieren verwenden. Das erstellt unnötige Zwischenlisten. groupingBy { }.eachCount() oder .fold() ist sauberer und leichter.
  • Nebeneffekte in map verursachen. Zum Beispiel paymentService.charge(it) in einem map aufrufen. map ist für Datentransformationen. Wenn du eine Aktion auf jedem Element durchführen musst, verwende forEach, um deine Absicht klar zu machen.
  • map { }.filterNotNull() verwenden. Ersetze es mit mapNotNull.
  • Sequence für alles verwenden. Mit kleinen Listen ist Sequence oft langsamer wegen der Kosten für Iterator- und Lambda-Erstellung. Wechsle nur zu Sequence, wenn deine Daten groß sind, die Pipeline lang, oder es gibt frühe Abbruchoperationen.
  • groupBy mehrmals in einer Schleife auf denselben Daten aufrufen. Berechne einmal, speichere die Map in einer Variablen und verwende sie dann.

Hinweis: Wenn Daten aus einer Datenbank stammen, sollten Filterung und Gruppierung mit SQL (WHERE, GROUP BY) erfolgen, statt alle Daten in den Speicher zu laden und dann filter/groupBy zu verwenden. Diese Collection-Funktionen sind am besten für bereits im Speicher befindliche Daten mit angemessener Größe geeignet.

9. Fazit

map transformiert jedes Element, filter behält nur Elemente bei, die eine Bedingung erfüllen, und groupBy gruppiert Elemente nach einem Schlüssel in einer Map. Jede Funktion hat spezialisierte Varianten:

  • mapNotNull, mapIndexed, mapValues für Transformationen.
  • filterNot, partition, filterIsInstance für Filterungen.
  • groupingBy, associateBy für Gruppierungen und Map-Erstellung.

Die richtige Variante zu wählen macht deinen Code kürzer und verhindert Bugs. Wenn du in einer Pipeline kombinierst, solltest du Tests mit AssertJ schreiben, um sowohl Inhalt als auch Reihenfolge des Ergebnisses zu verifizieren. Erwäge Sequence, wenn deine Daten groß sind oder du früh abbrechen musst.

kotlincollectionmapfiltergroupBy