wandres.dev
ANOTACIONES Y REFLEXIÓN · mirar el propio código

La reflexión completa: KClass, KFunction y KProperty en acción

Recorrido por la biblioteca `kotlin-reflect`: qué aparece en `KClass` cuando la dependencia está presente, cómo se invocan miembros con `call` y con `callBy` respetando valores por defecto, cómo se leen y escriben propiedades salvando la visibilidad, cómo se localizan anotaciones con retención de ejecución y cómo se instancia dinámicamente resolviendo el constructor primario y sus parámetros.

⏱ 22 min

Con la biblioteca completa en el camino de clases, el mismo objeto de clase que antes solo sabía decir su nombre se convierte en un modelo navegable del programa. Aparecen los miembros con sus tipos declarados, los constructores con sus parámetros y sus valores por defecto, los supertipos con sus argumentos genéricos, las subclases selladas, el objeto único de una declaración de objeto y las anotaciones que sobrevivieron a la compilación. La transformación no es magia: la información estaba grabada desde el principio en un bloque de metadatos que el compilador de Kotlin deja en cada artefacto, y lo que la biblioteca añade es el lector capaz de interpretarlo y de casarlo con la reflexión de la plataforma. Entender que se trata de un lector, y no de una fuente, explica de golpe muchas de sus limitaciones, su peso y su latencia. Este nivel es el del inventario honesto: qué se puede hacer exactamente, con qué sintaxis y con qué garantías.

🎯 Al terminar esta lección sabrás
  • Declarar la dependencia de kotlin-reflect y navegar el modelo que expone KClass.
  • Invocar miembros con call y con callBy, y saber cuándo la segunda forma es obligatoria.
  • Leer y escribir propiedades a través de KProperty y de su variante mutable, ajustando la accesibilidad.
  • Localizar anotaciones de ejecución e instanciar tipos resolviendo su constructor primario.

La dependencia y el modelo que abre

kotlin-reflect se declara como una dependencia más y su versión debe coincidir exactamente con la del compilador; una discordancia produce fallos de lectura del metadato que se manifiestan como excepciones oscuras al inspeccionar clases perfectamente correctas. Una vez presente, el objeto de clase expone el modelo completo.

import kotlin.reflect.full.*

val k = Pedido::class

k.memberProperties          // propiedades propias y heredadas
k.declaredMemberProperties  // solo las declaradas aqui
k.memberFunctions           // funciones propias y heredadas
k.constructors              // todos los constructores
k.primaryConstructor        // el primario, o nulo si no lo hay
k.supertypes                // supertipos con argumentos genericos
k.sealedSubclasses          // subclases directas si la clase es sellada
k.objectInstance            // la instancia unica si es una declaracion de objeto
k.isData                    // marcas del compilador conservadas en el metadato

Conviene distinguir dos ejes que se confunden a menudo. El primero separa lo declarado de lo heredado: los miembros declarados son los escritos en el cuerpo de esa clase, y los miembros a secas incluyen los que llegan por herencia. El segundo separa lo extensivo de lo intrínseco: las variantes que mencionan extensiones devuelven miembros con receptor, que exigen dos argumentos al invocarse en lugar de uno. Escoger la colección equivocada produce listas incompletas o listas con miembros que fallan al llamarse, y ninguno de los dos errores da un mensaje que lo explique.

Llamar: la forma directa y la forma por parámetros

Una función reflexiva se invoca con call, pasando el receptor como primer argumento cuando se trata de un miembro. La firma es variádica y sin tipos, de modo que toda la verificación que el compilador haría en una llamada normal se traslada a una comprobación en ejecución que lanza si algo no encaja.

class Servicio {
    fun saludar(nombre: String, entusiasmo: Int = 1): String =
        "hola " + nombre + "!".repeat(entusiasmo)
}

val f = Servicio::class.memberFunctions.first { it.name == "saludar" }
val r = f.call(Servicio(), "ana", 3)

La forma directa tiene un límite serio: no sabe nada de los valores por defecto. Si omites un argumento que tiene valor por defecto en la declaración, la llamada falla por número de argumentos, porque en la plataforma los valores por defecto se implementan con un método puente y una máscara de bits que call no construye. Para eso existe la segunda forma, que recibe un mapa de parámetros a valores y omite los ausentes que tengan valor por defecto.

val porNombre = f.parameters.associateBy { it.name }
val instancia = f.parameters.first()   // el receptor es el parametro cero

val r2 = f.callBy(
    mapOf(
        instancia to Servicio(),
        porNombre["nombre"]!! to "ana",
        // entusiasmo se omite y toma su valor por defecto
    )
)

Esta diferencia es la razón por la que las bibliotecas de deserialización usan siempre la segunda forma: una carga útil que no traiga un campo debe activar el valor por defecto de la clase, no fallar ni escribir un cero. El precio es que construir el mapa exige inspeccionar los parámetros, y esa inspección es una de las partes más costosas del proceso.

flowchart TD
A[Quiero invocar un miembro por reflexion] --> B[Conozco todos los argumentos]
A --> C[Puede faltar alguno con valor por defecto]
B --> D[call con argumentos posicionales]
C --> E[callBy con mapa de parametros]
D --> F[Fallo en ejecucion si el numero o el tipo no encaja]
E --> G[Omite los ausentes y activa los valores por defecto]

Leer y escribir propiedades

