wandres.dev
COLECCIONES Y SECUENCIAS · perezoso frente a ansioso

Escribir tu propia colección: iteradores, sequence y defensas

Qué hace falta exactamente para que un tipo propio se pueda recorrer, desde el iterador escrito a mano hasta el constructor sequence con yield y su corrutina restringida. Esta lección cubre la convención del bucle for, la máquina de estados que genera el compilador, las clases abstractas de la biblioteca estándar para implementar vistas perezosas, el contrato de igualdad de listas y conjuntos, y las tres formas honestas de devolver una colección de solo lectura sin mentir.

⏱ 20 min

Después de cuatro lecciones consumiendo colecciones ajenas llega la pregunta que cierra el nivel: qué hace falta para producir una propia. La respuesta mínima es sorprendentemente pequeña, porque el bucle de recorrido de Kotlin no exige implementar ninguna interfaz sino cumplir una convención de nombres, y a partir de ahí todo son grados de compromiso. Puedes quedarte en lo justo para que un for funcione, puedes implementar Iterable<T> para heredar el catálogo entero de operadores, puedes implementar Sequence<T> para heredar el catálogo perezoso, o puedes implementar List<T> y adquirir de golpe un contrato de igualdad, de tamaño y de coherencia que casi nadie lee y casi todo el mundo incumple. En medio de todo eso está el constructor que hace innecesaria la mayor parte del trabajo manual, porque deja que el compilador escriba por ti la máquina de estados que tú habrías escrito mal.

🎯 Al terminar esta lección sabrás
  • Implementar un iterador propio y explicar por qué el bucle de recorrido no exige heredar de nada.
  • Generar secuencias con el constructor sequence y entender la corrutina restringida que hay detrás.
  • Construir vistas perezosas apoyándose en las clases abstractas de la biblioteca estándar.
  • Devolver colecciones de solo lectura eligiendo conscientemente entre copia, vista y envoltura.

El contrato mínimo: un iterador a mano

El bucle de recorrido de Kotlin es azúcar sobre una convención: funciona sobre cualquier expresión que ofrezca un operator fun iterator() cuyo resultado tenga hasNext y next, aunque el tipo no implemente Iterable<T> ni descienda de nada. Esa es la razón por la que se puede recorrer un rango, una cadena o un tipo tuyo sin que ninguno comparta jerarquía.

class Anillo<T>(private val elementos: List<T>, private val vueltas: Int) {
    operator fun iterator(): Iterator<T> = object : Iterator<T> {
        private var restantes = elementos.size * vueltas
        private var i = 0
        override fun hasNext(): Boolean = restantes > 0
        override fun next(): T {
            if (!hasNext()) throw NoSuchElementException()
            restantes--
            return elementos[i++ % elementos.size]
        }
    }
}

for (x in Anillo(listOf("a", "b"), 3)) print(x)   // ababab

Implementar Iterable<T> en lugar de quedarse en la convención cambia una sola cosa, pero es enorme: todas las funciones de extensión del catálogo pasan a estar disponibles sobre tu tipo sin escribir una línea. Ese es el motivo real para heredar de la interfaz, y no hay ningún otro. Escribir el iterador a mano es directo mientras el recorrido sea lineal y se convierte en un ejercicio de dolor en cuanto hay recursión: recorrer un árbol en orden obliga a mantener una pila explícita y a reconstruir a mano el estado que la propia recursión te habría dado gratis.

sequence y yield: la corrutina restringida

El constructor sequence resuelve exactamente ese problema. Recibe una lambda en la que escribes el recorrido de forma directa, emitiendo valores con yield y sublistas enteras con yieldAll, y el compilador convierte ese código en una máquina de estados que se suspende en cada emisión y reanuda donde se quedó cuando alguien pide el siguiente elemento.

class Nodo<T>(val valor: T, val izq: Nodo<T>? = null, val der: Nodo<T>? = null)

fun <T> Nodo<T>.enOrden(): Sequence<T> = sequence {
    izq?.let { yieldAll(it.enOrden()) }
    yield(valor)
    der?.let { yieldAll(it.enOrden()) }
}

val paginas = sequence {
    var cursor: String? = null
    do {
        val pagina = descargar(cursor)
        yieldAll(pagina.items)
        cursor = pagina.siguiente
    } while (cursor != null)
}

