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

La reflexión ligera: lo que puedes hacer sin la biblioteca completa

Inventario riguroso de la capacidad reflexiva que la biblioteca estándar ofrece sin dependencias añadidas: referencias de clase, nombres simples y cualificados, comprobación de pertenencia, referencias a función y a propiedad, y el puente hacia la reflexión de la plataforma. Por qué este subconjunto minúsculo resuelve la mayoría de los problemas que la gente cree que exigen `kotlin-reflect`, y qué patrón de diseño lo hace posible.

⏱ 19 min

Existe una confusión persistente que conviene deshacer cuanto antes: el tipo KClass forma parte de la biblioteca estándar, pero casi todo lo que sabe hacer no. Cuando escribes el operador de referencia de clase sobre un tipo, el compilador te entrega un objeto perfectamente real que puedes comparar, usar como clave y pasar por ahí sin haber añadido una sola dependencia; en cuanto le pides la lista de sus propiedades, ese mismo objeto lanza una excepción explicando que falta una biblioteca que nunca declaraste. Esa frontera, invisible en el editor y visible solo en ejecución, separa dos mundos con costes radicalmente distintos. El de este lado es gratis, funciona en todos los objetivos de compilación, sobrevive a la ofuscación y no penaliza el arranque. El del otro lado pesa megabytes y no existe fuera de la máquina virtual de Java. La pregunta interesante no es cómo cruzar la frontera, sino cuántas veces creemos necesitar cruzarla sin necesitarlo realmente.

🎯 Al terminar esta lección sabrás
  • Enumerar con precisión qué miembros de KClass funcionan sin la biblioteca completa y cuáles fallan en ejecución.
  • Usar referencias a función y a propiedad para obtener nombres e invocaciones sin coste reflexivo.
  • Distinguir la reflexión de Kotlin de la reflexión de la plataforma y saber cuándo cada puente es legítimo.
  • Diseñar registros y despachadores tipados que sustituyan la introspección por construcción explícita.

Lo que la biblioteca estándar ya te da

El operador de referencia de clase produce un KClass en todos los objetivos de compilación. Sin kotlin-reflect en el camino de clases, ese objeto conserva un núcleo pequeño pero suficiente: identidad estable para comparar y para usar como clave, nombre simple, nombre cualificado y comprobación de pertenencia de una instancia. Todo lo demás, es decir, miembros, constructores, supertipos, subclases selladas y anotaciones, exige la biblioteca completa y falla en ejecución si no está.

val k = Pedido::class

println(k.simpleName)          // Pedido
println(k.qualifiedName)       // com.ejemplo.Pedido
println(k.isInstance(algo))    // comprobacion de pertenencia
println(k == otro::class)      // identidad estable, apta como clave

val j = k.java                 // puente hacia la reflexion de la plataforma

El nombre cualificado merece un matiz importante: no está disponible para clases locales ni anónimas, y en objetivos distintos de la máquina virtual de Java puede no estarlo en absoluto. El nombre simple es más portátil, pero también más ambiguo. Cuando el nombre se usa como clave de serialización o como identificador estable en un protocolo, ninguno de los dos es una buena idea, porque ambos cambian con un renombrado y desaparecen con la ofuscación.

Sobre una instancia, la propiedad de clase equivale a la referencia de clase de su tipo dinámico, no del estático. Esa distinción es exactamente la misma que separa el despacho dinámico del estático, y es la fuente de sorpresas cuando se compara la clase de un valor recibido por una interfaz.

val f: Formateador = FormateadorJson()
println(f::class.simpleName)          // FormateadorJson, el tipo dinamico
println(Formateador::class.simpleName) // Formateador, el tipo estatico

Referencias que no reflexionan

Las referencias a función y a propiedad tienen fama de reflexivas y en su uso más común no lo son. Cuando escribes una referencia a una función de nivel superior y la invocas, el compilador genera una clase que implementa el tipo función correspondiente y llama al método directamente: no hay búsqueda por nombre, ni comprobación de accesibilidad, ni empaquetado de argumentos en un arreglo. El nombre de la declaración también está disponible sin la biblioteca completa, porque el compilador lo graba como una cadena constante en la clase generada.

fun normalizar(t: String): String = t.trim().lowercase()

val ref = ::normalizar
println(ref.name)        // normalizar, sin kotlin-reflect
println(ref("  Hola "))  // llamada directa, sin coste reflexivo

data class Cliente(val alias: String, val activo: Boolean)

val prop = Cliente::alias
println(prop.name)               // alias
println(prop.get(unCliente))     // acceso directo al metodo de lectura

Ese detalle tiene una consecuencia práctica muy grande: una referencia a propiedad es una forma segura y verificada por el compilador de nombrar un miembro. Sustituir una cadena literal por una referencia convierte un error de ejecución por nombre mal escrito en un error de compilación, y además sobrevive a los renombrados del entorno de desarrollo. Es la técnica que hay detrás de la mayoría de las interfaces de consulta y de validación bien diseñadas.

Lo que sí exige la biblioteca completa es todo lo que vaya más allá del nombre y de la invocación directa: la lista de parámetros, el tipo de retorno, las anotaciones de la declaración, la invocación por nombre de argumento y el acceso a miembros privados. La frontera es sorprendentemente nítida.

flowchart TD
A[Necesito informacion sobre un tipo] --> B[Identidad, nombre o pertenencia]
A --> C[Nombre e invocacion de un miembro conocido]
A --> D[Lista de miembros, parametros o anotaciones]
B --> E[Biblioteca estandar: coste cero]
C --> F[Referencias del compilador: coste cero]
D --> G[kotlin-reflect: coste real]
G --> H[Antes de aceptarlo, prueba con un registro explicito]

El registro tipado: el patrón que casi siempre basta

