Retornos: no local, etiquetado e inline
Dentro de una lambda la palabra return no significa lo que parece. Esta lección estudia el retorno no local que abandona la función envolvente, la razón por la que solo el compilador puede permitirlo cuando la función receptora es inline, las etiquetas implícitas y explícitas que permiten volver únicamente de la lambda, el efecto de crossinline y noinline sobre esta capacidad, las funciones anónimas como alternativa con semántica local, y los idiomas correctos para simular la salida anticipada de un bucle.
Casi todas las decisiones de diseño de Kotlin se pueden explicar como una negociación entre la comodidad y la honestidad, y pocas lo muestran tan bien como el comportamiento de return dentro de una lambda. El lenguaje quiere que forEach se lea como un for, que let se lea como un bloque y que repeat se lea como una construcción del lenguaje; pero si esas cosas se leen como estructuras de control, entonces un return escrito dentro debería comportarse como se comporta dentro de un for, es decir, abandonando el método entero. Kotlin se toma esa expectativa en serio y la cumple, lo cual es sorprendente y es también la fuente de una de las confusiones más persistentes del lenguaje, porque la promesa no se puede sostener siempre. Hay un requisito técnico ineludible detrás, y esa lección es la que separa a quien memoriza que hay que escribir una arroba de quien sabe exactamente cuándo hace falta y por qué.
- Explicar qué abandona un
returndesnudo escrito dentro de una lambda y en qué condiciones el compilador lo permite. - Usar etiquetas implícitas y explícitas para retornar únicamente de la lambda, con y sin valor.
- Justificar por qué el retorno no local solo es posible cuando la función receptora es
inline, apelando al código generado. - Elegir entre lambda, función anónima y reestructuración cuando se necesita una salida anticipada.
El return desnudo salta más lejos de lo que parece
Un return sin etiqueta escrito dentro de una lambda no devuelve el valor de la lambda: abandona la función que contiene esa lambda. Esa forma se llama retorno no local, porque el punto de salida está fuera del bloque donde se escribe la instrucción. Es exactamente el comportamiento que tendría el mismo return escrito dentro de un bucle ordinario, y esa coincidencia es deliberada.
fun contieneNegativo(numeros: List<Int>): Boolean {
numeros.forEach { n ->
if (n < 0) return true // abandona contieneNegativo, no forEach
}
return false
}
La consecuencia inmediata es que este código hace lo que un lector desprevenido esperaría de un bucle, y por eso funciona sin que casi nadie repare en la sutileza. La consecuencia mediata es más incómoda: si esa misma lambda se pasa a una función que no está marcada como inline, el compilador rechaza el return con un mensaje que dice que ahí no se permite retornar. La misma línea de código es válida o inválida según una propiedad de la función que la recibe, que puede estar declarada en otro archivo o en otra librería.
Cuando escribes un return dentro de una lambda estás dependiendo de que quien diseñó la función receptora la marcara inline. Si esa función es forEach, map, let o run, la marca está y funciona. Si es un callback de una librería, un parámetro almacenado en una propiedad o una lambda que cruza una frontera de corrutina, no está y el compilador se niega. Por eso el error de retorno no permitido no se corrige mirando la lambda: se corrige mirando la firma de la función que la recibe.
Etiquetas: cómo volver solo de la lambda
Para salir únicamente de la lambda y continuar con lo siguiente hay que etiquetar el retorno. Toda lambda pasada como argumento tiene ya una etiqueta implícita cuyo nombre es el de la función que la recibe, de modo que dentro de un forEach se puede escribir return@forEach sin declarar nada. Si la lambda produce un valor, la etiqueta lo acepta después.
fun imprimirPositivos(numeros: List<Int>) {
numeros.forEach { n ->
if (n < 0) return@forEach // equivale a continue
println(n)
}
}
val longitudes = palabras.map { p ->
if (p.isBlank()) return@map 0 // devuelve el valor de esta iteracion
p.length
}
La etiqueta implícita tiene un límite evidente: si hay dos lambdas anidadas pasadas a la misma función, ambas comparten nombre y no hay forma de distinguirlas. Para eso existen las etiquetas explícitas, que se declaran anteponiendo un identificador y una arroba a la expresión, y que sustituyen a la implícita cuando están presentes.
fun buscarPar(matriz: List<List<Int>>): Int? {
externo@ for (fila in matriz) {
fila.forEach interno@ { valor ->
if (valor % 2 != 0) return@interno
if (valor > 100) return valor // no local: sale de buscarPar
}
}
return null
}
flowchart TD
A[Instruccion return dentro de una lambda] --> B{Lleva etiqueta}
B -- Si --> C[Retorno local: termina solo esta lambda]
B -- No --> D{La funcion receptora es inline}
D -- Si --> E[Retorno no local: abandona la funcion envolvente]
D -- No --> F[Error de compilacion: return no permitido aqui]Merece la pena nombrar una asimetría del lenguaje que confunde mucho. Un retorno etiquetado dentro de una lambda equivale a lo que en un bucle sería continuar con la siguiente vuelta, pero no existe ningún equivalente directo de romper el bucle, porque la lambda no controla la iteración: la controla la función que la invoca en su interior. El idioma habitual para simular la ruptura es envolver la llamada en un bloque etiquetado y retornar de él, aunque casi siempre es mejor señal reconocer que la operación buscada ya existe en la librería estándar.
val primeroGrande = run bucle@ {
numeros.forEach { n -> if (n > 100) return@bucle n }
null
}
val mejor = numeros.firstOrNull { it > 100 } // lo mismo, sin acrobacias
Por qué esto exige inline
La restricción no es una decisión arbitraria de estilo: es una consecuencia directa de cómo se representa una lambda en la máquina. Según establecimos en la lección anterior, una lambda ordinaria es un objeto y su cuerpo vive dentro de un método llamado invoke. Una instrucción de retorno dentro de ese método solo puede hacer una cosa, que es terminar ese método y devolver el control a quien llamó a invoke. No hay ninguna instrucción de la máquina que permita a un método terminar el marco de pila de otro método arbitrario situado más abajo; eso solo lo consiguen las excepciones, y usarlas para esto sería caro y semánticamente sucio.
Cuando la función receptora está marcada como inline, la situación cambia por completo. El compilador no crea ningún objeto ni ningún método aparte: copia literalmente el cuerpo de la lambda dentro del cuerpo de la función que la escribe, y copia también el cuerpo de la función receptora. Después de esa copia, el return ya no está dentro de un método ajeno sino dentro del mismo método donde se escribió, y por tanto la instrucción de retorno ordinaria hace exactamente lo que promete. El retorno no local no es una capacidad extra que inline añada, sino la desaparición de un obstáculo que solo existía porque había un método intermedio.
// Recibe la lambda pero no puede permitir retorno no local
fun ejecutar(bloque: () -> Unit) = bloque()
// Recibe la lambda y la copia en el punto de llamada
inline fun ejecutarEnLinea(bloque: () -> Unit) = bloque()
fun demostracion() {
ejecutar { /* return aqui no compila */ }
ejecutarEnLinea { return } // valido: abandona demostracion
}
Hay una comprobación mental que resuelve cualquier duda sobre este punto sin necesidad de consultar nada. Pregúntate en qué método acabará viviendo, después de compilar, la instrucción de retorno que estás escribiendo. Si el cuerpo de la lambda se copia dentro del método que la escribe, el retorno pertenece a ese método y por tanto puede terminarlo. Si el cuerpo acaba dentro de un invoke de un objeto separado, el retorno pertenece a ese invoke y no puede alcanzar nada más. Toda la regla del nivel cabe en esa pregunta.
De esta explicación se deduce sin esfuerzo el comportamiento de los dos modificadores que acompañan a inline. El modificador noinline marca un parámetro concreto para que no se copie, porque hace falta guardarlo o pasarlo a otro sitio; al volver a existir un objeto con su método, el retorno no local vuelve a ser imposible para esa lambda. El modificador crossinline es más sutil: la lambda sí se copia, pero se copia dentro de otro contexto de ejecución, por ejemplo dentro de un objeto anónimo o de otra lambda, y en ese destino el marco de la función original ya no está garantizado; por eso el compilador conserva la inserción y prohíbe únicamente el retorno no local.
Funciones anónimas y la elección correcta
Existe una tercera forma de escribir una función sin nombre en Kotlin, y su semántica de retorno es la opuesta a la de la lambda. Una función anónima se escribe con la palabra fun sin nombre, declara sus parámetros y su tipo de retorno como cualquier función, y dentro de ella un return desnudo termina esa función anónima y nada más. No hay retorno no local, no hacen falta etiquetas y no importa si la receptora es inline.
val positivos = numeros.filter(fun(n: Int): Boolean {
if (n == 0) return false // retorno local, sin etiqueta
return n > 0
})
Nótese un detalle sintáctico que a menudo desanima su uso: una función anónima es una expresión ordinaria y no una lambda, así que no puede salir fuera de los paréntesis de la llamada. Se pierde la apariencia de bloque, y esa pérdida es exactamente el precio de recuperar una semántica de retorno convencional. Tampoco hay parámetro implícito ni inferencia del tipo de retorno cuando el cuerpo es un bloque, de modo que la forma es más verbosa en todos los frentes.
Lambda con `return` desnudo
Abandona la función envolvente. Solo válido si la receptora es inline y el parámetro no está marcado crossinline ni noinline.
Lambda con `return` etiquetado
Termina solo esa lambda y aporta su valor. Siempre válido, con etiqueta implícita del nombre de la función o explícita si hay anidamiento.
Función anónima
Retorno siempre local y tipo de retorno declarado. Útil cuando hay varias salidas y la sintaxis de etiquetas empieza a estorbar la lectura.
El criterio para elegir es de legibilidad antes que de mecánica. La lambda con retorno no local es la herramienta correcta cuando la salida anticipada es realmente una salida del algoritmo completo, como en una búsqueda que termina en cuanto encuentra. La lambda etiquetada es la correcta cuando lo que se quiere es saltar un elemento y seguir, y conviene escribir la etiqueta aunque sea la implícita porque hace explícita la intención. La función anónima gana cuando hay tres o cuatro puntos de retorno con valores distintos, donde una cascada de arrobas resulta ilegible. Y la cuarta opción, casi siempre la mejor, es preguntarse si el bucle con salida anticipada no será en realidad un first, un any, un takeWhile o un firstNotNullOfOrNull disfrazado.
Vale la pena mirar este mecanismo desde arriba, porque explica una ambición del lenguaje que va mucho más allá de la comodidad de escribir bucles. En la mayoría de los lenguajes existe una separación infranqueable entre las estructuras de control, que son palabras clave con privilegios especiales, y las funciones de la librería, que son código de usuario y nunca podrán comportarse como palabras clave. Un for puede contener un return que abandona el método; un método que reciba un bloque no puede, porque el bloque es un objeto y los objetos no controlan la pila de quien los creó. Kotlin decidió que esa frontera no era una ley de la naturaleza sino un accidente de implementación, y la desplazó: al insertar el código de la función receptora y el de la lambda en el punto de llamada, hace que una función escrita en la librería estándar disponga de exactamente los mismos privilegios que tendría una palabra clave del compilador, incluida la capacidad de que un retorno atraviese sus límites. Ese es el motivo real de que forEach no sea una traición al for sino una alternativa completa, de que let no sea peor que un bloque anidado, de que synchronized y use puedan ser funciones ordinarias en lugar de sintaxis dedicada, y de que quien escribe una librería en Kotlin pueda inventar estructuras de control nuevas sin pedirle nada al equipo del lenguaje. Y explica también por qué la restricción existe y por qué es honesta: en el momento en que la lambda deja de copiarse y vuelve a ser un objeto, el privilegio desaparece, y Kotlin prefiere negarse a compilar antes que fingir con excepciones que serían caras y engañosas. La lección de fondo es que las capacidades sintácticas de este lenguaje no son magia repartida de forma arbitraria, sino consecuencias exactas de una transformación concreta del compilador; y que conocer la transformación permite predecir, sin consultar nada, qué se puede escribir en cada sitio.
- Escribe una búsqueda con retorno no local y otra equivalente con
firstOrNull, y compara qué comunica cada versión a quien la lea sin contexto. - Coge una lambda tuya con un
returnetiquetado implícito y renombra la etiqueta a una explícita. Explica en qué caso concreto la implícita habría sido insuficiente. - Declara una función que reciba una lambda sin marcarla
inline, intenta escribir unreturndesnudo dentro y traduce el mensaje del compilador al lenguaje de marcos de pila. - Simula la ruptura de un bucle con un bloque etiquetado y luego reescribe la misma lógica con una función de la librería estándar. Justifica cuál mantendrías.
- Argumenta por qué
crossinlineprohíbe el retorno no local aunque la lambda sí se inserte, apelando al lugar donde acaba la copia.