Smart casts: cuando el compilador demuestra la no nulidad
El análisis de flujo que convierte una comprobación en una garantía de tipo: qué es la estabilidad de un valor, por qué una propiedad open o un var de otro módulo rompen el razonamiento, qué desbloqueó K2, y las tres formas de darle al compilador la prueba que le falta, incluidos los contratos de la biblioteca estándar.
Si cada acceso a un valor nulable exigiera un operador, Kotlin sería insoportable. Lo que lo hace habitable es que, tras comprobar algo, el compilador recuerda lo comprobado y estrecha el tipo por sí mismo: escribes la comprobación una vez y el resto del bloque trabaja con el tipo estrecho, sin aserciones ni variables auxiliares. Eso es un smart cast, y no es azúcar sintáctico sino un análisis de flujo de datos completo sobre el grafo de control de la función. Entender cuándo se concede y cuándo se deniega es lo que convierte los errores más frustrantes del lenguaje en consecuencias previsibles de una regla sencilla.
- Describir el análisis de flujo que permite estrechar un tipo tras una comprobación.
- Enunciar la condición de estabilidad y aplicarla a variables locales y propiedades.
- Diagnosticar por qué falla el smart cast con propiedades
open, con getters propios y convarde otro módulo. - Suministrar la prueba que falta mediante copia local,
let,requireNotNullo un contrato propio.
El análisis de flujo
Tras una comprobación que domina el uso, el tipo declarado se estrecha al tipo intersección entre el declarado y lo demostrado. Con nulabilidad, String? comprobado distinto de nulo pasa a comportarse como String.
fun saludar(nombre: String?) {
if (nombre != null) {
println(nombre.length) // String: sin ?., sin !!
}
}
El análisis no se limita al if positivo. Entiende la negación con salida temprana, los cortocircuitos de las conjunciones y disyunciones, las ramas de un when y los bloques else:
fun longitud(texto: String?): Int {
if (texto == null) return 0
return texto.length // aqui ya es String: la rama nula no llega
}
fun util(texto: String?): Boolean =
texto != null && texto.isNotBlank() // el lado derecho ya lo ve como String
fun describir(v: Any?): String = when (v) {
null -> "ausente"
is Int -> "entero " + (v + 1)
is String -> v.uppercase()
else -> v.toString()
}
La reescritura del frontend con K2 amplió notablemente el alcance. Hoy el análisis propaga la información a través de una variable booleana que guarda el resultado de la comprobación, a través de la disyunción, dentro de las lambdas de funciones inline cuyo contrato garantiza que se invocan en el sitio, a través de bloques try/catch y sobre variables que se incrementan o decrementan. Muchos errores que la gente aprendió a esquivar en la era del frontend antiguo simplemente ya no se producen.
fun k2(v: Any?) {
val esTexto = v is String // se guarda la evidencia
if (esTexto) println(v.length) // K2 la recupera aqui
}
Cuándo el compilador se niega
La regla que gobierna todas las denegaciones es una sola: un valor solo puede estrecharse si el compilador puede probar que no cambia entre la comprobación y el uso. A esa propiedad se la llama estabilidad, y no es una lista arbitraria de casos sino la consecuencia directa de esa exigencia.
flowchart TD A[Comprobacion de no nulidad] --> B[El valor es estable] B -->|val local| S[Smart cast concedido] B -->|var local sin captura que lo modifique| S B -->|val del mismo modulo sin getter propio| S B -->|propiedad open o con getter personalizado| F[Denegado] B -->|var de otro modulo| F B -->|propiedad delegada| F B -->|var miembro modificable por otro hilo| F F --> C[Copia a un val local o usa let] C --> S style S fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b
Los tres casos que más desconciertan tienen la misma raíz y merecen verse en código:
open class Base {
open val nombre: String? = "fijo"
}
class Traviesa : Base() {
private var n = 0
override val nombre: String?
get() = if (n++ % 2 == 0) "a" else null // dos lecturas, dos resultados
}
fun usar(b: Base) {
if (b.nombre != null) {
// println(b.nombre.length) // error: nombre es open y podria estar sobrescrita
}
}
Una propiedad open puede sobrescribirse por un getter que devuelva algo distinto en cada lectura, así que la comprobación no dice nada sobre la lectura siguiente. Exactamente lo mismo ocurre con un val que declara getter propio: sintácticamente parece un campo, semánticamente es una llamada a función. Y un var declarado en otro módulo queda fuera del alcance del análisis porque el compilador no ve todas las escrituras posibles: la unidad de compilación es la frontera del razonamiento. Las propiedades delegadas caen por la misma razón, ya que cada acceso pasa por el operador de lectura del delegado.
El caso del var miembro añade el argumento de la concurrencia: entre la comprobación y el uso, otro hilo puede escribir. Aquí la negativa del compilador no es pedantería, es prevención de una condición de carrera clásica que en Java se resuelve exactamente con el mismo remedio, copiar a una variable local.
Cómo darle la prueba que le falta
Las tres soluciones son la misma idea con distinta ergonomía: fijar el valor en algo estable.
class Servicio(private val repo: Repo) {
var sesion: Sesion? = null // propiedad mutable: nunca hay smart cast
fun refrescar() {
val s = sesion ?: return // 1. copia local con guarda
repo.renovar(s.token) // s es Sesion, estable por ser val local
}
fun notificar() {
sesion?.let { s -> // 2. el receptor pasa como argumento
repo.avisar(s.id) // dentro del bloque ya no es nulable
}
}
fun exigir() {
val s = requireNotNull(sesion) { "no hay sesion activa" } // 3. con causa
repo.renovar(s.token)
}
}
La primera forma es la preferida cuando hay más de un uso o cuando quieres salir. La segunda encaja para un bloque corto y con efecto. La tercera es para invariantes que, de romperse, indican un error de programación y no un caso de negocio.
Queda la pieza que permite extender el análisis a tus propias funciones: los contratos. La biblioteca estándar los usa para que comprobaciones envueltas sigan informando al compilador, y por eso isNullOrBlank funciona como si hubieras escrito la comparación a mano:
fun etiqueta(t: String?): String {
if (t.isNullOrBlank()) return "vacio"
return t.trim() // String: el contrato de la stdlib lo garantiza
}
Puedes declarar el tuyo. La sintaxis sigue marcada como experimental, así que exige activación explícita, y el compilador confía en lo que afirmas sin verificarlo: un contrato falso es tan peligroso como una aserción falsa.
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun Pedido?.esFacturable(): Boolean {
contract { returns(true) implies (this@esFacturable != null) }
return this != null && total > 0
}
fun facturar(p: Pedido?) {
if (p.esFacturable()) emitir(p.total) // p es Pedido dentro del if
}
Cuando un smart cast se te resiste, la pregunta útil no es qué operador añadir sino dónde está la copia local que falta. Fijar el valor en un val no es un rodeo para contentar al compilador: es la manera de decir explícitamente este es el valor con el que voy a trabajar, que además elimina relecturas de un getter y cierra la ventana de carrera con otro hilo. El compilador te está señalando un problema real, no un límite de su análisis.
La reacción habitual ante un smart cast denegado es leerlo como una carencia del compilador, y esa lectura es exactamente la inversa de lo que ocurre. El mensaje que dice que una propiedad mutable no puede estrecharse no describe un límite del análisis, describe una propiedad verdadera de tu programa: entre la línea donde compruebas y la línea donde usas, ese valor puede efectivamente ser distinto. Un getter sobrescrito puede devolver otra cosa en la segunda lectura. Un var miembro puede ser reescrito por un hilo concurrente, por un callback, por una corrutina reanudada, por el propio código que llamas en medio. Una propiedad delegada puede recalcularse. En todos esos casos, el código que tú querías escribir habría sido incorrecto, y no de forma teórica: la condición de carrera entre comprobar y usar es una de las familias de fallos más antiguas y más difíciles de reproducir que existen, precisamente porque el fallo depende de un intervalo temporal que en las pruebas nunca se abre. Lo notable es que Kotlin haya elegido resolverlo con la misma disciplina con la que resolvió la nulabilidad: no vigilando en ejecución, sino reduciendo el análisis a lo que puede demostrar y negándose cuando no puede. Y fíjate en que el remedio que te impone —copiar a un val local— no es un truco para callar al compilador, sino la solución canónica al problema subyacente, la misma que un programador de Java experimentado aplica a mano y por costumbre cuando lee un campo compartido. Kotlin la convierte en obligatoria justo donde importa. Cuando interiorizas esto, cambia tu relación con los errores del compilador: dejan de ser obstáculos entre tú y el código que querías escribir, y pasan a ser el único revisor que nunca se cansa señalándote que el código que querías escribir tenía un agujero.
- Escribe una clase con
open val nombre: String?y una subclase cuyo getter alterne entre valor y ausencia; comprueba que el smart cast se deniega y explica por qué la negativa te salvó. - Convierte una propiedad
varnulable de una clase tuya en una guarda con copia local y mide cuántas relecturas del getter eliminaste. - Reescribe un
?.letde tu código como guarda con elvis yreturn, y decide cuál lee mejor según cuántos usos haya del valor. - Declara un contrato propio sobre una función de validación tuya y verifica en el editor que el tipo se estrecha en el llamante.
- Escribe deliberadamente un contrato falso, observa que compila y razona qué garantía acabas de romper para el resto del programa.