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:
TrimmedStringimplementaReadWriteProperty, que consta de dos funcionesgetValueysetValue.- Cada vez que asignas
customer.name, Kotlin llama asetValue, y la cadena setrim()antes de guardarla. - El parámetro
propertyte dice el nombre de la propiedad que se está accediendo.observableusa 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
ReportServiceno 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.
lazysolo funciona conval. Escribirvar x by lazy { ... }causará un error de compilación, porqueLazyno tienesetValue.
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:
NONEes 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 conval. El valor se calcula por la propia propiedad en la primera lectura.lateinit: se usa convary tipos no-null, no aplica a tipos primitivos comoInt. El valor se asigna desde el exterior después, por ejemplo mediante inyección de dependencias o una función@BeforeEachen 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.NEWes el valor inicial. Asignar este valor no activa el callback.- El callback recibe tres parámetros:
property(metadatos),old(valor anterior) ynew(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
vetoableno 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 conrequire(...)para que el error sea evidente.vetoablees ú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.