wandres.dev
FLOW I · streams fríos

Qué es un Flow de verdad

Un flujo no es un tipo elaborado con muchas capacidades: es una interfaz pública con un único método suspendido, acompañada de otra interfaz con un único método suspendido. Esta lección lee esas dos declaraciones línea a línea, deduce de ellas la frialdad sin necesidad de ninguna regla adicional, explica por qué la suspensión del emisor hace innecesario el protocolo de petición explícita que otras librerías necesitan, y muestra cómo un catálogo de doscientos operadores se construye entero sobre tan poquísima superficie.

⏱ 21 min

Hay una manera rápida de comprobar si alguien ha entendido los flujos, y consiste en pedirle que escriba de memoria la declaración de Flow. Quien contesta que es una clase con muchos métodos no ha abierto nunca el fuente; quien contesta que es un tipo especial que el compilador conoce, tampoco. La respuesta correcta resulta casi decepcionante: es una interfaz pública con un solo método, y ese método está marcado como suspendido. Nada más. Todo lo que se cuenta después sobre frialdad, contrapresión, cancelación, operadores y contextos no son capacidades añadidas encima de esa declaración, sino consecuencias que se deducen de ella. Este nivel entero consiste en hacer esas deducciones despacio, y empieza por leer las dos únicas declaraciones que hacen falta.

🎯 Al terminar esta lección sabrás
  • Escribir de memoria las declaraciones de Flow y FlowCollector y justificar la varianza de cada una.
  • Deducir la frialdad a partir de la firma, sin recurrir a ninguna regla externa al tipo.
  • Explicar por qué la suspensión de emit sustituye al protocolo de petición explícita de otras librerías.
  • Reconocer que constructores, operadores y operaciones terminales son funciones de extensión sobre esa interfaz.

Las dos declaraciones que hay que saberse

El fuente de la librería cabe, para este asunto, en diez líneas. Dos interfaces, un método cada una, y ambos métodos suspendidos.

public interface Flow<out T> {
    public suspend fun collect(collector: FlowCollector<T>)
}

public fun interface FlowCollector<in T> {
    public suspend fun emit(value: T)
}

La varianza no es decorativa y se lee sola en cuanto se pregunta quién produce y quién consume. Un Flow produce valores de tipo T y jamás los recibe, de modo que es covariante: donde se espera un flujo de un supertipo sirve uno de un subtipo. Un FlowCollector consume valores de tipo T y jamás los devuelve, de modo que es contravariante por la razón simétrica. Esta pareja covariante y contravariante aparece una y otra vez en el lenguaje, y aquí sostiene que puedas pasar un flujo de perros donde se pide uno de animales sin que nada se rompa.

La segunda interfaz está declarada como interfaz funcional, y de ahí sale la sintaxis que todo el mundo escribe a diario. Cuando alguien recolecta pasando una lambda no está usando el método del ejemplo anterior, sino una extensión en línea que fabrica el colector por él.

// La forma que se escribe
flujo.collect { valor -> println(valor) }

// Lo que ocurre por debajo, aproximadamente
flujo.collect(object : FlowCollector<Int> {
    override suspend fun emit(value: Int) { println(value) }
})

Merece la pena detenerse en que los dos métodos suspenden, porque de ahí sale casi todo lo demás. Que collect suspenda significa que recolectar es una llamada corriente que termina cuando el flujo se agota, no una suscripción que devuelve un manejador. Que emit suspenda significa que entregar un valor puede hacer esperar al productor. Con esas dos propiedades ya no hace falta inventar nada más.

ℹ️
No hay ningún objeto de suscripcion

En otras librerías, suscribirse devuelve algo que hay que guardar y cancelar después. Aquí no existe ese objeto porque no hace falta: recolectar es estar dentro de una llamada suspendida, y cancelar esa recolección es cancelar la corrutina que la contiene. La gestión del ciclo de vida se la come entera la concurrencia estructurada que ya conoces.

De la firma se deduce la frialdad

Un valor de tipo Flow no contiene datos ni tiene nada en marcha. Lo único que sabe hacer es responder a collect, y hasta que alguien llama a ese método no ocurre absolutamente nada. Esa es toda la definición de frío: la ausencia de trabajo antes de la recolección no es una decisión de diseño añadida, es que no hay ningún otro método por el que el trabajo pudiera empezar.

val f = flow {
    println("empiezo")
    emit(1)
    emit(2)
}
// Hasta aqui no se ha impreso nada y no existe ningun valor

f.collect { println(it) }   // empiezo, 1, 2
f.collect { println(it) }   // empiezo, 1, 2  otra vez, desde cero

