Operaciones con colecciones I: Operaciones de transformación

Operaciones de transformación

Como ya adelantamos en la entrada sobre construcción de colecciones, la biblioteca estándar de Kotlin ofrece una amplia variedad de funciones para realizar ciertas operaciones sobre colecciones, incluyendo operaciones simples como obtener y agregar elementos y otras más complejas que incluyen búsqueda, clasificación, filtrado y transformación.

En general, y salvo que se indique lo contrario, estas operaciones están disponibles tanto para colecciones de solo lectura como mutables, y se pueden agrupar en estas categorías:
La mayoría de estas operaciones (salvo las de escritura) devuelven sus resultados sin afectar a la colección original, por lo que es habitual almacenar sus resultados en variables. Por ejemplo, la siguiente operación de filtrado produce una nueva colección que contiene todos los elementos que coinciden con la condición de filtrado, pero no altera ni modifica la colección sobre la que opera:
val numeros = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
numeros.filter { it % 2 == 0 }  // no afecta a numeros y el resultado se pierde
println(numeros) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
val pares = numeros.filter { it % 2 == 0 } // el resultado es almacenado en la variable
println(pares) // [2, 4, 6, 8, 10]

Pero para ciertas operaciones (de filtro, asociación y agrupamiento, entre otras) existe la opción de especificar un objeto de destino, que es una colección mutable a la que la función agrega los elementos resultantes. Para utilizar estas operaciones se agrega el sufijo To al nombre de la función como, por ejemplo, filterTo() o associateTo(), y se toma el nombre de la colección de destino como parámetro:
val numeros = listOf("uno", "dos", "tres", "cuatro")
val filtroResultados = mutableListOf<String>()  // objeto de destino de los resultados
numeros.filterTo(filtroResultados) { it.length > 3 } // filtra strings con longitud mayor que tres
numeros.filterIndexedTo(filtroResultados) { index, _ -> index != 0 } // filtra índices distintos a cero
println(filtroResultados) // contiene los resultados de ambas operaciones
// [tres, cuatro, dos, tres, cuatro]

Al utilizar estas funciones suele ser más práctico crear la colección de destino directamente en el argumento de la función, por ejemplo:
val numeros = listOf(1, 2, 3, 4, 5, 4, 3, 2, 1)

val resultadoList = numeros.filterTo(ArrayList()) { it > 3 }
println(resultadoList) // [4, 5, 4]

val resultadoSet = numeros.filterTo(HashSet()) { it > 3 }
println(resultadoSet) // [4, 5]

En esta entrada iniciamos una serie dedicada a las funciones para realizar operaciones sobre colecciones que ahora empieza con las operaciones de transformación y continuará en posteriores entradas sobre el resto de operaciones disponibles para trabajar con colecciones.

La biblioteca estándar de Kotlin proporciona un conjunto de funciones de extensión para realizar transformaciones en colecciones, es decir, son funciones que crean nuevas colecciones a partir de las existentes basándose en las reglas de transformación que se proporcionan. Dentro de esta categoría se incluyen las transformaciones de mapping, de zipping, de asociación, de flattening y de representación de String.

Mapping

La transformación de mapping crea una nueva colección a partir de los resultados de una función sobre los elementos de una colección. La función básica de mapping es map(), que aplica una determinada función lambda a cada elemento y devuelve una lista de los resultados respetando el orden original de los elementos en la colección original. Si además queremos usar los índices de los elementos como argumento, usaremos mapIndexed().
val numeros = setOf(1, 2, 3, 4, 5)
println(numeros.map { it * 2 }) // [2, 4, 6, 8, 10]
println(numeros.mapIndexed { idx, value -> value * idx + 1 }) // [1, 3, 7, 13, 21]

Si la transformación produce null en ciertos elementos, se pueden filtrar los null de la colección de resultados llamando a la función mapNotNull() en lugar de map() y mapIndexedNotNull() en lugar de mapIndexed():
val numeros = setOf(1, 2, 3, 4, 5)
println(numeros.mapNotNull { if (it == 2) null else it * 3 })
// [3, 9, 12, 15]
println(numeros.mapIndexedNotNull { idx, value -> if (idx == 0) null else value * idx })
// [2, 6, 12, 20]

Al transformar diccionarios tenemos dos opciones: transformar claves sin cambiar los valores o viceversa. Para aplicar una transformación determinada a las claves usamos mapKeys(), y mapValues() para transformar los valores, aunque ambas pueden utilizar tanto las claves como los valores:
val numerosDic = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key11" to 11)
println(numerosDic.mapKeys { it.key.toUpperCase() + it.value})
// {KEY11=1, KEY22=2, KEY33=3, KEY1111=11}
println(numerosDic.mapValues { it.value + it.key.length })
// {key1=5, key2=6, key3=7, key11=16}

