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:
filterbehält Bestellung 1, 4 und 5.groupBy+mapValuesergeben{An=120, Chi=200, Bình=45}.toList()konvertiert dieMapinList<Pair<String, Int>>, um sortiert zu werden.sortedByDescendingsortiert nach Umsatz.
Hinweis: Jeder Schritt auf einer
Listist 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 zuasSequence(), 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
filterzweimal mit umgekehrten Bedingungen aufrufen. Der Code durchläuft die Liste zweimal und Bedingungen können leicht abweichen. Verwende stattdessenpartition.associateByfü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.groupBynur zum Zählen oder Aufsummieren verwenden. Das erstellt unnötige Zwischenlisten.groupingBy { }.eachCount()oder.fold()ist sauberer und leichter.- Nebeneffekte in
mapverursachen. Zum BeispielpaymentService.charge(it)in einemmapaufrufen.mapist für Datentransformationen. Wenn du eine Aktion auf jedem Element durchführen musst, verwendeforEach, um deine Absicht klar zu machen. map { }.filterNotNull()verwenden. Ersetze es mitmapNotNull.Sequencefür alles verwenden. Mit kleinen Listen istSequenceoft langsamer wegen der Kosten für Iterator- und Lambda-Erstellung. Wechsle nur zuSequence, wenn deine Daten groß sind, die Pipeline lang, oder es gibt frühe Abbruchoperationen.groupBymehrmals in einer Schleife auf denselben Daten aufrufen. Berechne einmal, speichere dieMapin 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 dannfilter/groupByzu 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,mapValuesfür Transformationen.filterNot,partition,filterIsInstancefür Filterungen.groupingBy,associateByfü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.