wandres.dev
CONTRATOS Y SMART CASTS · lo que el compilador deduce

Cuándo falla el smart cast y por qué la negativa es correcta

El catálogo completo de valores inestables y la razón única que los agrupa: propiedades mutables declaradas en otro módulo, variables capturadas y modificadas por un cierre, propiedades abiertas susceptibles de sobrescritura, getters personalizados que son llamadas disfrazadas y propiedades delegadas cuyo acceso ejecuta código ajeno. Para cada caso, el contraejemplo que justifica la denegación y el rodeo canónico que restituye la demostración sin silenciar al compilador.

⏱ 20 min

El error más citado del lenguaje dice que un valor no puede estrecharse porque es una propiedad mutable que podría haber cambiado en ese momento. Casi todo el mundo lo lee como una limitación del compilador y responde con un operador de aserción, que es precisamente la peor respuesta posible. La lectura correcta es la inversa: ese mensaje no describe una carencia del análisis sino una propiedad verdadera del programa, y en cada uno de los cinco casos que lo producen existe un contraejemplo concreto, escribible en veinte líneas, donde el código que querías escribir habría sido incorrecto. Esta lección construye esos contraejemplos uno por uno, porque la única manera de dejar de pelearse con la estabilidad es verla como lo que es, que es una condición necesaria para que una demostración siga siendo válida un instante después de haberse hecho.

🎯 Al terminar esta lección sabrás
  • Enunciar la condición de estabilidad y derivar de ella el catálogo completo de denegaciones.
  • Construir el contraejemplo que justifica cada caso: propiedad abierta, getter propio, delegada, var de otro módulo y variable capturada.
  • Elegir el rodeo adecuado entre copia local, elvis con salida, let y aserción con causa, según la forma del uso.
  • Reconocer cuándo la denegación señala una condición de carrera real y no un simple estorbo sintáctico.

La condición única de la que todo se deduce

Un valor solo puede estrecharse si el compilador puede probar que la lectura del punto de uso devolverá lo mismo que la lectura del punto de comprobación. A esa propiedad se la llama estabilidad, y no admite grados: o el compilador la puede probar dentro de la unidad que analiza, o no. El catálogo de negativas no es una lista arbitraria memorizable, sino el conjunto de situaciones en las que esa prueba se vuelve imposible.

flowchart TD
A[Valor comprobado] --> B{Que clase de valor es}
B -->|val local o parametro| S[Estable: estrechamiento concedido]
B -->|var local sin captura modificadora| S
B -->|val del mismo modulo con campo de respaldo| S
B -->|propiedad open o abstracta| N[Inestable: denegado]
B -->|propiedad con getter propio| N
B -->|propiedad delegada| N
B -->|var declarada en otro modulo| N
B -->|var capturada y modificada por un cierre| N
N --> R[Fija el valor en un val local]
R --> S

Las tres primeras filas de la rama negativa comparten una raíz que conviene nombrar con precisión: en los tres casos, lo que parece un acceso a un campo es en realidad una invocación. Una propiedad abierta puede tener en la subclase un getter que devuelva algo distinto en cada llamada; una propiedad con getter explícito ya es una función aunque se lea como un campo; y una propiedad delegada convierte cada lectura en una llamada al operador de lectura del delegado. Leer dos veces significa llamar dos veces, y nadie garantiza que dos llamadas devuelvan lo mismo.

open class Origen {
    open val etiqueta: String? = "constante"
}

class Alternante : Origen() {
    private var n = 0
    override val etiqueta: String?
        get() = if (n++ % 2 == 0) "impar" else null
}

fun usar(o: Origen) {
    if (o.etiqueta != null) {
        // println(o.etiqueta.length)   // denegado, y con toda la razon
    }
}

Sustituye la denegación por una aserción y tendrás un fallo que aparece exactamente una vez de cada dos, en producción, sobre una subclase que quizá ni existía cuando escribiste la función. El compilador no está siendo tímido: te está mostrando que tu invariante depende de código que todavía no se ha escrito.

Las dos fronteras: el módulo y el cierre

