Propiedades delegadas en Kotlin: lazy y observable

1. Resumen

Kotlin te permite delegar la lectura y escritura de una propiedad a otro objeto mediante la palabra clave by. Este mecanismo se llama delegated properties. La librería estándar tiene varios delegados muy utilizados. Los más comunes son lazy para inicialización perezosa, observable para escuchar cambios y vetoable para evitar cambios no válidos.

Después de leer este artículo, sabrás cómo usar cada delegado y elegir el modo de thread-safety correcto para lazy. También sabrás cómo escribir tests para ellos y qué situaciones debes evitar.

2. Configuración

Los delegados en este artículo están incluidos en kotlin-stdlib, así que no necesitas agregar librerías. Para escribir tests, agrega JUnit 5 y AssertJ:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

Si usas Spring Boot 3.x, spring-boot-starter-test ya incluye ambas librerías.

3. Cómo funcionan las delegated properties

Cuando escribes val x: T by someDelegate, el compilador genera un campo oculto para guardar el delegado. El getter de x llamará a someDelegate.getValue(...). Si x es var, el setter llamará a setValue(...).

Puedes escribir tu propio delegado simple para ver el mecanismo claramente:

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}

class Customer {
    var name: String by TrimmedString()
}

fun main() {
    val customer = Customer()
    customer.name = "   Nguyễn Văn An  "
    println("[${customer.name}]")
}

Explicación de cada parte:

  • TrimmedString implementa ReadWriteProperty, que consta de dos funciones getValue y setValue.
  • Cada vez que asignas customer.name, Kotlin llama a setValue, y la cadena se trim() antes de guardarla.
  • El parámetro property te dice el nombre de la propiedad que se está accediendo. observable usa este parámetro para informarte qué propiedad acaba de cambiar.
[Nguyễn Văn An]

Raramente necesitarás escribir tu propio delegado, porque la librería estándar ya tiene los más comunes.

4. Inicialización perezosa con by lazy

4.1. Uso básico

lazy toma una lambda. La lambda se ejecuta solo la primera vez que lees la propiedad. El resultado se almacena en caché para todas las lecturas posteriores.

class ReportService {
    val exchangeRates: Map<String, Double> by lazy {
        println("Cargando tipos de cambio...")
        mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
    }
}

fun main() {
    val service = ReportService()
    println("Servicio creado")
    println(service.exchangeRates["USD"])
    println(service.exchangeRates["EUR"])
}

Resultado:

Servicio creado
Cargando tipos de cambio...
25400.0
27300.0

Hay tres puntos a notar:

  • Crear ReportService no activa la carga de tipos de cambio.
  • El mensaje “Cargando tipos de cambio…” se imprime solo una vez, aunque la propiedad se lea dos veces.
  • lazy solo funciona con val. Escribir var x by lazy { ... } causará un error de compilación, porque Lazy no tiene setValue.

lazy es útil para valores que son costosos de crear pero no siempre se necesitan. Por ejemplo: una expresión regular compleja, una configuración leída de un archivo, o un cliente que llama a un servicio externo.

4.2. Modos de thread-safety

Por defecto, lazy usa LazyThreadSafetyMode.SYNCHRONIZED. Este modo garantiza que la lambda se ejecute exactamente una vez, incluso si múltiples threads la acceden. Puedes cambiar el modo si lo necesitas:

class PriceCalculator {
    // Solo un thread ejecuta el inicializador, los otros esperan
    val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        mapOf("VN" to 0.1, "SG" to 0.09)
    }

    // Múltiples threads pueden ejecutar el inicializador, se mantiene el primer valor
    val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
        listOf("BLACK_FRIDAY", "TET_SALE")
    }

    // Sin sincronización, úsalo solo si estás seguro de que la propiedad se accede desde un thread
    val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
        java.text.DecimalFormat("#,###")
    }
}

