wandres.dev
PROPIEDADES DELEGADAS · interceptar el acceso

lateinit y los campos de respaldo explícitos

Qué genera lateinit y hasta dónde llega su legitimidad, el viejo patrón de la propiedad duplicada con guion bajo, y cómo los explicit backing fields estables en Kotlin 2.4 lo eliminan de raíz.

⏱ 15 min

Las cuatro lecciones anteriores giraron alrededor de una misma idea: interponer código entre el mundo y el estado. Esta lección cierra el nivel por el otro extremo, con las dos herramientas que resuelven problemas parecidos sin delegación y con coste cero en tiempo de ejecución. lateinit responde a la pregunta de qué hacer cuando el valor llega después de la construcción, y lleva desde 2016 pagando esa comodidad con un agujero en el sistema de tipos. Los campos de respaldo explícitos responden a la pregunta de cómo exponer una vista de solo lectura sobre un estado mutable, y desde Kotlin 2.4 son estables y jubilan definitivamente el patrón más repetido de todo el ecosistema.

🎯 Al terminar esta lección sabrás
  • Saber qué código genera lateinit y por qué no admite tipos primitivos ni val.
  • Reconocer los usos legítimos de lateinit y los tres límites que no se pueden negociar.
  • Identificar el patrón de la propiedad duplicada y el precio real que cobra.
  • Usar los campos de respaldo explícitos de Kotlin 2.4 y conocer lo que no arreglan.

lateinit por dentro

lateinit var es una promesa al compilador: este valor no es nulo, pero todavía no lo tengo, confía en mí y deja de exigirme una inicialización. Lo que genera es un campo que arranca en nulo y un getter con una comprobación:

class Servicio {
    lateinit var repo: Repositorio          // sin inicializador

    fun arrancar(r: Repositorio) { repo = r }

    fun usar() {
        if (::repo.isInitialized) repo.consultar()
    }
}

El getter equivale a devolver el campo si no es nulo y lanzar UninitializedPropertyAccessException en caso contrario. De ese código generado se deducen todas las restricciones sin memorizarlas: tiene que ser var, porque un val no admite asignación posterior; no admite tipos que se compilan a primitivos en la JVM, porque no existe un valor centinela nulo para un Int; no admite tipos nulos, porque el centinela ya está ocupado; no admite accesores propios ni delegación, porque el getter ya está escrito por el compilador; y no puede declararse en el constructor primario, que es justo lo que se está evitando. Para los primitivos, la stdlib ofrece Delegates.notNull, que es exactamente lo mismo pero implementado como delegado.

Para los tipos que sí se compilan a primitivos, el sustituto es un delegado de la stdlib, lo cual cierra el círculo de este nivel: la misma necesidad resuelta por dos mecanismos distintos, uno del compilador y otro de la biblioteca.

import kotlin.properties.Delegates

class Ventana {
    var ancho: Int by Delegates.notNull()   // lateinit no vale para Int en la JVM
}

La diferencia de coste es real: lateinit es un campo con una comprobación de nulo incrustada en el getter, mientras que Delegates.notNull es un objeto delegado extra por instancia con una llamada virtual en cada acceso. Cuando puedas usar lateinit, úsalo.

Es legítimo cuando un marco de trabajo con ciclo de vida es el dueño real de la inicialización y tú no puedes adelantarla: inyección de dependencias por campos, montaje de pruebas antes de cada caso, y componentes cuyo constructor lo llama la plataforma. En todos esos escenarios el valor está garantizado antes del primer uso por un contrato externo que el compilador no puede ver.

Y tiene tres límites que ninguna disciplina de equipo arregla. Primero: el compilador deja de ayudarte; una propiedad lateinit es exactamente igual de segura que una variable que tú prometiste no dejar nula. Segundo: no ofrece garantía alguna de visibilidad entre hilos, así que un hilo puede ver la propiedad todavía sin inicializar después de que otro la haya asignado. Tercero: el fallo aparece en el punto de lectura y no en el de la inicialización ausente, que suele estar en otro fichero y en otro momento. Si el valor puede darse en el constructor, lateinit es siempre la peor opción disponible.

El patrón de la propiedad duplicada

El otro problema clásico no es cuándo llega el valor sino quién puede modificarlo. Quieres guardar una colección o un flujo mutable y exponer al exterior una vista de solo lectura. Durante una década la única solución fue duplicar la propiedad:

class BuscadorViewModel {
    private val _estado = MutableStateFlow<Estado>(Estado.Inicial)
    val estado: StateFlow<Estado> get() = _estado

    private val _historial = mutableListOf<String>()
    val historial: List<String> get() = _historial

    fun buscar(q: String) {
        _historial.add(q)
        _estado.value = Estado.Cargando
    }
}

El precio no es la verbosidad, que sería lo de menos. El precio es que el mismo concepto tiene dos nombres y que mantener la relación entre ellos queda en manos de la convención: nada impide exponer el mutable por error, nada impide que un método interno use el nombre público y se quede sin poder mutar, nada avisa si alguien cambia el tipo de uno y no del otro, y toda la disciplina descansa en un guion bajo que ningún compilador comprueba. En bases de código grandes ese patrón aparece cientos de veces y es una fuente constante de revisiones de código dedicadas a algo que debería ser estructural.

Campos de respaldo explícitos

Kotlin 2.4 estabiliza los explicit backing fields: una propiedad puede declarar su campo de respaldo con un tipo distinto del que expone.

class BuscadorViewModel {
    val estado: StateFlow<Estado>
        field = MutableStateFlow(Estado.Inicial)

    val historial: List<String>
        field = mutableListOf<String>()

