Los operadores de la stdlib y la colección intermedia
El catálogo de operaciones que define el estilo de Kotlin sobre colecciones, leído no como una lista de recetas sino como una gramática con coste. Esta lección recorre map, filter, fold, groupBy, associate, partition, zip y flatMap explicando qué estructura devuelve cada uno, qué asigna por debajo, qué variantes existen para evitar esa asignación, y por qué una cadena de cinco operadores recorre la entrada cinco veces y construye cuatro listas que nadie vuelve a mirar.
Los operadores de colecciones son la superficie más visible de Kotlin y la que convierte quince líneas de bucle con acumuladores en una expresión que se lee de izquierda a derecha. La transformación es real y no hay que renunciar a ella; pero hay un hecho estructural que casi nunca se enseña junto a la sintaxis y que gobierna todo lo que viene después en este nivel: cada operador de la biblioteca estándar definido sobre Iterable<T> es ansioso, recorre entera la colección que recibe y devuelve una colección nueva y completa. Encadenar cinco operadores no significa recorrer los datos una vez aplicando cinco pasos, sino recorrerlos cinco veces construyendo cuatro estructuras intermedias que solo existen para alimentar al siguiente eslabón. Entender exactamente qué asigna cada nombre del catálogo es lo que permite escribir en este estilo sin superstición y decidir con criterio cuándo hay que cambiar de herramienta.
- Explicar qué estructura devuelve y qué asigna cada operador de transformación y filtrado.
- Distinguir
folddereducey saber cuándo la agregación necesita un acumulador de otro tipo. - Elegir con criterio entre
groupBy,groupingBy,associate,associateByyassociateWith. - Contar las colecciones intermedias de una cadena y conocer las variantes que las eliminan.
Transformar y filtrar: dónde nace la lista intermedia
map y filter son extensiones inline sobre Iterable<T>, de modo que la lambda no produce ningún objeto, pero la lista resultante sí. La diferencia entre ambas está en lo que saben antes de empezar: map conoce el número exacto de elementos que va a producir y reserva capacidad; filter no puede saberlo y arranca con una lista vacía que crecerá por duplicación.
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> =
mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> =
filterTo(ArrayList<T>(), predicate)
Esa firma revela algo aprovechable: ambas están implementadas sobre una variante con sufijo To que recibe el destino como parámetro y lo devuelve. Cuando vas a fusionar varios resultados en la misma estructura, o cuando quieres un MutableSet en lugar de una lista, pasar el destino evita construir una colección para tirarla inmediatamente después. El catálogo entero sigue esta convención: mapTo, filterTo, flatMapTo, groupByTo, associateTo, partitionTo no existe pero filterTo cubre el caso, y así sucesivamente.
Junto a esos dos conviene conocer las variantes que resuelven en un solo paso lo que la composición ingenua hace en dos. mapNotNull transforma y descarta los nulos sin construir la lista con huecos; mapIndexed entrega la posición sin obligar a un withIndex previo; filterIsInstance filtra y estrecha el tipo a la vez, lo que sustituye a un filter seguido de un map con conversión; filterNot evita la negación del predicado.
val nombres: List<String> = entradas.mapNotNull { it.nombreONulo } // una sola lista
val soloTextos: List<String> = valores.filterIsInstance<String>() // filtra y tipa
Agregar: fold, reduce y sus formas acumulativas
reduce combina los elementos usando el primero como valor inicial, lo que impone dos restricciones: el resultado tiene forzosamente el mismo tipo que los elementos y la colección no puede estar vacía, porque no habría con qué empezar. fold recibe un valor inicial explícito y con él desaparecen las dos restricciones: el acumulador puede ser de cualquier tipo y una colección vacía devuelve simplemente el valor inicial.
val suma = numeros.reduce { acc, n -> acc + n } // lanza si esta vacia
val total = numeros.fold(0L) { acc, n -> acc + n } // acumulador de otro tipo
val indice = palabras.fold(mutableMapOf<Char, MutableList<String>>()) { acc, p ->
acc.getOrPut(p.first()) { mutableListOf() }.add(p); acc
}
La regla práctica es que reduce solo es apropiado cuando la operación es cerrada sobre el tipo y sabes con certeza que hay al menos un elemento; en cualquier otro caso fold es la elección por defecto, y su variante runningFold, también llamada scan, devuelve la lista de todos los estados intermedios cuando lo que quieres es la historia y no solo el resultado. Existen además las versiones con sufijo Right que recorren desde el final, útiles cuando la operación no es asociativa.
val saldos = movimientos.runningFold(0L) { acc, m -> acc + m.importe } // la historia
val saldoFinal = movimientos.sumOf { it.importe } // solo el total
val masCaro = productos.maxByOrNull { it.precio } // fold disfrazado
Las tres últimas familias de ese listado merecen reconocerse como lo que son: agregaciones especializadas que evitan el paso intermedio que la composición ingenua construiría. sumOf suma sobre la marcha en lugar de encadenar un map con un sum, y además tiene sobrecargas por tipo numérico que evitan el empaquetado; maxByOrNull y minByOrNull recorren una vez comparando la clave sin ordenar la colección entera, que es lo que hace la alternativa habitual de ordenar y tomar el primero. Elegir el agregador correcto ahorra, en el caso de la ordenación, un factor logarítmico y una lista completa.
Hay una superstición extendida según la cual usar un mapa mutable como acumulador de un fold es hacer trampa. No lo es: el acumulador viaja por la pila de la propia llamada, no está compartido con nadie y no se copia en cada paso, de modo que agregar así es exactamente igual de seguro que un bucle y bastante más barato que una versión que construya una estructura nueva por elemento. Lo que sí sería un error es capturar una variable externa desde la lambda y devolver Unit, porque entonces el acumulador deja de ser un parámetro y pasa a ser estado compartido con todo lo que eso implica.
Clasificar: groupBy, associate y partition
Los tres reparten la entrada en estructuras distintas y los tres tienen una variante mejor que la evidente. groupBy devuelve un Map<K, List<V>> construyendo una lista por cada clave, lo que es exactamente lo que quieres si vas a usar los grupos y un desperdicio completo si solo vas a contarlos o a agregarlos. Para ese caso existe groupingBy, que no materializa nada y devuelve un objeto perezoso sobre el que se aplica después eachCount, fold o aggregate.
val porInicial: Map<Char, List<String>> = palabras.groupBy { it.first() }
val cuantasPorInicial: Map<Char, Int> = palabras.groupingBy { it.first() }.eachCount()
associate construye un Map<K, V> a partir de una lambda que devuelve un Pair, y ese detalle tiene consecuencias: se asigna un objeto Pair por elemento que se descarta en cuanto se inserta la entrada en el mapa. Sus dos hermanas evitan ese intermediario porque solo piden una de las dos mitades. associateBy toma el selector de clave y conserva el elemento como valor; associateWith hace lo contrario, usa el elemento como clave y calcula el valor.
val porId = usuarios.associate { it.id to it } // un Pair por elemento
val mejor = usuarios.associateBy { it.id } // sin Pair intermedio
val cargas = usuarios.associateWith { calcularCarga(it) }
Ese objeto perezoso que devuelve groupingBy es más versátil de lo que sugiere su uso más común, porque además de contar sabe plegar cada grupo por separado sin materializarlo. La operación fold sobre él aplica un acumulador independiente por clave, y aggregate da acceso además a la clave y a si es el primer elemento del grupo, que es lo que hace falta cuando el valor inicial depende de la propia clave.
val gastoPorCliente = pedidos
.groupingBy { it.cliente }
.fold(0L) { acc, pedido -> acc + pedido.importe } // sin listas de grupo
partition es el operador de este grupo que más se olvida y el que más código ahorra: recorre una sola vez y devuelve un Pair<List<T>, List<T>> con los elementos que cumplen el predicado y los que no. La alternativa habitual, escribir un filter y un filterNot con el mismo predicado, recorre dos veces y evalúa la condición dos veces por elemento.
flowchart LR A[Lista de entrada] --> B[map] B --> C[Lista intermedia uno] C --> D[filter] D --> E[Lista intermedia dos] E --> F[sortedBy] F --> G[Lista intermedia tres] G --> H[first] H --> I[Un solo resultado]
Combinar y aplanar: zip y flatMap
zip empareja dos colecciones posición a posición y se detiene en la más corta, sin avisar de que ha descartado la cola de la otra. Devuelve un List<Pair<A, B>>, con la asignación de un Pair por elemento que la sobrecarga con lambda de transformación evita por completo. Sus parientes son unzip, que deshace la operación, y zipWithNext, que empareja cada elemento con el siguiente de la misma colección y es la forma idiomática de calcular diferencias o comprobar monotonía.
val etiquetadas = nombres.zip(valores) // List de Pair
val formateadas = nombres.zip(valores) { n, v -> "$n=$v" } // sin Pair intermedio
val saltos = tiempos.zipWithNext { a, b -> b - a }
flatMap aplica una transformación que devuelve una colección por elemento y concatena todos los resultados en una sola lista. Su coste es el que cabe esperar y suele subestimarse: se construye una colección por cada elemento de la entrada, se copian todos sus elementos en la lista final y se descartan todas las intermedias. Cuando la transformación ya devuelve colecciones existentes, flatten hace lo mismo sin lambda; cuando lo que quieres es un conjunto, flatMapTo con un MutableSet como destino evita construir la lista completa antes de deduplicar.
val todasLasEtiquetas = articulos.flatMap { it.etiquetas } // una lista por articulo
val sinDuplicados = articulos.flatMapTo(mutableSetOf()) { it.etiquetas } // deduplica al vuelo
val aplanada = listaDeListas.flatten() // sin lambda
Con esto el catálogo básico queda cerrado, y conviene fijar la cuenta que lo atraviesa entero: salvo partition, que produce dos listas en un recorrido, y las variantes con destino explícito, cada nombre de esta lección significa un recorrido completo de su entrada y una estructura nueva de salida. Escribir cinco operadores encadenados sobre un millón de registros son cinco millones de iteraciones y cuatro listas que se convierten en basura en cuanto el eslabón siguiente termina de leerlas.
Variantes con sufijo `To`
Todo operador que construya una colección tiene una versión que recibe el destino. Es la vía para fusionar resultados o elegir la implementación concreta sin una asignación de más.
`groupingBy` frente a `groupBy`
Si solo vas a contar o agregar, groupingBy no materializa las listas de grupo. groupBy solo se justifica cuando de verdad necesitas los elementos de cada clase.
`associateBy` frente a `associate`
associate asigna un Pair por elemento. Si la clave o el valor es el propio elemento, sus dos hermanas hacen lo mismo sin ese objeto intermedio.
`partition` en vez de dos filtros
Un solo recorrido y una sola evaluación del predicado por elemento, frente a dos de cada con filter más filterNot.
La manera más pobre de aprender estos operadores es memorizarlos como abreviaturas de bucles, porque produce la creencia de que son intercambiables con un for bien escrito y de que elegir entre ellos es cuestión de gusto. Lo que hacen en realidad es mucho más interesante: cada nombre del catálogo nombra una forma de cómputo distinta y al elegirlo estás declarando, en el propio código y de manera verificable, qué clase de operación es la tuya. map declara que hay exactamente un resultado por entrada y que ninguno depende de los demás. filter declara que no transformas nada y que solo decides. fold declara que hay una dependencia secuencial genuina entre elementos y que el orden importa. groupBy declara que la salida es una partición y partition declara que esa partición tiene exactamente dos clases. Un bucle con un if dentro puede hacer cualquiera de esas cinco cosas, y precisamente por eso no comunica ninguna: quien lo lee tiene que reconstruir la intención a partir de las mutaciones del acumulador. Esta es la razón por la que el estilo funcional sobre colecciones se lee más rápido aunque a veces ejecute más despacio, y por la que merece la pena aprender el catálogo entero en vez de resolverlo todo con map y filter encadenados. Ahora bien, esa expresividad se paga con una moneda concreta y hay que saber contarla: cada eslabón de la cadena es un recorrido completo y una colección nueva, de modo que una expresión de cinco operadores sobre un millón de elementos hace cinco millones de iteraciones y construye cuatro listas de las que tres se convierten en basura de inmediato. En la inmensa mayoría del código esa cuenta es irrelevante y perseguirla sería una optimización prematura de manual. Pero cuando deja de serlo, y solo entonces, la respuesta correcta no es abandonar el vocabulario y volver al bucle, sino conservar exactamente la misma expresión y cambiar el tipo sobre el que se aplica. Ese cambio, que no toca una sola letra de tu cadena de operadores, es el asunto de la lección siguiente.
- Escribe una cadena de cuatro operadores sobre una lista y anota cuántas veces se recorre la entrada y cuántas colecciones intermedias se construyen.
- Sustituye un
filterseguido de unfilterNotcon el mismo predicado por unpartitiony explica qué ahorras exactamente. - Resuelve el mismo recuento por categoría con
groupBymásmapValuesy congroupingBymáseachCount, y razona qué materializa cada versión. - Reescribe un
associateconit.clave to itusandoassociateByy justifica cuántos objetos dejas de asignar. - Implementa una agregación cuyo resultado sea de un tipo distinto al de los elementos y explica por qué
reduceno puede expresarla.