Kotlin Collection: map, filter y groupBy con ejemplos prácticos

1. Resumen general

La mayoría del código de negocio es transformar listas: extraer algunos campos, filtrar por condición, agrupar para calcular totales. Kotlin tiene funciones de extensión integradas map, filter y groupBy en la biblioteca estándar para esto, sin necesidad de bibliotecas adicionales.

Este artículo recorre cada función y sus variantes importantes, luego las combina en un pipeline completo. Al final hay pruebas de validación y una lista de errores comunes.

2. Datos de ejemplo

Todos los ejemplos del artículo comparten una lista de órdenes:

enum class Status { NEW, PAID, CANCELLED }

data class Order(
    val id: Int,
    val customer: String,
    val status: Status,
    val total: Int,          // unidad: miles 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 tiene el tipo List<Order>, es decir, una lista de solo lectura. Las funciones siguientes no modifican la lista original. Cada vez que se llaman devuelven una nueva colección.

3. map: transformar cada elemento

3.1. map básico

map recibe un lambda (T) -> R, lo aplica a cada elemento y devuelve 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]

El resultado siempre tiene el mismo número de elementos que la lista original y mantiene el orden. Si solo quieres valores únicos, añade .distinct(): orders.map { it.customer }.distinct() produce [An, Bình, Chi].

Para una propiedad simple, puedes usar una referencia de propiedad en lugar de lambda: orders.map(Order::id).

3.2. mapNotNull: transformar y filtrar nulos

Cuando el lambda puede devolver null, usa mapNotNull para transformar y filtrar valores null a la vez:

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

El tipo de retorno es List<String>, no List<String?>. Este enfoque es más conciso y seguro en tipos que map { it.coupon }.filterNotNull().

3.3. mapIndexed: cuando necesitas la posición

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

3.4. map en un Map

Con Map, llamar a map devuelve List. Si quieres que el resultado siga siendo un Map, usa mapValues o 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: mantener elementos que cumplen la condición

4.1. filter y filterNot

filter recibe un predicado (T) -> Boolean y mantiene los elementos donde el predicado devuelve 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, la segunda línea es incorrecta. La orden 1 no fue cancelada, así que debe estar presente. El resultado correcto es:

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

filterNot es solo una forma más legible de escribir filter { !(...) }. Úsalo cuando una condición negada hace que el código sea difícil de leer.

4.2. partition: dividir en dos en una sola pasada

Si necesitas tanto el grupo que cumple como el que no cumple, no llames a filter dos veces. partition devuelve un Pair con dos listas:

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) ayuda a nombrar claramente cada mitad.

4.3. filterIsInstance: filtrar por tipo

Cuando trabajes con listas heterogéneas, filterIsInstance<T>() filtra y hace smart cast a la vez:

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]

Sin necesidad de lanzamiento manual con as. El resultado ya tiene el tipo List<OrderShipped>.

5. groupBy: agrupar por clave

5.1. groupBy básico

groupBy recibe una función selectora de clave y devuelve 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]}

El Map devuelto es LinkedHashMap. El orden de las claves sigue la primera aparición en la lista original, y el orden de los elementos dentro de cada grupo también se mantiene.

5.2. groupBy con valueTransform

La segunda sobrecarga permite transformar valores mientras se agrupa, así no necesitas mapValues por 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. Agregar después de agrupar

El patrón más común es agrupar y luego calcular un 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: agregar sin crear listas intermedias

groupBy siempre crea una List para cada grupo. Si solo necesitas contar o acumular, groupingBy es más eficiente porque acumula directamente:

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 devuelve un objeto Grouping, sin realizar ningún cálculo aún. La agregación solo se ejecuta cuando llamas a eachCount, fold, reduce o aggregate.

5.5. ¿En qué se diferencia groupBy de associateBy?

Estas dos funciones suelen confundirse:

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

associateBy crea Map<K, T>, es decir, cada clave corresponde a un elemento. Cuando las claves coinciden, el elemento posterior sobrescribe el anterior. Por lo tanto, usa associateBy solo cuando las claves son únicas, por ejemplo associateBy { it.id }. Si las claves pueden repetirse, usa groupBy.

