Secuencias En Kotlin

En este tutorial verás la definición y uso de secuencias en Kotlin con el fin de evitar la creación de colecciones intermedias cuando deseas encadenar múltiples operaciones.

¿Qué Son Secuencias?

Las secuencias en Kotlin representan el mismo concepto que los streams de Java 8. Son un diseño para optimizar las operaciones sobre colecciones, evitando la creación de objetos temporales entre los pasos de una cadena de computaciones.

Su implementación se encuentra en el paquete kotlin.sequences y son representadas por la interfaz Sequence<T> para denotar una secuencia de elementos que pueden ser enumerados uno por uno.

Ahora, la pregunta es: ¿Qué diferencias existen entre las secuencias y colecciones al ejecutar cadenas de operaciones?

La siguiente tabla resume la respuesta:

EjecuciónEvaluación
ColeccionesEjecución ansiosa (eager): Se completa cada paso hasta que retorne una instancia temporal (colección intermedia), sobre la cual se aplicara el paso siguienteEvaluación horizontal: Aplica cada operación sobre toda la colección
SecuenciasEjecución perezosa (lazy): La ejecución de todos los pasos es realizada, solo cuando se solicita el resultado final de la cadena sin usar colecciones intermediasEvaluación vertical: Aplica todas las operaciones sobre cada elemento

Veamos este comportamiento en un ejemplo visual, donde se desea aplicar una cadena de operaciones map(), filter() y average() sobre la lista L = [a, b, c, d]:

Ejemplo de secuencias en Kotlin

La colección crea una colección intermedia cuando se mapean los caracteres a su código numérico. Luego crea otra al momento de filtrar los códigos impares. Y finalmente usa la última colección para obtener el promedio.

fun main() {
    val letters = listOf('a', 'b', 'c', 'd')
    val numberCodes = letters.map { it.toInt() } // [97, 98, 99, 100]
    val evenCodes = numberCodes.filter { it % 2 != 0 } // [97, 99]
    println(evenCodes.average()) // 98
}

En el caso de la secuencia, esta construye una expresión vertical que mapea y filtra al carácter 'a' con objetos secuencia, que colaboran para mantener este orden.

Luego siguen con 'b' y así sucesivamente. Finalmente se llaman a todas estas cadenas verticales al usar la operación terminal average().

fun main() {
    val letters = listOf('a', 'b', 'c', 'd').asSequence()
    val numberCodes = letters.map { it.toInt() } // kotlin.sequences.TransformingSequence@306a30c7
    val evenCodes = numberCodes.filter { it % 2 != 0 } // kotlin.sequences.FilteringSequence@421faab1
    println(evenCodes.average()) // 98
}

¿Cuándo usar secuencias sobre colecciones?

En el momento en que vas a encadenar múltiples operaciones que serán aplicadas en una colección extensa. Esto te ayudará a optimizar la ejecución.

De lo contrario, usa colecciones para manejar pocos elementos. Las secuencias también pueden producir sobrecarga si desaprovechas su potencial en casos de pequeño alcance, que no justifiquen el uso de múltiples abstracciones del framework kotlin.sequences.

Clasificación De Operaciones Sobre Secuencias

Las operaciones de secuencias se pueden clasificar a partir de dos criterios.

Por su estado, es decir, la cantidad de conocimiento requerida sobre los elementos de la secuencia, que la operación usará:

  • Operación sin estado (stateless): Requiere ninguna o poca información para procesar independientemente cada elemento de la secuencia (filter(), map())
  • Operación con estado (stateful): Requiere bastante información de los elementos de la secuencia antes de ser evaluada (sortedBy(), chunked())

Por ejemplo:

Tomemos como referencia una lista con algunos los nombres de los personajes de Dragon Ball Z para expresar la diferencia entre operaciones stateless y stateful.

Apliquemos 3 operaciones:

  1. Filtrar nombres que tengan más de 5 caracteres
  2. Mapearlos para dejarlos con solo 3 iniciales
  3. Organizarlos alfabéticamente por la inicial
fun main() {
    val dragonBallparty = listOf("Vegeta", "Goku", "Piccolo", "Gohan", "Trunks").asSequence()
    val dbz = dragonBallparty
        .filter { println("1. filter($it)");it.length > 5 }
        .map { println("2. map($it)");it.take(3) }
        .sortedBy { println("3. sortedBy($it) ");it.first() }
    println(dbz.toList())
}

Salida:

1. filter(Vegeta)
2. map(Vegeta)
1. filter(Goku)
1. filter(Piccolo)
2. map(Piccolo)
1. filter(Gohan)
1. filter(Trunks)
2. map(Trunks)
3. sortedBy(Pic) 
3. sortedBy(Veg) 
3. sortedBy(Tru) 
3. sortedBy(Pic) 
3. sortedBy(Tru) 
3. sortedBy(Veg) 
3. sortedBy(Tru) 
3. sortedBy(Pic) 
[Pic, Tru, Veg]

Tanto map() como filter() son stateless. Ambas pueden procesar cada elemento independientemente de las otras operaciones. Por esta razón vemos como se encadena el filtro y mapa para Vegeta o como Goku no tiene mapeado, debido a que no cumple con el filtro.

Por otro lado, la operación 3 nunca se encadena de una sola vez con las 1 y 2 en una línea vertical. El ordenamiento requiere el conocimiento de todos los elementos antes de aplicar el algoritmo. Esta razón hace a sortedBy() sea una operación stateful.

Clasificación por resultado

Dependiendo del retorno de la operación de la secuencia se generan dos categorías:

  • Operación intermedia: retorna otra secuencia ligada a la colección inicial, como filter() y map()
  • Operación terminal: retorna un resultado final, producto de la evaluación de las operaciones intermedias, como maxOrNull(), toList() y first().

