mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 14/39

14. Annotazioni: metadati e controlli

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

Un'informazione che il programma può leggere

Nel capitolo precedente abbiamo usato @Override davanti a un metodo. Quelle poche lettere non costituiscono il corpo del metodo: dichiarano al compilatore l'intenzione di ridefinire un metodo ereditato. Se abbiamo sbagliato la firma, il compilatore ci ferma. Senza l'annotazione, un metodo con lo stesso nome ma parametri diversi potrebbe essere soltanto un sovraccarico, e il nostro errore passerebbe inosservato. Per esempio, se il metodo di un veicolo accetta int e la sottoclasse scrive per sbaglio double, @Override fa emergere subito l'errore.

Un'annotazione associa metadati a una dichiarazione o, in certi casi, a un uso di un tipo. I metadati sono dati sul programma: non sono la velocità del veicolo o il titolo del libro, ma informazioni che un compilatore, una libreria o uno strumento possono interpretare. La presenza di un'annotazione non esegue automaticamente un'azione. @Override ha un effetto perché il linguaggio glielo assegna; un'annotazione inventata da noi acquista un effetto solo se un componente la legge. Questo distingue il meccanismo Java dai comportamenti aggiunti da framework esterni.

Definizione – Annotazione e processore. L'annotazione è il dato scritto nel sorgente. Un annotation processor è un programma richiamato durante la compilazione per esaminare gli elementi del programma e, se previsto, produrre diagnostiche o nuovi file. La reflection, invece, può leggere a esecuzione avviata le annotazioni mantenute fino al runtime. Sono tre momenti distinti.

Dichiarare un'intenzione non significa farla accadere

La programmazione dichiarativa ci aiuta a capire perché tanti progetti usano annotazioni. Quando scriviamo una query SQL come SELECT titolo FROM libri WHERE disponibile = true, dichiariamo il risultato cercato; il motore decide come attraversare indici e pagine di dati. Java rimane un linguaggio in cui normalmente descriviamo esplicitamente i passi, ma alcune sue caratteristiche permettono di aggiungere intenzioni che altri componenti interpreteranno. Scrivere @Etichetta("catalogo") sopra una classe è più vicino a «questo elemento appartiene al catalogo» che a «esegui ora queste istruzioni».

Il paragone con SQL ha un limite importante. Una query ha un motore con una semantica definita; una nostra annotazione non possiede da sola un motore. Possiamo annotare una classe @DaSalvare e scoprire che non viene salvato nulla, se non abbiamo scritto o installato il componente che legge quel metadato. Prima di adottare uno stile dichiarativo chiediamoci quindi tre cose: chi interpreta la dichiarazione, quando lo fa e quale errore otteniamo se la dichiarazione è incoerente? Per @Override la risposta è il compilatore. Per @Etichetta nel programma che segue è il nostro codice di reflection. Per alcune annotazioni di framework è una libreria esterna, magari avviata quando l'applicazione costruisce i suoi componenti.

Questa distinzione evita di attribuire poteri misteriosi alla chiocciola. Le annotazioni sono state introdotte in Java 5 per associare informazioni strutturate agli elementi del programma. Possono aiutare strumenti, controlli di compilazione e librerie, e in certi casi riducono file di configurazione separati. Ma spostare una regola da un file XML a un'annotazione non la rende automaticamente semplice: la regola deve ancora avere un significato, un lettore e una prova. In tutto il capitolo useremo la stessa domanda come bussola: se tolgo questa annotazione, quale componente cambia comportamento e come posso vederlo?

Definire un'annotazione piccola

Nel sorgente Etichetta.java dichiariamo un'annotazione che attribuisce parole a una classe. Il suo unico elemento si chiama value, perciò @Etichetta("catalogo") è la forma breve di @Etichetta(value = "catalogo").

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Repeatable(Etichette.class)
public @interface Etichetta {
    String value();
}

La sintassi @interface dichiara un tipo di annotazione. String value(); non è un normale metodo da implementare in una classe applicativa: definisce l'elemento che chi usa l'annotazione deve valorizzare. Possiamo assegnargli un valore predefinito con default, per esempio String value() default "generale";; in tal caso l'uso senza argomenti diventa valido. I tipi ammessi per gli elementi comprendono primitivi, String, Class, enum, altri tipi di annotazione e array di questi tipi. Un oggetto arbitrario, come List<String>, non è ammesso. La specifica Java 25 fissa questi vincoli.

