El problema del callback
Antes de preguntarse qué genera el compilador para una función suspendida conviene entender con precisión qué problema estaba resolviendo. Esta lección reconstruye la degradación que sufre el código cuando una operación deja de devolver su resultado por retorno: la pérdida del orden textual como orden temporal, el anidamiento que llamamos pirámide, la desconexión entre el punto donde se lanza una excepción y el punto donde se podría capturar, la imposibilidad de componer y de cancelar, y el nombre técnico exacto de lo que un callback reifica.
La asincronía no es difícil para la máquina. Un procesador que emite una petición de red y atiende otra cosa mientras llega la respuesta está haciendo algo perfectamente natural para él, y el sistema operativo lleva décadas ofreciendo primitivas para expresarlo. La dificultad es nuestra, y es de naturaleza lingüística: los lenguajes que usamos codifican el orden temporal en el orden textual de las instrucciones, y esa correspondencia se rompe en el instante exacto en que una operación deja de entregar su resultado por retorno. Todo lo que viene después en este nivel, incluido el bytecode que el compilador fabrica y el valor centinela que devuelve una función suspendida, es la respuesta técnica a esta única fractura. Conviene por tanto medirla con cuidado antes de estudiar la reparación, porque quien no ha visto de cerca el daño tiende a interpretar la solución como una comodidad sintáctica en lugar de como lo que realmente es: una reconstrucción del mecanismo de retorno.
- Explicar qué información concreta guarda la pila de llamadas y por qué esa información se pierde cuando una operación se vuelve asíncrona.
- Enumerar las tres pérdidas del estilo de callbacks: flujo de control, manejo de errores y composición.
- Identificar un callback como la reificación de una continuación y nombrar el estilo de programación resultante.
- Formular con precisión qué transformación haría falta para recuperar la escritura secuencial sin bloquear ningún hilo.
Lo que la pila guardaba por nosotros
Cuando una función llama a otra de forma ordinaria, la máquina apila un marco con los parámetros, las variables locales y, sobre todo, la dirección de retorno. Esa dirección es la respuesta a la pregunta más importante del modelo de ejecución: qué hay que hacer cuando esto termine. El programador nunca la escribe porque el compilador la deduce del orden de las líneas, y esa deducción automática es el servicio que la pila presta y que damos por descontado.
fun perfilCompleto(id: String): Perfil {
val usuario = cargarUsuario(id) // 1
val avatar = cargarAvatar(usuario.foto) // 2, sabe volver aqui
return Perfil(usuario, avatar) // 3
}
El problema aparece cuando la operación no puede terminar ahora. Si cargarUsuario emite una petición de red y su resultado tardará doscientos milisegundos, solo hay dos salidas. La primera es dejar el marco de pila ocupado y detener el hilo hasta que llegue la respuesta, lo cual conserva la escritura secuencial al precio de inmovilizar un recurso caro. La segunda es liberar el hilo, y para eso hay que retornar; pero retornar significa destruir el marco, y destruir el marco significa que la información de qué hacer después desaparece. Si queremos conservarla tenemos que escribirla nosotros, y la única forma que un lenguaje sin soporte especial ofrece para escribir lo que hay que hacer después es pasar una función.
flowchart TD A[Llamada ordinaria] --> B[Marco en la pila con direccion de retorno] B --> C[La maquina sabe volver sola] D[Llamada asincrona] --> E[El marco se destruye al retornar] E --> F[Hay que pasar a mano lo que sigue] F --> G[Callback]
Esa función es el callback, y conviene fijar desde ya su naturaleza exacta porque de ella depende todo el nivel: un callback no es un mecanismo de notificación ni un patrón de diseño de la programación por eventos. Un callback es la dirección de retorno convertida en un valor de primera clase. Lo que la máquina guardaba implícitamente en una ranura de la pila pasa a ser un objeto que nosotros creamos, nombramos y transportamos a mano.
La pirámide y sus tres pérdidas
La consecuencia inmediata de escribir a mano lo que la pila hacía sola es el anidamiento. Cada operación asíncrona introduce un nivel de bloque, porque todo lo que sigue debe estar dentro del callback de lo anterior. Con dos operaciones el resultado es tolerable; con cinco, el código se lee en diagonal y ya nadie sabe cuál es su forma.
fun perfilCompleto(id: String, alTerminar: (Perfil) -> Unit) {
cargarUsuario(id) { usuario ->
cargarAvatar(usuario.foto) { avatar ->
cargarAmigos(usuario.id) { amigos ->
alTerminar(Perfil(usuario, avatar, amigos))
}
}
}
}
El anidamiento es el síntoma visible y el menos grave, porque la sangría se puede aplanar extrayendo funciones con nombre. Lo que no se puede aplanar son las tres capacidades que se pierden por debajo, y que no son cuestión de estética sino de expresividad del lenguaje.
Conviene además notar lo que le ha ocurrido a la firma. La función original devolvía un Perfil y podía fallar lanzando una excepción, y ambas cosas estaban escritas en su declaración o eran verificables por el compilador. La versión con callback devuelve Unit, es decir, declara que no produce nada, y esconde su resultado dentro del tipo de un parámetro. Cualquiera que lea la firma ya no sabe qué hace la función sin leer su cuerpo, y el sistema de tipos ha dejado de poder garantizar que el resultado se use, que se use una sola vez o que se use en algún momento.
El flujo de control
Un bucle sobre operaciones asíncronas no se puede escribir con for, porque el cuerpo termina antes de que llegue el resultado. Hay que reescribirlo como una recursión manual que se invoca desde el propio callback, y las condiciones se convierten en ramas duplicadas.
El manejo de errores
Un try solo protege lo que ocurre en su mismo marco de pila. El callback se ejecuta más tarde y sobre otro marco, así que la excepción que lance no atraviesa ningún bloque que hayamos escrito y acaba en el manejador global del hilo.
La composición
Dos operaciones independientes no se pueden esperar juntas sin un contador compartido y mutable. Y nada permite cancelar lo que ya está en vuelo: entregado el callback, la operación seguirá su curso aunque el resultado ya no le importe a nadie.
La primera pérdida se ve con especial crudeza al intentar recorrer una colección. Un bucle ordinario funciona porque cada vuelta termina antes de que empiece la siguiente, y esa garantía desaparece en cuanto el cuerpo contiene una operación que devuelve su resultado más tarde. La reescritura obligada convierte el índice en un parámetro y el avance en una llamada hecha desde dentro del propio callback.
fun cargarTodos(ids: List<String>, alTerminar: (List<Usuario>) -> Unit) {
val acumulado = mutableListOf<Usuario>()
fun paso(i: Int) {
if (i == ids.size) return alTerminar(acumulado)
cargarUsuario(ids[i]) { u ->
acumulado += u
paso(i + 1) // el bucle se ha vuelto una recursion
}
}
paso(0)
}
La segunda pérdida merece verse escrita porque suele subestimarse. En un flujo secuencial el manejo de errores es una construcción del lenguaje que abarca una región de código con independencia de cuántas llamadas contenga; en el estilo de callbacks es un parámetro más que cada función debe declarar, que cada implementación debe recordar invocar y que cada llamante debe propagar hacia arriba a mano.
fun perfilCompleto(
id: String,
alTerminar: (Perfil) -> Unit,
alFallar: (Throwable) -> Unit,
) {
cargarUsuario(id, { usuario ->
cargarAvatar(usuario.foto, { avatar ->
alTerminar(Perfil(usuario, avatar))
}, alFallar) // hay que reenviarlo, y nadie obliga a hacerlo
}, alFallar)
}
Es habitual diagnosticar este código como un problema de legibilidad y atacarlo aplanando la sangría. El diagnóstico correcto es otro: la firma de la función ha dejado de decir la verdad. Una función que devuelve Unit y recibe dos lambdas no declara ni su resultado ni sus fallos, de modo que el sistema de tipos ya no puede verificar nada sobre ninguno de los dos. Aplanar la pirámide mejora la vista y no recupera ni una sola de las garantías perdidas.
Continuaciones: el nombre técnico de lo que pasamos
Existe un término preciso para el objeto que un callback representa. La continuación de un punto de un programa es la totalidad del cómputo que queda por hacer a partir de ese punto: en el ejemplo inicial, la continuación de la llamada a cargarUsuario es cargar el avatar, construir el perfil y devolverlo. Es un concepto que en la ejecución ordinaria permanece implícito, disperso entre la dirección de retorno y el contenido de los marcos apilados.
Un callback hace explícito ese concepto. Cuando escribimos cargarUsuario(id) { usuario -> ... } estamos tomando la continuación de esa llamada y convirtiéndola en un objeto que se pasa como argumento. A la disciplina de escribir programas donde toda función recibe su continuación en lugar de retornar se le llama estilo de paso de continuaciones, y su propiedad característica es que ninguna función retorna nunca de verdad: todas terminan invocando a otra.
// Estilo directo: el resultado sale por retorno
fun sumar(a: Int, b: Int): Int = a + b
// Estilo de paso de continuaciones: el resultado sale por parametro
fun sumarCps(a: Int, b: Int, k: (Int) -> Unit): Unit = k(a + b)
Este ejemplo, deliberadamente trivial, contiene la observación que abre el resto del nivel. La transformación entre ambas formas es puramente mecánica: se añade un parámetro, se cambia el tipo de retorno y se sustituye cada return por una llamada a ese parámetro. Cualquier cosa mecánica puede hacerla un compilador, y ese es exactamente el punto de partida del diseño de las corrutinas de Kotlin. No se trata de darnos una librería que oculte los callbacks tras una fachada, sino de que el compilador escriba por nosotros el estilo de paso de continuaciones, de la misma forma que ya escribía por nosotros el manejo de la pila.
Lo que querríamos escribir
El objetivo, formulado con exactitud, no es eliminar los callbacks: es dejar de escribirlos. Queremos que el orden textual vuelva a ser el orden temporal, que try vuelva a cubrir todo lo que hay dentro de sus llaves, que un for pueda contener una operación que tarda, y que nada de eso obligue a inmovilizar un hilo mientras se espera.
suspend fun perfilCompleto(id: String): Perfil {
val usuario = cargarUsuario(id)
val avatar = cargarAvatar(usuario.foto)
return Perfil(usuario, avatar)
}
Esa función tiene exactamente la forma de la primera versión de esta lección, con una sola palabra de diferencia, y sin embargo no bloquea ningún hilo en ninguna de sus dos esperas. La pregunta que gobierna las cuatro lecciones siguientes es cómo puede ser eso cierto, y la respuesta no está en ninguna librería ni en ningún planificador oculto: está en el código que el compilador genera a partir de ese texto. La palabra suspend no describe un comportamiento del programa en ejecución, describe una transformación que ocurre antes, y es esa transformación la que vamos a leer.
Vale la pena situar todo este nivel en su lugar correcto antes de entrar en el detalle mecánico, porque el orden en que suele contarse esta historia produce un malentendido tenaz. Se presenta primero la sintaxis cómoda, luego los constructores de corrutinas, luego los dispatchers, y el estudiante acaba con la impresión de que las corrutinas son una capa de infraestructura que se instala encima del lenguaje para hacer concurrencia, una especie de planificador amable con sintaxis bonita. La realidad es casi la contraria. Lo que ocurrió históricamente es que la asincronía le arrebató al código una propiedad que tenía desde siempre y que nadie había necesitado nombrar: que el orden en que se leen las instrucciones es el orden en que ocurren, y que por tanto las construcciones que agrupan regiones de código, el bloque, el bucle, el condicional y el manejador de excepciones, significan algo. En cuanto una llamada empieza a devolver su resultado por un parámetro en vez de por retorno, esas construcciones dejan de abarcar lo que parecen abarcar, y el lenguaje se degrada a un ensamblador de saltos donde cada salto se escribe a mano en forma de lambda. Los callbacks no son una mala práctica que alguien inventó por descuido: son la única forma de expresar una continuación explícita en un lenguaje que no tiene ninguna otra, y aparecen inevitablemente en cualquier plataforma que llegue a este problema, con nombres distintos y la misma forma. Lo que Kotlin decidió, y lo que hace de las corrutinas una decisión de diseño de lenguaje y no una librería, es que si la traducción del estilo directo al estilo de paso de continuaciones es mecánica, entonces le corresponde al compilador hacerla, exactamente igual que le corresponde generar el manejo de la pila en lugar de exigirnos apilar marcos a mano. Bajo esa luz, suspend no es una marca de asincronía ni una promesa de rendimiento: es la declaración de que esta función va a ser reescrita, y todo lo que ocurra en las próximas cuatro lecciones es la lectura de esa reescritura.
- Escribe una cadena de tres operaciones asíncronas encadenadas con callbacks y luego la misma cadena en estilo directo. Cuenta cuántos identificadores existen en cada versión que no correspondan a ningún concepto del dominio.
- Toma un bucle que procese diez elementos llamando a una operación que tarda y reescríbelo con callbacks sin usar corrutinas. Explica por qué te ha hecho falta una función recursiva y dónde ha ido a parar la variable del índice.
- Coloca un
tryalrededor de una llamada con callback, haz que el callback lance una excepción y razona, en términos de marcos de pila, por qué elcatchno se ejecuta. - Convierte a mano una función ordinaria de tres líneas al estilo de paso de continuaciones. Anota cada paso mecánico que has aplicado y comprueba que ninguno ha requerido una decisión creativa.
- Argumenta por qué aplanar la pirámide extrayendo funciones con nombre no recupera ninguna de las tres pérdidas descritas en esta lección.