Coleções Kotlin: map, filter e groupBy com exemplos práticos

1. Visão geral

A maioria do código de negócio é transformar listas: extrair alguns campos, filtrar por condição, agrupar para calcular totais. Kotlin tem funções de extensão map, filter e groupBy prontas na standard library para isso, então você não precisa de bibliotecas extras.

Este artigo passa por cada função e suas variantes importantes, depois as encadeia num pipeline completo. No final há testes e uma lista de erros comuns.

2. Dados de exemplo

Todos os exemplos deste artigo usam uma lista comum de pedidos:

enum class Status { NEW, PAID, CANCELLED }

data class Order(
    val id: Int,
    val customer: String,
    val status: Status,
    val total: Int,          // unidade: mil reais
    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 tem tipo List<Order>, ou seja, uma lista somente leitura. As funções abaixo não modificam a list original. Cada chamada retorna uma nova coleção.

3. map: transformar cada elemento

3.1. map básico

map recebe uma lambda (T) -> R, aplica-a a cada elemento e retorna 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]

O resultado sempre tem o mesmo número de elementos que a list original e mantém a ordem. Se você só quer valores únicos, encadeie com .distinct(): orders.map { it.customer }.distinct() retorna [An, Bình, Chi].

Para uma propriedade simples, você pode escrever usando property reference em vez de lambda: orders.map(Order::id).

3.2. mapNotNull: transformar e remover null

Quando uma lambda pode retornar null, use mapNotNull para transformar e remover valores null:

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

O tipo retornado é List<String>, não List<String?>. Isso é mais conciso e seguro em relação ao tipo comparado com map { it.coupon }.filterNotNull().

3.3. mapIndexed: quando você precisa saber a posição

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

3.4. map em Map

Com Map, chamar map retorna List. Se você quer que o resultado seja um Map, use 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: manter elemento que satisfaz condição

4.1. filter e filterNot

filter recebe um predicado (T) -> Boolean e mantém elementos para os quais o predicado retorna 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]

Espera aí, a segunda linha está errada. O pedido 1 não foi cancelado então deve estar lá. O resultado correto é:

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

filterNot é apenas uma forma mais legível de filter { !(...) }. Use quando a condição negada torna o código difícil de ler.

4.2. partition: separar em dois numa única passagem

Se você precisa de ambos o grupo que satisfaz e o que não satisfaz, não chame filter duas vezes. partition retorna um Pair com duas listas:

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

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

O destructuring val (a, b) ajuda a dar nomes claros para cada metade.

4.3. filterIsInstance: filtrar por tipo

Ao trabalhar com listas heterogêneas, filterIsInstance<T>() filtra e faz 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]

Sem casting manual com as. O resultado já tem o tipo List<OrderShipped>.

5. groupBy: agrupar por chave

5.1. groupBy básico

groupBy recebe uma função seletora de chave e retorna 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]}

O Map retornado é um LinkedHashMap. A ordem das chaves segue a primeira ocorrência na list original, e a ordem dos elementos em cada grupo também é preservada.

5.2. groupBy com valueTransform

A segunda sobrecarga permite transformar valores no momento do agrupamento, então você não precisa de um mapValues separado:

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. Agregação após agrupamento

O padrão mais comum é agrupar e depois calcular um valor para cada grupo:

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: agregação sem criar listas intermediárias

groupBy sempre constrói uma List completa para cada grupo. Se você só precisa contar ou acumular, groupingBy é mais eficiente porque acumula diretamente:

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 retorna um objeto Grouping, nada computado ainda. A agregação só é executada quando você chama eachCount, fold, reduce ou aggregate.

5.5. Como groupBy difere de associateBy

Essas duas funções são frequentemente confundidas:

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

associateBy cria Map<K, T>, ou seja, cada chave mapeia para um elemento. Quando as chaves colidem, o elemento posterior sobrescreve o anterior. Então use associateBy apenas quando a chave é única, por exemplo associateBy { it.id }. Se a chave pode colidir, use groupBy.

