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:
filtermantém os pedidos 1, 4 e 5.groupBy+mapValuesresulta em{An=120, Chi=200, Bình=45}.toList()converteMapparaList<Pair<String, Int>>para poder ordenar.sortedByDescendingordena 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 paraasSequence()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
filterduas vezes com condições opostas. O código percorre a lista duas vezes e é fácil errar a condição. Usepartition. - Usar
associateBypara chaves que podem colidir. Dados são sobrescritos sem nenhuma mensagem de erro. Esse é um bug muito difícil de encontrar. - Usar
groupByapenas 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 chamarpaymentService.charge(it)dentro demap.mapé para transformação de dados. Se você precisa executar uma ação em cada elemento, useforEachpara deixar clara a intenção. - Usar
map { }.filterNotNull(). Substitua pormapNotNull. - Usar
Sequencepara tudo. Com listas pequenas,Sequenceé frequentemente mais lento porque criar iteradores e lambdas tem overhead. Mude paraSequenceapenas quando os dados são grandes, o pipeline é longo, ou há parada antecipada. - Chamar
groupBymúltiplas vezes nos mesmos dados num loop. Compute uma vez, salve oMapnuma 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 chamarfilter/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,mapValuespara transformação.filterNot,partition,filterIsInstancepara filtragem.groupingBy,associateBypara agrupamento e criação deMap.
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.