Kotlin Collection : map, filter et groupBy par l'exemple
1. Vue d’ensemble
La plupart du code métier consiste à transformer des listes : extraire quelques champs, filtrer selon une condition, regrouper pour calculer des totaux. Kotlin fournit les fonctions d’extension map, filter et groupBy dans sa standard library, vous n’avez donc besoin d’aucune bibliothèque supplémentaire.
Cet article parcourt chaque fonction et ses variantes importantes, puis les combine en un pipeline complet. À la fin, vous trouverez des tests de validation et une liste des erreurs courantes.
2. Données d’exemple
Tous les exemples de cet article utilisent une liste commune de commandes :
enum class Status { NEW, PAID, CANCELLED }
data class Order(
val id: Int,
val customer: String,
val status: Status,
val total: Int, // unité : millier de 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 a pour type List<Order>, c’est-à-dire une liste immuable. Les fonctions ci-dessous ne modifient pas la liste d’origine. Chaque appel retourne une nouvelle collection.
3. map : transformer chaque élément
3.1. map basique
map accepte une lambda (T) -> R, l’applique à chaque élément et retourne 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]
Le résultat a toujours le même nombre d’éléments que la liste d’origine et conserve l’ordre. Si vous ne voulez que des valeurs uniques, ajoutez .distinct() : orders.map { it.customer }.distinct() donne [An, Bình, Chi].
Pour un attribut simple, vous pouvez utiliser une référence de propriété à la place d’une lambda : orders.map(Order::id).
3.2. mapNotNull : transformer et rejeter null
Lorsque la lambda peut retourner null, utilisez mapNotNull pour transformer tout en rejetant les valeurs null :
val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]
Le type retourné est List<String>, pas List<String?>. Cela c’est plus concis et type-safe que map { it.coupon }.filterNotNull().
3.3. mapIndexed : quand vous avez besoin de l’index
val lines = orders.mapIndexed { index, order -> "${index + 1}. Commande #${order.id} - ${order.total}k" }
lines.forEach(::println)
1. Commande #1 - 120k
2. Commande #2 - 80k
3. Commande #3 - 50k
4. Commande #4 - 200k
5. Commande #5 - 45k
3.4. map sur une Map
Avec une Map, appeler map retourne une List. Si vous voulez que le résultat reste une Map, utilisez mapValues ou 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 : conserver les éléments qui satisfont une condition
4.1. filter et filterNot
filter accepte un prédicat (T) -> Boolean et conserve les éléments pour lesquels le prédicat retourne 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]
Attendez, la deuxième ligne est fausse. La commande 1 n’a pas été annulée, elle devrait être présente. Le résultat correct est :
[1, 4, 5]
[1, 2, 4, 5]
filterNot est juste une façon plus lisible d’écrire filter { !(...) }. Utilisez-la quand une condition négative rend le code difficile à lire.
4.2. partition : diviser en deux en une seule passe
Si vous avez besoin à la fois du groupe qui satisfait et du groupe qui ne satisfait pas, n’appelez pas filter deux fois. partition retourne une Pair contenant deux listes :
val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }
println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]
La destructuring val (a, b) permet de nommer clairement chaque moitié.
4.3. filterIsInstance : filtrer par type
Lorsqu’on travaille avec une liste hétérogène, filterIsInstance<T>() filtre et effectue un 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]
Pas besoin de cast manuel avec as. Le résultat a déjà le type List<OrderShipped>.
5. groupBy : regrouper par clé
5.1. groupBy basique
groupBy accepte une fonction sélectrice de clé et retourne 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]}
La Map retournée est une LinkedHashMap. L’ordre des clés suit leur première apparition dans la liste d’origine, et l’ordre des éléments dans chaque groupe est également préservé.
5.2. groupBy avec valueTransform
La deuxième surcharge permet de transformer les valeurs en cours de regroupement, vous n’avez donc pas besoin d’un mapValues séparé :
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. Agréger après regroupement
Le modèle le plus courant est de regrouper puis de calculer une valeur pour chaque groupe :
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 : agréger sans créer de listes intermédiaires
groupBy construit toujours une List complète pour chaque groupe. Si vous ne faites que compter ou accumuler, groupingBy est plus efficace car il accumule directement :
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 retourne un objet Grouping, qui n’a rien calculé. L’agrégation ne s’exécute que lorsque vous appelez eachCount, fold, reduce ou aggregate.
5.5. Différence entre groupBy et associateBy
Ces deux fonctions sont souvent confondues :
val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}
associateBy crée une Map<K, T>, c’est-à-dire que chaque clé correspond à un seul élément. En cas de collision de clés, l’élément suivant écrase le précédent. Utilisez donc associateBy uniquement quand la clé est unique, par exemple associateBy { it.id }. Si les clés peuvent se répéter, utilisez groupBy.
6. Combiner en un pipeline
Les fonctions ci-dessus peuvent être enchaînées. L’exemple suivant calcule le revenu des commandes payées par client, puis les classe par ordre décroissant :
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)]
Le pipeline passe par les étapes suivantes :
filterconserve les commandes 1, 4 et 5.groupBy+mapValuesproduit{An=120, Chi=200, Bình=45}.toList()convertit laMapenList<Pair<String, Int>>pour pouvoir la trier.sortedByDescendingtrie par revenu.
Remarque : Chaque étape sur une
Listest eager. Elle parcourt toutes les données et crée une nouvelle liste. Avec quelques milliers d’éléments, c’est négligeable. Avec de très grandes données, ou quand vous ne voulez que quelques éléments au début (take,first), passez àasSequence()pour un traitement lazy élément par élément :
val firstTwoBigPaid = orders.asSequence()
.filter { it.status == Status.PAID }
.map { it.id }
.take(2)
.toList()
println(firstTwoBigPaid)
[1, 4]
Avec une Sequence, le traitement s’arrête dès qu’on a 2 éléments. La commande 5 n’est pas examinée.
7. Écrire des tests pour le pipeline
Ajoutez JUnit 5 et AssertJ à pom.xml. Votre projet Kotlin doit déjà avoir 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 `classer le revenu calculer seulement les commandes payées`() {
val result = paidRevenueRanking(orders)
assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
}
@Test
fun `groupingBy eachCount compte le nombre correct de commandes par client`() {
val count = orders.groupingBy { it.customer }.eachCount()
assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
}
@Test
fun `filter ne change pas la liste d'origine`() {
orders.filter { it.total > 100 }
assertThat(orders).hasSize(5)
}
}
containsExactly vérifie à la fois le contenu et l’ordre. C’est important car le résultat du classement dépend de l’ordre. containsExactlyEntriesOf compare aussi l’ordre des entrées, confirmant ainsi que LinkedHashMap préserve l’ordre.
8. Erreurs courantes
- Utiliser
filterdeux fois avec des conditions opposées. Le code parcourt la liste deux fois et les conditions peuvent se désynchroniser. Utilisezpartition. - Utiliser
associateBypour les clés qui peuvent se répéter. Les données sont écrasées sans aucun message d’erreur. C’est un bug très difficile à détecter. - Utiliser
groupByseulement pour compter ou accumuler. Cela crée des listes intermédiaires inutiles.groupingBy { }.eachCount()ou.fold()sont plus concis et légers. - Causer des effets secondaires dans
map. Par exemple, appelerpaymentService.charge(it)à l’intérieur demap.mapest destinée à transformer les données. Si vous devez effectuer une action sur chaque élément, utilisezforEachpour être explicite. - Utiliser
map { }.filterNotNull(). UtilisezmapNotNullà la place. - Utiliser
Sequencepour tous les cas. Avec les petites listes,Sequenceest souvent plus lente à cause du coût de création d’itérateurs et de lambdas. Passez àSequenceuniquement quand les données sont grandes, le pipeline est long, ou il y a des opérations qui s’arrêtent tôt. - Appeler
groupByplusieurs fois sur les mêmes données dans une boucle. Calculez une seule fois, stockez laMapdans une variable, puis consultez-la.
Remarque : Quand les données viennent d’une base de données, filtrez et regroupez avec SQL (
WHERE,GROUP BY) plutôt que de tout charger en mémoire puis d’appelerfilter/groupBy. Les fonctions de collection sont appropriées pour les données déjà en mémoire et de taille raisonnable.
9. Conclusion
map transforme chaque élément, filter conserve les éléments qui satisfont une condition, et groupBy regroupe les éléments dans une Map par clé. Chaque fonction a des variantes spécialisées :
mapNotNull,mapIndexed,mapValuespour la transformation.filterNot,partition,filterIsInstancepour le filtrage.groupingBy,associateBypour le regroupement et la création deMap.
Choisir la bonne variante rend le code plus concis et prévient les bugs. Quand vous combinez en un pipeline, écrivez des tests avec AssertJ pour verrouiller à la fois le contenu et l’ordre des résultats. Envisagez d’utiliser Sequence quand les données sont grandes ou quand vous avez besoin de vous arrêter tôt.