6. Combinar en un pipeline

Estas funciones se pueden encadenar. El siguiente ejemplo calcula los ingresos de órdenes pagadas por cliente y las ordena en forma descendente:

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

El pipeline pasa por los siguientes pasos:

  • filter mantiene las órdenes 1, 4 y 5.
  • groupBy + mapValues produce {An=120, Chi=200, Bình=45}.
  • toList() convierte el Map a List<Pair<String, Int>> para poder ordenar.
  • sortedByDescending ordena por ingresos.

Nota: Cada paso en List es eager. Recorre todos los datos y crea una nueva lista. Con algunos miles de elementos, es insignificante. Con datos muy grandes, o cuando solo necesitas algunos elementos iniciales (take, first), cambia a asSequence() para el procesamiento lazy elemento por elemento:

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

println(firstTwoBigPaid)
[1, 4]

Con Sequence, la cadena de procesamiento se detiene después de tener 2 elementos. La orden 5 no se considera.

7. Escribir pruebas para el pipeline

Añade JUnit 5 y AssertJ a pom.xml. Tu proyecto Kotlin debe tener 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 ingresos de ordenes pagadas`() {
        val result = paidRevenueRanking(orders)

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

    @Test
    fun `groupingBy eachCount cuenta correctamente ordenes 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 no modifica la lista original`() {
        orders.filter { it.total > 100 }

        assertThat(orders).hasSize(5)
    }
}

containsExactly verifica tanto el contenido como el orden. Esto es importante porque el resultado del ranking depende del orden. containsExactlyEntriesOf también compara el orden de las entradas, así que también confirma la propiedad de orden de LinkedHashMap.

8. Errores comunes

  • Usar filter dos veces con condiciones opuestas. El código recorre la lista dos veces y es fácil desalinear las condiciones. Usa partition.
  • Usar associateBy para claves que pueden repetirse. Los datos se sobrescriben sin ningún error reportado. Este es un bug muy difícil de detectar.
  • Usar groupBy solo para contar o acumular. Este enfoque crea listas intermedias innecesarias. groupingBy { }.eachCount() o .fold() son más concisos y ligeros.
  • Causar efectos secundarios dentro de map. Por ejemplo, llamar paymentService.charge(it) dentro de map. map se usa para transformar datos. Si necesitas ejecutar una acción en cada elemento, usa forEach para indicar claramente tu intención.
  • Usar map { }.filterNotNull(). Reemplaza con mapNotNull.
  • Usar Sequence para todos los casos. Con listas pequeñas, Sequence suele ser más lenta porque cuesta crear iteradores y lambdas. Solo cambia a Sequence cuando los datos son grandes, el pipeline es largo, o hay operaciones de parada anticipada.
  • Llamar a groupBy múltiples veces con los mismos datos en un bucle. Calcula una sola vez, guarda el Map en una variable y luego consulta.

Nota: Cuando los datos vienen de una base de datos, el filtrado y agrupamiento se deben hacer con SQL (WHERE, GROUP BY) en lugar de cargar todo en memoria y luego llamar filter/groupBy. Las funciones de colección son apropiadas para datos que ya están en memoria y tienen un tamaño razonable.

9. Conclusión

map transforma cada elemento, filter mantiene los elementos que cumplen la condición, y groupBy agrupa elementos en un Map según una clave. Cada función tiene variantes especializadas:

  • mapNotNull, mapIndexed, mapValues para la transformación.
  • filterNot, partition, filterIsInstance para el filtrado.
  • groupingBy, associateBy para la agrupación y creación de Map.

Elegir la variante correcta hace que el código sea más conciso y evita bugs. Cuando compones un pipeline, debes escribir pruebas con AssertJ para fijar tanto el contenido como el orden del resultado. Considera usar Sequence cuando los datos son grandes o cuando necesitas parar anticipadamente.

kotlincollectionmapfiltergroupBy