Los dos casos restantes no tienen que ver con lo que ocurre al leer, sino con quién puede escribir entre la lectura de comprobación y la de uso. El primero es la frontera de compilación. Una propiedad var declarada en otro módulo queda fuera del alcance del análisis porque el compilador no puede enumerar todas las asignaciones posibles: la unidad de compilación es literalmente el límite de lo demostrable, y más allá de él solo hay firmas.

El segundo es la captura por un cierre. Kotlin, a diferencia de Java, permite capturar variables mutables en una lambda y modificarlas desde dentro. Esa capacidad tiene un precio exacto: si existe en la función alguna lambda que asigne a la variable, el compilador ya no puede saber en qué momento se ejecuta esa lambda, porque puede almacenarse, pasarse a otro hilo o invocarse desde un callback arbitrario.

fun procesar(inicial: String?, programar: (() -> Unit) -> Unit) {
    var actual: String? = inicial
    programar { actual = null }        // la captura modificadora envenena el analisis

    if (actual != null) {
        // println(actual.length)      // denegado: nadie sabe cuando corre la lambda
    }
}

Nótese la simetría con el caso del módulo: en ambos hay una escritura potencial que el análisis no puede situar en el tiempo. También conviene notar la asimetría con un detalle que sí se concede desde la reescritura del frontend, y es que una lambda que solo lee la variable no rompe nada, y una lambda que se invoca en el sitio de forma garantizada tampoco, siempre que ese hecho esté declarado en el contrato de la función que la recibe. Ese matiz es la puerta que abre la lección siguiente.

El caso concurrente merece su propio párrafo porque es el único donde la denegación previene un fallo de reproducción imposible. Un var miembro accesible desde varios hilos puede ser reescrito entre la comprobación y el uso sin que exista ninguna prueba capaz de exhibirlo con fiabilidad. La solución canónica en cualquier lenguaje, aplicada a mano por los programadores de Java desde siempre, es copiar el campo compartido a una variable local antes de razonar sobre él. Kotlin no la sugiere: la exige.

Los rodeos correctos, en orden de preferencia

Los cuatro remedios son la misma idea con ergonomías distintas, y elegir bien es una cuestión de cuántos usos hay y de qué debe ocurrir cuando el valor falta.

class Sesion(val token: String, val id: Long)

class Servicio(private val repo: Repo) {
    var sesion: Sesion? = null

    fun renovar() {
        val s = sesion ?: return           // 1. copia con salida temprana
        repo.renovar(s.token)
        repo.registrar(s.id)               // varios usos, una sola lectura
    }

    fun avisar() {
        sesion?.let { repo.avisar(it.id) } // 2. bloque corto con un solo efecto
    }

    fun exigir() {
        val s = requireNotNull(sesion) { "no hay sesion activa" }  // 3. invariante
        repo.renovar(s.token)
    }
}

La copia con elvis y salida es la opción por defecto y debería ser la primera que se intente: fija el valor, elimina relecturas del getter, cierra la ventana de carrera y deja el resto de la función trabajando con un tipo no nulable. El bloque con let gana cuando hay un único uso y ninguna salida que dar. La aserción con causa es para invariantes cuya violación es un error de programación y no un caso de negocio, y su virtud frente al operador de doble exclamación es que deja un mensaje legible en la traza.

Hay un cuarto remedio que no aparece en la lista porque no es un rodeo sino un cambio de diseño, y sin embargo suele ser el correcto. Si una propiedad mutable y nulable obliga a repetir la misma guarda en cada método de la clase, lo que el compilador está señalando no es una molestia sintáctica sino que el estado de esa clase admite combinaciones que el código no quiere tratar. Modelar los dos estados como tipos distintos elimina la nulidad, elimina la mutabilidad y elimina de paso todas las guardas.

sealed interface EstadoSesion {
    data object Anonimo : EstadoSesion
    data class Autenticado(val sesion: Sesion) : EstadoSesion
}

fun renovar(estado: EstadoSesion) = when (estado) {
    is EstadoSesion.Anonimo -> Unit
    is EstadoSesion.Autenticado -> repo.renovar(estado.sesion.token)
}

