noinline y crossinline: las dos excepciones necesarias
Cuando una función se marca como insertable, todos sus parámetros de tipo función lo son también, y esa uniformidad choca con dos situaciones legítimas: guardar la lambda en algún sitio, y ejecutarla desde un contexto distinto del que la escribió. Esta lección explica los dos modificadores que resuelven cada caso, qué garantía cede cada uno, cómo leer los mensajes de error que los reclaman, y por qué el modificador que prohíbe el retorno no local es el más útil de los dos pese a parecer el más restrictivo.
La marca de inserción no es un ajuste que se aplique a una función: es una promesa sobre todos sus parámetros de tipo función a la vez. Al escribirla, el compilador se compromete a copiar el cuerpo de cada lambda literal en el punto donde se la invoca, y a partir de ese compromiso concede dos privilegios que dependen de él, el retorno no local y la conservación del parámetro de tipo. El problema es que la promesa no siempre se puede cumplir, y no por capricho del compilador sino porque hay dos cosas perfectamente razonables que se pueden querer hacer con una lambda y que son incompatibles con copiarla en el sitio: guardarla para más tarde, y ejecutarla desde un lugar que no es el punto de llamada. Kotlin no resuelve esa tensión con un permiso genérico ni con una excepción silenciosa, sino con dos modificadores que obligan a declarar, parámetro por parámetro, exactamente qué privilegio se está renunciando. Aprender a leer sus mensajes de error es aprender a leer la transformación misma.
- Explicar por qué todos los parámetros de tipo función de una función insertable son insertables por defecto.
- Aplicar
noinlinecuando la lambda debe existir como valor y describir qué coste reintroduce. - Aplicar
crossinlinecuando el cuerpo se ejecuta desde otro contexto y justificar la prohibición del retorno no local. - Traducir los dos mensajes de error del compilador a la propiedad concreta de la transformación que los origina.
Todo se inserta salvo que digas lo contrario
Dentro de una función insertable, cada parámetro de tipo función solo puede aparecer en una posición: siendo invocado. No se puede asignar a una variable, no se puede pasar como argumento a otra función, no se puede devolver, no se puede guardar en una colección ni comparar con nulo. La razón es directa: si el compilador va a copiar el cuerpo de la lambda en el punto de la invocación, entonces no existe ningún objeto al que referirse, y por tanto no hay nada que asignar ni que almacenar.
inline fun ejecutar(bloque: () -> Unit) {
bloque() // unico uso permitido
}
inline fun invalida(bloque: () -> Unit) {
val guardado = bloque // error: uso ilegal del parametro insertable
registrar(guardado)
}
El mensaje que produce el compilador es explícito hasta el punto de sugerir la corrección: informa de un uso ilegal del parámetro insertable y recomienda añadir el modificador noinline a su declaración. Conviene leerlo como una descripción y no como una regla arbitraria, porque dice literalmente lo que ocurre: se está tratando como valor algo que la transformación había decidido que no llegaría a existir.
Cuando el compilador rechaza el uso de un parámetro insertable no está aplicando una restricción de diseño ni una convención. Está señalando que el código escrito exige que haya un objeto en tiempo de ejecución, mientras que la marca de la función prometía que no lo habría. Los dos modificadores de esta lección son las dos formas de deshacer el conflicto: uno retira la promesa para ese parámetro concreto, el otro la mantiene y retira en cambio uno de los privilegios que se derivaban de ella.
noinline: cuando la lambda tiene que existir
El modificador noinline se aplica a un parámetro concreto y significa que ese, y solo ese, se comportará como en una función corriente: llegará como un objeto, se podrá guardar, pasar y devolver, y su invocación será una llamada real. Es la herramienta correcta cuando la función tiene varios parámetros de tipo función y solo alguno necesita sobrevivir al punto de llamada.
inline fun conFallback(
principal: () -> String,
noinline respaldo: () -> String
): String {
val resultado = runCatching { principal() }
return resultado.getOrElse { registrarYUsar(respaldo) }
}
fun registrarYUsar(f: () -> String): String = f()
Lo que se cede al marcarlo es todo el paquete: vuelve la asignación del objeto, vuelve la llamada virtual, vuelve el empaquetado de primitivos y desaparece la posibilidad de escribir un retorno no local dentro de esa lambda, porque su cuerpo vuelve a vivir dentro de un método invoke ajeno. A cambio se gana la única cosa que hacía falta, que es que la lambda sea un valor. La asimetría es sana: se paga exactamente por el parámetro que lo necesita y no por los demás.
flowchart TD
A[Parametro de tipo funcion en una funcion inline] --> B{Necesitas tratarlo como valor}
B -- Si --> C[noinline: vuelve a ser un objeto]
B -- No --> D{Se invoca desde otro contexto}
D -- Si --> E[crossinline: se inserta pero sin retorno no local]
D -- No --> F[Insertable por defecto: cero objetos]Hay una consecuencia de tipos que se descubre al primer intento y conviene anticipar. Un parámetro no insertable admite todo lo que admite cualquier otra referencia, incluida la comparación con nulo y la nulabilidad en su propio tipo, mientras que un parámetro insertable no puede ser nulable de forma útil porque no hay objeto que comparar. De ahí que las firmas con bloques opcionales de la librería estándar y de tantas bibliotecas lleven casi siempre el modificador, aunque quien las lee lo atribuya a otra cosa.
inline fun cargar(
datos: List<Registro>,
noinline alTerminar: (() -> Unit)? = null
) {
datos.forEach { procesar(it) }
alTerminar?.invoke() // requiere que exista un objeto que comparar con nulo
}
Hay un caso límite que merece nombrarse porque el compilador también lo advierte. Si todos los parámetros de tipo función de una función insertable están marcados como no insertables, la marca de la función pierde su motivo de ser, y salvo que exista un parámetro de tipo conservado la advertencia recomienda quitarla. Es la misma lógica de la lección anterior aplicada al caso extremo: se está pagando la duplicación del cuerpo sin recibir nada a cambio.
crossinline: insertar, pero en otro sitio
El segundo modificador resuelve una situación distinta y bastante más interesante. Supongamos que la función insertable no invoca la lambda directamente, sino que la ejecuta desde dentro de otra cosa: el cuerpo de un objeto anónimo, una lambda no insertable anidada, un método que se llamará más tarde. El compilador puede seguir copiando el cuerpo de la lambda, porque sigue sabiendo qué código es; lo que ya no puede garantizar es dónde ni cuándo se ejecutará esa copia, y en particular no puede garantizar que el marco de la función que escribió la lambda siga vivo en ese momento.
inline fun enHilo(crossinline bloque: () -> Unit) {
Thread { bloque() }.start()
}
fun lanzar() {
enHilo {
// return aqui no compila: lanzar podria haber terminado ya
println("trabajo")
}
}
Sin el modificador, el compilador rechaza la declaración con un mensaje que explica la causa con notable honestidad: dice que no puede insertar ahí porque el bloque puede contener retornos no locales, y sugiere añadir crossinline a la declaración del parámetro. La palabra clave, por tanto, no es una etiqueta de rendimiento sino una renuncia declarada: se conserva la inserción, con todo su ahorro de asignaciones, y se abandona únicamente el privilegio que la transformación ya no puede sostener.
Se conserva la inserción
El cuerpo se sigue copiando, así que no hay instancia de la lambda ni llamada por interfaz. El ahorro de la marca sigue intacto para ese parámetro.
Se prohíbe el retorno no local
Un return desnudo dentro del bloque deja de compilar. Los retornos etiquetados siguen siendo válidos, porque terminan la copia y no el marco de fuera.
El envoltorio sí se asigna
Si el destino de la copia es un objeto anónimo o una lambda no insertable, ese objeto sí existe. Se ahorra la instancia del bloque, no la del contenedor.
La tercera tarjeta señala el error de razonamiento más habitual con este modificador. Marcar un parámetro con crossinline no vuelve gratuita la construcción que lo envuelve: en el ejemplo del hilo se sigue creando un objeto que implementa la interfaz de ejecución, porque la máquina necesita algo que arrancar. Lo que desaparece es una capa, la del bloque del usuario, que se funde dentro del cuerpo de ese objeto en lugar de ser un segundo objeto encadenado al primero. Es un ahorro real y medible, pero no es cero.
inline fun Vista.alPulsar(crossinline accion: () -> Unit) {
// El objeto que implementa el escuchador sigue existiendo
// pero el cuerpo de accion vive dentro de el, sin segunda instancia
registrarEscuchador { accion() }
}
Elegir bien entre los dos
La decisión no admite ambigüedad si se plantea desde la pregunta correcta, que no es cuál de los dos modificadores es más restrictivo sino qué está haciendo la función con la lambda. Si la trata como un valor, es decir, si la nombra en cualquier posición que no sea la de invocarla, el único modificador posible es el que la devuelve al mundo de los objetos. Si la invoca pero desde un contexto de ejecución distinto del punto de llamada, el modificador correcto es el que conserva la inserción y retira el retorno no local. Y si la invoca directamente en el cuerpo, no hace falta ninguno.
// La lambda se guarda: no queda mas remedio
inline fun registrar(noinline manejador: (Evento) -> Unit) {
manejadores.add(manejador)
}
// La lambda se invoca, pero dentro de otra lambda que no se inserta
inline fun cadaSegundo(crossinline tarea: () -> Unit) {
temporizador.programar { tarea() }
}
Queda un matiz que se descubre tarde y conviene adelantar. Un parámetro marcado con crossinline sigue admitiendo retornos etiquetados, incluida la etiqueta implícita con el nombre de la función, de modo que la salida anticipada dentro del propio bloque nunca se pierde. Lo que se pierde es únicamente la capacidad de terminar la función de fuera, que es precisamente la que no tendría sentido en un bloque que puede ejecutarse cuando esa función ya retornó. La restricción, mirada así, no es una limitación del lenguaje sino la negativa a compilar un programa cuyo significado no existiría.
Merece la pena detenerse en lo que estos dos modificadores revelan sobre la manera de diseñar de Kotlin, porque el patrón se repite por todo el lenguaje y reconocerlo ahorra años de sorpresas. Un lenguaje que quisiera ser cómodo por encima de todo habría resuelto ambos casos en silencio: al detectar que una lambda se guarda, generaría el objeto sin decir nada; al detectar que se ejecuta en otro contexto, permitiría el retorno no local implementándolo con una excepción interna que atravesara la pila. Las dos soluciones existen en otros ecosistemas y las dos producen el mismo daño, que es un modelo mental que funciona hasta que deja de funcionar sin que nadie sepa por qué. Kotlin toma el camino contrario y convierte cada renuncia en una palabra que hay que escribir. El resultado es que la firma de una función insertable no es solo una descripción de sus tipos: es una descripción de la transformación que sufrirá cada uno de sus bloques, legible por quien la usa antes de escribir la primera línea. Ver noinline en un parámetro comunica que ahí habrá un objeto, que puede sobrevivir a la llamada y que dentro no cabe un retorno que abandone nada. Ver crossinline comunica que el bloque se ejecutará en un sitio del que no se sabe cuándo, y que por tanto no debe suponerse vivo el contexto que lo escribió. Ninguna de las dos cosas es deducible del tipo () -> Unit, y en la mayoría de los lenguajes ninguna de las dos se puede saber sin leer la implementación. Esa es la ganancia real: no la de escribir menos, sino la de que una firma diga toda la verdad sobre el destino de lo que se le entrega. Quien aprende a leer estos modificadores como afirmaciones sobre el ciclo de vida de un bloque deja de necesitar los mensajes de error, porque ya sabe cuál va a aparecer antes de compilar.
- Escribe una función insertable que intente guardar su lambda en una lista, lee el mensaje del compilador y explica qué existencia estaba prometida y cuál se exige.
- Escribe una función insertable que ejecute su lambda dentro de un objeto anónimo y traduce el error resultante al lenguaje de marcos de pila.
- Añade
crossinlineal caso anterior y comprueba qué formas dereturnsiguen siendo válidas dentro del bloque. Justifica la diferencia. - Diseña una función con dos parámetros de tipo función donde solo uno necesite ser un valor, y razona qué se paga y qué se ahorra en cada uno.
- Argumenta por qué marcar todos los parámetros como no insertables hace que la marca de la función pierda sentido, y en qué caso concreto no lo pierde.