Las operaciones intermedias representan computaciones que son pospuestas hasta el momento que llamas una operación terminal.

Por ejemplo:

Intenta filtrar nombres del caso anterior, que contengan la letra o, luego obtener los dos primeros elementos y convertirlos a mayúsculas. Al final imprime ese resultado:

fun main() {
    val dragonBallparty = listOf("Vegeta", "Goku", "Piccolo", "Gohan", "Trunks").asSequence()
    val dbz = dragonBallparty
        .filter { 'o' in it }
        .take(2)
        .map { it.toUpperCase() }
    println(dbz)
}

Salida:

kotlin.sequences.TransformingSequence@3eb07fd3

El resultado es solo la instancia de la secuencia que es consciente de las expresiones por realizar. Esto es debido a que las funciones filter(), take() y map() son terminales. Retornan referencias de secuencias que saben cómo construir la colección original.

Para que se ejecuten todas las operaciones llamamos a una operación terminal como toList().

println(dbz.toList())

Salida:

[GOKU, PICCOLO]

Este comportamiento tiene todo el sentido, ya que es parte del diseño de ejecución perezosa hablada al inicio.

Las operaciones terminales retornaran valores básicos, partes de colecciones, colecciones u objetos que representen la concreción final de todas las operaciones intermedias previas.

Crear secuencias

Para instanciar secuencias usa las siguientes funciones de construcción según el contexto de origen.

Crear Secuencias Desde Elementos

Usa la función sequenceOf() para crear una secuencia a partir de un conjunto de elementos pasados como argumento.

fun main() {
    val numbers = sequenceOf(1, 0, -4, 3)
    for (number in numbers) {
        println(number)
    }
}

Si intentas imprimir la secuencia con println(numbers) no obtendrás la representación en String de los elementos, si no el nombre de la instancia Sequence.

La forma de recorrer la secuencia es a través de su única función iterator(). Por eso el bucle for usado anteriormente es capaz de proyectar los números declarados:

1
0
-4
3

Crear Secuencias Desde Iterables

Si deseas crear una instancia de una secuencia que envuelva a los elementos de un iterable, entonces invoca su función de extensión asSequence() para construir la secuencia.

fun main() {
    val list = listOf("Carlos", "Juana", "Samanta").asSequence()
    val set = setOf(1.2, 3.2, 4.1).asSequence()
    val range = (2..5).asSequence()
    val array = arrayOf(1, 0, 0, 0, 1).asSequence()
    val string = "Develou".asSequence()
    val map = mapOf("Rojo" to 0xff0000, "Verde" to 0x00ff00, "Azul" to 0x0000ff).asSequence()
}

Como ves, las colecciones especiales como arreglos, strings o mapas también poseen una función asSequence().

Crear Secuencia Desde Función Lambda

También es posible crear una secuencia con valores generados a partir de una lambda con generateSequence(). La generación de los elementos también puede ir acompañada de una semilla (seed o seedFunction) que será usada para el cálculo del siguiente elemento.

Esta función calcula el valor siguiente para cada iteración hasta que su resultado sea null. Por lo que la secuencia será infinita si la función nunca produce null.

fun <T : Any> generateSequence(
    nextFunction: () -> T?
): Sequence<T>

// Versión con semilla 
fun <T : Any> generateSequence(
    seed: T?,
    nextFunction: (T) -> T?
): Sequence<T>

// Versión con semilla tipo función
fun <T : Any> generateSequence(
    seedFunction: () -> T?,
    nextFunction: (T) -> T?
): Sequence<T>

Secuencias Infinitas

Por ejemplo, crear la secuencia para los múltiplos de tres hasta el infinito y luego imprimir la tabla de multiplicar del 3:

fun main() {
    val multiplesOfThree = generateSequence(3) { it + 3 }
    val threeTimesTable = multiplesOfThree.take(10)
    println(threeTimesTable.toList())
}

Salida:

[3, 6, 9, 12, 15, 18, 21, 24, 27, 30]

Como ves, la secuencia puede ser expresada hasta el infinito sin problemas, ya que sus valores son producidos solo cuando los necesitamos. Si ejecutaras multiplesOfThree.forEach(::println) verías que la impresión de múltiplos se extendería sin parar.

Secuencias Finitas

Claro está, que una secuencia finita es aquella que usa a null como condición para frenar la fabricación de elementos.

El ejemplo anterior lo podemos reescribir para que la secuencia resuelva los primeros diez elementos en la invocación de generateSequence():

fun main() {
    val threeTimesTable = generateSequence(3) { if (it < 30) it + 3 else null }
    println(threeTimesTable.toList())
}

Crear Secuencia Desde Trozos

Otra alternativa de construir secuencias en Kotlin es a partir de grupos de tamaño arbitrario con ayuda de la función sequence().

Esta función toma como parámetro una función lambda cuyo cuerpo se compone con llamadas de:

  • yield(): Entrega a la secuencia el valor que pases como parámetro y luego suspende el bloque hasta que se solicite el siguiente valor.
  • yieldAll(): Es igual que yield() pero recibiendo múltiples valores de Iterables, Iterators u otras secuencias.

Por ejemplo, expresemos la creación de una secuencia a través de diferentes grupos de elementos enteros. Luego ordenemos los elementos, dividámoslos en partes de tamaño 4 y retornamos al primer pedazo:

fun main() {
    val alphabet = sequence {
        yield(45)
        yieldAll(2..10)
        yieldAll(sequenceOf(-4,20,35,0))
    }
    val firstChunkOf4 = alphabet
        .sorted()
        .chunked(4)
        .first()
    println(firstChunkOf4)
}

Salida:

[-4, 0, 2, 3]

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!