Zipping (comprimir)

La transformación zipping consiste en construir pares a partir de elementos con las mismas posiciones en dos colecciones. En la biblioteca estándar de Kotlin esto se realiza mediante la función de extensión zip(), que cuando utilizamos sobre una colección con otra colección como argumento, devuelve una lista de objetos Pair en la que los elementos de la primera colección (receptor) son los primeros elementos en estos pares. Si las colecciones tienen diferentes tamaños, el resultado de zip() es el tamaño más pequeño y los últimos elementos de la colección más grande no se incluyen en el resultado. La función zip() también puede ser llamada en forma infix: a zip b.
val capitales = listOf("Madrid", "Berlín", "París")
val paises = listOf("España", "Alemania", "Francia")
println(capitales zip paises)  // [(Madrid, España), (Berlín, Alemania), (París, Francia)]
println(capitales.zip(paises)) // [(Madrid, España), (Berlín, Alemania), (París, Francia)]

val dosPaises = listOf("España", "Alemania")
println(capitales.zip(dosPaises)) // [(Madrid, España), (Berlín, Alemania)]

También se puede utilizar zip() con una función de transformación que toma dos parámetros: el elemento receptor y el elemento argumento, y en este caso la lista que devuelve contiene los valores resultantes de esa función:
val capitales = listOf("Madrid", "Berlín", "París")
val paises = listOf("españa", "alemania", "francia")

val capitalPais = capitales.zip(paises) { capital, pais -> "La capital de ${pais.capitalize()} es $capital" }
for (item in capitalPais) println(item)
La capital de España es Madrid
La capital de Alemania es Berlín
La capital de Francia es París

Una vez que tenemos una lista de objetos Pair, podemos hacer la transformación inversa (descomprimir) con la función unzip() que genera dos listas a partir de estos pares de elementos, y obtener así las listas originales:
val capitales = listOf("Madrid", "Berlín", "París")
val paises = listOf("España", "Alemania", "Francia")

// List<Pair<String, String>>
val capitalPais = capitales zip paises
println(capitalPais) // [(Madrid, España), (Berlín, Alemania), (París, Francia)]

// Pair<List<String>, List<String>>
val capitalPaisUnzip = capitalPais.unzip()
println(capitalPaisUnzip) // ([Madrid, Berlín, París], [España, Alemania, Francia])

// List<Pair<String, Int>>
val numerosParejas = listOf("uno" to 1, "dos" to 2, "tres" to 3, "cuatro" to 4)
// Pair<List<String>, List<Int>>
val numerosUnzip = numerosParejas.unzip()
println(numerosUnzip) // ([uno, dos, tres, cuatro], [1, 2, 3, 4])

Asociación

Las transformaciones de asociación permiten construir diccionarios (Map) a partir de los elementos de una colección y ciertos valores asociados con ellos y, según el tipo de asociación, los elementos pueden ser claves o valores en el diccionario.

La función básica associateWith() crea un Map en el que los elementos de la colección original son claves y los valores se generan a partir de ellos mediante una función de transformación determinada (si dos elementos son iguales, solo el último permanece):
val numeros = listOf("uno", "dos", "tres", "cuatro")
println(numeros.associateWith { it.capitalize() })
// {uno=Uno, dos=Dos, tres=Tres, cuatro=Cuatro}
println(numeros.associateWith { it.length })
// {uno=3, dos=3, tres=4, cuatro=6}

La función associateBy() permite crear diccionarios en los que los elementos de la colección original son valores y las claves se generan a partir de ellos (si dos elementos son iguales, solo el último permanece en el mapa). Esta función también se puede llamar con una función de transformación de valor.
val numeros = listOf("uno", "dos", "tres", "cuatro", "cinco")

println(numeros.associateBy { it.first().toUpperCase() })
// {U=uno, D=dos, T=tres, C=cinco} // C=cuatro se omite

println(numeros.associateBy(keySelector = { it.first().toUpperCase() }, valueTransform = { it.length }))
// {U=3, D=3, T=4, C=5} // C=6 se omite

Otra forma de crear diccionarios en los que tanto las claves como los valores se producen a partir de los elementos de una colección es la función associate(), que utiliza una función lambda que devuelve un Pair con la clave y el valor correspondientes. Si cualquiera de los pares tuviera la misma clave, solo la última se agregará al mapa.
val lista = listOf("a", "b", "c", "d")
val dict = lista.associate { it.capitalize() to it }
println(dict) // {A=a, B=b, C=c, D=d}