    fun buscar(q: String) {
        historial.add(q)                 // dentro de la clase es MutableList
        estado.value = Estado.Cargando   // dentro de la clase es MutableStateFlow
    }
}

Un solo nombre, dos vistas. Dentro de la clase, estado es el MutableStateFlow y el compilador lo sabe; desde fuera, estado es un StateFlow y no hay forma de escribirlo. La regla que lo gobierna es de subtipado y se recuerda sola: con el getter por defecto, el tipo del campo debe ser subtipo del tipo de la propiedad. La regla simétrica para el setter por defecto exige lo contrario, de modo que un var con ambos accesores por defecto obligaría a que los dos tipos coincidieran; por eso esta característica brilla en propiedades de solo lectura y en las que sí definen un setter propio.

Ese segundo caso es el que suele pasarse por alto y resuelve el problema de la copia defensiva al asignar:

class Documento {
    var etiquetas: Set<String>
        field = mutableSetOf<String>()
        set(nuevas) {
            field.clear()
            field.addAll(nuevas)     // dentro del setter, field es el MutableSet
        }

    fun etiquetar(t: String) {
        etiquetas.add(t)             // dentro de la clase, la vista es mutable
    }
}

Desde fuera se asigna un Set cualquiera y la clase conserva su colección; nadie retiene una referencia al conjunto interno y nadie puede mutarlo por la puerta de atrás. Escribir eso con el patrón antiguo exigía dos propiedades, un setter con nombre distinto y una convención más que recordar.

flowchart TD
A[Necesito un valor en una propiedad] --> B[Puedo pasarlo por el constructor]
B -->|si| C[Parametro del constructor y val normal]
B -->|no| D[Se calcula una vez y es puro]
D -->|si| E[by lazy]
D -->|no| F[Lo inyecta un framework externo]
F -->|si| G[lateinit var o Delegates notNull]
F -->|no| H[Expongo solo lectura sobre algo mutable]
H -->|si| I[Campo de respaldo explicito]
style I fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b

Lo que no cambian conviene decirlo con la misma claridad. No hay copia defensiva: el objeto expuesto sigue siendo el mismo, y quien haga una conversión de tipo desde fuera alcanza el mutable, exactamente igual que con el patrón antiguo. Tampoco añaden seguridad entre hilos ni sustituyen a lateinit: son herramientas ortogonales que resuelven problemas distintos. Y no ahorran memoria, porque el patrón antiguo también tenía un único campo. Lo que eliminan es el segundo nombre, y con él una clase entera de errores de mantenimiento.

Cómo elegir

🏗️

Parámetro del constructor

La opción por defecto siempre. Si el valor puede existir al construir el objeto, cualquier otra alternativa es una regresión.

💤

by lazy

Cálculo puro, caro y cuyo momento da igual. Recuerda que congela el resultado en el primer acceso ajeno.

lateinit var

Solo cuando un marco externo es el dueño de la inicialización. Comprueba con isInitialized si el orden no está garantizado.

🔒

Campo de respaldo explícito

Estado mutable dentro, vista inmutable fuera, un único nombre y comprobado por el compilador.

Los campos de respaldo explícitos no son azúcar: cierran la brecha entre lo que una API dice y lo que su implementación necesita

Merece la pena entender por qué el patrón del guion bajo sobrevivió tanto tiempo, porque la respuesta explica qué se ha arreglado de verdad. Ese patrón existía por una asimetría estructural del lenguaje: una propiedad tenía un solo tipo, y ese tipo servía a la vez para el contrato público y para el almacenamiento privado. Como esos dos papeles tienen requisitos opuestos —fuera quieres el tipo más restrictivo posible para no prometer capacidades que nadie debería usar, dentro quieres el más capaz posible para poder trabajar—, la única salida era partir el concepto en dos declaraciones y confiar en que un convenio de nombres mantuviese la relación. Es decir, se resolvía un problema de tipos con un problema de disciplina, que es siempre un mal cambio: la disciplina no se comprueba, no se refactoriza sola, no aparece en el mensaje de error y se degrada exactamente en el momento en que el equipo crece. Los campos de respaldo explícitos devuelven el problema al lugar donde se puede razonar: una única propiedad con dos tipos, uno por cada lado de la frontera de visibilidad, y un compilador que verifica que la relación de subtipado entre ambos es coherente con los accesores que has declarado. Ese cambio tiene un efecto de segundo orden que se aprecia con el tiempo: cuando exponer una vista de solo lectura deja de costar una declaración extra y un nombre feo, la gente lo hace por defecto en vez de hacerlo cuando se acuerda, y el estado mutable se queda dentro de sus objetos sin necesidad de una revisión de código que lo recuerde. La lección general de este nivel entero es esa. La delegación, lazy, lateinit y los campos explícitos son cuatro respuestas distintas a la misma pregunta —quién controla el acceso a un estado y bajo qué garantías—, y la calidad de un diseño en Kotlin se mide por lo poco que necesita apoyarse en promesas que el compilador no puede verificar.

⚔️ Jubila el guion bajo
  1. Busca en tu código una pareja de propiedad privada mutable y propiedad pública de solo lectura, y reescríbela con un campo de respaldo explícito.
  2. Comprueba desde un test externo que la propiedad expuesta no permite mutación, y luego intenta alcanzarla con una conversión de tipo. Documenta el resultado.
  3. Coge una propiedad lateinit de tu proyecto y responde por escrito si el valor podría haberse pasado por el constructor. Si la respuesta es sí, cámbiala.
  4. Provoca una UninitializedPropertyAccessException a propósito y observa dónde apunta la traza frente a dónde está el error real.
  5. Sustituye un lateinit var contador: Int imposible por Delegates.notNull y explica qué cambia en el código generado.