La segunda consecuencia es igual de mecánica. Como el único método recibe el colector como parámetro, cada llamada trae su propio colector y ejecuta el cuerpo entero otra vez. No hay estado compartido entre dos recolecciones porque no hay dónde guardarlo: el objeto Flow es, literalmente, una receta. Dos recolectores no se reparten los valores ni comparten progreso; obtienen cada uno su ejecución completa e independiente.

La tercera consecuencia es la que sostiene el resto del nivel. Si recolectar es ejecutar la receta desde el principio, entonces reintentar un flujo fallido es trivial: basta con volver a llamar a collect. El operador de reintento que veremos en la última lección no necesita almacenar nada ni rebobinar nada, porque el flujo no ha avanzado a ningún sitio del que haya que volver.

flowchart TD
A[Alguien llama a collect] --> B[Se ejecuta el bloque productor]
B --> C[emit entrega el valor al colector]
C --> D[Se ejecuta el cuerpo del recolector]
D --> E[emit retorna y el productor continua]
E --> B
B --> F[El bloque termina y collect retorna]

Por qué no hay protocolo de petición

Conviene comparar con lo que existía antes para medir cuánto se ha eliminado. La especificación clásica de flujos reactivos define cuatro interfaces, unos diez métodos entre todas ellas y un documento de reglas normativas que cualquier implementación debe cumplir para ser conforme. Hay señales de suscripción, de valor, de error y de terminación, y hay un método con el que el consumidor declara cuántos elementos está dispuesto a recibir. Toda esa maquinaria existe para resolver un solo problema: impedir que el productor vaya más rápido que el consumidor.

Aquí ese problema no se resuelve, se disuelve. Mientras el cuerpo del recolector se está ejecutando, la llamada a emit todavía no ha retornado, y por tanto el productor está detenido en esa línea. La demanda no se pide porque es implícita y siempre vale uno: el productor puede continuar exactamente cuando el consumidor ha terminado con el valor anterior. La palabra suspend en emit hace, sin protocolo alguno, lo que aquellas librerías hacen con un método dedicado y un contrato escrito.

Las otras señales desaparecen por motivos igual de simples. No hay una señal de terminación porque una función suspendida ya termina; no hay una señal de error porque una función suspendida ya lanza; no hay señal de suscripción porque no hay suscripción. Cada uno de los conceptos que la especificación reactiva tuvo que inventar existía ya en el lenguaje, y las corrutinas se limitaron a no duplicarlo.

La comparación se puede cerrar con una cuenta que resulta elocuente. Aquella especificación necesita reglas escritas para fijar que las señales no se solapen, que la terminación sea definitiva, que la cancelación sea idempotente y que la demanda no se pueda pedir en cantidad negativa. Ninguna de esas reglas hace falta aquí, porque todas ellas son consecuencias de que la producción ocurra dentro de una llamada suspendida ordinaria: dos emisiones no se solapan porque son dos líneas de la misma función, la terminación es definitiva porque una función que retornó no retorna dos veces, y no hay cantidad de demanda que validar porque no hay cantidad.

📝
Compatibilidad sin conversion de conceptos

Existen adaptadores que convierten un flujo en un publicador de la especificación reactiva y al revés, y funcionan bien porque los conceptos coinciden uno a uno: la demanda se traduce en cuándo se llama a emit, la señal de terminación en el retorno y la de error en la excepción. Que la traducción sea mecánica es la mejor prueba de que no se ha inventado nada nuevo, solo se ha expresado lo mismo con las piezas que el lenguaje ya tenía.

🌊

Frio por ausencia

Nada arranca sin recolector porque no hay ningún otro punto de entrada. La frialdad no es una política, es la forma de la interfaz.

🧊

Uno a uno sin pedirlo

emit suspende hasta que el recolector acaba. La contrapresión es el estado natural y hay que trabajar para quitarla.

🧵

Sin salto de hilo

emit es una llamada directa al colector. No hay cola, ni planificador, ni cambio de contexto entre productor y consumidor.

🪶

Superficie minima

Dos interfaces y dos métodos. Todo el catálogo de operadores vive fuera del tipo, como extensiones.

Todo lo demás son extensiones

Si la interfaz solo tiene un método, cualquier operador tiene que ser una función externa que fabrique un flujo nuevo. Y así es: el catálogo entero está escrito como funciones de extensión sobre Flow, sin acceso privilegiado a nada. Cualquiera podría haberlas escrito, y la prueba es que se escriben en tres líneas.

// Version conceptual, fiel a la idea aunque no al fuente literal
fun <T, R> Flow<T>.map(transform: suspend (T) -> R): Flow<R> = flow {
    collect { valor -> emit(transform(valor)) }
}