Le tre annotazioni davanti alla dichiarazione sono meta-annotazioni: annotazioni che descrivono un tipo di annotazione. @Target(ElementType.TYPE) limita l'uso a classi, interfacce, record ed enum. @Retention(RetentionPolicy.RUNTIME) permette alla reflection di ritrovare i valori durante l'esecuzione. @Repeatable(Etichette.class) ammette più @Etichetta sullo stesso elemento; Etichette è l'annotazione contenitore richiesta per rappresentarle. Il file completo la dichiara con un elemento Etichetta[] value() e con target e retention coerenti.

Le politiche di conservazione rispondono a domande diverse. Con SOURCE, l'annotazione serve al sorgente e non viene conservata nel file .class; con CLASS, viene conservata nel file compilato ma non resa disponibile dalla normale reflection a runtime; con RUNTIME, può essere letta durante l'esecuzione. Se omettiamo @Retention, il valore predefinito è CLASS. La figura 14.1 collega le tre politiche ai momenti in cui le informazioni sono disponibili.

Disponibilità delle annotazioni durante compilazione ed esecuzione
Figura 14.1 – SOURCE, CLASS e RUNTIME indicano fino a quale fase arriva un'annotazione. La politica non decide da sola quale lavoro verrà compiuto su quei metadati.

Nel programma DemoAnnotazioni.java scriviamo due etichette su una classe e le leggiamo con getAnnotationsByType(Etichetta.class). Il ciclo stampa catalogo e didattica. L'API restituisce entrambe anche se la ripetizione viene rappresentata tramite il contenitore. Se cambiassimo la retention in CLASS, questo esempio non troverebbe le etichette a runtime: il codice compilerebbe, ma il ciclo non stamperebbe i due valori. È un esperimento utile per vedere che la disponibilità dei metadati fa parte del contratto.

Annotazioni note, responsabilità diverse

@Override chiede un controllo sulla ridefinizione. @FunctionalInterface chiede che un'interfaccia abbia un solo metodo astratto utilizzabile come bersaglio di una lambda; la vedremo nel capitolo 16. @Deprecated(since = "25", forRemoval = false) comunica che un'API non è più raccomandata e può registrare da quando vale tale indicazione; forRemoval segnala un'intenzione di rimozione, non una data automatica. @SuppressWarnings limita una diagnostica del compilatore in un ambito specifico: va usata dopo aver compreso e motivato il warning, non per nascondere problemi.

@SafeVarargs riguarda metodi e costruttori con argomenti variabili generici o di tipo parametrizzato. È una promessa di sicurezza fatta da chi implementa il metodo, non una cura applicata dal compilatore al corpo. Nel capitolo 15 vedremo perché array e generics possono produrre heap pollution e quando tale promessa è giustificata. Mettere tutte queste annotazioni sotto l'etichetta «documentazione» nasconderebbe differenze importanti: alcune attivano controlli del linguaggio, altre comunicano informazioni alle API o agli strumenti.

Un'annotazione di type use compare su un uso del tipo, per esempio List<@NonNull String> se esiste un'annotazione adatta e uno strumento che ne interpreta il significato. La sola piattaforma Java non fa scattare una verifica universale di non nullità per un'annotazione personalizzata @NonNull. Occorre distinguere il punto sintattico in cui un metadato può essere scritto dal controllo concreto che qualcuno esegue. Lo stesso vale per annotazioni di librerie che generano codice o configurano un framework: senza quello strumento non producono l'effetto promesso.

Un processore eseguito durante la compilazione

Il processore EtichettaProcessor.java estende AbstractProcessor. Dichiara i nomi delle annotazioni che osserva e la versione del linguaggio supportata, poi legge gli elementi annotati nel metodo process. Quando incontra DemoAnnotazioni, scrive una nota del compilatore: etichetta presente su DemoAnnotazioni. Il messaggio compare durante javac, prima che main venga avviato. Il contratto di Processor in Java 25 spiega che l'elaborazione può procedere per più round, o tornate: un processore può generare sorgenti che entrano in una tornata successiva.

Per riprodurre l'esempio, dalla cartella dei tre sorgenti esegui prima javac --release 25 -Xlint:all -d build Etichetta.java DemoAnnotazioni.java EtichettaProcessor.java. Poi chiedi esplicitamente una compilazione con il processore: javac --release 25 -cp build -processorpath build -processor EtichettaProcessor -proc:only DemoAnnotazioni.java. L'opzione -proc:only fa eseguire l'elaborazione senza generare una nuova classe dal sorgente esaminato. Non assumiamo che javac scopra ed esegua automaticamente il processore dalla cartella: il comando dichiara il processore e il suo percorso. Questa distinzione è importante anche per la sicurezza della build e per la sua riproducibilità.