fun main() {
    val calculator = PriceCalculator()
    println(calculator.taxTable["VN"])
    println(calculator.discountRules.first())
    println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000

Puedes elegir el modo según la siguiente tabla:

Modo El inicializador se ejecuta Cuándo usarlo
SYNCHRONIZED Exactamente 1 vez Por defecto, seguro para la mayoría de casos
PUBLICATION Posiblemente múltiples veces, mantiene el primer resultado El inicializador es económico, sin side effects, quieres evitar bloqueos
NONE Sin garantías El código solo se ejecuta en un thread (thread de UI, bucle interno)

Nota: NONE es un poco más rápido porque no tiene bloqueos. Si lo usas en un bean singleton de Spring al que acceden múltiples requests en paralelo, el inicializador puede ejecutarse varias veces. En ese caso, los threads pueden ver instancias diferentes. No cambies el modo solo para “optimizar” sin medir primero.

4.3. Cuando el inicializador lanza una excepción

Si la lambda lanza una excepción, el valor no se almacena en caché. La siguiente lectura ejecutará la lambda de nuevo:

class ConfigLoader {
    var attempts = 0

    val apiKey: String by lazy {
        attempts++
        if (attempts < 2) error("No se pudo leer el archivo de configuración")
        "secret-key-123"
    }
}

fun main() {
    val loader = ConfigLoader()
    runCatching { loader.apiKey }.onFailure { println("Error: ${it.message}") }
    println(loader.apiKey)
    println("Número de intentos: ${loader.attempts}")
}
Error: No se pudo leer el archivo de configuración
secret-key-123
Número de intentos: 2

Este comportamiento es útil cuando el error es temporal. Pero si el inicializador es muy costoso y el error se repite, cada acceso incurrirá en ese costo nuevamente.

4.4. Verificar si ya se ha inicializado

Si necesitas saber si el valor ya se ha creado o no, mantén el objeto Lazy en una variable separada y delega hacia él:

class CacheHolder {
    private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
    val productCache: MutableMap<String, Int> by productCacheDelegate

    fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}

fun main() {
    val holder = CacheHolder()
    println(holder.isCacheReady())
    holder.productCache["SKU-1"] = 10
    println(holder.isCacheReady())
}
false
true

Este enfoque es útil cuando quieres liberar recursos en el shutdown, pero solo si ese recurso realmente se ha creado.

4.5. ¿lazy o lateinit?

Estos dos enfoques a menudo se confunden:

  • lazy: se usa con val. El valor se calcula por la propia propiedad en la primera lectura.
  • lateinit: se usa con var y tipos no-null, no aplica a tipos primitivos como Int. El valor se asigna desde el exterior después, por ejemplo mediante inyección de dependencias o una función @BeforeEach en tests.

Si sabes cómo calcular el valor dentro de la clase, usa lazy. Si el valor es “proporcionado” desde fuera, usa lateinit.

5. Monitorear cambios con Delegates.observable

Delegates.observable toma un valor inicial y un callback. El callback se ejecuta después de que el nuevo valor se ha asignado.

import kotlin.properties.Delegates

enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }

class Order(val id: String) {
    val history = mutableListOf<String>()

    var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
        history += "${property.name}: $old -> $new"
    }
}

fun main() {
    val order = Order("ORD-001")
    order.status = OrderStatus.PAID
    order.status = OrderStatus.SHIPPED
    order.status = OrderStatus.SHIPPED
    order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED

Explicación:

  • OrderStatus.NEW es el valor inicial. Asignar este valor no activa el callback.
  • El callback recibe tres parámetros: property (metadatos), old (valor anterior) y new (valor nuevo).
  • La tercera línea muestra que el callback se ejecuta incluso cuando asignas el mismo valor.

Si solo quieres reaccionar cuando el valor realmente cambia, filtra dentro del callback:

var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
    if (old != new) history += "$old -> $new"
}

observable se usa comúnmente para audit logging, marcar un objeto como “dirty” que necesita guardarse, o actualizar un valor derivado.

6. Evitar cambios no válidos con Delegates.vetoable

vetoable es similar a observable, pero el callback se ejecuta antes de asignar y devuelve un Boolean. Si el callback devuelve false, el valor anterior se mantiene.

import kotlin.properties.Delegates

class CartItem(val sku: String) {
    var quantity: Int by Delegates.vetoable(1) { _, _, new ->
        new in 1..99
    }
}

fun main() {
    val item = CartItem("SKU-42")
    item.quantity = 5
    println(item.quantity)
    item.quantity = 0
    println(item.quantity)
    item.quantity = 150
    println(item.quantity)
}
5
5
5

Las asignaciones de 0 y 150 se rechazan silenciosamente, y quantity sigue siendo 5.

Nota: Como vetoable no lanza una excepción, el código que llama no sabrá que la asignación falló. Para datos empresariales importantes, como un monto de pago, debes usar un setter personalizado con require(...) para que el error sea evidente. vetoable es útil en casos donde ignorar silenciosamente un valor inválido es el comportamiento deseado, como limitar un valor de un deslizador en la interfaz de usuario.

