if y when como expresiones
La diferencia real entre sentencia y expresión y por qué Kotlin no necesita operador ternario, when con sujeto y sin sujeto, la exhaustividad como requisito de tipado y cómo se calcula el valor de retorno de cada rama.
Kotlin no tiene operador ternario y eso desconcierta a todo el que llega de C o de Java. No lo tiene porque no lo necesita: if ya es una expresión, así que el ternario sería una segunda sintaxis para lo mismo. Detrás de esa ausencia hay una decisión de diseño más profunda —casi todo el control de flujo produce un valor— cuya consecuencia práctica es que puedes escribir mucho menos estado mutable temporal.
- Distinguir sentencia de expresión y ver la diferencia en código real.
- Usar
ifcomo expresión, incluyendo la forma con bloques. - Dominar
whencon sujeto y sin sujeto, con rangos, tipos y guardas. - Entender la exhaustividad y cómo se calcula el tipo del resultado.
Sentencia y expresión
Una sentencia se ejecuta por su efecto; una expresión se evalúa y produce un valor que puede colocarse allí donde se espera un valor. En Java if es sentencia, y de ahí nace un patrón que todo el mundo ha escrito mil veces: declarar una variable vacía, asignarla desde las ramas y confiar en no olvidar ninguna.
// El patrón heredado, con estado mutable innecesario
var etiqueta: String
if (n > 0) etiqueta = "positivo" else etiqueta = "no positivo"
// El mismo cálculo como expresión
val etiqueta2 = if (n > 0) "positivo" else "no positivo"
La segunda versión gana tres cosas a la vez. La variable pasa a ser val, con lo que nadie puede reasignarla después. Desaparece el estado intermedio no inicializado. Y el compilador exige el else: un if usado como expresión sin rama alternativa es un error, porque no habría valor que producir en un caso.
Con bloques funciona igual; el valor de un bloque es el de su última expresión:
val tarifa = if (esSocio) {
val base = calcularBase()
base * 0.8 // este es el valor del bloque
} else {
calcularBase()
}
Esa regla, la de la última expresión, es la misma que gobierna los lambdas y los cuerpos de expresión. No es una excepción de if: es un principio uniforme del lenguaje.
when con sujeto y sin sujeto
when es la generalización. Con sujeto compara ese valor contra cada rama; sin sujeto evalúa condiciones booleanas en orden y toma la primera cierta, sustituyendo con elegancia a cualquier cadena de else if.
// Con sujeto: igualdad, pertenencia a rango, comprobación de tipo
val categoria = when (codigo) {
0 -> "vacio"
in 1..9 -> "digito"
in 10..99, in 100..999 -> "corto"
is Int -> "entero grande"
else -> "desconocido"
}
// Sin sujeto: cadena de condiciones arbitrarias
val nivel = when {
temperatura > 40 -> "critico"
temperatura > 30 -> "alto"
hayAlerta && esNoche -> "vigilar"
else -> "normal"
}
Las ramas admiten varios valores separados por coma, pertenencia con in y !in, comprobación de tipo con is y !is —que además activa el smart cast dentro de la rama— y cualquier expresión arbitraria cuando hay sujeto.
El sujeto puede capturarse en el propio when, lo cual acota su alcance a la construcción y evita ensuciar el ámbito exterior:
when (val respuesta = servicio.consultar()) {
is Exito -> procesar(respuesta.datos) // smart cast a Exito
is Fallo -> registrar(respuesta.causa)
}
A eso se suman las guardas, disponibles desde la línea 2.1 del lenguaje, que permiten añadir una condición extra a una rama con sujeto sin renunciar al smart cast:
val accion = when (evento) {
is Pulsacion if evento.repetida -> "doble"
is Pulsacion -> "simple"
else -> "ninguna"
}
flowchart TD A[Necesito elegir entre varios caminos] --> B[Dos ramas y condicion booleana] B -->|si| C[if como expresion] B -->|no| D[Varias ramas] D --> E[Comparo un mismo valor] E -->|si| F[when con sujeto] E -->|no| G[when sin sujeto] style C fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b style G fill:#89b4fa,color:#11111b
Exhaustividad y valor de retorno
Aquí está el matiz que separa a quien usa when de quien lo entiende. Un when usado como expresión debe ser exhaustivo: o tiene else, o cubre demostrablemente todos los casos posibles del sujeto. El compilador sabe demostrarlo cuando el sujeto es un enum, una jerarquía sealed o un Boolean.
sealed interface Estado
data object Cargando : Estado
data class Listo(val n: Int) : Estado
data class Error(val causa: String) : Estado
fun describir(e: Estado): String = when (e) {
Cargando -> "cargando"
is Listo -> "listo con ${e.n}"
is Error -> "error: ${e.causa}"
} // sin else: el compilador comprueba que están todos
Esa propiedad es la que convierte a sealed más when en la herramienta de refactor más valiosa del lenguaje: al añadir un nuevo subtipo, todos los when exhaustivos del proyecto dejan de compilar y te señalan exactamente dónde falta lógica. Añadir un else por comodidad destruye esa red de seguridad, porque el caso nuevo caería silenciosamente en él.
Desde Kotlin 1.7 un when con sujeto enum o sealed usado como sentencia y sin cubrir todos los casos es un error, no solo un aviso. La exhaustividad dejó de ser una exigencia del tipado para convertirse en una regla del lenguaje.
El tipo del resultado es el supertipo común mínimo de los tipos de todas las ramas. Si todas devuelven String, el resultado es String; si una devuelve Int y otra String, obtendrás un supertipo incómodo que casi siempre indica un error de diseño. Y las ramas que no producen valor —las que lanzan o retornan— tienen tipo Nothing y, por ser subtipo de todo, no ensucian el cálculo:
val puerto: Int = when (perfil) {
"dev" -> 8080
"prod" -> 443
else -> error("perfil desconocido") // Nothing: no altera el tipo
}
En la JVM, un when con sujeto entero o de cadena y ramas constantes se compila a una instrucción de salto por tabla, igual que un switch; en el resto de casos degrada a comparaciones encadenadas. La elegancia sintáctica no cuesta rendimiento en el caso habitual.
La diferencia entre sentencia y expresión parece cosmética hasta que se mira lo que cada una obliga a escribir. Un lenguaje de sentencias fuerza a comunicar resultados a través de variables mutables: declaras el hueco antes de saber qué va dentro, lo rellenas desde varias ramas y confías en tu disciplina para que ninguna quede sin cubrir. Un lenguaje de expresiones invierte la dirección: la construcción de control devuelve el valor y tú lo enlazas a un nombre inmutable de una sola vez. Lo importante es lo que el compilador puede exigir a cambio. Como el if debe producir un valor, el else deja de ser opcional. Como el when debe producir un valor, la exhaustividad se vuelve verificable, y con jerarquías sealed se convierte en una comprobación de cobertura total en tiempo de compilación. Ninguna de esas garantías existe cuando las ramas se limitan a producir efectos. Por eso Kotlin no añadió el ternario: no le faltaba una sintaxis para elegir valores, le sobraba la categoría de las construcciones que no valen nada.
- Busca en tu código un
varinicializado desde varias ramas de unify reescríbelo como unvalconifexpresión. - Escribe un
ifexpresión sinelsey lee el error; después úsalo como sentencia y comprueba que ahí sí se permite. - Define una interfaz
sealedcon tres implementaciones y unwhenexhaustivo sinelse. Añade una cuarta y observa qué rompe. - Reescribe una cadena de
else ifcomowhensin sujeto y compara la legibilidad. - Investiga: escribe un
whendonde una rama llame aerrory comprueba con el tipo inferido que la ramaNothingno altera el resultado.