Il processore minimo si limita a produrre una nota. Un processore reale potrebbe generare sorgenti o segnalare un errore usando l'API Messager, ma non modifica arbitrariamente il sorgente che gli viene consegnato. Per costruirne uno robusto occorrono anche regole sui nomi dei tipi, sui round e sui file generati. Qui basta aver visto dove agisce e come si verifica che abbia agito.

Che cosa legge davvero il processore

Il parametro annotations del metodo process contiene i tipi di annotazione rilevanti per quella tornata; RoundEnvironment permette di chiedere quali elementi del programma ne sono marcati. Un elemento del modello di compilazione può essere una classe, un metodo, un campo o un altro costrutto dichiarato. Non è un'istanza Java creata da new durante l'esecuzione dell'applicazione. Nel nostro processore leggiamo element.getSimpleName() e otteniamo DemoAnnotazioni, il nome della classe dichiarata. Questa distinzione spiega perché il processore può operare anche quando il programma non ha ancora un main eseguibile.

round.processingOver() segnala che l'elaborazione è arrivata alla tornata conclusiva. Nel processore evitiamo di emettere la nota in quella fase. Un processore che genera un nuovo sorgente può causare un'altra tornata in cui quel sorgente viene a sua volta esaminato. Se il codice generato porta altre annotazioni riconosciute, il lavoro può proseguire. La progettazione deve evitare di generare ripetutamente lo stesso file o di basarsi su un ordine casuale di visita degli elementi.

Il nostro esempio usa @SupportedAnnotationTypes e @SupportedSourceVersion per dichiarare al compilatore che cosa tratta. Il valore SourceVersion.RELEASE_25 rende esplicita la versione del linguaggio usata per questa prova. In una libreria destinata a più versioni, la scelta andrebbe verificata contro i JDK supportati, non copiata meccanicamente. Il return false del metodo process indica che il processore non rivendica esclusivamente quelle annotazioni: altri processori possono ancora riceverle secondo il protocollo. Non cambia la stampa delle due etichette a runtime.

Approfondimento – Generare senza modificare. Un processor può chiedere a Filer di creare un nuovo sorgente o una risorsa. Il file originale annotato rimane un input; non viene riscritto come se il processore fosse un editor di testo. Un progetto può poi compilare insieme sorgenti scritti dall'autore e sorgenti generati. Questo richiede una build che controlli percorsi di output, provenienza del processor e riproducibilità, perché il codice eseguito durante la compilazione è parte della catena di produzione del programma.

Quando il processore diventa una dipendenza della build

Nel piccolo esperimento abbiamo indicato -processor EtichettaProcessor direttamente. In una libreria, il processore può essere distribuito in un JAR e dichiarato come servizio tramite META-INF/services/javax.annotation.processing.Processor, con il nome completo della classe che lo implementa. È così che uno strumento di build può scoprire un processor sul percorso configurato. Ma «scoprire» non significa affidarsi al caso: per una build ripetibile dichiariamo quale JAR fornisce il processor, quale versione usiamo e in quale fase deve essere eseguito. La configurazione del compiler plugin di Maven, per esempio, appartiene al progetto tanto quanto la versione della libreria che useremo a runtime.

Il processore è codice che il compilatore esegue mentre costruisce l'applicazione. Se scarichiamo una dipendenza non verificata e la lasciamo eseguire durante la build, le conseguenze non sono limitate a un'annotazione sbagliata nel .class: quel codice può leggere file e generare output. Il capitolo 29 affronterà la provenienza delle dipendenze e la ripetibilità della build; qui il collegamento è concreto. Una diagnostica attesa come etichetta presente su DemoAnnotazioni è una prova che il processor è stato invocato, mentre la sola presenza di @Etichetta nel sorgente non lo dimostra.

Un processor che produce una classe, per esempio CatalogoComandiGenerato, deve poter convivere con più tornate di compilazione. Nella prima vede @Comando e genera il sorgente; nella successiva il compilatore analizza anche quella nuova classe. Se a ogni tornata il processor tenta di ricreare lo stesso file, la build fallisce o diventa dipendente da dettagli dell'ordine di visita. Per questo distinguiamo la fase in cui raccogliamo informazioni dalla fase in cui emettiamo un risultato, controlliamo processingOver() e proviamo almeno una compilazione pulita. Quando una build incrementale produce un esito diverso da clean verify, è un segnale da investigare, non una ragione per considerare superflua la build pulita.

