La domanda prima dello strumento
Un programma conosce normalmente i tipi che usa mentre viene compilato: chiama un metodo perché il compilatore può verificarne nome, parametri e accessibilità. In alcuni sistemi, però, il tipo concreto viene scelto più tardi. Un framework può cercare classi annotate, un serializzatore può esaminare la struttura di un record, uno strumento diagnostico può mostrare i metodi disponibili. La Reflection API permette di osservare informazioni sui tipi e, entro i confini di accesso previsti, di costruire oggetti o invocare membri a tempo di esecuzione. Non serve a rendere «dinamico» ogni programma Java; serve quando la struttura stessa è un dato del problema.
Nel capitolo sui classloader abbiamo visto come una definizione arrivi alla JVM. La reflection parte da un oggetto Class<?> già disponibile e chiede che cosa quella definizione esponga. Si può ottenere l'oggetto con Libro.class, con oggetto.getClass() o con una ricerca per nome come Class.forName(nome). Le tre forme non sono sempre intercambiabili: la prima ha un tipo noto al compilatore, la seconda parte da un'istanza già presente, la terza può ricevere un nome scelto a tempo di esecuzione e deve gestire ClassNotFoundException.
Nel capitolo 20A abbiamo incontrato VarHandle come strumento avanzato per accedere a variabili con operazioni e ordini di memoria specifici. Un VarHandle non è la stessa cosa di un Field riflessivo: può essere ottenuto anche da una ricerca con MethodHandles.Lookup, ma il suo compito principale è eseguire accessi tipizzati alla variabile secondo il contratto del handle. La reflection di questo capitolo risponde anzitutto alla domanda «quali membri possiede questo tipo?». Non usiamo la sola scoperta di un campo per dichiarare sicura una modifica concorrente.
Definizione – Metadato. Un metadato descrive un altro elemento. Il nome di una classe, i suoi metodi pubblici, il tipo di un parametro e un'annotazione disponibile a runtime sono metadati. La reflection legge o usa queste descrizioni; non cambia retroattivamente il sorgente compilato. Il valore di un campo di una specifica istanza è invece stato dell'oggetto, a cui si può accedere tramite un membro riflessivo quando le regole lo permettono.
Un programma che si lascia ispezionare
Nel programma completo definiamo un record Libro con due componenti, titolo e pagine, e un metodo descrizione(). La classe del record porta l'annotazione @Etichetta("catalogo"). Il programma ottiene Libro.class, chiama isRecord(), percorre getRecordComponents() e legge l'annotazione con getAnnotation(Etichetta.class). La sequenza stampata inizia con il nome binario EsploraReflection$Libro, poi record: true, i due nomi delle componenti e l'etichetta.
Il simbolo $ nel nome binario deriva dal fatto che, per mantenere l'esempio in un solo file, Libro è dichiarato come record membro di EsploraReflection. Un record dichiarato come tipo di primo livello avrebbe un nome diverso. Questa osservazione è utile quando si confronta il nome usato dal sorgente con quello richiesto da Class.forName o mostrato da un classloader.
L'annotazione definisce @Retention(RetentionPolicy.RUNTIME). Senza questa politica, getAnnotation non potrebbe recuperarla durante l'esecuzione. getAnnotation restituisce l'annotazione cercata oppure null: il programma sa che il proprio tipo è annotato e usa direttamente value(), ma un lettore generico dovrebbe controllare il risultato. Le annotazioni da sole non eseguono alcuna logica; è il codice che le legge a decidere che cosa significano. Qui la stringa «catalogo» è soltanto un dato descrittivo.
Costruire e invocare
getConstructor(String.class, int.class) cerca un costruttore pubblico con esattamente quei tipi di parametro e restituisce un Constructor<Libro>. La chiamata newInstance("Java", 320) crea un Libro; il compilatore non ha scritto direttamente new Libro("Java", 320) nel punto d'uso, ma la verifica dei parametri e l'invocazione avvengono comunque secondo il contratto del costruttore riflessivo. Poi getMethod("descrizione") cerca un metodo pubblico senza parametri e invoke(libro) lo esegue su quell'istanza. Il risultato viene stampato come Java (320 pagine).
Constructor<Libro> costruttore = tipo.getConstructor(String.class, int.class);
Libro libro = costruttore.newInstance("Java", 320);
Method metodo = tipo.getMethod("descrizione");
System.out.println(metodo.invoke(libro));
La forma riflessiva richiede più controlli della chiamata ordinaria. Il metodo potrebbe non esistere, la firma potrebbe essere diversa, il chiamante potrebbe non avere accesso, il costruttore potrebbe lanciare un'eccezione. Nell'esempio main dichiara ReflectiveOperationException perché è un programma dimostrativo; un'applicazione dovrebbe tradurre o propagare gli errori con un messaggio che spieghi quale componente non ha rispettato il contratto. Se il metodo invocato lancia un'eccezione, Method.invoke la incapsula in InvocationTargetException, la cui causa va esaminata per capire il problema originale.
Schema – Dove si sposta il controllo.
libro.descrizione()viene controllato dal compilatore rispetto al tipo statico dilibro.metodo.invoke(libro)controlla a tempo di esecuzione chemetodosia invocabile su quell'oggetto. La flessibilità costa controlli più tardivi e rende il codice meno facile da leggere, refattorizzare e verificare automaticamente. Per una chiamata il cui tipo è già noto, la forma diretta resta più chiara.
Metodi pubblici, metodi dichiarati e accessibilità
getMethod cerca un metodo pubblico tenendo conto dell'ereditarietà. getDeclaredMethod cerca un metodo dichiarato direttamente dal tipo, anche se non è pubblico, ma il semplice fatto di ottenerne un oggetto Method non concede il diritto di invocarlo. La stessa distinzione vale per getFields e getDeclaredFields, per i costruttori pubblici e quelli dichiarati. Quando si costruisce uno strumento di ispezione, occorre scegliere se interessa l'API pubblica vista dai chiamanti oppure la struttura interna della classe esaminata. Mischiare le due viste può generare un rapporto fuorviante.
In Java 25 i moduli contano anche per la reflection. Esportare un pacchetto rende disponibili i tipi pubblici secondo le regole modulari; aprire un pacchetto con opens abilita l'accesso riflessivo profondo nei limiti dichiarati. setAccessible(true) non è un passepartout: se l'accesso non può essere abilitato, può essere lanciata InaccessibleObjectException. Un framework che richiede reflection sui membri non pubblici deve documentare la configurazione del modulo. Non suggeriamo di aprire indiscriminatamente tutti i pacchetti o di usare opzioni JVM di aggiramento come soluzione generale: il confine di incapsulamento è parte dell'architettura del programma.
Approfondimento – Perché
trySetAccessibleesiste. Il metodo tenta di abilitare l'accesso e restituisce un booleano. Questo consente di trattare esplicitamente un rifiuto, quando l'applicazione prevede una strada alternativa. Se l'operazione è indispensabile, la soluzione corretta è definire un contratto di modulo e di API compatibile con quel bisogno. Anche la reflection su una classe presente non giustifica automaticamente l'accesso a ogni suo dettaglio privato.
Tipi generici e identità delle classi
Un oggetto Class<List> non conserva come parte della sua identità ogni argomento di tipo usato in una variabile List<String> o List<Integer>. L'erasure dei generics, già studiata, non scompare con la reflection. Alcune dichiarazioni conservano però metadati generici: getGenericReturnType() può descrivere la firma dichiarata di un metodo, e le API di java.lang.reflect.Type rappresentano forme come tipi parametrizzati e variabili di tipo. Questo non significa che, osservando un'istanza qualunque di ArrayList, possiamo recuperare sempre l'argomento String con cui una variabile fu dichiarata. Per un framework che deve conoscere il tipo degli elementi, il contratto deve fornire informazioni sufficienti.
Vale anche l'identità dei tipi spiegata nel capitolo sui classloader. Due oggetti Class<?> con lo stesso getName() non rappresentano necessariamente lo stesso tipo se provengono da caricatori definitori diversi. Un registro di plugin che usa soltanto il nome testuale come chiave può confondere definizioni isolate. Quando il programma passa un oggetto a Method.invoke o tenta un cast, la JVM verifica la vera identità del tipo, non soltanto le lettere del nome.
Prima di invocare: costruire una descrizione affidabile
Immaginiamo di estendere l'ispettore a tipi che non abbiamo scritto noi. Stampare soltanto getName() non basta: un parametro int, una classe Integer e un array int[] richiedono trattamenti diversi. Class<?> rappresenta anche primitive, array e void; non significa sempre «classe da istanziare». Usiamo isPrimitive(), isArray() e getComponentType() per riconoscere queste forme. Per una gerarchia chiusa, isSealed() e getPermittedSubclasses() descrivono le sottoclassi direttamente ammesse; non enumerano tutti gli oggetti che potranno esistere. Queste informazioni aiutano a validare un contratto prima di tentare operazioni che non hanno senso.
Per il nome distinguiamo il nome binario, adatto alla ricerca di classi ordinarie, dal nome canonico, vicino alla forma del sorgente. getCanonicalName() può restituire null per classi locali o anonime. Per mostrare un tipo a un lettore possiamo usare getTypeName(), ma una stringa leggibile non sostituisce l'identità dell'oggetto Class. Anche gli array hanno nomi binari particolari: non costruiamo un protocollo di caricamento concatenando a mano parentesi quadre al nome di una classe.
L'ispettore deve inoltre dichiarare quale vista dei membri sta mostrando. Un metodo ereditato è utile a chi vuole conoscere ciò che può chiamare; un campo privato dichiarato nella superclasse appartiene invece a un'altra vista. Per esaminare quest'ultimo dobbiamo risalire con getSuperclass() e interrogare ciascun tipo. I costruttori non sono ereditati: cercare il costruttore della sottoclasse non cerca quello del padre. L'ordine restituito per campi e metodi non è una presentazione stabile da usare in un file di confronto. Ordiniamo esplicitamente per nome e firma; per le componenti di un record conserviamo invece l'ordine dichiarato, significativo anche per il costruttore canonico.
Approfondimento – Il compilatore lascia tracce. Un metodo bridge è un ponte generato per mantenere il polimorfismo dopo la cancellazione dei tipi generici. Un metodo synthetic è una struttura introdotta nella traduzione, anziché un metodo scritto ordinariamente nel sorgente.
isBridge()eisSynthetic()aiutano a distinguerli. Un browser del sorgente può filtrarli; un diagnostico della JVM può aver bisogno di mostrarli. Filtrare tutto ciò che non riconosciamo produce un rapporto incompleto, mentre mostrare tutto senza spiegarlo fa credere al lettore di avere scritto metodi che non vede nel sorgente.
Modifier.isStatic(membro.getModifiers()) permette di scegliere se occorre un destinatario. Un Field identifica una dichiarazione, non un valore già letto: get(oggetto) osserva il valore in quell'istanza, mentre per un campo statico possiamo passare null. Un primitivo letto con get viene restituito attraverso il relativo wrapper. Scrivere con set richiede accesso e conversioni ammesse; non esegue automaticamente la validazione di un setter. In un ispettore di catalogo scegliamo quindi la sola lettura. Se il requisito cambia in «modificare il numero di pagine», preferiamo un metodo applicativo che protegga l'invariante invece di trasformare ogni campo scoperto in una porta di scrittura.
La firma cercata e gli argomenti passati sono due problemi diversi
Supponiamo di aggiungere descrizione(int limite) al record. La ricerca deve usare int.class, anche se nell'invocazione passiamo un Integer: getMethod non applica la risoluzione del sovraccarico che il compilatore applica a una chiamata Java. Una firma con Number va cercata con Number.class, non con la classe concreta dell'argomento. Perciò un sistema configurabile deve conoscere i tipi della firma o definire una propria regola di selezione, con una risposta esplicita alle ambiguità.
Il frammento seguente usa il Servizio del programma ContrattiReflection. Il metodo pagine riceve un int; quello conta riceve un array di stringhe. La prima ricerca fissa una firma primitiva, la seconda una firma array.
var pagine = Servizio.class.getMethod("pagine", int.class);
Object risultato = pagine.invoke(servizio, Integer.valueOf(7));
var conta = Servizio.class.getMethod("conta", String[].class);
Object totale = conta.invoke(servizio, (Object) new String[]{"Java", "I/O"});
Otteniamo rispettivamente i wrapper del numero sette e del numero due. Cercare pagine con Integer.class produce invece NoSuchMethodException, prima di qualsiasi esecuzione del corpo.
Dopo la ricerca, invoke ammette unboxing e alcune conversioni di ampliamento verso primitive. Non effettua conversioni arbitrarie da stringhe né restringimenti numerici. null non può diventare un int. Un metodo statico può essere invocato con destinatario null; un metodo di istanza richiede un oggetto compatibile. Il risultato di un metodo void è null, quello di un risultato primitivo è un wrapper. Una chiamata a un metodo di istanza conserva il dispatch dinamico: trovare un metodo sul padre non impone di eseguire il corpo del padre quando il figlio lo ridefinisce.
Nota bene – Due livelli di varargs.
Method.invokericeve un numero variabile di argomenti, ma il metodo cercato può avere a sua volta un parametro array. Per chiamare un metodo dichiarato conString... valori, cerchiamoString[].classe passiamo l'array come un unico argomento, per esempiometodo.invoke(oggetto, (Object) valori). Il cast evita che quell'array venga interpretato come l'elenco degli argomenti diinvoke. La reflection non confeziona automaticamente gli argomenti del metodo bersaglio come una chiamata varargs scritta nel sorgente.
Per provare la distinzione, aggiungiamo due firme con int e Integer, poi cerchiamole separatamente. Il successo della chiamata non dimostra di avere selezionato la firma voluta: controlliamo getParameterTypes() e il risultato. Se il contratto richiede la versione primitiva, un fallback silenzioso sul wrapper nasconde un errore di configurazione.
Parametri e annotazioni: leggere ciò che è stato conservato
Un mapper potrebbe associare i nomi dei parametri ai campi di una configurazione. Method e Constructor condividono la base Executable, da cui otteniamo Parameter[]. Tuttavia i nomi del sorgente non sono automaticamente disponibili per ogni metodo: per conservarli con javac si usa -parameters. Parameter.isNamePresent() permette di controllare il contratto. Se manca il nome, una stringa come arg0 non è una chiave di dominio affidabile. Una variante robusta richiede un'annotazione esplicita oppure usa una posizione documentata.
Anche per le annotazioni bisogna scegliere quale domanda stiamo facendo. getDeclaredAnnotations() osserva quelle direttamente presenti. @Inherited riguarda l'eredità di annotazioni sulla classe attraverso le superclassi; non propaga automaticamente annotazioni dai metodi ridefiniti né dalle interfacce. Per annotazioni ripetibili, getAnnotationsByType considera il contenitore previsto dal contratto: cercare soltanto una singola annotazione può perdere occorrenze. Infine un'annotazione sul tipo usato da un parametro non coincide con un'annotazione sulla dichiarazione del parametro: il modello AnnotatedType serve al primo caso.
Il criterio riusabile è stabilire dove vive il significato. Se un validatore vuole un vincolo su ciascun elemento di List<String>, leggere soltanto le annotazioni della lista come parametro non descrive necessariamente il suo argomento di tipo. Torniamo al capitolo 14 e dichiariamo retention e target prima di scrivere il lettore. Verifichiamo poi una dichiarazione annotata e una priva di annotazione: l'assenza deve essere una situazione prevista, non un NullPointerException accidentale.
Attraversare un tipo generico senza fingere che sia una classe
Consideriamo un campo dichiarato List<? extends Number> valori. getType() dà la classe raw List, utile per alcune operazioni runtime. getGenericType() descrive invece la dichiarazione. Il risultato è un Type, non necessariamente un Class<?>: un cast diretto a Class fallirebbe proprio sul caso che volevamo capire.
| Forma osservata | Informazione da esaminare | Domanda nel catalogo |
|---|---|---|
Class<?> |
Identità del tipo; eventuale componente array | È String, int o un array concreto? |
ParameterizedType |
Tipo raw, argomenti e tipo proprietario | Quali argomenti dichiara questa List? |
TypeVariable<?> |
Dichiarazione di origine e limiti | Che cosa ammette il parametro T? |
WildcardType |
Limiti superiori e inferiori | Che cosa possiamo assumere su ? extends Number? |
GenericArrayType |
Tipo generico della componente | Come è dichiarato un array T[]? |
Per il campo valori dello Schema nel programma di prova, possiamo svolgere il primo livello così:
var lista = (ParameterizedType) Schema.class
.getDeclaredField("valori").getGenericType();
var limite = (WildcardType) lista.getActualTypeArguments()[0];
Type confine = limite.getUpperBounds()[0];
Qui Type va importato da java.lang.reflect. I cast sono leciti perché conosciamo la dichiarazione di questo esempio: confine è Number.class. Un ispettore generale deve invece controllare la forma con instanceof prima di convertirla. Se il campo diventasse semplicemente Number, il primo cast non sarebbe più valido.
La tabella è una mappa per costruire un visitatore: riconosciamo la forma, leggiamo le sue parti, ripetiamo il ragionamento sulle parti. List<List<String>> richiede due livelli. Un limite come T extends Comparable<T> può riportarci alla variabile iniziale: un descrittore ricorsivo deve ricordare i tipi già visitati per non procedere senza fine. Questa cautela nasce dal problema concreto, non dalla quantità di classi dell'API.
Nel programma di verifica controlliamo una lista parametrizzata, una wildcard, una variabile e un array generico. Il limite è intenzionale: stiamo descrivendo firme, non verificando ogni elemento di una lista ricevuta. Se un importatore deve accettare soltanto numeri, deve validare i valori. Una firma generica utile per documentare lo schema non annulla l'erasure né impedisce a codice legacy di consegnarci dati incompatibili.
Accesso profondo: una prova deve attraversare davvero il modulo
canAccess(destinatario) risponde se il membro è accessibile al chiamante nelle condizioni attuali. Per membri statici e costruttori il destinatario richiesto è null; per membri di istanza è un oggetto compatibile. trySetAccessible() pone una domanda diversa: possiamo sopprimere i controlli di accesso secondo le regole della piattaforma? Un false va gestito; dopo un tentativo riuscito non abbiamo trasformato la dichiarazione privata in una API pubblica stabile.
Una prova in cui ispettore e oggetto sono classi annidate nella medesima classe esterna può avere accessi già consentiti dal rapporto di nestmate, cioè appartenenza allo stesso gruppo di classi annidate. Non dimostra che un framework separato possa fare altrettanto. Proviamo quindi due moduli nominati: il modulo del catalogo esporta il package pubblico, ma inizialmente non lo apre; quello dell'ispettore richiede il catalogo. L'accessore pubblico rimane invocabile, il campo privato non diventa accessibile solo perché il package è esportato. Aggiungendo un'apertura qualificata opens biblioteca.modello to biblioteca.ispettore cambiamo precisamente il contratto della seconda operazione.
Concetto chiave – Apertura mirata.
exportsserve al contratto pubblico;opensautorizza la reflection profonda verso i destinatari previsti. Un'apertura non equivale a rendere importabili dal sorgente tutti i membri privati. La configurazione appartiene al modulo che possiede il package. Se il framework può lavorare con accessori pubblici, quella alternativa riduce la dipendenza dalla rappresentazione interna.
La prova modulare completa usa il modulo del modello. Lo script di verifica compila prima il modulo chiuso, poi ne prepara una copia temporanea con apertura qualificata. Le due esecuzioni stampano accesso profondo: false e accesso profondo: true, mantenendo in entrambe il successo dell'accessore pubblico. La copia temporanea evita di confondere i due contratti.
Non usiamo il successo di trySetAccessible per promettere che qualunque campo final sia modificabile. In particolare i campi finali dei record e i campi static final non diventano scrivibili in questo modo. Per ricostruire un record leggiamo nomi e tipi delle componenti, cerchiamo il costruttore canonico e passiamo valori validati nel medesimo ordine. Il costruttore conserva il punto in cui far rispettare gli invarianti. La variante è un record con una componente aggiuntiva: il mapper deve adattare la firma o rifiutare lo schema, anziché assumere per sempre due parametri.
Array costruiti quando il tipo arriva più tardi
Se un importatore scopre soltanto a runtime il tipo della componente, non può scrivere new T[n] per un generico arbitrario. java.lang.reflect.Array.newInstance(tipoComponente, lunghezza) costruisce l'array concreto; Array.getLength, Array.get e Array.set permettono di usarlo attraverso un riferimento Object. Con componente int.class otteniamo un vero int[], non un Integer[]. Per un array di primitive non è valido il cast a Object[].
La prova è creare tre interi, assegnarne uno, leggere valore e lunghezza. Poi tentiamo di inserire una stringa: il rifiuto dimostra che l'array mantiene il proprio tipo. Se il programma deve soltanto raccogliere oggetti eterogenei, una List<Object> può essere più chiara. L'array riflessivo risolve il requisito «serve proprio un array di questo tipo runtime», non il requisito generico «mi serve una raccolta».
Proxy dinamici: intercettare un contratto di interfaccia
Ora la richiesta è contare le chiamate al servizio del catalogo senza duplicare il suo lavoro. Un proxy è un oggetto che si presenta al chiamante attraverso lo stesso contratto e decide come inoltrare la richiesta. Proxy.newProxyInstance crea un'implementazione runtime delle interfacce indicate; un InvocationHandler riceve il metodo e gli argomenti. Non crea una sottoclasse arbitraria della classe concreta, e le interfacce devono rispettare i vincoli dell'API, inclusa l'esclusione delle interfacce sealed.
Nel programma ContrattiReflection il servizio ha una sola operazione: il gestore incrementa un contatore e chiama il metodo sull'oggetto reale. La parte decisiva è il destinatario. Invocare lo stesso metodo sul proxy dall'interno del suo gestore produrrebbe ricorsione; lo invochiamo sul servizio. Se il servizio lancia un'eccezione, estraiamo la causa di InvocationTargetException. Lasciare il wrapper aggiungerebbe un livello artificiale e potrebbe alterare il contratto delle eccezioni checked dell'interfaccia.
Anche equals, hashCode e toString passano attraverso il gestore. Il nostro esempio assegna al proxy uguaglianza per identità e un testo descrittivo, senza contarli come richieste al catalogo. Una delega ingenua di equals al servizio può perfino rendere falsa l'uguaglianza del proxy con sé stesso. I metodi default richiedono una politica esplicita; InvocationHandler.invokeDefault consente di eseguire il default sul proxy quando questa è la scelta prevista.
Per trasferire l'idea, sostituiamo il contatore con un controllo di autorizzazione, mantenendo l'interfaccia e la gestione degli errori. Se il servizio è unico e noto, un decoratore scritto a mano può offrire lo stesso comportamento con più controlli del compilatore. Il proxy diventa utile quando le interfacce sono scoperte o trattate uniformemente da un'infrastruttura. La comodità di intercettare non elimina il bisogno di definire quali chiamate contare e quale identità assegnare all'oggetto.
Reflection, handle e class file: scegliere il livello
Un MethodHandle rappresenta un'operazione invocabile con un tipo esplicito e controlli di accesso legati alla ricerca tramite Lookup. È adatto a infrastrutture che preparano e compongono chiamate; non è una promessa automatica di maggiore velocità. invokeExact richiede corrispondenza esatta del tipo della chiamata, mentre gli adattamenti vanno progettati. Un VarHandle riguarda accessi a variabili e relativi modi di ordinamento, come abbiamo visto in C20A. La Core Reflection rimane più diretta per scoprire e descrivere membri.
La Class-File API di java.lang.classfile, stabile da Java 24, lavora invece sulla rappresentazione dei file di classe: serve per analizzarli, generarli o trasformarli. Non è la chiamata a un metodo di un oggetto già presente. Se vogliamo inventariare un archivio senza caricare le classi, questo livello può essere pertinente; se vogliamo leggere una componente di un record vivo, il livello è diverso.
Un'ultima scelta riguarda il riuso delle descrizioni. Scoprire la stessa firma a ogni chiamata può essere inutile: possiamo conservarla per il tipo già verificato. Una mappa globale con riferimenti forti a classi di plugin può però trattenere anche i loro classloader. ClassValue offre un'associazione per classe utile per questo genere di metadati; il valore deve essere progettato senza collegamenti indesiderati ad altre generazioni di plugin. Prima di ottimizzare misuriamo il carico reale: il costo di accesso al file o di parsing può dominare quello della reflection. Il capitolo 29 fornisce gli strumenti per rendere ripetibile la misura.
Riferimenti essenziali – Dal contratto al controllo. La specifica Core Reflection chiarisce il rapporto con il modello JVM. Le specifiche di Class, Method, AccessibleObject e Proxy sono i riferimenti per ricerca, invocazione, accesso e intercettazione. Descrivono le garanzie della piattaforma; la scelta dello schema, la validazione e la politica del proxy restano responsabilità dell'applicazione.
Quando la reflection è una buona scelta
Uno strumento che esplora record e annotazioni a runtime ha una motivazione precisa per usare reflection. Anche un framework di dipendenze, un test runner o un sistema di serializzazione possono averne bisogno. Una normale chiamata applicativa fra due classi note al compilatore, invece, è più semplice senza reflection. Conviene valutare costi di prestazione, gestione degli errori e stabilità dei nomi: se rinominiamo un metodo, una stringa passata a getMethod potrebbe non essere rilevata dal compilatore. Test che coprono la scoperta riflessiva diventano quindi parte del contratto.
Come esercizio, si aggiunga a Libro un metodo pubblico con un parametro e si modifichi la ricerca con getMethod usando il tipo esatto del parametro. Poi si cerchi un nome inesistente e si osservi NoSuchMethodException, distinguendola da ClassNotFoundException del capitolo sui classloader. Infine si sposti il record in un modulo che non apre il pacchetto e si ragioni su quali operazioni pubbliche restano lecite e quali richieste di accesso profondo falliscono. Lo scopo è imparare a individuare il confine, non a superarlo automaticamente.
Una ricerca riflessiva che fallisce: leggere l'errore
Supponiamo che il nome di un metodo arrivi da una configurazione e che il programma esegua tipo.getMethod(nomeConfigurato). Se la configurazione contiene un refuso, non esiste alcun controllo del compilatore su quella stringa. A tempo di esecuzione la ricerca lancia NoSuchMethodException. La risposta utile non è stampare soltanto una lunga traccia: occorre dire quale nome è stato richiesto, su quale classe, con quali tipi di parametri e da quale configurazione proveniva. I tipi dei parametri fanno parte della ricerca. getMethod("descrizione") e getMethod("descrizione", String.class) pongono domande diverse anche se il nome è uguale.
Trovato il metodo, l'invocazione può ancora fallire. Se l'oggetto passato non è un'istanza compatibile con il metodo di istanza, oppure il numero e il tipo dei parametri non corrispondono, l'API segnala l'errore al chiamante. Se invece il corpo del metodo viene eseguito e lancia una sua eccezione, InvocationTargetException conserva quella causa. Per diagnosticare, distinguiamo l'errore nel trovare e chiamare il metodo dall'errore prodotto dal metodo chiamato. È la stessa disciplina usata nel capitolo sulle eccezioni: il punto in cui l'errore viene visto non coincide sempre con quello in cui nasce.
Un framework può nascondere questi dettagli dietro messaggi più comprensibili, ma il codice che usa reflection deve conoscerli. Se il contratto del framework dice «un componente annotato deve avere un costruttore pubblico senza argomenti», un NoSuchMethodException indica una violazione precisa di quel contratto. Se l'accesso è negato dal modulo, l'errore è diverso: modificare il nome del metodo non aprirà il pacchetto. Se il metodo stesso lancia un'eccezione di dominio, aprire il modulo non la risolverà. Una diagnosi accurata evita correzioni casuali come aggiungere opzioni JVM finché il programma smette di fallire.
La reflection è dunque potente quando il programma deve lavorare con metadati scoperti a tempo di esecuzione, ma richiede contratti espliciti e prove che attraversino anche i casi di errore. Un capitolo sui classloader spiega da dove arriva la definizione; questo capitolo spiega come interrogarla e usarla entro i suoi confini. Il passo successivo, in un'applicazione reale, è decidere se la flessibilità guadagnata giustifica la complessità aggiunta. Spesso un'interfaccia comune, un servizio modulare o una normale chiamata tipizzata esprimono meglio lo stesso bisogno. Quando la struttura non è nota in anticipo, invece, la reflection permette di scoprirla senza fingere che il compilatore potesse verificarla prima.
Per verificare
Nel programma di questo capitolo cambia prima il nome richiesto in getMethod, poi il tipo del parametro del costruttore, in due copie separate. Confronta le eccezioni e identifica quale ricerca le produce. Ripristinato il codice, fai lanciare un'eccezione da descrizione() e osserva la causa riportata da Method.invoke. Racconta i tre punti del percorso: ricerca del membro, controllo dell'accesso e sua esecuzione. Infine chiediti se, per un tipo noto già in compilazione, questa ricerca sia necessaria: la scelta dello strumento deve seguire il problema, come nel caso del plugin del capitolo 21.