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

Definir anotaciones: el metadato que el compilador transporta

Anatomía completa de la declaración de anotaciones en Kotlin: qué tipos admite su constructor y por qué esa lista es tan corta, cómo delimita posiciones legales `@Target` y hasta qué fase del ciclo de vida sobrevive el metadato según `@Retention`, qué resuelve realmente `@Repeatable` y cómo se dirige la etiqueta al elemento exacto cuando una sola propiedad genera campo, accesores y parámetro de constructor a la vez.

⏱ 20 min

Una anotación no hace nada. Esa es su definición más honesta y también el origen de casi todos los malentendidos que la rodean. Declarar una clase de anotación no añade comportamiento, no genera código y no cambia la semántica de ninguna llamada: crea una etiqueta que el compilador acepta escribir en determinadas posiciones y que, si se lo pides, deja grabada en el artefacto compilado para que alguien la lea más tarde. Todo el valor aparece después, cuando un procesador en tiempo de compilación o una biblioteca en tiempo de ejecución encuentra la etiqueta y decide qué hacer con ella. Kotlin, sin embargo, no trata ese trozo de metadato con desdén: le impone un sistema de tipos propio para sus parámetros, un vocabulario cerrado de posiciones legales, una política explícita de supervivencia y un mecanismo de desambiguación que en Java no hace falta porque en Java una propiedad no es cuatro elementos a la vez. Declarar bien una anotación es tomar esas cuatro decisiones antes de escribir la primera línea del procesador que la consumirá.

🎯 Al terminar esta lección sabrás
  • Declarar anotaciones con parámetros válidos y entender por qué la lista de tipos admitidos es tan restringida.
  • Delimitar las posiciones legales con @Target y elegir la política de supervivencia con @Retention.
  • Aplicar @Repeatable y reconocer la representación contenedora que aparece en la plataforma.
  • Dirigir la etiqueta al elemento exacto cuando una propiedad produce campo, accesores y parámetro de constructor.

El vocabulario de una anotación

Una anotación se declara como una clase con el modificador correspondiente, y sus parámetros se escriben en el constructor primario como propiedades inmutables. El cuerpo no admite funciones ni propiedades con respaldo, porque una anotación no es un objeto con conducta sino un registro de valores constantes; sí admite miembros anidados, como un objeto compañero que aloje constantes de apoyo.

enum class Nivel { BAJO, MEDIO, ALTO }

annotation class Auditada(
    val motivo: String,
    val nivel: Nivel = Nivel.MEDIO,
    val etiquetas: Array<String> = [],
    val formato: KClass<out Formateador> = FormateadorPorDefecto::class,
)

La lista de tipos admitidos es corta y cerrada: tipos primitivos, cadenas, enumeraciones, referencias de clase, otras anotaciones y arreglos de cualquiera de los anteriores. Nada más. No se admiten tipos anulables, ni colecciones, ni clases de datos propias, ni valores calculados. La razón es que el valor de una anotación debe quedar codificado literalmente en el artefacto compilado, en una zona pensada para constantes, y debe poder reconstruirse sin ejecutar nada del programa. Un valor por defecto que fuera el resultado de una llamada obligaría al lector del metadato a ejecutar código del módulo anotado antes de saber qué dice la etiqueta, y eso invertiría por completo la relación entre el metadato y su consumidor.

Los valores se escriben siempre como constantes de compilación. Los arreglos aceptan la sintaxis de corchetes en la posición de argumento, las referencias de clase se escriben con el sufijo de clase y las anotaciones anidadas se construyen como si fueran llamadas ordinarias, aunque no lo sean.

annotation class Columna(val nombre: String, val nulable: Boolean = false)
annotation class Tabla(val nombre: String, val indices: Array<Columna> = [])

@Tabla(nombre = "pedido", indices = [Columna("cliente"), Columna("fecha", true)])
class Pedido

Posición y supervivencia

