wandres.dev
PROPIEDADES DELEGADAS · interceptar el acceso

lazy: inicialización perezosa y su letra pequeña

Cómo funciona lazy por dentro, los tres modos de sincronización y lo que cuesta cada uno, la excepción que no se cachea y los errores clásicos al mezclar pereza con estado mutable.

⏱ 15 min

lazy es el delegado más usado de Kotlin y también el peor entendido. La versión de folleto dice que retrasa un cálculo caro hasta el primer acceso, y eso es cierto pero irrelevante: lo importante es que lazy introduce, sin pedir permiso, un objeto extra, una lectura volátil por acceso, una política de sincronización que casi nadie elige conscientemente y un instante de congelación del estado que rara vez coincide con el que el programador imaginaba. Entender esas cuatro cosas es la diferencia entre usar lazy como una herramienta de rendimiento y usarlo como una fuente silenciosa de bugs de concurrencia.

🎯 Al terminar esta lección sabrás
  • Distinguir la función lazy del tipo Lazy y del operador getValue que los une.
  • Elegir con criterio entre los modos SYNCHRONIZED, PUBLICATION y NONE.
  • Saber qué ocurre cuando el inicializador lanza una excepción.
  • Detectar los fallos clásicos: pereza sobre estado mutable, fugas de contexto y ciclos.

Qué es lazy exactamente

lazy no es una palabra clave ni un delegado: es una función de la stdlib que devuelve un objeto de tipo Lazy, más una función de extensión operator fun <T> Lazy<T>.getValue(...) que convierte a ese objeto en un delegado válido. Por eso puedes guardar un Lazy en una variable, pasarlo por parámetro o preguntarle isInitialized sin forzar el cálculo.

La implementación por defecto, resumida pero fiel al espíritu del original, es un bloqueo con doble comprobación:

