Escribir tu propio delegado
Implementar ReadWriteProperty y PropertyDelegateProvider, explotar los metadatos de KProperty sin pagar reflexión en cada acceso, y construir un delegado real de ajustes persistidos con clave derivada del nombre.
Los delegados de la biblioteca cubren los casos genéricos precisamente porque no saben nada de tu dominio. En cuanto la regla que quieres imponer menciona una clave de almacenamiento, un esquema, una unidad de medida o un ciclo de vida, ya no hay delegado prefabricado que valga y toca escribir el tuyo. La buena noticia es que el listón técnico es bajísimo: dos funciones y un tipo. La mala es que el listón de diseño es alto, porque un delegado convierte una llamada a función en algo que parece un campo, y esa apariencia es una promesa implícita —barato, sin efectos, sin fallos— que tu implementación debe honrar o desmentir con mucha claridad.
- Implementar
ReadOnlyPropertyyReadWritePropertyentendiendo su varianza. - Usar
PropertyDelegateProviderpara hacer una sola vez el trabajo costoso. - Extraer metadatos de
KPropertysin arrastrar reflexión a cada acceso. - Diseñar un delegado real que persista ajustes con la clave derivada del nombre.
Los dos contratos con nombre
Nada te obliga a implementar una interfaz, pero hacerlo da un tipo nombrable al delegado y documenta dónde puede usarse:
public fun interface ReadOnlyProperty<in T, out V> {
public operator fun getValue(thisRef: T, property: KProperty<*>): V
}
public interface ReadWriteProperty<in T, V> : ReadOnlyProperty<T, V> {
public override operator fun getValue(thisRef: T, property: KProperty<*>): V
public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}
La varianza no es decorativa. T es contravariante, así que un delegado declarado sobre Any? sirve en cualquier clase y también en propiedades locales o de nivel superior, donde thisRef llega como null. Si en cambio lo declaras sobre una clase concreta, el compilador te garantiza que ese delegado jamás se usará fuera de ella —una restricción muy útil cuando el delegado necesita colaborar con su dueño—. V es covariante en la versión de solo lectura y forzosamente invariante en la de lectura y escritura, porque aparece en posición de entrada y de salida.
La tercera pieza es el proveedor, que ya viste en la primera lección y que ahora tiene un uso claro:
public fun interface PropertyDelegateProvider<in T, out D> {
public operator fun provideDelegate(thisRef: T, property: KProperty<*>): D
}
flowchart LR A[Construccion del objeto] --> B[provideDelegate una sola vez] B --> C[Trabajo caro y metadatos] C --> D[Delegado ya configurado] D --> E[getValue en cada lectura] D --> F[setValue en cada escritura] style B fill:#f9e2af,color:#11111b style D fill:#a6e3a1,color:#11111b
Los metadatos, y lo que cuestan
El segundo parámetro de los accesores es la joya del mecanismo, pero sus campos no valen todos lo mismo. property.name es prácticamente gratis: el compilador incrusta el nombre en el objeto de referencia que genera, sin tocar la reflexión. En cambio property.returnType, property.annotations, property.visibility o property.isLateinit exigen la librería kotlin-reflect completa en la JVM, cuestan órdenes de magnitud más y no están disponibles en todos los objetivos de compilación.
De ahí la regla de oro de esta lección: la información cara se consulta en provideDelegate y se guarda en el delegado; los accesores solo leen campos ya calculados. Un delegado que llama a property.annotations en cada lectura es un delegado que convierte un acceso a campo en una operación de milisegundos, y nadie que lea el código de llamada sospechará por qué.
El patrón de configuración por anotación, hecho bien, se parece a esto:
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class Clave(val nombre: String)
// caro: solo desde provideDelegate, nunca desde getValue
fun claveDe(propiedad: KProperty<*>): String =
propiedad.annotations.filterIsInstance<Clave>().firstOrNull()?.nombre
?: propiedad.name
Dos avisos sobre esa ruta. La anotación debe declararse con retención de ejecución y objetivo de propiedad, o simplemente no estará ahí cuando la busques. Y si el mismo código debe compilar para Kotlin/Native o Wasm, la reflexión completa no está disponible con las mismas garantías, así que la configuración por anotación deja de ser portable y el camino recomendable pasa a ser un parámetro explícito o la generación de código con KSP.
Un caso real: ajustes persistidos
El escenario es universal: una pantalla de preferencias respaldada por un almacén clave-valor, con tipos reales en el código y cadenas en el disco. Sin delegados acaba siendo un getter y un setter repetidos por cada ajuste, con la clave escrita a mano y por tanto susceptible de erratas.
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
interface Almacen {
fun leer(clave: String): String?
fun escribir(clave: String, valor: String)
fun registrar(clave: String)
}
interface Codec<T : Any> {
fun decodificar(texto: String): T?
fun codificar(valor: T): String
}
class Ajuste<T : Any>(
private val clave: String,
private val porDefecto: T,
private val codec: Codec<T>,
private val almacen: Almacen,
) : ReadWriteProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T =
almacen.leer(clave)?.let(codec::decodificar) ?: porDefecto
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
almacen.escribir(clave, codec.codificar(value))
}
}
Y el proveedor, que es donde ocurre lo interesante: deriva la clave del nombre de la propiedad, la valida y la registra una única vez, al construir el objeto.
fun <T : Any> ajuste(
porDefecto: T,
codec: Codec<T>,
almacen: Almacen,
claveExplicita: String? = null,
) = PropertyDelegateProvider<Any?, Ajuste<T>> { _, propiedad ->
val clave = claveExplicita ?: propiedad.name.aSnakeCase()
require(clave.isNotBlank()) { "clave vacia para ${propiedad.name}" }
almacen.registrar(clave)
Ajuste(clave, porDefecto, codec, almacen)
}
class Preferencias(almacen: Almacen) {
var temaOscuro: Boolean by ajuste(false, Codec.Booleano, almacen)
var tamanoFuente: Int by ajuste(14, Codec.Entero, almacen)
var idioma: String by ajuste("es", Codec.Texto, almacen, claveExplicita = "locale")
}
Lo que se ha ganado no es brevedad. Se ha ganado que la clave y el nombre no puedan divergir, que el valor por defecto viva junto a la declaración, que el conjunto de claves quede registrado sin mantener una lista aparte y que añadir un ajuste sea una línea que no puede olvidarse de nada. La decisión deliberada de exponer claveExplicita es el escape para cuando el nombre en disco ya está fijado por versiones anteriores.
Cuatro errores de diseño que se repiten
- Prometer barato y cobrar caro. Una propiedad parece una lectura de memoria. Si tu
getValuehace entrada y salida, bloquea o puede lanzar, estás mintiendo con la sintaxis; ahí una función con nombre de verbo es más honesta. - Guardar
thisRefen un campo. Ya te lo entregan en cada llamada. Almacenarlo crea un ciclo de referencias entre objeto y delegado y convierte al delegado en una fuga potencial. - Olvidar los hilos. Los accesores pueden invocarse concurrentemente. Si el delegado guarda estado mutable, es tuyo el deber de protegerlo; y si el proveedor devuelve una instancia compartida, ese estado pasa a ser global sin que se note en el código de llamada.
- Ignorar la serialización. El campo real de la clase es el delegado, no el valor. Cualquier herramienta que serialice por campos verá un objeto
Ajustedonde esperaba unBoolean; los serializadores que trabajan sobre propiedades no tienen ese problema, pero conviene comprobarlo antes de descubrirlo en producción.
La razón por la que las propiedades delegadas son tan potentes es exactamente la razón por la que se usan mal: eliminan por completo la señal visual de que ahí está pasando algo. Quien lee prefs.idioma no ve una llamada, no ve un paréntesis, no ve nada; ve lo que su intuición de veinte años de programación le dice que es un campo, y sobre esa intuición construye suposiciones que jamás verbaliza —que leerlo dos veces da lo mismo, que no lanza, que no bloquea, que no cuesta, que se puede leer dentro de un bucle o de un synchronized sin pensarlo—. Todas esas suposiciones son contrato, aunque no estén escritas en ninguna parte, y tu delegado las cumple o las rompe. Por eso el trabajo difícil de escribir un delegado no está en implementar dos funciones sino en decidir a qué categoría pertenece el acceso que estás disfrazando. Hay una frontera bastante nítida: si la operación es determinista, barata, sin efectos observables y sin modos de fallo propios, el disfraz de campo es legítimo y el código de llamada gana claridad de verdad. Si la operación puede tardar, fallar, tocar la red o el disco, cambiar de resultado entre dos lecturas consecutivas o depender del hilo desde el que se llama, entonces el disfraz es una trampa que alguien pisará dentro de seis meses, probablemente en un for sobre diez mil elementos. La técnica no distingue esos dos casos y el compilador tampoco: los dos compilan igual de bien. Solo lo distingue el criterio de quien diseña, y ese criterio es lo que separa un delegado que hace el código mejor de uno que convierte un bug de rendimiento en algo invisible.
- Implementa
Ajustecon un almacén en memoria y comprueba que el valor por defecto se devuelve cuando la clave no existe. - Añade al proveedor la derivación de la clave desde
property.namey verifica con una traza que solo se ejecuta una vez por instancia, no por acceso. - Escribe una variante de solo lectura con
ReadOnlyPropertyy observa qué cambia en la varianza y en el uso. - Mide: cuenta cuánto tarda un millón de lecturas de una propiedad delegada frente a un campo normal, y luego repite consultando
property.returnTypedentro delgetValue. - Añade una operación de invalidación al delegado, expuesta por una función miembro, y discute cómo la llamaría el código de fuera si el delegado no tiene nombre accesible.