val intList = listOf(1, 2, 3)
println(intList.associate { Pair(it.toString(), it) })
// {1=1, 2=2, 3=3}

El siguiente código muestra otro ejemplo donde se invoca a una función con cada elemento como argumento, y ésta devuelve un objeto con dos propiedades a partir de las cuales se crea la clave y el valor del diccionario:
fun main() {
    data class NombreCompleto(val nombre: String, val apellido: String)

    fun getNombreCompleto(nombreLista: String): NombreCompleto {
        val nombreParte = nombreLista.split(" ")
        if (nombreParte.size == 2) {
            return NombreCompleto(nombreParte[0], nombreParte[1])
        } else throw Exception("Error de formato de nombre")
    }

    val nombres = listOf("Charles Darwin", "Isaac Newton", "Albert Einstein")
    println(nombres.associate { nombre -> getNombreCompleto(nombre).let { it.apellido to it.nombre } })
    // {Darwin=Charles, Newton=Isaac, Einstein=Albert}
}

Flattening

Cuando se utilizan colecciones anidadas (por ejemplo una lista de conjuntos) pueden ser útiles algunas funciones de la biblioteca estándar que proporcionan acceso a sus elementos, como la función flatten() que devuelve una lista única de todos los elementos de las colecciones anidadas.
// List<Set<Int>>
val listaSet = listOf(setOf(1, 2, 3), setOf(4, 5, 6), setOf(1, 2))
println(listaSet.flatten()) // [1, 2, 3, 4, 5, 6, 1, 2]

Por su parte, la función flatMap() toma el resultado que devuelve una función que opera sobre cada uno de los elementos de las colecciones anidadas para obtener una lista única de todos los valores de los elementos de las colecciones anidadas:
data class ContenedorListas(val values: List<String>)

fun main() {
    // List<ContenedorListas>
    val listas = listOf(
        ContenedorListas(listOf("uno", "dos", "tres")),
        ContenedorListas(listOf("cuatro", "cinco", "seis")),
        ContenedorListas(listOf("siete", "ocho"))
    )
    println(listas.flatMap { it.values })
    // [uno, dos, tres, cuatro, cinco, seis, siete, ocho]
}

Representación de String

Cuando se necesita recuperar el contenido de una colección en un formato legible, podemos usar funciones que transforman las colecciones en String, como joinToString() y joinTo(). Mientras que joinToString() construye un único String partir de los elementos de la colección en función de los argumentos proporcionados, la función joinTo() hace lo mismo pero agrega el resultado a un objeto determinado. Cuando se utilizan con los argumentos por defecto, estas funciones devuelven un resultado similar a la función toString() sobre una colección:
val numeros = listOf("uno", "dos", "tres", "cuatro")

println(numeros)            // [uno, dos, tres, cuatro]
println(numeros.toString()) // [uno, dos, tres, cuatro]
println("$numeros")         // [uno, dos, tres, cuatro]

println(numeros.joinToString()) // uno, dos, tres, cuatro

val listString = StringBuffer("Lista de números: ")
numeros.joinTo(listString)
println(listString) // Lista de números: uno, dos, tres, cuatro

Para crear una representación personalizada, podemos especificar los parámetros separator, prefix y postfix de la función:
val numeros = listOf("uno", "dos", "tres", "cuatro")
println(numeros.joinToString(separator = " | ", prefix = "Inicio: ", postfix = "... Fin"))
// Inicio: uno | dos | tres | cuatro... Fin

Para colecciones más grandes, es práctico especificar el parámetro limit que indica una serie de elementos que se incluirán en el resultado, mientras que si el tamaño de la colección lo excede, todos los demás elementos se reemplazarán por un solo valor determinado por el parámetro truncated:
val numeros = (1..100).toList()
println(numeros.joinToString(limit = 10, truncated = "... hasta ${numeros.last()}"))
// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ... hasta 100

También podemos personalizar la representación de los elementos de la colección utilizando una función de transformación sobre cada uno de ellos:
val numeros = listOf("uno", "dos", "tres", "cuatro")
println(numeros.joinToString { "${it.toUpperCase()}: ${it.length} letras" })
// UNO: 3 letras, DOS: 3 letras, TRES: 4 letras, CUATRO: 6 letras

Muy pronto iremos publicando nuevas entradas de esta serie dedicada a las funciones para realizar operaciones sobre colecciones.

Comentarios

Entradas populares

Recursos gratis para aprender Kotlin

I/O: entrada y salida de datos en consola

Lectura y escritura de archivos