Conviene también nombrar el remedio que no lo es. El operador de doble exclamación no aporta ninguna prueba: convierte una denegación en tiempo de compilación en una excepción en tiempo de ejecución, y lo hace en el punto exacto donde el compilador acababa de advertir que el valor podía cambiar. Su uso legítimo se reduce a la frontera con código que no expresa nulidad, y aun ahí conviene sustituirlo por una validación con mensaje.

🔮

Copia local con guarda

Preferible siempre que haya más de un uso o haga falta salir. Convierte un valor inestable en un val estable y documenta cuál es el valor sobre el que razona el bloque.

🧪

Bloque con `let`

Adecuado para un uso único con efecto. Recuerda que el receptor pasa como argumento y que por tanto ya no es la propiedad sino una copia.

⚠️

Aserción con causa

requireNotNull y checkNotNull estrechan gracias a su contrato y dejan un mensaje. El operador de doble exclamación hace lo mismo sin explicar nada, y por eso es el último recurso.

⚠️
Un cambio ajeno puede retirarte un smart cast que ya tenías

La estabilidad depende de la declaración, no del uso. Marcar una propiedad como open, moverla a otro módulo, añadirle un getter calculado o convertirla en delegada retira el estrechamiento de todos los llamantes a la vez, incluidos los que no cambiaron una sola línea. Conviene tenerlo presente al diseñar una API pública: la forma de una propiedad es parte de su contrato observable.

La estabilidad es la traducción, al lenguaje de tipos, de una vieja regla de la programación concurrente

Merece la pena reconocer de dónde viene realmente esta exigencia, porque no nació con Kotlin. La familia de fallos que llamamos comprobar y luego usar es una de las más antiguas y más caras de la historia del software: se comprueba una condición sobre un estado compartido, se actúa en función de ella, y en el intervalo entre ambas cosas el estado cambia. Ocurre con ficheros, con permisos, con sesiones, con referencias nulas y con casi cualquier recurso que no esté bajo control exclusivo del hilo que razona. Durante décadas la única defensa fue la disciplina: los manuales de estilo repetían que un campo compartido debía copiarse a una variable local antes de examinarlo, y quien no lo hacía descubría su error meses después en un informe irreproducible. Lo que hace Kotlin es tomar esa regla de estilo y convertirla en una condición del sistema de tipos, con la particularidad de que no la impone en abstracto sino exactamente allí donde importa, que es allí donde el programador pretende usar un hecho demostrado sobre un valor. Y va más lejos, porque generaliza el argumento más allá de la concurrencia: un getter sobrescrito, un delegado que recalcula, una lambda almacenada que reescribe la variable son formas distintas del mismo problema, la de un estado que puede cambiar en un intervalo que el programador creía cerrado. Al tratarlas todas con la misma regla, el lenguaje logra algo poco frecuente, que es que la solución obligatoria coincida con la solución que un experto habría escrito voluntariamente. Por eso conviene resistir la tentación de callar al compilador con una aserción: cada vez que lo haces estás afirmando, sin prueba y sin dejar constancia, que ese intervalo es seguro. A veces lo es. La cuestión es que, cuando dejes de serlo, no habrá nada en el código que lo señale, y el compilador ya te habrá dicho una vez lo que ahora nadie recuerda.

⚔️ Fabrica los contraejemplos
  1. Escribe la subclase alternante del primer ejemplo y demuestra con una prueba que la aserción falla de forma intermitente.
  2. Convierte un val con campo de respaldo en un val con getter calculado y observa qué llamantes pierden el estrechamiento sin haber cambiado.
  3. Captura una variable mutable en una lambda que asigne, y comprueba que la denegación aparece incluso si la lambda se define después del uso.
  4. Toma una propiedad var compartida de tu código, aplica la copia local con guarda y cuenta cuántas relecturas del getter has eliminado.
  5. Sustituye todos los operadores de doble exclamación de un archivo tuyo por requireNotNull con mensaje y valora qué información habrías tenido en la última incidencia.