Una propiedad reflexiva se comporta como una función de acceso con receptor. La variante de solo lectura ofrece la obtención del valor; la variante mutable añade la escritura. Cuando el miembro no es público, hay que abrir la accesibilidad explícitamente, y esa apertura afecta al elemento subyacente de la plataforma, no al modelo de Kotlin.

class Cuenta(val alias: String) {
    private var saldo: Long = 0
}

val c = Cuenta("ana")

val propAlias = Cuenta::class.memberProperties.first { it.name == "alias" }
println(propAlias.get(c))

val propSaldo = Cuenta::class.memberProperties
    .filterIsInstance<KMutableProperty1<Cuenta, Long>>()
    .first { it.name == "saldo" }

propSaldo.isAccessible = true    // salta la visibilidad
propSaldo.set(c, 1_000)

Abrir la accesibilidad merece una advertencia de diseño, no solo de seguridad. Un miembro privado no forma parte del contrato publicado de la clase: puede desaparecer, cambiar de nombre o cambiar de tipo en una versión menor sin que nadie considere eso una ruptura. El código que lo escribe por reflexión depende de un detalle que su autor jamás prometió mantener, y ese acoplamiento no aparece en ninguna firma ni lo detecta ninguna herramienta.

Anotaciones e instanciación dinámica

Las anotaciones con retención de ejecución aparecen en la colección correspondiente de cada elemento, y la biblioteca ofrece una búsqueda tipada que evita el filtrado manual. Recordar aquí el destino de uso del nivel anterior es imprescindible: una etiqueta que fue al campo no aparecerá al inspeccionar la propiedad.

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Columna(val nombre: String)

class Pedido(@property:Columna("id_pedido") val id: Long)

val col = Pedido::class.memberProperties
    .first { it.name == "id" }
    .findAnnotation<Columna>()

println(col?.nombre)   // id_pedido

La instanciación dinámica es el uso más delicado y el que más veces se hace mal. Cuando todos los parámetros tienen valor por defecto existe un atajo que construye la instancia sin argumentos; en el caso general hay que resolver el constructor primario, emparejar sus parámetros con los valores disponibles por nombre y por tipo, y llamarlo con la forma por parámetros para que los ausentes tomen su valor por defecto.

fun <T : Any> construir(k: KClass<T>, datos: Map<String, Any?>): T {
    val ctor = k.primaryConstructor ?: error("sin constructor primario")
    val args = ctor.parameters
        .filter { it.name in datos || !it.isOptional }
        .associateWith { datos[it.name] }
    return ctor.callBy(args)
}
⚠️
El tipo del parámetro no se comprueba hasta la llamada

El mapa de argumentos es de tipos sin relacionar entre sí, de modo que un valor de tipo incorrecto no produce error hasta el momento exacto de la invocación, y el mensaje resultante habla de la firma de la plataforma y no de tu modelo. Toda instanciación dinámica necesita su propia capa de validación y de mensajes, porque la que trae la biblioteca es un diagnóstico para quien escribió la biblioteca.

La reflexión completa es un intérprete de metadatos, no una ventana al programa

Hay una imagen mental que conviene sustituir, porque explica a la vez la potencia y las limitaciones de todo lo anterior. La reflexión no observa el programa en ejecución: lee una descripción del programa que el compilador dejó escrita y la interpreta. En la máquina virtual de Java hay dos descripciones superpuestas, la del propio formato de clases, que la plataforma sabe leer desde siempre, y la de Kotlin, guardada en una anotación de metadatos que contiene una estructura binaria con la información que el formato de clases no sabe representar: nulabilidad, propiedades frente a campos, valores por defecto, receptores de extensión, tipos suspendidos, clases selladas, alias y variancia. kotlin-reflect es esencialmente el analizador de esa segunda descripción más el pegamento que la casa con la primera, y de ahí salen todas sus propiedades características. Es pesado porque incluye ese analizador entero. Es lento en el arranque porque debe decodificar la descripción antes de responder la primera pregunta sobre cada clase. Falla con versiones cruzadas porque el formato de la descripción evoluciona con el compilador. Se rompe con la ofuscación porque el ofuscador reescribe una de las dos descripciones y no siempre la otra. Y no existe en objetivos donde no hay ni formato de clases ni carga dinámica. Vistas así, sus limitaciones dejan de parecer defectos de implementación y se revelan como consecuencias necesarias de lo que es: un intérprete de metadatos que llega tarde a un trabajo que ya se hizo. Todo lo que la reflexión te dice, alguien lo supo con certeza en el momento de compilar. La pregunta que el nivel siguiente convierte en decisión de arquitectura es por qué esperar hasta la ejecución para volver a preguntarlo.

⚔️ Navega el modelo
  1. Añade kotlin-reflect, imprime miembros declarados y heredados de una clase con herencia y explica la diferencia entre ambas listas.
  2. Invoca una función con parámetro por defecto con la forma directa, observa el fallo y resuélvelo con la forma por parámetros.
  3. Escribe y lee una propiedad privada abriendo la accesibilidad, y anota qué garantía de compatibilidad estás perdiendo al hacerlo.
  4. Coloca la misma anotación con destino de campo y con destino de propiedad y comprueba desde cuál de los dos elementos es visible.
  5. Implementa la función de construcción dinámica del ejemplo y añádele mensajes de error propios para tipo incorrecto y parámetro obligatorio ausente.