La recursión de la primera función es la que habrías escrito para imprimir el árbol, y sin embargo produce una secuencia perezosa que no materializa ningún nodo hasta que se pide. Su hermano iterator hace lo mismo devolviendo directamente un Iterator<T>, que es lo que quieres cuando vas a exponer tu tipo como Iterable<T>. Hay dos cosas que conviene saber antes de usarlos en serio. La primera es de rendimiento: yieldAll sobre una llamada recursiva encadena un nivel de indirección por cada nivel de profundidad, de modo que en árboles muy desequilibrados el coste por elemento crece con la profundidad. La segunda es de alcance, y es la más instructiva.

💡
Dentro de `sequence` no puedes llamar a cualquier función suspendida

El receptor de la lambda está marcado como de suspensión restringida, lo que significa que solo admite las funciones suspendidas declaradas sobre él mismo, es decir yield y yieldAll. No puedes esperar dentro una llamada de red, ni una pausa, ni nada del universo de corrutinas. La restricción no es un descuido: una secuencia se consume desde código normal a través de hasNext y next, que son llamadas bloqueantes, y permitir una suspensión real ahí significaría bloquear el hilo que consume. Cuando lo que necesitas de verdad es emitir valores producidos de forma asíncrona, el tipo correcto no es Sequence<T> sino un flujo.

flowchart TD
A[Consumidor llama a next] --> B[Reanuda la maquina de estados]
B --> C[Ejecuta hasta el siguiente yield]
C --> D[Entrega el valor y suspende]
D --> E[El estado queda guardado]
E --> A
F[Sin llamadas a next] --> G[No se ejecuta nada del cuerpo]

Implementar la interfaz completa sin traicionar el contrato

Cuando tu tipo no solo se recorre sino que es una lista o un conjunto de pleno derecho, implementar la interfaz a pelo es mala idea porque el contrato incluye mucho más que las firmas. La biblioteca estándar ofrece clases abstractas de solo lectura que ya resuelven la parte protocolaria: AbstractList<E> pide únicamente el tamaño y el acceso por índice, e implementa a partir de ahí el iterador, la búsqueda de índice, la sublista, la igualdad estructural y el código de dispersión con la fórmula que exige el contrato.

class Repetida<E>(private val elemento: E, override val size: Int) : AbstractList<E>() {
    override fun get(index: Int): E {
        if (index !in 0 until size) throw IndexOutOfBoundsException("$index")
        return elemento
    }
}

class Invertida<E>(private val origen: List<E>) : AbstractList<E>() {
    override val size: Int get() = origen.size
    override fun get(index: Int): E = origen[origen.size - 1 - index]
}

Las dos son vistas y no copias: la primera no guarda un millón de elementos aunque su tamaño lo diga, y la segunda refleja los cambios del origen sin ocupar memoria adicional. Ese es el patrón que hay que tener en la cabeza cuando aparece la tentación de construir una lista entera para exponerla. Y el contrato que estas clases te regalan es justo el que se incumple al implementar a mano: la igualdad de dos listas es elemento a elemento y en orden, la de dos conjuntos ignora el orden, el código de dispersión debe ser coherente con esa igualdad, size tiene que coincidir con el número de elementos que produce el iterador, y contains tiene que ser consistente con lo que ese iterador emite. Una implementación que se salte cualquiera de esos puntos rompe cosas lejanas y difíciles de diagnosticar, empezando por el comportamiento de tu tipo como clave de un mapa.

Solo lectura defensiva: copia, vista y envoltura

Cerrado el nivel, vuelve la cuestión de la primera lección con las herramientas para resolverla. Devolver una colección de solo lectura admite tres implementaciones honestas y una deshonesta, y la deshonesta es la que se escribe por defecto.

class Registro {
    private val entradas = mutableListOf<Entrada>()

    // 1. Copia: independiente, coste proporcional al tamano en cada llamada
    fun instantanea(): List<Entrada> = entradas.toList()

    // 2. Vista viva: sin copia, refleja cambios, no admite conversion descendente
    val vista: List<Entrada> = object : AbstractList<Entrada>() {
        override val size: Int get() = entradas.size
        override fun get(index: Int): Entrada = entradas[index]
    }

    // 3. Perezosa: no promete tamano ni repeticion, solo recorrido
    fun comoSecuencia(): Sequence<Entrada> = entradas.asSequence()

    // 4. Deshonesta: es la misma instancia, una conversion basta para escribirla
    val fuga: List<Entrada> get() = entradas
}