Sin restricción explícita, una anotación es aplicable en casi cualquier declaración, y eso rara vez es lo que quieres: una etiqueta pensada para propiedades que alguien puede escribir sobre una clase entera es una fuente silenciosa de metadatos que ningún procesador leerá. El modificador @Target recibe las posiciones admitidas del catálogo de destinos, y el compilador rechaza cualquier uso fuera de esa lista con un error, no con una advertencia.

@Retention decide hasta dónde llega el metadato. La retención de fuente hace que el compilador lo descarte tras usarlo: es la política correcta para todo lo que consuma un procesador de anotaciones o un verificador estático, porque nada de eso necesita leerse en ejecución. La retención binaria lo conserva en el artefacto pero no lo expone a la reflexión. La retención de ejecución, que es la política por defecto, lo conserva y además lo hace legible en tiempo de ejecución, y es la única que obliga a arrastrar la maquinaria de reflexión más adelante. Elegir la retención por defecto sin pensarlo es la manera más habitual de convertir un metadato de compilación en una dependencia de ejecución.

flowchart TD
A[Anotacion escrita en el codigo] --> B[Retencion de fuente: el compilador la descarta tras usarla]
A --> C[Retencion binaria: queda grabada pero invisible a la reflexion]
A --> D[Retencion de ejecucion: queda grabada y legible en runtime]
B --> E[Consumidor: procesador de anotaciones o linter]
C --> F[Consumidor: herramientas que leen el artefacto]
D --> G[Consumidor: biblioteca de reflexion]

Hay dos restricciones cruzadas que conviene recordar porque el compilador las impone sin margen: una anotación aplicable a expresiones solo puede tener retención de fuente, ya que una expresión no deja rastro propio en el artefacto compilado, y lo mismo ocurre con las aplicables a ficheros cuando pretenden sobrevivir más allá de la compilación.

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class Generar

@Target(AnnotationTarget.EXPRESSION)
@Retention(AnnotationRetention.SOURCE)   // obligatorio para expresiones
annotation class NoOptimizar

Repetir la misma etiqueta

Por defecto una anotación solo puede aparecer una vez sobre el mismo elemento. Cuando la etiqueta describe una relación que admite varias instancias, como una regla de validación o una asociación con un esquema, marcarla con @Repeatable permite escribirla tantas veces como haga falta.

@Repeatable
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Regla(val expresion: String, val mensaje: String)

class Cuenta(
    @Regla("no vacio", "el alias es obligatorio")
    @Regla("longitud minima 3", "el alias es demasiado corto")
    val alias: String,
)

La repetición no es gratuita en la plataforma. En la máquina virtual de Java no existe la noción de anotación repetida: lo que existe es una anotación contenedora que guarda un arreglo de las repeticiones, y el compilador la sintetiza por ti. Eso importa cuando el consumidor lee el metadato con las herramientas de Java en lugar de con las de Kotlin, porque allí encontrará el contenedor y no las repeticiones sueltas.

// Lo que escribes                    // Lo que ve la plataforma
// @Regla("a") @Regla("b")            // @Regla.Container([@Regla("a"), @Regla("b")])

Antes de alcanzar la repetibilidad conviene preguntarse si el problema no se resuelve mejor con un parámetro de arreglo. Una sola etiqueta con un arreglo de valores es más simple de leer para el consumidor, no depende del contenedor sintetizado y se comporta igual en todos los objetivos; la repetición gana cuando cada instancia lleva varios parámetros que deben agruparse, porque entonces el arreglo obligaría a declarar una anotación anidada de todos modos.

Cuando una propiedad son cuatro elementos

Aquí aparece la diferencia real con Java. Una propiedad de Kotlin puede compilar simultáneamente a un campo, a un método de lectura, a un método de escritura y, si está en el constructor primario, a un parámetro. Escribir una etiqueta delante de la propiedad deja abierta la pregunta de a cuál de esos elementos se refiere. Kotlin resuelve la ambigüedad con un orden de preferencia implícito, que atiende primero al parámetro, después a la propiedad y por último al campo, y con un prefijo explícito que permite decidirlo a mano.