Questo chiarisce una distinzione terminologica importante. Un annotation processor lavora sul modello del programma esposto dal compilatore e può creare nuovi file; non è, in generale, un semplice preprocessore testuale che sostituisce parole prima di javac. Alcuni strumenti esterni usano meccanismi ulteriori per trasformare ciò che il compilatore vede. Quando leggiamo la documentazione di uno strumento, dobbiamo distinguere il contratto standard dell'API javax.annotation.processing dalle capacità specifiche di quella libreria. È la stessa disciplina che abbiamo applicato a @Override e alla reflection: individuare l'attore prima di attribuire un effetto all'annotazione.

Una scelta concreta per @Target e @Retention

Supponiamo di voler annotare i metodi che espongono un comando all'utente. Scrivere @Target(ElementType.METHOD) impedisce di mettere l'annotazione per sbaglio su una classe. Se il programma deve scoprire i comandi all'avvio tramite reflection, serve RetentionPolicy.RUNTIME. Se invece un processor genera una tabella dei comandi durante la compilazione e l'applicazione usa soltanto quella tabella, la retention runtime potrebbe essere superflua. La scelta deriva dal lettore previsto del metadato, non dalla sensazione che RUNTIME sia sempre «più completa».

La reflection usa l'interfaccia AnnotatedElement, implementata da rappresentazioni come Class e Method. I metodi per leggere una singola annotazione e quelli per leggere annotazioni ripetibili hanno contratti distinti; getAnnotationsByType è la forma adatta al nostro esempio. Anche qui conta l'ereditarietà: una meta-annotazione come @Inherited ha regole precise per certe annotazioni su classi e non rende ereditabile ogni annotazione su metodi o interfacce. Invece di confidare nel nome, si verifica il contratto della meta-annotazione scelta.

Un'annotazione può documentare una decisione, chiedere un controllo o fornire configurazione. È utile quando il metadato appartiene stabilmente all'elemento dichiarato e può essere letto da uno strumento definito. Se per capire il flusso di una funzione occorre inseguire dieci annotazioni di framework e regole esterne, la sintassi compatta non ha reso il programma automaticamente più semplice. Il lettore deve sapere quale parte è Java, quale è la libreria e quale è la configurazione della build.

Nota bene – Reflection non è compilazione. DemoAnnotazioni.class.getAnnotationsByType(...) legge metadati durante l'esecuzione. EtichettaProcessor osserva il modello del programma durante la compilazione. Una retention RUNTIME è necessaria per il primo uso, ma non è una condizione generale per elaborare un'annotazione nel sorgente con un processore.

Il bersaglio fa parte del contratto

ElementType descrive i luoghi in cui possiamo applicare un’annotazione. Non serve impararla a memoria: serve riconoscere che il metadato di una classe e quello di un uso di tipo rispondono a domande diverse. Se @Responsabile descrive chi mantiene un metodo, METHOD è un bersaglio sensato. Se @Unità descrive un numero usato come metri o secondi, potremmo voler annotare l'uso del tipo, non la dichiarazione di una classe: quello è il caso di TYPE_USE. Il secondo esempio non produce una verifica delle unità per magia; richiede un analizzatore che ne conosca le regole.

Bersaglio Quale elemento marca Domanda da porsi
TYPE, ANNOTATION_TYPE Classi, interfacce, record, enum o dichiarazione di un'annotazione Il metadato descrive il tipo intero o un altro tipo di annotazione?
FIELD, METHOD, CONSTRUCTOR Campo, metodo o costruttore dichiarato Il controllo riguarda quell'operazione o l'intera classe?
PARAMETER, LOCAL_VARIABLE Parametro formale o variabile locale Chi può ancora osservare l'informazione dopo la compilazione?
PACKAGE, MODULE Dichiarazioni di package o modulo La configurazione appartiene a tutto il confine?
TYPE_PARAMETER, TYPE_USE Parametro generico o uso di un tipo Stiamo parlando di T dichiarato o di un punto in cui il tipo è usato?

Per più bersagli scriviamo un array, per esempio @Target({ElementType.TYPE, ElementType.METHOD}). Senza @Target, un'annotazione è applicabile ai contesti di dichiarazione ammessi dalle regole predefinite, ma non acquista automaticamente tutti i contesti di type use. Definire il bersaglio restringe l'errore possibile: se @Comando vale solo sui metodi, il compilatore rifiuta un uso distratto sulla classe. La tabella non sostituisce però la progettazione. Un'annotazione applicabile dappertutto è raramente utile se il suo lettore sa interpretare solo metodi.