La segunda opción merece atención porque resuelve el agujero concreto que dejamos abierto: el objeto devuelto es una instancia de una clase de solo lectura genuina, no un ArrayList disfrazado, de modo que la conversión descendente a la interfaz mutable falla en tiempo de ejecución en lugar de tener éxito. Sigue siendo una vista viva, con lo que quien la guarde verá los cambios, pero ya nadie puede provocarlos a través de ella. La elección entre las cuatro depende de una única pregunta, y no es cuál es más segura sino qué necesita el consumidor: una instantánea estable, una ventana que se actualiza, un recorrido sin garantías de tamaño, o la comodidad de no pensarlo.

🔁

La convención basta para el `for`

Un operator fun iterator hace recorrible cualquier tipo. Implementar Iterable<T> se hace para heredar el catálogo de operadores, no para que funcione el bucle.

🪄

`sequence` para lo recursivo

Escribe el recorrido como si fuera directo y deja que el compilador construya la máquina de estados. Es la diferencia entre cuatro líneas y una pila explícita.

🧱

`AbstractList` para vistas

Con el tamaño y el acceso por índice heredas iterador, sublista, igualdad y dispersión correctos. Implementar List a mano casi nunca compensa.

🛡️

Vista real frente a conversión

Una vista basada en AbstractList no es un ArrayList por debajo, así que la conversión descendente a la interfaz mutable falla en vez de tener éxito.

Una colección no es un almacén de datos: es un contrato sobre cómo se puede preguntar por ellos, y por eso implementarla es diseñar y no rellenar métodos

Vale la pena terminar el nivel deshaciendo la intuición ingenua con la que casi todo el mundo empieza, que es pensar en una colección como una caja donde hay cosas guardadas. Ninguna de las implementaciones de esta lección guarda nada: la lista repetida devuelve el mismo elemento un millón de veces sin almacenar ninguno, la invertida no tiene datos propios, la vista sobre el registro es una ventana sin memoria y la secuencia paginada produce elementos que todavía no existían cuando se creó. Todas ellas son, sin embargo, colecciones perfectamente legítimas, y lo son porque cumplen lo único que se les pide: responder de forma coherente a las preguntas que la interfaz permite hacer. Esa es la naturaleza real del asunto. Iterable es la promesa de que se puede preguntar por el siguiente elemento hasta que no haya más. Collection añade la promesa de que hay un número finito y conocido y de que se puede preguntar por la pertenencia sin recorrer. List añade la de que las posiciones son estables y significativas. Set añade la de que no hay repeticiones. Ninguna de esas promesas dice una palabra sobre dónde viven los datos, ni sobre si existen antes de que los pidas, ni sobre si hay memoria detrás; y esa indiferencia deliberada es lo que permite que el mismo filter funcione sobre un array cargado en memoria, sobre un fichero de veinte gigabytes que se lee línea a línea y sobre una serie infinita que se calcula sobre la marcha. Cuando implementas una colección propia no estás rellenando huecos de una plantilla, estás firmando esas promesas, y el compilador solo puede comprobar las firmas mientras que el resto queda bajo tu palabra. De ahí que las dos únicas maneras responsables de hacerlo sean apoyarse en las clases abstractas que ya cumplen la parte protocolaria, o leerse el contrato entero antes de implementar la interfaz desnuda. Y de ahí, sobre todo, que la pregunta correcta al diseñar cualquier API no sea qué colección devolver, sino qué preguntas quieres que tu consumidor pueda hacer y cuáles quieres poder seguir contestando dentro de dos años cuando los datos ya no quepan en memoria.

⚔️ Fabrica una colección que no guarde nada
  1. Haz recorrible un tipo tuyo con un operator fun iterator sin implementar Iterable<T>, y comprueba qué funciones del catálogo no tienes disponibles.
  2. Escribe el recorrido en orden de un árbol binario dos veces: con un iterador manual y con pila explícita, y con sequence más yieldAll. Compara las dos versiones.
  3. Intenta llamar a una función suspendida cualquiera dentro de un sequence y explica con precisión por qué el compilador lo rechaza.
  4. Implementa con AbstractList una vista invertida sobre otra lista y verifica que la igualdad estructural con una lista normal funciona en ambos sentidos.
  5. Expón el estado interno de una clase de las cuatro formas de la última sección e intenta la conversión descendente en cada una. Documenta qué ocurre y por qué.