6. Encadear num pipeline

As funções acima podem ser encadeadas. O exemplo abaixo calcula a receita de pedidos pagos por cada cliente, depois ordena em ordem decrescente:

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

O pipeline executa estas etapas:

  • filter mantém os pedidos 1, 4 e 5.
  • groupBy + mapValues resulta em {An=120, Chi=200, Bình=45}.
  • toList() converte Map para List<Pair<String, Int>> para poder ordenar.
  • sortedByDescending ordena por receita.

Nota: Cada etapa sobre List é eager. Ela percorre todos os dados e cria uma nova lista. Com alguns milhares de elementos é negligenciável. Com dados muito grandes, ou quando você só precisa de poucos elementos (take, first), mude para asSequence() para processamento lazy por elemento:

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

println(firstTwoBigPaid)
[1, 4]

Com Sequence, a cadeia de processamento para assim que 2 elementos são obtidos. O pedido 5 não é verificado.

7. Escrever testes para o pipeline

Adicione JUnit 5 e AssertJ ao pom.xml. Seu projeto Kotlin deve ter 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 `ranking de receita apenas de pedidos pagos`() {
        val result = paidRevenueRanking(orders)

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

    @Test
    fun `groupingBy eachCount conta número correto de pedidos por cliente`() {
        val count = orders.groupingBy { it.customer }.eachCount()

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

    @Test
    fun `filter não modifica a lista original`() {
        orders.filter { it.total > 100 }

        assertThat(orders).hasSize(5)
    }
}

containsExactly verifica tanto conteúdo quanto ordem. Isso é importante porque a ordem do ranking depende disso. containsExactlyEntriesOf também compara a ordem das entradas, confirmando a propriedade de preservação de ordem do LinkedHashMap.

8. Erros comuns

  • Usar filter duas vezes com condições opostas. O código percorre a lista duas vezes e é fácil errar a condição. Use partition.
  • Usar associateBy para chaves que podem colidir. Dados são sobrescritos sem nenhuma mensagem de erro. Esse é um bug muito difícil de encontrar.
  • Usar groupBy apenas para contar ou acumular. Isso cria listas intermediárias desnecessárias. groupingBy { }.eachCount() ou .fold() é mais conciso e leve.
  • Causar side effects em map. Por exemplo chamar paymentService.charge(it) dentro de map. map é para transformação de dados. Se você precisa executar uma ação em cada elemento, use forEach para deixar clara a intenção.
  • Usar map { }.filterNotNull(). Substitua por mapNotNull.
  • Usar Sequence para tudo. Com listas pequenas, Sequence é frequentemente mais lento porque criar iteradores e lambdas tem overhead. Mude para Sequence apenas quando os dados são grandes, o pipeline é longo, ou há parada antecipada.
  • Chamar groupBy múltiplas vezes nos mesmos dados num loop. Compute uma vez, salve o Map numa variável, depois consulte.

Nota: Quando os dados vêm de um banco de dados, filtragem e agrupamento devem ser feitos com SQL (WHERE, GROUP BY) em vez de carregar tudo na memória e chamar filter/groupBy. Funções de coleção são adequadas para dados já na memória com tamanho razoável.

9. Conclusão

map transforma cada elemento, filter mantém elementos que satisfazem uma condição, e groupBy agrupa elementos num Map por chave. Cada função tem variantes especializadas:

  • mapNotNull, mapIndexed, mapValues para transformação.
  • filterNot, partition, filterIsInstance para filtragem.
  • groupingBy, associateBy para agrupamento e criação de Map.

Escolher a variante correta torna o código mais curto e evita bugs. Ao encadear num pipeline, você deve escrever testes com AssertJ para confirmar tanto o conteúdo quanto a ordem do resultado. Considere usar Sequence quando os dados são grandes ou quando você precisa parar cedo.

kotlincollectionmapfiltergroupBy