Il caso delle variabili locali è particolarmente istruttivo. Un'annotazione sulla dichiarazione di una variabile locale non diventa interrogabile con la normale reflection a esecuzione avviata, anche se il tipo di annotazione dichiara retention RUNTIME. Quella variabile non è un membro che possiamo cercare come un campo. Diverso è annotare un uso del tipo della variabile: le regole di conservazione nel file .class sono specificate separatamente. Confondere queste due posizioni porta a esperimenti in cui il sorgente sembra annotato correttamente ma il programma non trova nulla. La specifica Java 25 distingue i due casi; chi progetta l'annotazione deve sapere quale informazione vuole recuperare e con quale API.

Osservare retention ed ereditarietà, senza indovinare

Nel programma DemoPolitiche.java mettiamo @SoloSorgente, @NelClass e @ARunTime sulla stessa classe. La compilazione accetta tutte e tre. La reflection ne trova una sola: quella con retention RUNTIME. Non stiamo dicendo che le altre due fossero inutili; un analizzatore del sorgente può leggere la prima, e uno strumento che ispeziona il bytecode può leggere la seconda. Stiamo dicendo che questa prova usa la normale API reflection e quindi osserva soltanto ciò che il suo contratto consente.

import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

public final class DemoPolitiche {
    @Retention(RetentionPolicy.SOURCE)
    @Target(ElementType.TYPE)
    @interface SoloSorgente { }

    @Retention(RetentionPolicy.CLASS)
    @Target(ElementType.TYPE)
    @interface NelClass { }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface ARunTime { }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface NonEreditata { }

    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface Ereditata { }

    @SoloSorgente
    @NelClass
    @ARunTime
    static class Documento { }

    @NonEreditata
    @Ereditata
    static class Base { }

    static class Derivata extends Base { }

    private DemoPolitiche() { }

    public static void main(String[] args) {
        System.out.println("Documento a runtime: "
                + Documento.class.getAnnotations().length);
        System.out.println("Base dichiarate: "
                + Base.class.getDeclaredAnnotations().length);
        System.out.println("Derivata dichiarate: "
                + Derivata.class.getDeclaredAnnotations().length);
        System.out.println("Derivata visibili: "
                + Derivata.class.getAnnotations().length);
    }
}

L'output è Documento a runtime: 1, Base dichiarate: 2, Derivata dichiarate: 0, Derivata visibili: 1. La differenza fra gli ultimi due numeri spiega @Inherited meglio del suo nome. getDeclaredAnnotations() guarda soltanto ciò che è dichiarato direttamente su Derivata, quindi trova zero. getAnnotations() considera anche l'annotazione ereditabile della classe base e trova @Ereditata. @NonEreditata, pur avendo retention runtime, non passa alla sottoclasse. @Inherited opera sulle annotazioni delle classi secondo regole precise; non fa ereditare annotazioni di metodi né trasforma un'annotazione su un'interfaccia in un attributo universale delle implementazioni.

Possiamo compilare e lanciare il file con javac --release 25 -Xlint:all -d build DemoPolitiche.java e java -cp build DemoPolitiche. Se cambiamo @Ereditata da RUNTIME a CLASS, l'ultimo numero non sarà più uno: il metadato non è più esposto alla reflection. Se togliamo @Inherited mantenendo RUNTIME, Base continua a mostrare due annotazioni dichiarate ma Derivata non ne vede alcuna. Sono due modifiche diverse e la prova ci permette di attribuire il risultato alla proprietà giusta.

Riprendere l'errore di @Override

L'esempio storico del veicolo merita di essere svolto fino alla diagnostica. Immaginiamo che Veicolo dichiari void accelera(int incremento) e che Automobile scriva void accelera(double incremento). Le due firme differiscono nel parametro: la seconda dichiarazione crea un sovraccarico, non ridefinisce il metodo ricevuto dalla classe base. Una chiamata attraverso una variabile Veicolo continuerà quindi a usare il metodo che accetta int. Se il programmatore voleva cambiare il comportamento dell'auto per quella chiamata, ha commesso un errore che il codice senza annotazione potrebbe nascondere.

Scrivendo @Override davanti al metodo con double, il compilatore segnala che non sta ridefinendo né implementando il metodo atteso. La correzione non consiste nel togliere @Override per far sparire il messaggio: consiste nel riallineare la firma a int se quella era l'intenzione. Se invece serve davvero un metodo aggiuntivo per valori double, lo si tiene come sovraccarico e lo si documenta senza promettere una ridefinizione. Il caso mostra bene perché un'annotazione utile non è una decorazione: rende controllabile un'intenzione che la sola sintassi del metodo non esprime.