class Usuario(
    @field:Serializado("user_name")     // al campo
    @get:JvmName("nombreVisible")       // al metodo de lectura
    @param:NoNulo                       // al parametro del constructor
    val nombre: String,
) {
    @setparam:NoVacio                   // al parametro del metodo de escritura
    var apodo: String = ""
        @get:Interno get() = field       // al metodo de lectura del apodo
}
🏷️

property

Apunta a la propiedad de Kotlin. Solo lo ve la reflexión de Kotlin, nunca la de Java: es la elección correcta para metadatos propios del lenguaje.

🧱

field

Apunta al campo generado. Es lo que buscan casi todas las bibliotecas escritas en Java, que no saben nada de propiedades.

🔁

get y set

Apuntan a los accesores. Imprescindibles cuando la biblioteca inspecciona métodos y no estado.

📥

param y setparam

Apuntan al parámetro del constructor y al del método de escritura. Son el destino natural de las validaciones de entrada.

⚠️
El destino equivocado no da error, da silencio

Si escribes una etiqueta sin prefijo sobre una propiedad de constructor, irá al parámetro por el orden de preferencia. Un lector que busque en el campo no encontrará nada, y no habrá error de compilación ni excepción en ejecución: simplemente, la configuración no se aplicará. Es uno de los fallos más caros de diagnosticar de todo el ecosistema, porque el síntoma es una omisión y no una excepción.

La anotación es la frontera entre el lenguaje que escribes y el que te escriben

Conviene detenerse en lo que realmente ocurre cuando declaras una anotación, porque es más raro de lo que parece. Estás ampliando el vocabulario del compilador sin ampliar la gramática del lenguaje: has creado un término nuevo que el compilador acepta, verifica en sus posiciones, comprueba en sus argumentos y transporta hasta el artefacto, y que sin embargo no significa absolutamente nada para él. Es un hueco deliberado en la semántica, un espacio reservado para que otro llegue después y lo llene. Toda la industria de bibliotecas que rodea a Kotlin y a Java vive en ese hueco: la inyección de dependencias, la persistencia, la serialización, el mapeo de rutas y la validación no son extensiones del lenguaje sino lectores del metadato que el lenguaje se limita a custodiar. Entender esto cambia la manera de diseñar. Una anotación bien declarada no es la que tiene más parámetros, sino la que hace imposible escribirla donde no debe ir, la que no sobrevive más allá del punto en que se consume, y la que apunta sin ambigüedad al elemento que el lector va a inspeccionar. Las tres decisiones (posición, retención y destino de uso) son decisiones de diseño de interfaz, no detalles de configuración, porque una anotación es una interfaz publicada entre dos programas que nunca se compilan juntos: el tuyo y el del procesador. Y como toda interfaz entre programas que no comparten compilación, su única defensa contra el malentendido es la restricción. Cada posición que no permites, cada fase de vida que no concedes y cada destino que fijas explícitamente es una clase entera de errores que tu consumidor no tendrá que diagnosticar. La anotación laxa es cómoda de escribir y cruel de depurar; la anotación estricta es lo contrario, y esa asimetría es la razón por la que el catálogo de destinos y el de retenciones existen.

⚔️ Declara con precisión
  1. Declara una anotación con un parámetro de arreglo, uno de enumeración y uno de referencia de clase, e intenta añadir uno de tipo anulable para leer el error del compilador.
  2. Restringe una anotación a funciones y comprueba que el compilador la rechaza sobre una clase.
  3. Declara la misma anotación con las tres retenciones y comprueba en cuál de ellas sigue siendo legible desde la reflexión.
  4. Marca una anotación como repetible y observa cómo aparece la anotación contenedora al leerla con las herramientas de Java.
  5. Coloca una etiqueta sobre una propiedad de constructor sin prefijo, léela desde el campo, comprueba que no aparece y corrígelo con el destino de uso adecuado.