La palabra suspend es una transformación
La palabra suspend no describe un comportamiento en tiempo de ejecución sino una reescritura que ocurre en tiempo de compilación. Esta lección lee la firma real que una función suspendida tiene en el bytecode: el parámetro Continuation que el compilador añade al final, la ampliación del tipo de retorno a Any, la interfaz de tres miembros que sostiene todo el mecanismo, el convenio de llamada que impide invocar una función suspendida desde una ordinaria, y las consecuencias observables desde Java y desde la reflexión.
Hay una prueba sencilla para comprobar si se ha entendido de verdad qué son las corrutinas, y consiste en responder qué firma tiene una función suspendida vista desde Java. Quien responde que la misma pero asíncrona no ha entendido nada todavía; quien responde que Java ni siquiera la ve tampoco. La respuesta correcta es incómoda y luminosa: desde Java esa función tiene un parámetro más del que nunca escribimos y devuelve un tipo distinto del que declaramos. Esa discrepancia entre lo que escribimos y lo que existe es toda la historia de este nivel condensada en una sola observación, porque demuestra que suspend no es una anotación que el compilador conserve para que alguien la interprete después, sino la orden de fabricar una función diferente. Lo que sigue es la lectura literal de esa función diferente.
- Escribir la firma real que una función suspendida tiene en el bytecode a partir de su firma en Kotlin.
- Explicar por qué el tipo de retorno se amplía y por qué el parámetro añadido va en última posición.
- Describir los tres miembros de la interfaz
Continuationy el papel de cada uno. - Justificar mediante el convenio de llamada por qué una función suspendida solo puede invocarse desde otra función suspendida.
La firma que existe de verdad
Partamos de una declaración sin adornos y preguntémonos qué método aparece realmente en la clase compilada. La respuesta introduce dos cambios, y ninguno de los dos es cosmético.
// Lo que escribimos
suspend fun cargarUsuario(id: String): Usuario
// Lo que el compilador genera, escrito en notacion Kotlin aproximada
fun cargarUsuario(id: String, cont: Continuation<Usuario>): Any?
El primer cambio es la aparición de un parámetro adicional en última posición cuyo tipo es Continuation parametrizada con el tipo que la función declaraba devolver. Ese parámetro es el objeto del que hablaba la lección anterior: la continuación reificada, es decir, lo que hay que hacer con el resultado cuando exista. No lo escribimos nosotros y no aparece en ningún sitio del código fuente, pero está ahí, ocupa una ranura en la pila de la llamada y viaja en cada invocación.
El segundo cambio es la ampliación del tipo de retorno, que pasa de Usuario a Any?. La razón se estudiará en detalle en la cuarta lección, así que por ahora basta con el argumento estructural: esta función tiene ahora dos desenlaces posibles y no uno. Puede terminar de inmediato con un Usuario, y entonces lo devuelve; o puede no poder terminar todavía, y entonces necesita devolver algo que signifique no hay resultado aún, te avisaré por la continuación. Ese segundo valor no es un Usuario, de modo que el único tipo capaz de cubrir ambos desenlaces es el supertipo común.
La transformación es sistemática y se aplica igual a cualquier declaración, incluidas las que devuelven Unit, las que son métodos de una clase y las que son extensiones. Conviene practicarla sobre varios casos hasta que salga sola, porque es la herramienta con la que se leen las trazas de pila y los mensajes de interoperabilidad del resto del nivel.
// Declarado Generado, en notacion aproximada
suspend fun a() // fun a(c: Continuation<Unit>): Any?
suspend fun b(x: Int): String // fun b(x: Int, c: Continuation<String>): Any?
suspend fun List<Int>.c(): Int // fun c(r: List<Int>, c: Continuation<Int>): Any?
La tercera línea recuerda algo que ya sabíamos de los niveles de extensiones y que aquí se combina con lo nuevo: el receptor de una extensión también es un parámetro en la firma real, y ocupa la primera posición. Con la continuación al final y el receptor al principio, la firma generada encuadra a los parámetros declarados por el usuario entre dos argumentos que el usuario nunca escribió.
El parámetro Continuation va siempre al final, después de los declarados por el usuario y después incluso de los parámetros sintéticos de valores por defecto cuando los hay. Ese convenio estable es lo que permite que la JVM lo trate como cualquier otro argumento y que herramientas externas, depuradores y bibliotecas de interoperabilidad puedan localizarlo sin ambigüedad. Cuando lees una traza de pila y ves un argumento de más al final de un método, ya sabes qué estás mirando.
La interfaz que lo sostiene todo
Continuation es una interfaz de la librería estándar, no una construcción mágica del compilador, y su tamaño sorprende por lo pequeño que es. Tiene una propiedad y un método.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
La propiedad context transporta la información ambiental de la corrutina, que incluye el dispatcher, el trabajo asociado y cualquier otro elemento que se haya instalado; es el asunto de los niveles siguientes y aquí basta con saber que viaja junto a la continuación en lugar de vivir en una variable global. El método resumeWith es el corazón del mecanismo: es la forma de decirle a la continuación que su resultado ya está disponible, y su parámetro es un Result, es decir, un valor que contiene o bien el éxito o bien la excepción.
Ese detalle del Result resuelve por sí solo una de las tres pérdidas de la lección anterior. En el estilo de callbacks el camino del éxito y el camino del fallo eran dos parámetros distintos, cada uno con su tipo, y nada obligaba a propagar el segundo. Aquí hay un único canal de reanudación por el que llega siempre exactamente una de las dos cosas, y el código generado que recibe ese Result lo desempaqueta lanzando la excepción si la hay. Por eso un try escrito en una función suspendida sí captura lo que ocurrió al otro lado de la espera: la excepción no llega por un canal paralelo, llega por el mismo camino por el que habría llegado el valor.
Sobre esa base mínima, la librería estándar añade dos extensiones de conveniencia que son las que se usan a diario y que no aportan ningún mecanismo nuevo: una para reanudar con un valor y otra para reanudar con una excepción. Ambas se limitan a construir el Result correspondiente y a delegar en el único método que existe.
// Ambas terminan llamando a resumeWith con el Result adecuado
cont.resume(usuario)
cont.resumeWithException(IllegalStateException("no encontrado"))
Merece la pena señalar la varianza declarada en la interfaz, porque no es decorativa. La continuación es contravariante en su parámetro de tipo, lo cual tiene sentido inmediato en cuanto se recuerda para qué sirve: una continuación es un consumidor de resultados, no un productor, y cualquier cosa capaz de consumir un valor de un supertipo sirve también donde se espera consumir uno del subtipo. Esa es la razón de que en las firmas reales que aparecen en la interoperabilidad con Java el parámetro se vea con un comodín acotado por debajo.
flowchart LR A[Funcion suspendida] --> B[Parametro Continuation al final] B --> C[Propiedad context] B --> D[Metodo resumeWith con un Result] D --> E[Exito: se devuelve el valor] D --> F[Fallo: se relanza la excepcion]
El convenio de llamada explica la restricción
Con la firma real delante, la regla que más desconcierta a quien llega nuevo al lenguaje deja de ser una regla y se convierte en una obviedad. Una función suspendida solo puede llamarse desde otra función suspendida, y la razón no es una prohibición del diseñador: es que hace falta un argumento que solo existe en ese contexto.
suspend fun a(): Int = b() // b se llama pasandole la continuacion de a
fun c(): Int = b() // error: no hay ninguna continuacion que pasar
Cuando el compilador traduce el cuerpo de a, sabe que a recibió su propio parámetro Continuation porque él mismo se lo añadió, y por tanto puede construir a partir de él el que necesita b. Cuando traduce el cuerpo de c, no hay nada que pasar: c es un método ordinario cuyo marco desaparece al retornar, y no dispone de ningún objeto que represente lo que queda por hacer. El mensaje de error del compilador dice que solo se puede llamar dentro de una corrutina o de otra función suspendida, y lo que quiere decir es que solo se puede llamar allí donde ese argumento esté disponible.
Es un convenio, no un tipo nuevo
suspend no introduce ninguna categoría de tipos ajena a la JVM. Introduce una regla sobre cómo se declaran y se invocan ciertos métodos, verificada íntegramente en tiempo de compilación.
Los constructores son la frontera
Alguien tiene que fabricar la primera continuación. Eso hacen los constructores de corrutinas: son funciones ordinarias que crean el objeto raíz y desde ahí abren una región donde el argumento ya existe.
El color de la función
Que una función suspendida no pueda llamarse desde una ordinaria es la manifestación local de un fenómeno conocido: la propiedad se contagia hacia arriba por toda la cadena de llamantes hasta llegar a un constructor.
La segunda tarjeta señala el único punto por donde se entra en este mundo, y conviene verlo escrito. Los constructores de corrutinas son funciones ordinarias, sin marca alguna, cuya misión es fabricar la primera continuación de la cadena y arrancar la ejecución con ella; a partir de ahí, cada llamada suspendida se limita a propagar la que recibió o a envolverla. Toda la asincronía de un programa Kotlin cuelga de un número finito de esos puntos de entrada, y localizarlos es la forma más rápida de entender un código ajeno.
fun main() {
// Funcion ordinaria: crea la continuacion raiz y abre la region suspendida
ejecutarBloqueando {
val u = cargarUsuario("42") // aqui ya hay algo que pasar
println(u)
}
}
La tercera tarjeta nombra un fenómeno que conviene entender sin dramatismo. El contagio hacia arriba se percibe a veces como un defecto, y en realidad es información honesta: significa que el sistema de tipos está registrando qué partes del programa pueden ceder el control y cuáles no, exactamente igual que registra qué puede ser nulo. Una función que no es suspendida garantiza que se ejecutará de principio a fin sin puntos de cesión, y esa garantía tiene valor. La alternativa, que consistiría en permitir la llamada desde cualquier sitio, exigiría bloquear el hilo por debajo y devolvernos al problema que estamos resolviendo.
Lo que se ve desde fuera
La prueba definitiva de que todo esto es una transformación y no una capa de ejecución es que las herramientas que no saben nada de Kotlin ven el resultado tal cual. Desde Java, la función anterior es un método con un parámetro de más que devuelve Object, y puede invocarse construyendo a mano una continuación. Desde la reflexión de la JVM, el número de parámetros no coincide con el que se escribió. Y desde el sistema de tipos de Kotlin, un tipo función suspendida tiene también su representación concreta.
// Un tipo funcion suspendida sin parametros
val bloque: suspend () -> Usuario = { cargarUsuario("42") }
// Su forma real es aproximadamente esta: recibe la continuacion y devuelve Any
// Function1<Continuation<Usuario>, Any?>
De esa representación se deriva una consecuencia práctica que sorprende la primera vez que se tropieza con ella. Dos funciones que se diferencien únicamente en llevar o no llevar suspend no pueden coexistir como sobrecargas, porque después de la transformación tienen firmas distintas en Kotlin pero pueden colisionar en la plataforma, y el compilador lo señala como un choque de declaraciones. También explica por qué una función suspendida no puede pasarse directamente donde se espera una interfaz funcional de Java: la forma no encaja, porque la aridad real es una más de la aparente.
Queda un residuo de la palabra clave que sí sobrevive a la compilación, y conviene saber cuál es para no concluir de más. En los metadatos que Kotlin adjunta a cada clase se registra que la función era suspendida, y ese registro es lo que permite que otro módulo compilado por separado sepa tratarla correctamente en lugar de verla como un método cualquiera con un parámetro extraño. Es información para el compilador, no para la máquina virtual: nada en tiempo de ejecución la consulta para decidir cómo comportarse.
El compilador de Kotlin ve una función suspendida, con su tipo de retorno declarado y su regla de invocación. La máquina virtual ve un método con un parámetro de más que devuelve Object. Un depurador ve ambas cosas a la vez, y por eso las trazas de pila de una corrutina resultan desconcertantes hasta que se sabe qué se está mirando. Las tres visiones son correctas y describen el mismo artefacto en momentos distintos de su vida.
Merece la pena detenerse en la rareza conceptual de lo que acabamos de describir, porque contiene una lección sobre cómo se diseñan lenguajes que trasciende con mucho a las corrutinas. suspend es una palabra clave que no existe en el programa que se ejecuta. No hay ningún bit en el bytecode que la máquina consulte para decidir comportarse de otra manera, no hay ninguna comprobación en tiempo de ejecución que la interrogue, no hay ningún planificador que la lea. Lo único que queda de ella tras la compilación es una anotación de metadatos que sirve para que el propio compilador de Kotlin reconozca la función cuando la vea desde otro módulo, y un parámetro más en la firma. Todo lo demás, absolutamente todo, ocurrió antes: la palabra le dijo al compilador que reescribiera la función, la función quedó reescrita, y la palabra se disolvió. Esto sitúa a las corrutinas en la misma familia que la nulabilidad de Kotlin, que también desaparece en el bytecode dejando anotaciones y comprobaciones generadas, y en la misma familia que inline, que también es una instrucción al compilador y no una propiedad del programa final. Es la firma característica de este lenguaje: casi todo lo que parece una capacidad nueva de la plataforma resulta ser, al mirarlo de cerca, una reescritura sistemática que un humano podría hacer a mano y que sería insoportable hacer a mano. Y de ahí se sigue lo importante para quien programa. Si la asincronía en Kotlin fuera una librería, la forma de dominarla sería memorizar su API; como es una transformación, la forma de dominarla es saber leer el código que produce. Quien sabe qué firma tiene realmente una función suspendida puede deducir, sin consultar documentación, por qué no se puede llamar desde cualquier sitio, por qué la excepción viaja por el mismo canal que el valor, por qué Java la ve con un parámetro de más, por qué no se puede sobrecargar contra su gemela no suspendida y por qué el tipo de retorno declarado no es el que aparece. Cinco preguntas distintas con una sola respuesta: porque la función que existe no es la que escribiste.
- Escribe tres funciones suspendidas con distinto número de parámetros y redacta a mano la firma real de cada una, incluyendo el tipo exacto del parámetro añadido.
- Explica por qué el tipo de retorno generado es
Any?y no simplemente el declarado, apoyándote en que la función tiene dos desenlaces posibles. - Intenta declarar dos funciones con el mismo nombre y los mismos parámetros donde una sea suspendida y la otra no. Traduce el error del compilador a lo que ocurre en la plataforma.
- Razona qué tendría que construir un llamante escrito en Java para invocar una función suspendida, y qué haría con el valor devuelto en cada uno de los dos casos posibles.
- Argumenta por qué el contagio de
suspendhacia los llamantes es información útil del sistema de tipos y no una limitación del diseño.