Dalla forma dell'annotazione al suo lettore

Le annotazioni possono non avere elementi, contenere un solo valore o dichiararne più di uno. La distinzione diventa utile quando la colleghiamo al codice che le interpreta. Un'annotazione senza elementi, spesso detta marker, comunica la propria presenza: @Controllato non porta un argomento, e lo strumento interessato chiede se il segno sia presente. Un tipo con il solo elemento String value(); permette la forma breve @Etichetta("catalogo"). Con due elementi, per esempio String nome(); int priorita() default 0;, chi la usa deve scrivere il nome dell'elemento obbligatorio: @Comando(nome = "salva"); la priorità assente vale zero. Non esiste un oggetto arbitrario costruito con new da conservare come valore dell'annotazione.

Il valore di un elemento deve appartenere ai tipi ammessi dalla specifica ed essere esprimibile secondo le regole delle annotazioni. null non è un valore consentito. Per un String non possiamo usare una concatenazione che dipende da un input letto durante l'esecuzione: l'informazione deve poter essere registrata nel programma compilato. L'array è ammesso come tipo di elemento, ma non diventa per questo una collezione List<String>. Questa limitazione non è un difetto casuale; permette agli strumenti di leggere metadati con una rappresentazione definita, anche prima che le classi applicative vengano istanziate.

La domanda decisiva rimane: chi legge il metadato? Se un processore produce un file durante la compilazione, prova il risultato compilando con quel processore e controllando il file o la diagnostica prodotti. Se una libreria legge la reflection a runtime, prova avviando il programma e scegliendo RUNTIME. Se l'annotazione serve soltanto a un controllo di sorgente, conservarla fino al runtime può non aggiungere alcun valore. Due annotazioni graficamente identiche possono dunque vivere in fasi diverse e avere effetti diversi perché diversi sono i loro lettori.

Una dichiarazione può comparire più volte

Nell'esempio storico del libro un servizio doveva inviare un messaggio a più orari. Un'unica annotazione @Scheduler(ora = 12, ...) raccontava un solo appuntamento; ripeterla sulla stessa classe era inizialmente un errore di compilazione. Il bisogno è rimasto attuale: un elemento può avere più etichette, più regole o più momenti di attivazione. @Repeatable consente la ripetizione, ma richiede un'annotazione contenitore con un elemento value() che restituisca l'array delle annotazioni ripetute. In pratica il contenitore offre una rappresentazione in cui i valori possono essere raccolti.

Il programma DemoPromemoria.java usa due orari. Abbiamo scelto TYPE per l'annotazione e per il suo contenitore perché il metadato descrive la classe Notifiche. Se volessimo pianificare singoli metodi, cambieremmo entrambi i target a METHOD e il codice che cerca le annotazioni dovrebbe esaminare quei metodi. La retention è RUNTIME perché il programma legge gli orari durante l'esecuzione. Senza un componente che pianifichi e invii davvero il messaggio, però, questo esempio stampa gli orari: non è uno scheduler funzionante.

import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

public final class DemoPromemoria {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Repeatable(PromemoriaMultipli.class)
    @interface Promemoria {
        int ora();
        int minuto();
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface PromemoriaMultipli {
        Promemoria[] value();
    }

    @Promemoria(ora = 9, minuto = 30)
    @Promemoria(ora = 17, minuto = 0)
    static class Notifiche { }

    private DemoPromemoria() { }