7. Escribir tests para los delegados

La sección de test a continuación verifica tres comportamientos clave: lazy se inicializa solo una vez, observable registra el historial y vetoable rechaza valores inválidos.

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class DelegatedPropertiesTest {

    class ExpensiveHolder(private val counter: AtomicInteger) {
        val value: String by lazy {
            counter.incrementAndGet()
            Thread.sleep(50)
            "ready"
        }
    }

    @Test
    fun `lazy solo ejecuta el inicializador una vez aunque múltiples threads lo accedan`() {
        val counter = AtomicInteger()
        val holder = ExpensiveHolder(counter)
        val executor = Executors.newFixedThreadPool(8)

        repeat(8) { executor.submit { holder.value } }
        executor.shutdown()
        executor.awaitTermination(5, TimeUnit.SECONDS)

        assertThat(holder.value).isEqualTo("ready")
        assertThat(counter.get()).isEqualTo(1)
    }

    @Test
    fun `observable registra cada cambio de estado`() {
        val order = Order("ORD-002")

        order.status = OrderStatus.PAID
        order.status = OrderStatus.DELIVERED

        assertThat(order.history).containsExactly(
            "status: NEW -> PAID",
            "status: PAID -> DELIVERED"
        )
    }

    @Test
    fun `vetoable mantiene el valor anterior cuando se rechaza`() {
        val item = CartItem("SKU-7")

        item.quantity = 10
        item.quantity = -3

        assertThat(item.quantity).isEqualTo(10)
    }
}

El primer test usa 8 threads que acceden al mismo tiempo a value. Con el modo por defecto SYNCHRONIZED, counter siempre es 1. Si cambias a LazyThreadSafetyMode.NONE y ejecutas de nuevo varias veces, el test fallará en algunas ocasiones. Esa es una forma rápida de verificar por ti mismo la diferencia entre los modos.

8. Errores comunes y cuándo no usar delegados

Usar delegados en entidades JPA. El campo real que genera el compilador es status$delegate, que tiene el tipo ObservableProperty, no OrderStatus. Hibernate no puede mapear este tipo de campo de forma natural. Con entidades, usa campos normales con setters personalizados o escuchadores de entidad.

La lambda de lazy mantiene referencias. La lambda se retiene hasta que se ejecuta. Si captura un objeto grande y la propiedad nunca se lee, ese objeto seguirá siendo retenido en memoria.

Leer lazy en el bloque init demasiado pronto. Si la lambda usa una propiedad declarada más abajo, y lees la propiedad lazy dentro de init, podrías obtener un valor sin inicializar, como null o 0.

Reemplazar el mecanismo del framework. En Spring, no uses by lazy para crear beans tú mismo o gestionar el ciclo de vida de un connection pool. Usa @Lazy u ObjectProvider, para que el contenedor pueda gestionar el cierre de recursos.

Side effects pesados en observable. El callback se ejecuta de forma síncrona dentro del setter. Hacer llamadas HTTP o escribir en la base de datos dentro del callback hará que una asignación simple sea lenta y propensa a errores. Emite un evento en lugar de hacer trabajo pesado en el mismo lugar.

Confundir observable como thread-safe. observable y vetoable no tienen mecanismo de sincronización. Si múltiples threads asignan simultáneamente, el callback podría recibir un valor old incorrecto.

9. Conclusión

Las delegated properties te ayudan a separar la lógica de acceso de propiedades de la clase, haciendo el código más limpio y legible. Usa by lazy para valores costosos de crear, y mantén el modo SYNCHRONIZED por defecto a menos que tengas una razón clara para cambiar. Usa Delegates.observable cuando necesites reaccionar después de cada cambio, y recuerda que el callback se ejecuta incluso cuando el valor no cambia. Usa Delegates.vetoable cuando quieras ignorar silenciosamente valores inválidos; cuando necesites reportar errores claramente, usa require en el setter.

Una vez que entiendas getValue y setValue, puedes escribir tu propio delegado para necesidades específicas como normalizar cadenas, leer valores de un Map o registrar accesos. Solo necesitas evitar usarlos donde el framework necesita ver el campo real, como en entidades JPA.

kotlinpropiedades delegadaslazyobservabledelegados