Declarar funciones: bloque o expresión
Las dos formas de dar cuerpo a una función y por qué no son intercambiables: dónde se detiene la inferencia del tipo de retorno y por qué se detiene ahí, qué es realmente Unit frente al void de Java, y qué significa que una función no necesite una clase que la contenga.
Declarar una función parece el trámite más inocente del lenguaje. No lo es. En esa línea decides tres cosas que el compilador tratará como contratos: si el tipo de retorno lo escribes tú o lo deduce él, si tu función produce un valor o solo efectos, y si vive dentro de una clase o suelta en un fichero. Kotlin ofrece dos cuerpos posibles y una decisión deliberada de dónde parar la inferencia. Entender por qué paró justo ahí explica media ergonomía del lenguaje.
- Distinguir cuerpo de bloque y cuerpo de expresión, y qué implica cada uno.
- Entender por qué la inferencia del retorno solo funciona en uno de los dos.
- Comprender
Unitcomo tipo real y su diferencia con elvoidde Java. - Declarar funciones de nivel superior sin clase contenedora y saber qué genera el compilador.
Dos cuerpos para la misma idea
Una función Kotlin puede tener un cuerpo de bloque, delimitado por llaves y con return explícito, o un cuerpo de expresión, introducido por el signo igual, cuyo valor es el resultado.
// Cuerpo de bloque: el tipo de retorno es obligatorio.
fun area(base: Double, altura: Double): Double {
return base * altura / 2.0
}
// Cuerpo de expresion: el tipo se deduce de la expresion.
fun area2(base: Double, altura: Double) = base * altura / 2.0
No es solo abreviatura. La forma de expresión declara que la función es su resultado: no hay pasos, no hay estado intermedio, no hay más de una salida posible. Esa promesa la lee el humano antes que el compilador. Como en Kotlin casi todo es expresión, el cuerpo de expresión abarca mucho más de lo que parece: un when completo, un if con ramas, un try con catch, todos devuelven valor y todos caben a la derecha del igual.
fun clasificar(n: Int): String = when {
n < 0 -> "negativo"
n == 0 -> "cero"
n < 10 -> "digito"
else -> "grande"
}
fun leerPuerto(txt: String): Int = try {
txt.toInt()
} catch (e: NumberFormatException) {
8080
}
El criterio práctico es honesto: usa cuerpo de expresión cuando la función sea una transformación, y bloque cuando haya secuencia, variables locales o efectos. Un cuerpo de expresión de doce líneas encadenadas no gana legibilidad, solo esconde que estaba escribiendo un bloque.
Cuerpo de expresión
La función es su resultado. Una sola salida, sin estado intermedio, tipo deducible de un vistazo. Ideal para transformaciones, mapeos y clasificaciones con when.
Cuerpo de bloque
La función hace una secuencia. Variables locales, varias salidas, efectos. Exige escribir el tipo de retorno, y eso es una ventaja: el llamador lee el contrato sin abrir el cuerpo.
La inferencia se detiene en la llave
Aquí está la decisión de diseño. En el cuerpo de expresión el tipo de retorno se puede omitir; en el de bloque es obligatorio salvo que sea Unit. Podría parecer una inconsistencia. Es lo contrario: es una frontera trazada a propósito.
Para inferir el tipo de un cuerpo de expresión, el compilador resuelve una expresión. Para inferirlo en un bloque tendría que recorrer todas las rutas de ejecución, recoger cada return y calcular el supertipo común de todos ellos. Eso es inferencia global al estilo Hindley-Milner, y trae tres facturas que Kotlin no quiso pagar: errores de tipo que aparecen lejos del punto que los causó, un IDE que no puede responder sin analizar el cuerpo entero, y una firma pública que cambia sola cuando alguien añade un return en la línea cuarenta.
// No compila: el bloque exige tipo de retorno explicito.
// fun buscar(id: Int) {
// if (id < 0) return null
// return repo.get(id)
// }
// Con la firma escrita, el compilador comprueba en vez de adivinar.
fun buscar(id: Int): Usuario? {
if (id < 0) return null
return repo.get(id)
}
Cuando escribes el tipo de retorno, el compilador verifica que cada return encaje. Cuando lo infiere, el compilador decide por ti y tu API queda a merced del cuerpo. Por eso el modo de API explícita de las bibliotecas obliga a anotar el retorno incluso en cuerpos de expresión: en una API pública, un tipo inferido es un tipo que puede cambiar sin que nadie lo note.
flowchart TD
F[Declarar la funcion] --> C{Que cuerpo usas}
C -->|Igual y una expresion| E[Tipo de retorno inferido]
C -->|Llaves y return| B[Tipo de retorno obligatorio]
E --> P{Es API publica}
P -->|si| A[Anotalo igualmente]
P -->|no| OK[Dejalo inferido]
B --> V[El compilador verifica cada return]
style A fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111bUnit: el vacío que sí es un tipo
Una función sin valor útil devuelve Unit, y puedes omitirlo. Lo interesante es que Unit no es la nada: es un object, un singleton con una única instancia, y por tanto un tipo de pleno derecho.
fun registrar(msg: String) { // : Unit implicito
println(msg)
}
fun registrar2(msg: String): Unit = println(msg) // println devuelve Unit
val f: (String) -> Unit = ::registrar // Unit ocupa un hueco de tipo
El void de Java no es un tipo: no puede aparecer como argumento genérico, así que Java necesita Runnable, Consumer, Supplier y toda su fauna de interfaces funcionales para tapar el agujero. Kotlin, al hacer de Unit un tipo habitado, tiene un único constructor de tipos función, (A) -> B, que cubre también el caso sin resultado. Un genérico T puede instanciarse con Unit sin casos especiales.
En el bytecode, una función que devuelve Unit se compila a void cuando puede: no se paga nada por la uniformidad. Solo aparece la instancia real de Unit donde el tipo debe estar reificado, por ejemplo dentro de una lambda almacenada en un genérico. Su hermano oscuro es Nothing, el tipo vacío, sin instancias, subtipo de todos los demás: es lo que devuelve throw, y por eso val x: Int = miLista.firstOrNull() ?: throw IllegalStateException() tipa correctamente.
fun guardar(u: Usuario) = repo.insert(u) parece devolver algo. Si insert devuelve Unit hoy y mañana devuelve un Long, tu función pública cambia de tipo sin que hayas tocado su línea. Para funciones de efecto, usa cuerpo de bloque.
Funciones sin casa: el nivel superior
Una función Kotlin no necesita clase que la contenga. Se declara directamente en el fichero, al mismo nivel que el package.
package com.ejemplo.texto
fun normalizar(s: String): String = s.trim().lowercase()
fun esPalindromo(s: String): Boolean {
val n = normalizar(s).filter(Char::isLetterOrDigit)
return n == n.reversed()
}
En la JVM no existe tal cosa como un método fuera de una clase, así que el compilador fabrica una: si el fichero se llama Texto.kt, genera la clase TextoKt con normalizar y esPalindromo como métodos estáticos. Esa clase es un detalle de implementación para Kotlin y una parte visible de tu API para Java. El nivel 3.5 vuelve sobre ella.
La consecuencia de diseño es grande: el espacio de nombres es el paquete, no una clase artificial. Desaparecen las clases de utilidades con constructor privado y métodos estáticos que Java impone. Y la importación es granular: import com.ejemplo.texto.normalizar trae exactamente una función, con alias si hace falta mediante as.
La regla de que el bloque exige tipo y la expresión no parece un capricho de sintaxis; es en realidad una postura sobre qué es una firma. Un lenguaje con inferencia total trata la firma como un resumen derivable del cuerpo: si el cuerpo cambia, la firma cambia, y el compilador se encarga. Un lenguaje sin inferencia alguna la trata como una declaración de intención que el cuerpo debe honrar. Kotlin no elige un bando: traza la frontera exactamente donde el cuerpo cabe en un solo acto de lectura. Mientras la implementación sea una expresión, deducirla es tan barato para el compilador como para el humano que lee la línea, y la comodidad no cuesta claridad. En cuanto aparecen llaves, rutas y varios return, el cuerpo deja de ser legible de un vistazo y la firma pasa a ser lo único que el llamador puede leer sin abrir la función; entonces se exige escribirla. Detrás hay un principio que gobierna todo el lenguaje y que reconocerás luego en los smart casts, en los contratos y en la varianza: la inferencia de Kotlin es siempre local. Nunca hay que mirar lejos para saber qué tipo tiene algo. Ese compromiso es lo que permite que un IDE responda al instante en un fichero de diez mil líneas, que un error de tipos aparezca donde está la causa y no tres funciones más arriba, y que la compatibilidad binaria de una biblioteca no dependa de que nadie toque un cuerpo. La comodidad que sientes al escribir un igual en vez de tres líneas es la punta visible de esa decisión.
- Escribe la misma función de clasificación con cuerpo de bloque y con cuerpo de expresión sobre un
when. Decide cuál leerías mejor dentro de seis meses. - Quita el tipo de retorno a una función con bloque y lee el error exacto del compilador. Explica en una frase qué tendría que hacer para inferirlo.
- Declara
fun log(m: String) = println(m)y comprueba con el IDE qué tipo infirió. Cambia el cuerpo por algo que devuelvaInty observa cómo cambia la firma pública sin avisar. - Usa
Unitcomo argumento genérico, por ejemplo en unResult<Unit>, e intenta el equivalente convoiden Java. Explica la diferencia. - Crea un fichero con tres funciones de nivel superior, compílalo y mira con
javapla clase generada. Anota su nombre exacto.