La mayoría de los usos de reflexión que aparecen en el código de aplicación se reducen a una necesidad concreta: dado un valor de tipo desconocido, encontrar el colaborador que sabe tratarlo. La reflexión completa resuelve eso buscando; un registro tipado lo resuelve declarando. La diferencia de coste es de varios órdenes de magnitud y la diferencia de seguridad es total, porque el registro falla en el momento del registro y la búsqueda falla en el momento del uso.

class Registro {
    private val porTipo = mutableMapOf<KClass<*>, (Any) -> String>()

    fun <T : Any> registrar(k: KClass<T>, f: (T) -> String) {
        @Suppress("UNCHECKED_CAST")
        porTipo[k] = f as (Any) -> String
    }

    fun formatear(v: Any): String =
        porTipo[v::class]?.invoke(v) ?: error("sin formateador para " + v::class.simpleName)
}

val r = Registro().apply {
    registrar(Pedido::class) { "pedido " + it.id }
    registrar(Cliente::class) { "cliente " + it.alias }
}

La variante que devuelve el colaborador más específico requiere una decisión adicional, porque la clave es el tipo dinámico exacto y no cubre subtipos. Resolverlo recorriendo la jerarquía exigiría la biblioteca completa, de modo que la salida ligera consiste en registrar explícitamente cada tipo concreto o en pedir a cada colaborador que declare por sí mismo lo que sabe tratar.

interface Formateador<T : Any> {
    val tipo: KClass<T>
    fun formatear(v: T): String
}

class RegistroDeclarado(colaboradores: List<Formateador<*>>) {
    private val porTipo = colaboradores.associateBy { it.tipo }

    @Suppress("UNCHECKED_CAST")
    fun formatear(v: Any): String =
        (porTipo[v::class] as Formateador<Any>?)?.formatear(v)
            ?: error("sin formateador para " + v::class.simpleName)
}

Este patrón usa exactamente la parte gratuita de la reflexión: la clase como clave y nada más. Cuando la jerarquía es cerrada, ni siquiera hace falta el mapa, porque una clase sellada y una expresión de selección exhaustiva hacen el mismo trabajo con verificación completa en compilación y sin ninguna búsqueda en ejecución. El registro solo gana cuando la jerarquía es abierta y los colaboradores llegan de módulos que no se conocen entre sí.

🪞

Identidad sin introspección

Comparar clases y usarlas como clave no es reflexión completa: es un identificador de tipo con coste de comparación de referencias.

🔗

Nombres verificados

Una referencia a propiedad sustituye una cadena mágica por un símbolo que el compilador comprueba y el refactorizador actualiza.

🧭

Declarar en vez de buscar

Un registro poblado en el arranque hace en microsegundos lo que la búsqueda reflexiva hace en milisegundos, y falla antes.

🚪

El puente a la plataforma

La conversión hacia la clase de Java abre la reflexión de la máquina virtual, que es otra dependencia distinta y con otras reglas.

💡
El error que revela la frontera

Si una llamada reflexiva falla con un mensaje que menciona que la implementación no está disponible, no has escrito mal el código: has cruzado la frontera sin declarar la dependencia. Ese mensaje es en realidad un buen diagnóstico de diseño, porque aparece exactamente en el punto donde el coste empieza.

La reflexión es casi siempre la respuesta a una pregunta mal planteada

Vale la pena mirar de frente por qué el subconjunto gratuito alcanza tantas veces, porque la explicación no es que la reflexión completa sea innecesaria sino que la mayoría de los problemas para los que se invoca son problemas de acoplamiento disfrazados de problemas de introspección. Cuando alguien escribe código que recorre las propiedades de un objeto para construir una representación, lo que está diciendo en realidad es que no quiere escribir el mapeo a mano; cuando busca por nombre una clase para instanciarla, lo que está diciendo es que no quiere declarar la relación entre el identificador y el constructor. En ambos casos la reflexión no resuelve el problema, lo aplaza y lo traslada de fase: el trabajo que no se hizo en compilación se hará en cada arranque, y el error que se habría visto en el editor se verá en producción con un tipo que nadie previó. El subconjunto ligero de Kotlin no es una versión mutilada de la reflexión completa, es una afirmación sobre qué parte de la introspección es realmente irreducible. Saber el tipo dinámico de un valor lo es, porque ningún diseño puede eliminar la necesidad de distinguir en ejecución algo que en ejecución es distinto. Saber el nombre de una declaración que ya tienes en la mano también lo es, porque el nombre es un dato del programa y no del objeto. Enumerar miembros y llamarlos por cadena, en cambio, casi nunca lo es: es la manera de no escribir un mapa. Y un mapa escrito a mano, o mejor, generado en compilación, gana en todo lo que importa salvo en pulsaciones de teclado. Esta es también la razón por la que el ecosistema fue capaz de moverse hacia la generación de código sin que el lenguaje perdiera expresividad: nunca dependió de la reflexión para las cosas que solo la reflexión puede hacer, sino para las que se podían decidir antes.

⚔️ Mide la frontera
  1. Escribe un programa sin kotlin-reflect que imprima nombre simple, nombre cualificado y comprobación de pertenencia de una clase, y añade después una llamada a la lista de miembros para leer la excepción exacta.
  2. Comprueba que el nombre de una referencia a función está disponible sin la biblioteca completa y explica de dónde sale esa cadena.
  3. Sustituye en algún código propio una cadena literal que nombre una propiedad por una referencia a propiedad y renombra el miembro para verificar el efecto.
  4. Implementa el registro tipado del ejemplo y compáralo con una versión basada en clase sellada y selección exhaustiva.
  5. Determina en qué casos tu registro necesita la clase como clave y en cuáles bastaría con una interfaz declarada por cada colaborador.