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 :

  • filter conserve les commandes 1, 4 et 5.
  • groupBy + mapValues produit {An=120, Chi=200, Bình=45}.
  • toList() convertit la Map en List<Pair<String, Int>> pour pouvoir la trier.
  • sortedByDescending trie par revenu.

Remarque : Chaque étape sur une List est 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 filter deux fois avec des conditions opposées. Le code parcourt la liste deux fois et les conditions peuvent se désynchroniser. Utilisez partition.
  • Utiliser associateBy pour 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 groupBy seulement 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, appeler paymentService.charge(it) à l’intérieur de map. map est destinée à transformer les données. Si vous devez effectuer une action sur chaque élément, utilisez forEach pour être explicite.
  • Utiliser map { }.filterNotNull(). Utilisez mapNotNull à la place.
  • Utiliser Sequence pour tous les cas. Avec les petites listes, Sequence est souvent plus lente à cause du coût de création d’itérateurs et de lambdas. Passez à Sequence uniquement quand les données sont grandes, le pipeline est long, ou il y a des opérations qui s’arrêtent tôt.
  • Appeler groupBy plusieurs fois sur les mêmes données dans une boucle. Calculez une seule fois, stockez la Map dans 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’appeler filter/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, mapValues pour la transformation.
  • filterNot, partition, filterIsInstance pour le filtrage.
  • groupingBy, associateBy pour le regroupement et la création de Map.

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.

kotlincollectionmapfiltergroupBy