wandres.dev
PROPIEDADES DELEGADAS · interceptar el acceso

El mecanismo: qué ocurre cuando escribes by

La delegación de propiedades por dentro: la convención getValue y setValue, el campo oculto y el array de metadatos que genera el compilador, el gancho provideDelegate y el coste real de cada acceso.

⏱ 14 min

Una propiedad de Kotlin no es un campo: es un par de accesores con un campo opcional detrás. Esa distinción, que en los primeros niveles parecía burocracia terminológica, es exactamente lo que hace posible la delegación. Cuando escribes by, le pides al compilador que no genere el cuerpo de los accesores, sino que los reenvíe a otro objeto. Desde ese momento leer o escribir la propiedad deja de ser un acceso a memoria y pasa a ser una llamada a función que tú controlas: puedes cachear, validar, registrar, cargar de una base de datos o negarte a devolver nada. Este nivel entero trata de ese punto de intercepción, y empieza donde debe empezar: en el código que el compilador escribe en tu lugar.

🎯 Al terminar esta lección sabrás
  • Entender que by es una convención estructural, no una interfaz que haya que implementar.
  • Reconstruir el código generado: el campo oculto y el array de metadatos de propiedad.
  • Usar provideDelegate para interceptar la creación del delegado y fallar pronto.
  • Conocer el coste real de una propiedad delegada frente a un campo normal.

La convención, no la interfaz

Para que val x by d compile, d solo necesita ofrecer una función operator fun getValue(thisRef, property) cuyo tipo de retorno sea compatible con el de x. Si la propiedad es var, hace falta además operator fun setValue(thisRef, property, value). No hay ninguna interfaz obligatoria, ninguna anotación y ningún registro: la resolución es estructural y ocurre en tiempo de compilación, igual que con plus o invoke.

import kotlin.reflect.KProperty

class Trazador(private var valor: String) {
    operator fun getValue(thisRef: Any?, propiedad: KProperty<*>): String {
        println("get ${propiedad.name} sobre $thisRef")
        return valor
    }

    operator fun setValue(thisRef: Any?, propiedad: KProperty<*>, nuevo: String) {
        println("set ${propiedad.name} = $nuevo")
        valor = nuevo
    }
}

class Perfil {
    var nombre: String by Trazador("anonimo")
}

Dos consecuencias que casi nadie explota. La primera: los operadores pueden llegar como funciones de extensión, y por eso puedes delegar en un Map que no sabe absolutamente nada de delegación —la stdlib le añade el getValue desde fuera—. La segunda: el tipo de thisRef es un contrato de ubicación. Si lo declaras como Perfil, ese delegado solo compilará dentro de Perfil o de sus subclases; si quieres permitirlo también en propiedades de nivel superior o locales, thisRef debe aceptar null, porque ahí el compilador pasa null.

Las interfaces ReadOnlyProperty y ReadWriteProperty de kotlin.properties no son un requisito: son un atajo que ya declara esos operadores por ti, con la ventaja de dar un tipo nombrable al delegado.

El código que genera el compilador

Esta es la parte que convierte la delegación de truco en herramienta. Para el Perfil de arriba, el compilador produce algo equivalente a esto:

class Perfil {
    // un campo oculto por propiedad delegada y por instancia
    private val nombre$delegate: Trazador = Trazador("anonimo")

    var nombre: String
        get() = nombre$delegate.getValue(this, $$delegatedProperties[0])
        set(v) = nombre$delegate.setValue(this, $$delegatedProperties[0], v)

    // sintetico, estatico, compartido por todas las instancias
    // $$delegatedProperties: Array<KProperty<*>>
}

De aquí se deduce todo lo demás. El objeto delegado se crea una vez por instancia en el constructor, no en el primer acceso. El array $$delegatedProperties es estático y se construye una sola vez por clase con objetos ligeros de tipo PropertyReference1Impl. Y cada lectura es un acceso a campo más una llamada virtual, más lo que el delegado decida hacer.

flowchart LR
A[perfil.nombre] --> B[getter generado]
B --> C[campo oculto nombre delegate]
B --> D[metadato KProperty del array estatico]
C --> E[getValue thisRef y propiedad]
D --> E
E --> F[valor devuelto]
style E fill:#a6e3a1,color:#11111b

Matiz que ahorra dependencias: leer property.name no requiere kotlin-reflect, porque el nombre viaja incrustado en el PropertyReference que genera el compilador. En cambio, pedir property.returnType, sus anotaciones o su visibilidad sí exige la librería de reflexión completa en la JVM, y eso pasa de una lectura de campo a un coste apreciable. Un delegado bien escrito consulta la reflexión pesada como mucho una vez, nunca en cada acceso.

Las propiedades delegadas locales siguen el mismo esquema dentro del cuerpo de la función, con dos diferencias: thisRef llega como null y el delegado se construye cada vez que la ejecución pasa por la declaración. Ese segundo punto explica un desconcierto habitual: un by lazy declarado dentro de un bucle no cachea nada entre iteraciones, porque cada vuelta fabrica un objeto perezoso nuevo.

En las propiedades de nivel superior, en cambio, el campo oculto es estático y el delegado se crea al inicializar el fichero. Eso las convierte en el sitio natural para una cache de proceso y, simultáneamente, en un riesgo de inicialización temprana si el constructor del delegado hace trabajo de verdad.

provideDelegate: interceptar la creación