    public static void main(String[] args) {
        for (Promemoria p : Notifiche.class.getAnnotationsByType(Promemoria.class)) {
            System.out.printf("%02d:%02d%n", p.ora(), p.minuto());
        }
    }
}

L'output è 09:30 e 17:00. getAnnotationsByType chiede le annotazioni del tipo ripetuto e restituisce entrambe. Se cerchiamo soltanto una presenza generica o usiamo un metodo diverso della reflection senza capire il ruolo del contenitore, possiamo osservare una rappresentazione diversa. Togliere @Repeatable mantenendo i due usi è una prova negativa: il compilatore rifiuta la duplicazione. Lasciare @Repeatable ma assegnare al contenitore una retention più breve di quella dell'annotazione ripetuta è un altro errore: le regole di compatibilità sono controllate alla compilazione. Un’incoerenza fra il bersaglio di @Scheduler e quello del contenitore renderebbe invalida la dichiarazione. Dichiarare insieme target e retention permette di controllarne la compatibilità.

@Repeatable non è un'alternativa a una struttura dati quando gli orari devono cambiare durante l'esecuzione. Le annotazioni appartengono al programma compilato. Se un operatore deve spostare la notifica delle 17 alle 18 senza ricompilare, quegli orari appartengono a una configurazione o a dati persistiti. La scelta di mettere il dato in un'annotazione è appropriata solo quando il ciclo di vita del dato coincide con quello del codice che lo dichiara.

Lombok e il confine della piattaforma

Lombok è uno strumento esterno che può generare codice ripetitivo a partire da annotazioni. Per leggere un progetto che lo usa, occorre collocarlo correttamente: Lombok non è una parola chiave di Java né fa parte della libreria standard. Un progetto che lo usa dipende dalla versione della libreria, dalla configurazione della build e dal supporto degli strumenti di sviluppo. Un sorgente che usa @Getter può sembrare privo di un metodo che il codice chiamante invece usa; per capire davvero il programma bisogna vedere quale metodo è stato generato e quale compilazione lo ha prodotto.

Le annotazioni Lombok come @Getter, @Setter, @ToString e quelle dedicate ai costruttori risolvono ripetizioni diverse. Generare un setter per ogni campo non è automaticamente una buona scelta di incapsulamento: un oggetto che deve preservare invarianti può avere bisogno di operazioni con significato, non di accesso indiscriminato allo stato. Per semplici aggregati di dati immutabili, prima di introdurre una dipendenza esterna valutiamo i record già incontrati in C07; non coprono però tutti gli usi di una classe ordinaria. Se un progetto adotta Lombok, la revisione deve mostrare sorgente, configurazione e API risultante, senza attribuire il comportamento alla sola presenza di @.

Nota bene – Non tutte le annotazioni generano codice. @Override verifica una relazione del linguaggio; @Etichetta nel nostro esempio è leggibile a runtime; EtichettaProcessor produce una nota durante la compilazione; Lombok aggiunge un'elaborazione esterna. Chiamarle tutte «preprocessori» nasconderebbe il momento e il responsabile dell'azione.

Dal BoilerPlatePersona storico a una decisione di progetto

Una classe BoilerPlatePersona con sei campi privati richiede costruttori, getter e setter, oltre ai metodi equals, hashCode e toString quando il suo contratto li richiede. Questa quantità di codice rende concreto il costo della ripetizione e ci invita a scegliere la forma adatta al ruolo dell’oggetto. Se Persona è un contenitore di nome e cognome che viaggia fra due componenti e non deve cambiare, in Java 25 un record Persona(String nome, String cognome) { } fornisce un costruttore canonico, accessori e implementazioni basate sui componenti dei metodi di Object. È una possibilità stabile del linguaggio, senza un processore esterno. Non sostituisce però una classe che possiede un'identità persistente, campi modificabili, invarianti particolari o un protocollo di framework che richiede una forma differente. Prima descriviamo il ruolo dell'oggetto, poi confrontiamo la forma che ci costa meno mantenere.

Se il progetto usa Lombok, possiamo esprimere la classe storica con @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor, @EqualsAndHashCode e @ToString. Ognuna risponde a un pezzo distinto della ripetizione. @Getter produce un accessore; @Setter produce una mutazione; le annotazioni sui costruttori scelgono quali parametri sono disponibili; le ultime due decidono rappresentazione testuale e uguaglianza. L'annotazione @Data combina alcune di queste scelte, ma la sua brevità rende ancora più importante sapere quali metodi vengono prodotti. Non dobbiamo aggiungere tutti i setter soltanto perché è comodo: una Persona che non deve poter avere un nome vuoto ha bisogno di un controllo nel costruttore e nelle operazioni di modifica. Un setter generato che assegna direttamente un campo può aggirare la regola che intendevamo imporre.

L'uguaglianza merita un controllo a parte. Se equals e hashCode dipendono da nome e cognome, due persone con gli stessi nomi risultano uguali secondo quella definizione, anche se nel dominio sono individui diversi. Se cambiamo il cognome dopo aver inserito l'oggetto in un HashSet, il suo hash può cambiare e la raccolta potrebbe non ritrovarlo dove l'aveva collocato. Non è un difetto specifico di Lombok: è la conseguenza di una regola di uguaglianza basata su stato mutabile. La generazione di codice non sceglie al nostro posto quale identità appartenga al dominio. Analogamente, un toString che include un campo riservato può portarlo nei log; escludere il campo nella configurazione dell'annotazione è una decisione di sicurezza, non di estetica.

Consideriamo due oggetti Persona: confrontiamo l’uguaglianza e poi modifichiamo il secondo con setter. Il risultato false fra nomi diversi è prevedibile, ma non prova che l'uguaglianza sia corretta per ogni stato o che l'hash sia stabile nel tempo. Il numero stampato da hashCode() non va copiato come atteso universale: dipende dalla regola concreta e dai dati. Una prova utile confronta invarianti: oggetti che decidiamo uguali devono dare lo stesso hash; modifiche vietate devono essere rifiutate; toString non deve esporre segreti. In questo modo il lettore vede perché la riduzione delle righe è soltanto una parte del problema.

La documentazione ufficiale di Lombok sulle funzionalità descrive le annotazioni disponibili e il comando delombok, che produce una forma espansa del sorgente. Questo può aiutare nella revisione o quando un altro strumento deve leggere il codice generato. Nel caso di @EqualsAndHashCode, la pagina ufficiale spiega quali campi vengono considerati per impostazione predefinita e come modificare la selezione. La scelta va verificata con la versione effettiva dichiarata nella build. Lombok può anche interagire con IDE e compilatori in modi che cambiano tra versioni: per questo non stampiamo qui un comando di compilazione senza una dipendenza fissata e provata. Il programma didattico del capitolo usa soltanto il JDK; Lombok è un confronto guidato con la classe Persona, non una dipendenza nascosta.

Altre meta-annotazioni e altri controlli

@Documented merita una decisione esplicita quando un'annotazione è parte del contratto pubblico di una libreria. Segnala alla generazione della documentazione che l'uso di quell'annotazione va rappresentato nella documentazione dell'elemento annotato. Non fa eseguire un controllo e non cambia la retention scelta. Un'annotazione interna che aiuta soltanto il compilatore potrebbe non dover comparire nella pagina pubblica; una che descrive una restrizione per chi usa l'API potrebbe invece essere utile mostrarla. Come sempre, la presenza dell'etichetta non rende vera o completa la documentazione: dobbiamo leggere il risultato generato.

Con @SuppressWarnings("unchecked") possiamo circoscrivere un avviso dopo aver esaminato un cast che il compilatore non sa dimostrare sicuro. Nel capitolo 15 incontreremo esattamente quella situazione con i tipi generici e l'erasure. Mettere la soppressione su un'intera classe per zittire una riga allarga la zona cieca: altri avvisi della stessa categoria potrebbero comparire più tardi senza essere notati. La buona abitudine è tenere la soppressione nel punto più piccolo possibile, spiegare quale invariante la rende accettabile e accompagnarla con un test. @SafeVarargs è ancora più impegnativa: è una dichiarazione dell'autore del metodo sulla sicurezza dell'uso degli argomenti variabili, non un'impostazione per far sparire un messaggio fastidioso.

Una @Deprecated senza un percorso di migrazione lascia il chiamante a metà. Se manteniamo una vecchia API per compatibilità, la Javadoc dovrebbe dire quale metodo usare e quale differenza di comportamento attendersi. since registra la versione a partire dalla quale l'API è deprecata; forRemoval comunica se è prevista la rimozione futura, ma non fissa una data. Un team che trova l'annotazione può decidere se migrare subito, ma deve conoscere la release e il rischio del cambiamento. Questo è un altro esempio di metadato che aiuta soltanto se qualcuno lo legge e se il messaggio è abbastanza preciso per agire.

Per chiudere il cerchio, torniamo alla domanda iniziale: quale problema risolve il metadato? @Override rende verificabile una firma; @Retention e @Target regolano dove un'informazione vive; @Inherited definisce una forma limitata di visibilità nelle sottoclassi; @Repeatable rappresenta più valori; un processor e Lombok possono produrre diagnosi o codice. La sintassi comune non cancella queste differenze. Se davanti a una nuova annotazione sappiamo cercare il suo lettore, il momento in cui opera e la prova del risultato, abbiamo imparato a riusare il modello anche fuori da questo capitolo.

Per verificare

Esegui DemoAnnotazioni, DemoPolitiche e DemoPromemoria. Prima di ogni esecuzione, scrivi le righe attese e spiega quale API legge il metadato. Poi rimuovi @Repeatable lasciando entrambe le annotazioni sulla classe: prevedi la diagnostica del compilatore. Come seconda prova, cambia la retention in CLASS, ricompila ed esegui: spiega perché il processore può ancora vedere l'annotazione del sorgente mentre la reflection non la mostra. Per un caso applicativo, definisci un'annotazione @Responsabile su un metodo e decidi prima chi dovrà leggerla e in quale fase: compilatore, processore o applicazione in esecuzione.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