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.
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.
- Distinguir la función
lazydel tipoLazyy del operadorgetValueque los une. - Elegir con criterio entre los modos
SYNCHRONIZED,PUBLICATIONyNONE. - 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() }
SYNCHRONIZEDes 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.PUBLICATIONpermite 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.NONEno 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
thisy todo lo que use. Si elLazyse 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
lazyque se leen mutuamente producen un desbordamiento de pila; si además comparten cerrojo explícito, producen un interbloqueo, que es mucho peor de diagnosticar. lazyen propiedades de una clase con muchas instancias. Cada instancia paga un objetoLazyademás del valor. Para cachear algo común, el sitio correcto es elcompanion object.- Confundirlo con
by lazyrecalculable.lazysolo vale paraval; si necesitas invalidar y recomputar, no busques un modo delazy, 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 unDeferredperezoso o unFlowcon memoria, nolazy.
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.
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.
- Escribe una clase con una propiedad
by lazycuyo inicializador imprima una traza y duerma medio segundo; léela desde cien hilos y cuenta cuántas veces se imprimió. Repite conPUBLICATIONy conNONE. - 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. - Reproduce la trampa del estado mutable: cambia el
tituloantes y después de leerencabezadoy documenta qué orden produce qué valor. - Sustituye ese
lazypor una propiedad conget()y razona en un comentario qué has ganado y qué has perdido. - Guarda el
Lazyen una variable y usaisInitializedpara comprobar el estado sin forzar el cálculo.