Nothing y el flujo: el tipo del que no hay valores
Qué significa ser el tipo fondo de la jerarquía, por qué throw y return son expresiones de tipo Nothing, cómo lo usa el compilador para saber que una rama no vuelve y qué papel decisivo juega en la inferencia y en la varianza.
Nothing es la pieza más extraña del sistema de tipos de Kotlin: un tipo del que es imposible construir un valor. Parece inútil y es exactamente al contrario. Su ausencia de habitantes es lo que le permite ser subtipo de todos los demás tipos a la vez, y esa posición en la jerarquía convierte a Nothing en el canal por el que el compilador se entera de que una rama del programa no va a volver nunca.
- Entender qué es un tipo fondo y por qué no puede tener instancias.
- Reconocer las expresiones cuyo tipo es
Nothingy aprovecharlas. - Ver cómo el análisis de flujo usa
Nothingpara probar inalcanzabilidad. - Anticipar el papel de
Nothingen la inferencia genérica y en la varianza.
El tipo sin valores
En la biblioteca estándar la declaración es casi una broma:
public class Nothing private constructor()
Constructor privado y ninguna llamada a ese constructor en ninguna parte. Nadie puede crear una instancia, ni siquiera la propia biblioteca. Por tanto, el conjunto de valores de tipo Nothing es vacío, y de ahí se sigue el resto por pura lógica: la afirmación “todo valor de tipo Nothing es también un String” es cierta de forma vacua, porque no hay ningún valor que la pueda desmentir. Lo mismo con Int, con List y con cualquier otro tipo. Nothing es subtipo de todos, es decir, el fondo de la jerarquía, simétrico de Any? en la cima.
Declarar algo de ese tipo compila, pero no hay forma de inicializarlo:
// val x: Nothing = ??? no existe expresión que produzca un valor aquí
fun nuncaVuelve(): Nothing = throw IllegalStateException("fin")
Conviene separar Nothing de Nothing?. Añadir nulabilidad a un tipo vacío da un tipo con exactamente un habitante, el nulo, y ese es el tipo del literal null cuando no hay más información. Por eso val x = null infiere Nothing? y no Any?.
flowchart TD C[Any nullable] --> A[Any] A --> S[String] A --> I[Int] A --> L[List de algo] S --> N[Nothing] I --> N L --> N C --> NN[Nothing nullable con un unico valor nulo] NN --> N style N fill:#f38ba8,color:#11111b style C fill:#89b4fa,color:#11111b
throw y return son expresiones
En Kotlin, throw no es una sentencia: es una expresión, y su tipo es Nothing. Lo mismo vale para return, break y continue. Como Nothing encaja donde se espera cualquier tipo, esas expresiones pueden aparecer en posiciones donde otros lenguajes exigen contorsiones:
val nombre = usuario.nombre ?: throw IllegalArgumentException("sin nombre")
val puerto = config["puerto"]?.toIntOrNull() ?: return null
val etiqueta: String = when (codigo) {
0 -> "ok"
1 -> "aviso"
else -> throw IllegalStateException("codigo $codigo")
}
En la primera línea el operador elvis exige que ambos lados tengan un tipo común. El izquierdo es String? reducido a String; el derecho es Nothing, subtipo de String; el supertipo común mínimo es String, y el resultado se tipa como no nulable sin ninguna regla especial. En el when, la rama que lanza tiene tipo Nothing y por eso no ensucia el cálculo del tipo del when, que sigue siendo String.
Esto es lo que permite convertir la validación en un valor. La biblioteca estándar lo explota en funciones cuyo tipo de retorno declarado es Nothing:
fun fallar(msg: String): Nothing = throw IllegalStateException(msg)
val edad = leer() ?: fallar("falta la edad") // edad es Int, no Int?
Las funciones error, TODO y exitProcess de la biblioteca declaran exactamente ese retorno, igual que awaitCancellation en las corrutinas. No devuelven un valor especial: prometen no devolver.
Cómo lo usa el compilador
Aquí Nothing deja de ser una curiosidad de teoría de tipos y se convierte en información operativa. Cuando una expresión tiene tipo Nothing, el compilador sabe que el control no continúa a partir de ahí, y ese conocimiento alimenta tres análisis distintos.
El primero es la inalcanzabilidad. Todo lo que siga a una expresión de tipo Nothing está muerto y se marca como tal:
fun procesar(x: Int) {
fallar("siempre falla")
println("nunca") // aviso: código inalcanzable
}
El segundo es la asignación definitiva y la comprobación de retorno. Una función con tipo de retorno no nulable debe devolver por todos los caminos; una rama que llama a algo de tipo Nothing no es un camino que necesite devolver:
fun clasificar(x: Int): String {
if (x > 0) return "positivo"
fallar("solo admito positivos")
// sin return final: el compilador acepta, la función no puede caer aquí
}
El tercero es el análisis de nulabilidad y el smart cast. Como la rama que lanza no vuelve, después del if el compilador puede afirmar que el valor no era nulo:
fun longitud(s: String?): Int {
if (s == null) fallar("nulo")
return s.length // smart cast a String: la otra rama no vuelve
}
El compilador solo aplica este razonamiento si el tipo de retorno declarado es Nothing. Una función que en la práctica siempre lanza pero está declarada como Unit no aporta ninguna información al análisis de flujo, y perderás el smart cast y el control de retorno. Cuando escribas un ayudante que solo lanza, declara Nothing explícitamente.
Nothing en la inferencia y en la varianza
Ser el fondo tiene un efecto directo sobre los genéricos. Un List<Nothing> es, gracias a que List es covariante en su parámetro, subtipo de List de cualquier cosa. Por eso existe una única lista vacía compartida que sirve para todos los tipos:
val vacia: List<String> = emptyList() // por dentro: List<Nothing>
val otra: List<Int> = emptyList() // la misma instancia
El mismo mecanismo hace que null sirva para cualquier tipo nulable, ya que Nothing? es subtipo de todo T?.
Pero la inferencia también puede caer del lado equivocado. Cuando no hay contexto que fije el parámetro de tipo, el compilador intenta el menor candidato posible y aparece un diagnóstico característico:
// val lista = mutableListOf() // error: no hay información suficiente
val lista = mutableListOf<String>() // fija el parámetro explícitamente
val cosas = listOf(null, null) // List<Nothing?>: casi seguro un error
Ver Nothing en un tipo inferido es casi siempre una señal de que falta información, no un logro. La lectura correcta del mensaje del compilador no es “quiere que ponga un tipo”, sino “he agotado el contexto y solo me queda el fondo de la jerarquía”.
Nothing es el punto exacto en el que un sistema de tipos deja de hablar solo de datos y empieza a hablar de terminación. Decir que una expresión tiene tipo Nothing no es describir qué valor produce, porque no produce ninguno: es afirmar que la evaluación no termina normalmente. Esa afirmación, expresada como un tipo corriente, permite que el compilador la propague con la misma maquinaria de subtipado que usa para todo lo demás, sin añadir un análisis separado de “esto no vuelve”. Java necesita una anotación externa para lo mismo; C tiene un atributo del compilador; Kotlin lo obtiene gratis porque su retícula de tipos está completa por abajo. Y la simetría es exacta: Any? en la cima permite calcular el supertipo común de dos ramas cualesquiera, Nothing en el fondo permite que una rama que no vuelve se combine con cualquier otra sin alterar el resultado. Entre las dos, todo constructo de control puede ser una expresión —if, when, try, el elvis— sin una sola regla especial en el compilador. Nothing es lo que hace que “todo es una expresión” sea una afirmación coherente y no un eslogan con excepciones.
- Escribe
fun fallar(msg: String): Nothingy úsala a la derecha de un operador elvis; comprueba que el resultado deja de ser nulable. - Cambia su tipo de retorno a
Unity observa qué comprobaciones pierde el compilador. - Escribe una función con
ifque devuelva por una rama y llame afallarpor la otra, sinreturnfinal. - Asigna
emptyList()a variables de tres tipos distintos y razona por qué funciona la misma instancia. - Investiga: escribe
val x = nully consulta el tipo inferido; despuéslistOf(null)y explica el resultado.