A veces el momento interesante no es el acceso, sino la construcción. Si la expresión que sigue a by ofrece operator fun provideDelegate(thisRef, property), el compilador la llama al inicializar el campo oculto y guarda su resultado como delegado. Es el gancho para fallar pronto, para derivar la clave a partir del nombre una sola vez y para registrar la propiedad en algún contenedor.

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

class Columna<T>(val clave: String) : ReadWriteProperty<Entidad, T> {
    override fun getValue(thisRef: Entidad, property: KProperty<*>): T =
        thisRef.datos[clave] as T

    override fun setValue(thisRef: Entidad, property: KProperty<*>, value: T) {
        thisRef.datos[clave] = value
    }
}

class ColumnaFactory<T>(private val claveExplicita: String? = null) {
    operator fun provideDelegate(thisRef: Entidad, prop: KProperty<*>): Columna<T> {
        val clave = claveExplicita ?: prop.name
        require(clave.all { it.isLowerCase() || it == '_' }) {
            "clave invalida para ${prop.name}: $clave"
        }
        thisRef.esquema += clave          // registro en el contenedor
        return Columna(clave)
    }
}

fun <T> columna(clave: String? = null) = ColumnaFactory<T>(clave)

La validación se ejecuta al construir la entidad, no la primera vez que alguien lea la propiedad tres capas más abajo. La stdlib expone PropertyDelegateProvider para lo mismo cuando prefieres una interfaz con nombre en vez de una clase ad hoc.

Coste, límites y trampas

Una propiedad delegada no puede ser const, no puede ser lateinit y no puede llevar accesores propios: el delegado es el accesor. Tampoco puede declararse en una interfaz, porque allí no hay campo donde guardar el delegado. Sí puede ser local a una función, sobrescribir una propiedad open y aparecer en el nivel superior de un fichero.

En coste, cada propiedad delegada añade una referencia por instancia y un objeto delegado por instancia. Un by lazy en una clase que instancias un millón de veces son un millón de objetos Lazy además de un millón de valores; conviene saberlo antes de que lo diga el perfilador.

Y una trampa clásica de inicialización: si una clase base llama a una propiedad open desde su constructor y la subclase la delega, el campo oculto del delegado todavía no existe en ese instante. El resultado no es un valor por defecto sino una excepción, y el rastro apunta a un sitio que parece inocente.

Hay una consecuencia más, silenciosa y muy real en producción: el campo que existe de verdad en la clase es el delegado, no el valor. Cualquier herramienta que serialice o inspeccione por campos —serialización de la plataforma, librerías basadas en reflexión de campos, volcados de memoria— verá un objeto delegado donde esperaba un String. Los serializadores que trabajan sobre propiedades no sufren esto, pero conviene comprobarlo antes de que lo compruebe un cliente.

📥

getValue

Se ejecuta en cada lectura y recibe la instancia dueña más el metadato de la propiedad. Es el único gancho obligatorio.

📤

setValue

Solo se exige para var. Su ausencia es lo que hace que un delegado sea de solo lectura, sin ninguna anotación de por medio.

🏭

provideDelegate

Se ejecuta una vez, al construir el objeto. El sitio correcto para validar, derivar claves y registrar en un contenedor.

🏷️

KProperty

El nombre es gratis; el tipo, las anotaciones y la visibilidad cuestan reflexión completa. Consúltalos una vez y guárdalos.

Delegar no es reutilizar código: es tomar el control del punto de acceso

La lectura superficial de las propiedades delegadas dice que sirven para no repetir el mismo getter en varias clases, y esa lectura se queda en la mitad. Lo que by te entrega de verdad es la soberanía sobre el punto de acceso: el instante exacto en que alguien lee o escribe un estado, con dos datos que ningún getter escrito a mano recibe gratis —la instancia dueña y el metadato de la propiedad—. Ese par es el que permite que un delegado se autoconfigure a partir del nombre de la propiedad, que se registre solo en un esquema, que sepa a qué objeto pertenece para invalidar una cache o para emitir un evento, y que decida su comportamiento leyendo anotaciones. Casi todos los frameworks serios de Kotlin viven ahí: Exposed convierte propiedades en columnas, Koin resuelve dependencias, las preferencias de Android se enlazan a claves, y ninguno pide que heredes de nada. La diferencia con la reflexión clásica de Java es de naturaleza, no de grado: la reflexión inspecciona desde fuera y en tiempo de ejecución, con coste y sin garantías de tipo, mientras que un delegado está resuelto en tiempo de compilación, tipado, comprobado y con acceso a exactamente la información que necesita. Por eso el patrón escala: puedes construir una capa entera de infraestructura —persistencia, observabilidad, validación, inyección— sin que el modelo de dominio herede una sola clase ni implemente una sola interfaz. El precio es que tú te vuelves responsable de la semántica del acceso, incluida la de los hilos, y esa responsabilidad es la que se cobra en las cuatro lecciones siguientes.

⚔️ Desmonta la delegación
  1. Escribe un delegado Trazador que imprima cada get y cada set con property.name, y aplícalo a dos propiedades de la misma clase.
  2. Compila y mira el bytecode o el Kotlin descompilado desde tu IDE: localiza el campo oculto y el array $$delegatedProperties. Comprueba que el array es estático y compartido.
  3. Cambia el tipo de thisRef a la clase concreta y observa qué error aparece si intentas usar el delegado en una propiedad de nivel superior.
  4. Añade un provideDelegate que lance una excepción si el nombre de la propiedad empieza por guion bajo, y confirma que falla al construir el objeto y no al leerlo.