Léase despacio, porque ahí está el mecanismo entero del nivel. La función devuelve un flujo nuevo cuyo cuerpo, cuando alguien lo recolecte, recolectará el flujo original con un colector que transforma cada valor y se lo pasa al colector de abajo. Hay dos emit en juego que pertenecen a dos colectores distintos y el compilador los distingue por el receptor implícito de cada bloque. No se crea ninguna colección intermedia, no hay ningún búfer, y un valor recorre la cadena entera antes de que se produzca el siguiente.

Que la interfaz sea pública invita a una pregunta legítima: si solo tiene un método, por qué no implementarla directamente en una clase propia. La librería responde con una recomendación explícita y una anotación de advertencia. Implementar la interfaz a mano deja fuera las comprobaciones de contexto y de excepciones que estudiaremos en la última lección, y por eso existe una clase abstracta pensada para heredar de ella cuando alguien necesita de verdad un tipo propio en lugar de un valor construido.

class Sensor : AbstractFlow<Int>() {
    override suspend fun collectSafely(collector: FlowCollector<Int>) {
        collector.emit(leer())
    }
}

El nombre del método que hay que redefinir es la documentación de todo el asunto: recolectar con seguridad, es decir, con las invariantes comprobadas. En la práctica casi nunca hace falta, porque el constructor general ya hereda esas comprobaciones y una función que devuelve un flujo es más flexible que una clase que lo es.

De aquí sale una regla de lectura que sirve para todo lo que viene después. Cuando un operador te desconcierte, escribe su versión conceptual con flow y collect y verás que casi siempre cabe en cuatro líneas. Los que no caben son exactamente los interesantes, y son pocos: los que introducen una corrutina nueva, los que cancelan el productor y los que cambian el contexto. Esos tres grupos ocupan las lecciones tres, cuatro y cinco de este nivel.

Que un tipo tan importante quepa en dos metodos no es elegancia decorativa sino la prueba de que el lenguaje ya tenia las piezas y solo faltaba negarse a duplicarlas

Compárese la magnitud del problema con la magnitud de la solución. El problema es la comunicación asíncrona de secuencias de valores entre partes de un programa que avanzan a ritmos distintos, con cancelación, propagación de errores, control de flujo y composición: exactamente el problema que motivó una especificación industrial con cuatro interfaces, decenas de reglas normativas y una batería de conformidad que ninguna implementación aprueba a la primera. La solución en Kotlin es una interfaz con un método y otra interfaz con un método. La diferencia no se explica por ingenio; se explica porque las corrutinas ya habían resuelto antes, y en otro sitio, cada una de las piezas que aquella especificación tuvo que fabricar desde cero. El control de flujo ya estaba resuelto por la suspensión, que es la capacidad de detener a alguien sin bloquear a nadie; la terminación ya estaba resuelta por el retorno de una función; el error ya estaba resuelto por la excepción, que viaja por el mismo canal que el valor gracias al Result de la continuación; la cancelación ya estaba resuelta por la concurrencia estructurada, que hace que abandonar una recolección sea abandonar una corrutina; y la composición ya estaba resuelta por las funciones de extensión, que permiten que el catálogo crezca sin tocar el tipo. Lo verdaderamente instructivo es la disciplina de no reinventar ninguna de las cinco. Otra librería habría añadido su propio mecanismo de cancelación, su propio canal de errores, su propio contrato de terminación, y cada uno de ellos habría sido correcto por separado e incompatible con el resto del lenguaje. Al negarse a hacerlo, los flujos heredan gratis todo lo que las corrutinas ya garantizaban, y por eso un try funciona, un finally funciona, cancelar el ámbito funciona y componer con withContext funciona, sin que nadie haya escrito una línea de código para que funcionen. La lección de diseño se puede enunciar como una pregunta que conviene hacerse antes de introducir cualquier abstracción nueva: de todo lo que estoy a punto de construir, cuánto existe ya un nivel más abajo y lo estoy duplicando por no haber mirado. La respuesta honesta suele ser casi todo, y la diferencia entre una librería que se integra y una que compite con su propio lenguaje está exactamente ahí.

⚔️ Reconstruye el tipo desde cero
  1. Escribe de memoria, sin mirar, las declaraciones de Flow y FlowCollector completas, con su varianza. Compáralas con el fuente y anota cada diferencia.
  2. Explica por escrito, en tres frases y sin usar la palabra frío, por qué un flujo no puede hacer nada antes de que alguien lo recolecte. Apóyate solo en la firma.
  3. Implementa tu propia interfaz con un método suspendido de un solo argumento y escribe encima un map y un filter como extensiones. Comprueba que no necesitas nada más.
  4. Recolecta dos veces el mismo flujo con un println dentro del cuerpo productor. Cuenta las impresiones y razona qué habría ocurrido con un canal en su lugar.
  5. Enumera las cuatro señales de la especificación reactiva clásica y di, para cada una, qué mecanismo del lenguaje Kotlin la hace innecesaria.