private class SynchronizedLazyImpl<out T>(
    initializer: () -> T,
    lock: Any? = null,
) : Lazy<T> {
    private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    private val lock: Any = lock ?: this

    override val value: T
        get() {
            val v1 = _value                                   // lectura volatil
            if (v1 !== UNINITIALIZED_VALUE) return v1 as T    // camino rapido
            return synchronized(lock) {
                val v2 = _value
                if (v2 !== UNINITIALIZED_VALUE) v2 as T
                else {
                    val nuevo = initializer!!()               // aqui puede lanzar
                    _value = nuevo
                    initializer = null                        // libera la lambda
                    nuevo
                }
            }
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE
}

Tres detalles cargados de consecuencias. El campo es @Volatile, así que cada acceso posterior paga una lectura volátil, barata en x86 y no gratis en ARM. El inicializador se anula tras usarse, lo cual es la razón de que lazy no retenga para siempre la lambda ni lo que esta capturase. Y la asignación de _value ocurre solo si el inicializador retorna.

Que lazy sea una función y no una palabra clave tiene usos prácticos. Puedes tratar la pereza como un valor de primera clase, guardarla, pasarla y consultarla sin dispararla:

val perezoso: Lazy<Config> = lazy { cargar() }
fun necesitaConfig(c: Lazy<Config>) { if (c.isInitialized()) usar(c.value) }

val yaCalculado: Lazy<Int> = lazyOf(42)   // envoltorio trivial, sin sincronizacion

Y una restricción que se deduce de la implementación: lazy solo puede respaldar un val, porque el delegado no ofrece setValue. No existe ningún modo que permita reasignar o invalidar el valor.

Los tres modos y su coste

val a: Config by lazy { cargar() }                                   // SYNCHRONIZED
val b: Config by lazy(LazyThreadSafetyMode.PUBLICATION) { cargar() }
val c: Config by lazy(LazyThreadSafetyMode.NONE) { cargar() }
  • SYNCHRONIZED es el modo por defecto. Garantiza que el inicializador se ejecuta una sola vez aunque compitan mil hilos. Cuesta un monitor y una lectura volátil por acceso, y expone al mundo el propio delegado como cerrojo si no le pasas uno.
  • PUBLICATION permite que varios hilos ejecuten el inicializador a la vez, pero solo uno gana la publicación mediante una comparación e intercambio atómicos; todos los demás descartan su resultado y devuelven el ganador. Es correcto si el cálculo es puro e idempotente, y catastrófico si abre un fichero, registra un observador o incrementa una métrica.
  • NONE no sincroniza nada: sin volátil, sin monitor, sin garantías. Es el más rápido y el único que puede devolver un objeto medio construido a otro hilo. Solo es legítimo cuando puedes demostrar confinamiento a un único hilo, como el hilo principal de una interfaz.
flowchart TD
A[Acceso a la propiedad] --> B[Lectura del campo volatil]
B -->|ya inicializado| C[Devuelve el valor cacheado]
B -->|sin inicializar| D[Entra en el monitor]
D --> E[Segunda comprobacion]
E -->|otro hilo gano| C
E -->|sigo siendo el primero| F[Ejecuta el inicializador]
F -->|retorna| G[Publica y anula la lambda]
F -->|lanza| H[Sigue sin inicializar y se repetira]
G --> C
style H fill:#f38ba8,color:#11111b

La regla práctica: no cambies de modo por intuición de rendimiento. El coste de SYNCHRONIZED se paga una vez en la contención inicial y luego se reduce a una lectura volátil; si eso aparece en tu perfilador, el problema casi nunca es lazy.

La excepción que no se cachea

Si el inicializador lanza, _value sigue valiendo el centinela de no inicializado. El siguiente acceso volverá a ejecutar el inicializador entero. No hay memorización del fallo, y esto es deliberado: lazy no puede saber si el error era transitorio.

class Cliente(private val ruta: String) {
    val credenciales: Token by lazy { leerToken(ruta) }   // si falla, se reintenta
}

Es una virtud cuando el inicializador es puro y el fallo es de red, y una bomba cuando tiene efectos: un inicializador que abre una conexión, la registra en un pool y luego falla al validar dejará una conexión huérfana por cada acceso. La disciplina es simple: dentro de un lazy, o no hay efectos, o los efectos son idempotentes, o el bloque captura el error y devuelve un valor que representa el fallo.

Pereza sobre estado mutable y otras trampas

El error conceptual más caro es olvidar que lazy congela el resultado en el primer acceso, y que ese instante casi nunca está bajo tu control.

class Informe(var titulo: String) {
    // trampa: el encabezado queda fijado con el titulo que hubiera
    // en el momento de la primera lectura, no en el de la construccion
    val encabezado: String by lazy { titulo.uppercase() }
}

Aquí el valor depende del orden en que otro código lea la propiedad, que es la definición de un bug no determinista. Si el valor depende de estado mutable, la respuesta no es lazy: es una propiedad calculada con get(), que recalcula siempre, o un campo asignado en el constructor, que fija el instante explícitamente.

Los demás fallos recurrentes:

  • Fugas por captura. El inicializador captura this y todo lo que use. Si el Lazy se guarda en un objeto de vida larga y la lambda referencia un contexto de vida corta, la fuga dura hasta el primer acceso, no hasta la recolección.
  • Ciclos. Dos lazy que se leen mutuamente producen un desbordamiento de pila; si además comparten cerrojo explícito, producen un interbloqueo, que es mucho peor de diagnosticar.
  • lazy en propiedades de una clase con muchas instancias. Cada instancia paga un objeto Lazy además del valor. Para cachear algo común, el sitio correcto es el companion object.
  • Confundirlo con by lazy recalculable. lazy solo vale para val; si necesitas invalidar y recomputar, no busques un modo de lazy, escribe tu propio delegado con una operación de invalidación explícita.
  • Usarlo dentro de una corrutina esperando suspensión. El inicializador es una lambda normal, no suspend; si dentro necesitas esperar entrada y salida, acabarás bloqueando el hilo del dispatcher. La herramienta correcta ahí es un Deferred perezoso o un Flow con memoria, no lazy.

La versión honesta del ejemplo anterior deja de ser perezosa, y ese es justamente el punto:

class Informe(var titulo: String) {
    // recalcula siempre: correcto y predecible
    val encabezado: String get() = titulo.uppercase()
}

class InformeFijo(titulo: String) {
    // congela en la construccion, en un instante que tu eliges
    val encabezado: String = titulo.uppercase()
}

lazy encaja aquí

Cálculo puro y caro, resultado inmutable, momento irrelevante y valor compartido durante toda la vida del objeto.

⚠️

lazy no encaja aquí

El resultado depende de estado que puede cambiar, el inicializador tiene efectos, o hace falta poder invalidarlo.

lazy no aplaza un cálculo: traslada una decisión de tiempo y de hilos a un sitio que no ves

Cuando escribes by lazy crees estar tomando una decisión de rendimiento, y en realidad estás tomando tres decisiones de semántica. La primera es cuándo se evalúa la expresión, y la respuesta honesta es que no lo decides tú: lo decide el primer consumidor que toque la propiedad, que puede ser un test, un serializador recorriendo el objeto, un depurador mostrando campos o un log en producción. Todo lo que ese inicializador lea del entorno queda congelado en un instante que depende del comportamiento ajeno, y por eso mezclar pereza con estado mutable no produce un valor incorrecto sino un valor impredecible, que es una categoría distinta y mucho peor de depurar. La segunda decisión es con qué garantías de memoria se publica el resultado, y ahí el modo por defecto te protege precisamente de lo que casi nadie contempla: sin la barrera volátil, otro hilo puede ver la referencia al objeto ya asignada pero sus campos aún sin escribir, un fallo que no se reproduce en x86 y aparece en cuanto el código corre en ARM. La tercera es cuántas veces puede ejecutarse el bloque, y aquí hay dos sorpresas simétricas: PUBLICATION admite ejecuciones concurrentes descartables, y cualquier modo admite reejecución tras una excepción. Un lazy es, por tanto, una pequeña máquina de estados concurrente que has incrustado en tu objeto con dos palabras. Úsalo cuando el cálculo sea puro, caro y su momento sea irrelevante. En cuanto una de esas tres condiciones falle, el delegado adecuado no es lazy: es uno que hayas escrito tú con la política que tu problema necesita.

⚔️ Rompe lazy a propósito
  1. Escribe una clase con una propiedad by lazy cuyo inicializador imprima una traza y duerma medio segundo; léela desde cien hilos y cuenta cuántas veces se imprimió. Repite con PUBLICATION y con NONE.
  2. Haz que el inicializador lance una excepción la primera vez y devuelva un valor la segunda. Accede dos veces y explica el resultado a partir del código de SynchronizedLazyImpl.
  3. Reproduce la trampa del estado mutable: cambia el titulo antes y después de leer encabezado y documenta qué orden produce qué valor.
  4. Sustituye ese lazy por una propiedad con get() y razona en un comentario qué has ganado y qué has perdido.
  5. Guarda el Lazy en una variable y usa isInitialized para comprobar el estado sin forzar el cálculo.