Los delegados de la stdlib: observable, vetoable, mapas y alias
Las herramientas que ya vienen en la biblioteca estándar: notificar cambios con observable, rechazarlos con vetoable, respaldar propiedades en un Map y crear alias de otra propiedad con by dos puntos dos puntos.
Antes de escribir tu propio delegado conviene agotar los cuatro que la biblioteca estándar ya te regala, porque cubren una fracción sorprendente de los casos reales y porque cada uno enseña una idea distinta sobre qué se puede hacer en el punto de acceso. observable demuestra que un set puede notificar; vetoable, que puede negarse; la delegación en un Map demuestra que el estado no tiene por qué vivir en campos; y la delegación en otra propiedad demuestra que el nombre de una propiedad y su almacenamiento son cosas separables. Los cuatro tienen letra pequeña, y toda ella se deduce de mirar el setValue que ejecutan.
- Usar
observableyvetoableconociendo su orden de ejecución y sus silencios. - Reconocer
ObservablePropertycomo la base común y saber extenderla. - Respaldar propiedades en un
Mapy medir el riesgo de los tipos sin comprobar. - Crear alias con delegación a otra propiedad para renombrar sin romper a nadie.
observable y vetoable: el mismo motor, dos ganchos
Ambos salen de la misma clase abstracta, y leerla desmonta cualquier misterio:
abstract class ObservableProperty<V>(initialValue: V) : ReadWriteProperty<Any?, V> {
private var value = initialValue
protected open fun beforeChange(prop: KProperty<*>, viejo: V, nuevo: V): Boolean = true
protected open fun afterChange(prop: KProperty<*>, viejo: V, nuevo: V) {}
override fun getValue(thisRef: Any?, prop: KProperty<*>): V = value
override fun setValue(thisRef: Any?, prop: KProperty<*>, value: V) {
val viejo = this.value
if (!beforeChange(prop, viejo, value)) return // veto silencioso
this.value = value
afterChange(prop, viejo, value) // notificacion posterior
}
}
Delegates.observable implementa afterChange; Delegates.vetoable, beforeChange.
import kotlin.properties.Delegates
class Usuario {
var nombre: String by Delegates.observable("anonimo") { prop, viejo, nuevo ->
println("${prop.name} paso de $viejo a $nuevo")
}
var edad: Int by Delegates.vetoable(0) { _, _, nuevo -> nuevo in 0..130 }
}
Cuatro detalles que se pagan caros si se ignoran. Uno: observable no compara el valor viejo con el nuevo, así que asignar el mismo valor dispara la notificación igualmente; si tu observador redibuja una pantalla, acabas de crear trabajo inútil. Dos: la notificación llega después de la escritura, de modo que leer la propiedad dentro del callback devuelve el valor nuevo. Tres: un veto rechazado es silencioso, no lanza nada, y desde fuera parece que la asignación simplemente no ocurrió. Cuatro: ni el valor inicial pasa por beforeChange, ni la secuencia leer-modificar-notificar es atómica, así que en concurrencia dos hilos pueden notificar transiciones incoherentes.
flowchart TD A[Asignacion a la propiedad] --> B[setValue del delegado] B --> C[beforeChange con viejo y nuevo] C -->|devuelve false| D[Se descarta sin aviso] C -->|devuelve true| E[Escribe el campo interno] E --> F[afterChange notifica a quien escuche] style D fill:#f38ba8,color:#11111b style F fill:#a6e3a1,color:#11111b
Cuando necesites las dos mitades a la vez —validar y notificar— no encadenes delegados: extiende ObservableProperty y sobrescribe ambos métodos. Es la forma soportada, evita duplicar el campo y te deja además arreglar los dos silencios de la stdlib.
class Validada<V>(
inicial: V,
private val regla: (V) -> Boolean,
private val alCambiar: (V) -> Unit,
) : ObservableProperty<V>(inicial) {
override fun beforeChange(prop: KProperty<*>, viejo: V, nuevo: V): Boolean {
require(regla(nuevo)) { "valor invalido para ${prop.name}: $nuevo" }
return viejo != nuevo // ignora asignaciones que no cambian nada
}
override fun afterChange(prop: KProperty<*>, viejo: V, nuevo: V) = alCambiar(nuevo)
}
Ese return viejo != nuevo es el detalle que convierte el delegado en útil: rechaza sin ruido las escrituras idempotentes y solo notifica transiciones reales, mientras que la excepción convierte los valores inválidos en un fallo visible en el punto exacto de la asignación.
Delegar en un Map
Un Map no sabe nada de delegación; es la stdlib la que le añade un getValue por extensión, usando el nombre de la propiedad como clave. La versión mutable añade setValue.
class Configuracion(private val origen: MutableMap<String, Any?>) {
var host: String by origen // clave "host"
var puerto: Int by origen // clave "puerto"
}
Es un patrón excelente para envolver datos dinámicos —una respuesta JSON ya deserializada, variables de entorno, un documento de configuración— con una fachada tipada y legible. Y es un patrón pésimo para un modelo de dominio, por tres razones concretas.
La primera: la conversión al tipo declarado es una comprobación sin garantía estática, así que un tipo equivocado en el mapa no falla al construir el objeto sino en el instante en que alguien lee la propiedad, potencialmente muy lejos. La segunda: si la clave no existe, el acceso lanza NoSuchElementException, salvo que envuelvas el mapa con withDefault. La tercera, la más traicionera: al usar el nombre de la propiedad como clave, el nombre pasa a formar parte de tu formato de datos; renombrar una propiedad en una refactorización deja de ser una operación segura y se convierte en un cambio de contrato que ningún compilador va a señalar.
// sin red: NoSuchElementException al leer una clave ausente
class Estricta(origen: Map<String, Any?>) {
val host: String by origen
}
// con red: el valor por defecto se calcula a partir de la clave
class Tolerante(origen: Map<String, Any?>) {
private val datos = origen.withDefault { clave -> "sin $clave" }
val host: String by datos
}
Si el nombre en disco importa, la solución no es renunciar al patrón sino recuperarlo bajo control: un provideDelegate que reciba la clave explícita, como el de la lección anterior, te devuelve la comodidad sin atar tu formato de datos al refactorizador del IDE.
Delegar en otra propiedad
Desde Kotlin 1.4 puedes poner una referencia de propiedad detrás de by. El compilador genera acceso directo, sin reflexión en tiempo de ejecución, y el resultado es un alias real.
class Api {
var tiempoDeEspera: Int = 30
@Deprecated("Usa tiempoDeEspera", ReplaceWith("tiempoDeEspera"))
var timeout: Int by ::tiempoDeEspera // alias de la misma clase
}
class Vista(private val modelo: Modelo) {
val titulo: String by modelo::nombre // alias de otro objeto
}
var contadorGlobal: Int = 0
val copia: Int by ::contadorGlobal // alias de nivel superior
Sirve para tres cosas muy concretas: renombrar una propiedad pública sin romper a quien la usa, aplanar un acceso anidado que ensuciaba el código de llamada, y exponer en solo lectura algo que por debajo es mutable. La restricción es que los tipos deben coincidir, y que aliasar un var exige que el destino también sea var.
observable
Notifica siempre, incluso cuando el valor no cambia. Envuelve el callback en una comparación si vas a disparar trabajo caro.
vetoable
Rechaza en silencio. Si necesitas que el llamante se entere del rechazo, escribe tu propio delegado que lance.
by un Map
Estado fuera del objeto, con el nombre de la propiedad como clave. Perfecto para datos dinámicos, peligroso como modelo.
by una referencia
Alias sin coste en tiempo de ejecución. La forma limpia de deprecar un nombre manteniendo un único estado.
La tentación es catalogarlos como abreviaturas —un setter con log, un setter con if, un mapa disfrazado— y esa lectura pierde lo esencial. Lo que estos cuatro delegados hacen es materializar una regla que antes vivía en la cabeza del equipo. Cuando la restricción de que la edad está entre cero y ciento treinta se escribe en un vetoable, deja de depender de que cada nuevo setter recuerde comprobarla; cuando la relación entre un cambio de estado y su notificación se escribe en un observable, deja de depender de que nadie olvide llamar al listener; cuando el origen de la configuración se escribe como delegación a un Map, deja de depender de que todos usen la misma cadena literal como clave. Esa es la diferencia entre documentación y garantía. Pero la moneda tiene reverso, y es exactamente donde estos delegados fallan en producción: cada uno elige un modo de fallar y ese modo suele ser el silencio. Un veto rechazado no lanza, un observador que notifica sin cambio real no avisa de nada, y una clave ausente en un mapa explota en un punto del código que no tiene ninguna relación con el error. Por eso la pregunta correcta al elegir un delegado de la stdlib nunca es qué hace cuando todo va bien, sino qué hace cuando algo va mal y quién se entera. Si la respuesta es nadie, tienes dos opciones honestas: aceptar ese silencio como decisión de diseño consciente y documentarla, o escribir tu propio delegado. La lección siguiente va de lo segundo.
- Añade a una clase una propiedad con
observableque cuente cuántas veces se notificó, y comprueba que asignar el mismo valor tres veces produce tres notificaciones. - Extiende
ObservablePropertypara crear un delegado que valide y notifique a la vez, sin encadenar dos delegados ni duplicar el campo. - Cambia
vetoablepor una versión propia que, en vez de descartar en silencio, lanceIllegalArgumentExceptioncon el nombre de la propiedad. Discute cuál de las dos prefieres y por qué. - Construye una fachada tipada sobre un
MutableMapcon cuatro propiedades y provoca a propósito los dos fallos posibles: clave ausente y tipo incorrecto. Observa dónde aparece cada traza. - Renombra una propiedad pública de tu código y deja el nombre viejo como alias con
byy una referencia, marcado como obsoleto.