mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 15/39

15. Generics e controllo dei tipi

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 →

La pila che accettava tutto

Torniamo alla pila che accompagna il libro. La versione di C08 contiene soltanto int: è semplice e ci ha permesso di studiare stato, invarianti ed eccezioni. Se vogliamo una pila di titoli, copiarla e sostituire ogni int con String produce due classi quasi identiche. Possiamo provare a generalizzare usando Object, il supertipo degli oggetti. L'idea evita la duplicazione, ma trasferisce un problema a chi usa la pila: estrai() restituisce Object, quindi per ottenere una String occorre un cast. Se per errore qualcuno vi inserisce un Integer, il programma può compilare e fallire soltanto quando estrae il valore.

I generics permettono di dichiarare una relazione fra tipi. Pila<T> significa che il tipo degli elementi è scelto quando si usa la classe: Pila<String> accetta stringhe e restituisce stringhe; Pila<Integer> fa lo stesso con interi rappresentati come oggetti. T è un parametro di tipo, un nome che compare nella dichiarazione e viene sostituito concettualmente dall'argomento di tipo scelto dal chiamante. String in Pila<String> è un argomento di tipo. Questa somiglianza con parametri e argomenti dei metodi aiuta a leggere la sintassi, ma qui il compilatore controlla tipi, non valori passati durante una chiamata.

Concetto chiave – Sicurezza statica. Nel caso ordinario, inserire un valore di tipo incompatibile provoca un errore del compilatore prima dell’esecuzione. Alcune forme di codice riducono però i controlli disponibili. Un raw type usa una classe generica senza l’argomento di tipo, per esempio List anziché List<String>; un cast non controllato chiede una conversione generica che il compilatore non può verificare interamente. Sono forme che possiamo incontrare soprattutto ai confini con vecchie API e che approfondiremo nel capitolo. La garanzia ha quindi condizioni precise: usando coerentemente i tipi parametrizzati spostiamo molti errori dal momento dell’uso al momento della scrittura, senza rendere impossibile ogni errore durante l’esecuzione.

Il programma completo costruisce una piccola Pila<T> con nodi collegati. La scelta dei nodi ci permette di evitare array generici e cast nel percorso principale. Ogni nodo conserva un valore T e il riferimento al nodo precedente. inserisci crea una nuova cima; estrai restituisce il valore della cima e fa avanzare il riferimento. Quando la pila è vuota, mantiene il contratto già visto: lancia IllegalStateException invece di inventare un valore sentinella.

static final class Pila<T> {
    private static final class Nodo<T> {
        final T valore;
        final Nodo<T> precedente;

        Nodo(T valore, Nodo<T> precedente) {
            this.valore = valore;
            this.precedente = precedente;
        }
    }

    private Nodo<T> cima;

    void inserisci(T valore) {
        cima = new Nodo<>(valore, cima);
    }

    T estrai() {
        if (cima == null) {
            throw new IllegalStateException("pila vuota");
        }
        T valore = cima.valore;
        cima = cima.precedente;
        return valore;
    }
}

In main, Pila<String> titoli = new Pila<>(); usa il diamond <>: il compilatore ricava l'argomento di tipo dal lato sinistro. Dopo titoli.inserisci("Java"), possiamo assegnare titoli.estrai() direttamente a una String e chiamare toUpperCase(). L'output è JAVA. Scrivere titoli.inserisci(3) non è una variante che fallirà più tardi: è codice non compilabile, perché 3 diventa un Integer tramite boxing e non è una String.

Più tipi, nomi e valori primitivi

Un parametro di tipo può descrivere un ruolo; più parametri permettono di tenere distinti ruoli diversi nella stessa classe. Pensiamo a una coppia chiave–valore: Coppia<K, V> usa K per la chiave e V per il valore. Se costruiamo Coppia<String, Integer>, un metodo che restituisce K offre una String, mentre un metodo che restituisce V offre un Integer; non serve un cast. Nomi come T (type), E (element), K (key) e V (value) sono convenzioni riconoscibili nelle API Java. Non sono parole riservate: potremmo dichiarare Pila<Elementi> scegliendo Elementi come nome del parametro, ma una lettera convenzionale rende più immediate le firme ripetute. Se i parametri sono tanti e non sappiamo più quale dato rappresentino, fermiamoci a chiarire le responsabilità della classe.

L'argomento di un tipo parametrizzato deve essere un tipo riferimento. Non scriviamo Pila<int>: per gli interi usiamo Pila<Integer>. L'autoboxing rende naturale inserire 3, perché il compilatore produce la conversione verso Integer; questo non significa che boxing non abbia costo o che null si comporti come uno zero. In una struttura numerica molto grande, contenitore di wrapper e array primitivi hanno proprietà di memoria e accesso diverse. La scelta del generic risolve il problema del controllo statico dei tipi; non è automaticamente la scelta più efficiente per ogni carico. Il capitolo sulle collezioni riprenderà questa distinzione quando confronteremo le strutture.

Prima del diamond si ripeteva l'argomento su entrambi i lati, per esempio new Pila<String>(); con new Pila<>() il compilatore usa il tipo bersaglio già dichiarato. Il simbolo <> non indica «qualunque tipo» e non equivale al raw type Pila. Una variabile raw perde informazione e genera warning nelle operazioni non controllate. Se un vecchio progetto richiede quel confine, circoscriviamo l'interoperabilità, controlliamo il contenuto e non facciamo propagare il raw type in tutto il programma. Questo recupera il motivo della sintassi moderna: ridurre ripetizione senza rinunciare alla promessa della firma.

Perché List<Integer> non è List<Number>

Nel capitolo 9 abbiamo imparato che un Integer è un Number. Sarebbe allora lecito assegnare List<Integer> a List<Number>? No. Se fosse consentito, attraverso il secondo riferimento potremmo inserire un Double nella lista che il primo riferimento promette contenere solo Integer. I tipi generici Java sono normalmente invarianti: la relazione fra gli argomenti di tipo non si trasferisce automaticamente ai tipi parametrizzati.

La figura 15.1 separa la parentela fra elementi dalla relazione fra contenitori. È uno schema di tipi, non una disposizione degli oggetti nella memoria.

Invarianza delle liste generiche
Figura 15.1 – Integer è un sottotipo di Number, ma List<Integer> non è un sottotipo di List<Number>. La wildcard descrive la variazione ammessa dal singolo metodo.

Quando un metodo deve soltanto leggere numeri da liste di sottotipi diversi, può ricevere List<? extends Number>. Il punto interrogativo è una wildcard, un tipo non nominato che rispetta il limite Number. Dal parametro possiamo leggere un Number, ma non inserire arbitrariamente un Number: la lista effettiva potrebbe essere una List<Integer>. Quando un metodo deve scrivere valori Number in una lista, può ricevere List<? super Number>: la lista effettiva può essere, per esempio, List<Number> o List<Object>. Leggere da essa non promette un tipo più preciso di Object.

Nel programma, copiaNumeri attraversa una List<Integer> attraverso un parametro List<? extends Number> e aggiunge i valori a una List<Number> attraverso List<? super Number>. L'output è [2, 3]. La regola spesso ricordata come PECS (producer extends, consumer super) descrive chi produce valori da leggere e chi li consuma tramite scrittura. Non sostituisce la lettura del contratto: una stessa collezione può essere letta e scritta, e allora una wildcard potrebbe non essere adatta.

Definizione – Parametro di tipo e wildcard. T è un nome che può legare più posizioni della stessa firma: un metodo <T> T primo(List<T> valori) promette di restituire lo stesso tipo degli elementi ricevuti. ? rappresenta un tipo sconosciuto e si usa quando quella relazione non deve essere espressa con un nome. Sostituire sempre T con ? farebbe perdere informazioni al chiamante.

Un metodo può dichiarare i propri parametri di tipo senza che la classe sia generica, per esempio static <T> T primo(List<T> valori). Un'interfaccia può essere generica: Comparable<T> esprime quale tipo di valore può essere confrontato. Un parametro di tipo può avere un limite, per esempio <T extends Number>; la parola extends qui vale per tipi classe e interfaccia. Queste forme non obbligano a usare gerarchie complicate: servono quando il metodo deve conservare una relazione verificabile fra input e output.

Erasure e limiti reali

Java implementa i generics mediante erasure, o cancellazione dei tipi, per mantenere compatibilità con codice precedente. Il compilatore verifica le relazioni generiche e produce un file .class in cui una parte degli argomenti di tipo non è disponibile per le normali operazioni a runtime. Non significa che «i generics non esistano»: le firme possono conservare metadati generici leggibili con reflection, e soprattutto il controllo a compilazione ha già cambiato quali programmi sono ammessi. Significa che non possiamo scrivere new T() o new T[10] nel caso generale, né distinguere List<String> da List<Integer> usando instanceof.

Quando una classe generica implementa o ridefinisce un metodo con firma che, dopo erasure, richiede un adattamento, il compilatore può generare un bridge method. È un metodo sintetico nel bytecode che conserva il comportamento polimorfico atteso. Non occorre scriverlo a mano, ma sapere che esiste aiuta quando si esamina una classe con javap o reflection. La JLS 25 sui generics descrive i tipi parametrizzati, le wildcard e l'erasure.

Nel programma DemoPonte.java Deposito<T> dichiara imposta(T). La sottoclasse fissa T a String e ridefinisce imposta(String) aggiungendo una normalizzazione. Una variabile dichiarata Deposito<String> punta all'oggetto DepositoTitoli; la chiamata deve raggiungere la ridefinizione e stampare Java, senza gli spazi ai bordi.

public final class DemoPonte {
    static class Deposito<T> {
        private T valore;

        void imposta(T nuovo) {
            valore = nuovo;
        }

        T leggi() {
            return valore;
        }
    }

    static final class DepositoTitoli extends Deposito<String> {
        @Override
        void imposta(String nuovo) {
            super.imposta(nuovo.strip());
        }
    }

    private DemoPonte() { }

    public static void main(String[] args) {
        Deposito<String> deposito = new DepositoTitoli();
        deposito.imposta("  Java  ");
        System.out.println(deposito.leggi());
    }
}

Compilando con javac --release 25 -Xlint:all -d build DemoPonte.java, poi eseguendo java -cp build DemoPonte, osserviamo Java. Dopo l’erasure, la firma del metodo nella classe base usa Object; quella scritta nella sottoclasse usa String. Se ispezioniamo la sottoclasse con javap -p -classpath build 'DemoPonte$DepositoTitoli', vediamo sia imposta(java.lang.String) sia imposta(java.lang.Object). Il secondo è il metodo ponte generato dal compilatore: riceve un Object, lo controlla come String e inoltra la chiamata al metodo della sottoclasse. In questo modo una chiamata attraverso il contratto della classe base raggiunge ancora la ridefinizione che elimina gli spazi. È il dispatch, cioè la scelta dell’implementazione da eseguire. Il ponte preserva questa scelta dopo l’erasure; non ricrea a runtime gli argomenti di tipo cancellati.

Un raw type, come List senza argomento, serve soprattutto per interoperare con vecchie API. Può far perdere controlli e produrre warning. Se tramite un raw type inseriamo un valore incompatibile in una lista parametrizzata, otteniamo heap pollution: il contenuto reale non rispetta il tipo promesso dal sorgente. Per gli array conta anche quali informazioni restano durante l’esecuzione. Un tipo è reificabile quando è disponibile completamente a runtime: String lo è, mentre List<String> non conserva nello stesso modo l’argomento String. Poiché un array controlla il tipo dei propri componenti, non possiamo creare direttamente new List<String>[10]. I varargs, parametri che accettano un numero variabile di argomenti, usano un array e possono incontrare lo stesso limite con tipi generici. @SafeVarargs sopprime alcuni avvisi e dichiara che l’autore ha verificato la sicurezza del corpo; non esegue quella verifica al suo posto e non rende sicuro un array esposto o contaminato con elementi incompatibili.

Approfondimento – Cattura della wildcard. Un metodo che riceve List<?> può leggere gli elementi come Object, ma non sa quale tipo specifico sia stato catturato dal punto interrogativo. Talvolta un piccolo metodo generico privato può dare un nome temporaneo a quel tipo e compiere un'operazione sicura, come scambiare due posizioni della stessa lista. La wildcard non è «qualunque oggetto»: è un tipo preciso ma sconosciuto a quel punto del sorgente.

Tre firme che sembrano simili

Consideriamo tre metodi che cercano il primo elemento di una lista. Object primo(List<?> valori) può leggere un elemento, ma al chiamante restituisce soltanto Object: l'informazione sul tipo specifico non passa attraverso la firma. <T> T primo(List<T> valori) lega invece il tipo degli elementi al tipo restituito. Se passiamo List<String>, il risultato è una String per il compilatore. <T extends Number> T primoNumero(List<T> valori) conserva la stessa relazione, ma limita i tipi ammessi ai sottotipi di Number. La firma più restrittiva è giustificata soltanto se il corpo o il contratto hanno davvero bisogno di operazioni numeriche.

Una wildcard ? extends Number non è un modo più breve per dire che il metodo è generico. Va bene quando il metodo legge numeri e non deve restituire al chiamante lo stesso tipo specifico ricevuto. Se il risultato deve mantenere quella relazione, serve un parametro nominato. Questo è il motivo per cui le API standard contengono sia parametri di tipo sia wildcard: risolvono problemi diversi. Una firma troppo generale obbliga il chiamante a cast; una troppo stretta rifiuta usi perfettamente sicuri.

Quando basta rendere generico un metodo

Un metodo statico generico permette di legare tipi nella firma senza rendere generica l’intera classe. Un metodo static <T> boolean sonoUguali(T primo, T secondo) dichiara il suo T prima del tipo di ritorno; quel T appartiene alla chiamata del metodo, non a un'istanza di una classe generica. Possiamo chiamarlo con due stringhe e il compilatore inferisce un tipo compatibile. Possiamo anche scrivere un test con Integer; non abbiamo creato due classi diverse. Se la sola operazione del corpo è il confronto per uguaglianza, però, possiamo usare Objects.equals(primo, secondo) e domandarci se la firma generic aggiunga davvero un vincolo utile. Se il metodo non lega posizioni in modo significativo, la genericità può essere rumore.

Si può esplicitare l'argomento di tipo nella chiamata, per esempio Strumenti.<String>sonoUguali("a", "b"), ma nella maggior parte dei casi l'inferenza rende quella ripetizione inutile. L'esplicitazione torna utile quando l'inferenza sceglierebbe un tipo troppo ampio o quando vogliamo chiarire un'intenzione complessa. La domanda resta la stessa: quale relazione verificabile fra gli argomenti e il risultato stiamo promettendo?

Quando il costruttore ha un proprio tipo

Una classe può non essere generica e avere comunque un costruttore generico. Immaginiamo una ricevuta che deve conservare soltanto una descrizione testuale, ma può nascere da un numero, da una stringa o da un altro valore. Il chiamante fornisce sia il valore sia la funzione che sa descrivere proprio quel tipo. Il costruttore dichiara <T> prima del nome della classe: <T> Ricevuta(T origine, Function<T, String> descrivi). Il medesimo T lega i due argomenti della singola costruzione. Non è un parametro di tipo dell'intera classe e non rimane disponibile nei suoi altri metodi.

Nel programma completo, la prima ricevuta nasce dall'intero 42 e la seconda dalla stringa "java". Il compilatore deduce un tipo compatibile per ciascuna chiamata e controlla che la funzione accetti quel valore. Il risultato stampato è n=42 e poi JAVA. Se passassimo una funzione che accetta soltanto String insieme a un valore dichiarato Integer, il contratto non sarebbe soddisfatto: l'errore emerge in compilazione. Questo è il beneficio concreto del parametro di tipo, che Object non esprimerebbe con la stessa precisione.

import java.util.function.Function;

public final class DemoCostruttoreGenerico {
    static final class Ricevuta {
        private final String descrizione;

        <T> Ricevuta(T origine, Function<T, String> descrivi) {
            descrizione = descrivi.apply(origine);
        }

        String descrizione() {
            return descrizione;
        }
    }

    private DemoCostruttoreGenerico() { }

    public static void main(String[] args) {
        Ricevuta numero = new Ricevuta(42, n -> "n=" + n);
        Ricevuta parola = new Ricevuta("java", String::toUpperCase);
        System.out.println(numero.descrizione());
        System.out.println(parola.descrizione());
    }
}

Approfondimento – Due vite diverse per T. In Pila<T> il parametro è dichiarato dalla classe e collega metodi di una stessa istanza. In <T> Ricevuta(...) appartiene soltanto al costruttore e collega gli argomenti di quella chiamata. Un costruttore generico è utile quando questo legame evita cast o controlli tardivi; per convertire semplicemente qualunque oggetto con toString(), un parametro Object può bastare. La specifica Java 25 conferma che la classe può essere generica o meno indipendentemente dal suo costruttore.

Per una somma non basta <T> senza limite, perché un T qualunque non offre doubleValue(). <T extends Number> permette di leggere quel metodo e accetta Integer, Double e altri sottotipi di Number, ma esclude String già in compilazione. Se il corpo richiede anche che i valori siano confrontabili, possiamo esprimere più limiti, con la classe per prima e le interfacce dopo: <T extends Number & Comparable<T>>. Due classi come Integer & Double non formano invece un limite sensato e sono rifiutate. Non scegliamo i limiti per descrivere gli esempi usati nel test; li scegliamo a partire dalle operazioni che l'implementazione deve poter fare. È il legame fra problema e firma che rende leggibile una generic API.

Inferenza e tipo bersaglio

Nel codice new Pila<>(), il compilatore usa il tipo della variabile a sinistra per capire l'argomento omesso nel diamond. L'inferenza di tipo compare anche nelle chiamate a metodi generici: il compilatore combina argomenti passati e tipo atteso del risultato. Non significa che una variabile cambi tipo durante l'esecuzione. Dopo Pila<String> titoli, il contratto dei metodi resta quello per stringhe. Se un'inferenza produce un tipo più ampio del previsto, una dichiarazione esplicita o un nome di metodo più preciso può rendere il codice più leggibile senza combattere il compilatore.

I limiti possono essere multipli, per esempio un parametro che debba estendere una classe e implementare un'interfaccia. La sintassi usa & dopo extends; se compare un limite di classe, va per primo. È una capacità utile in librerie generiche, ma non dovrebbe comparire nel percorso principale senza un'operazione concreta che la richieda. Anche il principio di C13 vale qui: prima il contratto necessario, poi la sintassi che lo esprime.

Che cosa si può leggere con reflection

Sebbene un oggetto new Pila<String>() non conservi un'etichetta runtime che permetta di chiedergli direttamente «sei una pila di stringhe?», una dichiarazione come un campo Pila<String> titoli può conservare nella firma del file compilato informazioni generiche. Le API di reflection rappresentano queste forme con Type e specializzazioni come ParameterizedType, TypeVariable e WildcardType. Leggere una dichiarazione e interrogare un'istanza sono operazioni diverse: l'erasure limita la seconda, ma non cancella necessariamente ogni traccia della prima.

Questo chiarimento evita due estremi. Non possiamo fare un controllo instanceof Pila<String> su una pila qualunque, perché l'argomento di tipo non è reificato in quel modo. Non è vero nemmeno che il compilatore «dimentichi» ogni generic e che i file .class non possano documentare firme parametrizzate. Quando una libreria usa reflection per risolvere un tipo generico, occorre leggere esattamente dove quel tipo è stato dichiarato e quali metadati sono disponibili.

Una pila, tre contratti sui tipi

Facciamo evolvere la pila attraverso più forme, senza saltare la ragione di ogni passaggio. Una pila che usa int[] conserva solo interi primitivi; se serve anche una pila di titoli, copiare tutto il codice duplica l'invariante della cima e la gestione del caso vuoto. Una pila che conserva Object elimina la duplicazione ma accetta insieme "Java" e 42, senza poter promettere al chiamante quale tipo uscirà. Il cast (String) pila.pop() non ripara l'invariante: chiede soltanto alla JVM di controllare più tardi se l'ipotesi del chiamante era giusta.

Con Pila<T>, la relazione entra nella dichiarazione. void inserisci(T valore) e T estrai() usano lo stesso parametro T; quando scriviamo Pila<String>, il compilatore collega entrambe le posizioni a String. Questa è la parte essenziale, più importante del nome della classe o della scelta fra array e nodi. Il diamond new Pila<>() evita di ripetere String nel costruttore, ma non crea una pila «senza tipo». Il tipo bersaglio a sinistra fornisce il vincolo; se usiamo un raw type Pila, lo perdiamo e riapriamo proprio il rischio che volevamo chiudere.

Un'interfaccia generica può dichiarare il medesimo contratto, per esempio interface Deposito<T> { void inserisci(T valore); T estrai(); }. Una classe può implementarlo mantenendo il parametro, class Pila<T> implements Deposito<T>, oppure fissandolo, class PilaDiTitoli implements Deposito<String>. Nel secondo caso la classe concreta non è generica, ma i suoi metodi devono rispettare le firme specializzate: l'argomento e il risultato sono stringhe. Questa variante corregge un equivoco frequente: l'interfaccia generica non obbliga ogni classe che la implementa a restare genericamente parametrizzata.

Approfondimento – Il vincolo risponde a una domanda. <T extends Number> è utile se il corpo deve chiamare metodi garantiti da Number. Scriverlo soltanto perché gli esempi usano numeri impedirebbe una Pila<String> senza offrire alcun vantaggio: inserisci ed estrai non hanno bisogno di sommare. Se un algoritmo deve ordinare gli elementi, occorre invece un contratto di confronto e un limite appropriato, oppure un Comparator passato dall'esterno. Il limite si sceglie dall'operazione richiesta, non dal primo caso d'uso.

Quando i controlli statici incontrano gli array

Un array Java conosce a runtime il proprio tipo di componente. Per questo un riferimento Object[] che punta a un vero String[] non può accettare un Integer: il tentativo viene respinto a runtime. Una List<String>, invece, non porta nello stesso modo un controllo runtime dell'argomento String; l'erasure consente interoperabilità con codice più vecchio e sposta la verifica principale alla compilazione. Combinare direttamente array e tipi parametrizzati non reificabili crea quindi problemi: new List<String>[10] non è consentito.

Quando un metodo varargs riceve List<String>..., il parametro variabile viene rappresentato tramite un array. Un'implementazione imprudente può farvi entrare una lista di un altro tipo attraverso un alias meno preciso e produrre heap pollution. Un warning del compilatore non va zittito solo per rendere la build pulita: indica che il contratto generico e la rappresentazione dell'array richiedono una prova in più. @SafeVarargs è una dichiarazione di responsabilità dell'autore del metodo, ammessa nei casi previsti dal linguaggio; va applicata soltanto dopo aver verificato che il corpo non esponga o contamini quell'array.

Leggere e scrivere una wildcard senza indovinare

Riprendiamo il metodo copiaNumeri del sorgente completo. Riceve una sorgente List<? extends Number> e una destinazione List<? super Number>. Per la prima chiamiamo get(0) e otteniamo un valore che possiamo trattare come Number, perché qualunque tipo effettivo catturato dal punto interrogativo deve essere un sottotipo di Number. Non possiamo però chiamare add(3.5) sulla sorgente: se fosse una lista di Integer, il Double 3.5 violerebbe il tipo degli elementi. Possiamo aggiungere null dal punto di vista del tipo generico, ma farlo qui non avrebbe alcun senso per l'algoritmo.

Nella destinazione il ragionamento si inverte. Possiamo chiamare add(numero) con un Number: la lista reale è dichiarata per Number oppure per un suo supertipo, dunque può ospitarlo. Se poi leggiamo con get(0), il tipo statico sicuro del risultato è Object; la lista reale potrebbe essere List<Object> e contenere anche valori che non sono numeri. Non è una stranezza del compilatore: sono due promesse differenti, una sulla scrittura e l'altra sulla lettura. Per spostare i numeri usiamo entrambe nel verso in cui sono valide.

La parola PECS aiuta a ricordare la direzione, ma possiamo ricostruirla anche senza sigla. Chiediamoci che cosa deve produrre il parametro per il nostro metodo e che cosa deve accettare. Quando la sorgente produce valori leggibili come Number, extends è adatto; quando la destinazione consuma valori Number, super è adatto. Se un metodo deve aggiungere e leggere valori mantenendo esattamente lo stesso tipo, un parametro di tipo nominato T può esprimere la relazione meglio di due wildcard indipendenti. In quel caso la firma diventa parte della dimostrazione di sicurezza del metodo.

Il tipo sconosciuto resta un tipo, non diventa Object

Potremmo pensare di usare Pila<Object> come parametro per una funzione che non conosce il tipo della pila. Sarebbe comodo, ma Pila<String> non è assegnabile a Pila<Object>, per la stessa ragione per cui List<Integer> non è List<Number>. Se il metodo deve soltanto controllare se la pila è vuota o stamparne una descrizione, può ricevere Pila<?>: qualunque pila parametrizzata è compatibile con quella vista. Da estrai() potremo usare il valore solo come Object, perché la firma non promette altro. Non possiamo invece chiamare inserisci("nuovo"): la pila reale potrebbe essere una Pila<Integer>. La wildcard permette quindi di esprimere ciò che il metodo sa e ciò che non può promettere.

La figura 15.1 mostra la differenza fra la parentela di Integer e Number e l’invarianza di List<Integer> e List<Number>. Applichiamo ora lo stesso ragionamento alle pile: Pila<Integer> e Pila<String> non diventano sottotipi l’una dell’altra, ma possiamo usarle entrambe attraverso Pila<?> quando il metodo non richiede un tipo preciso di elemento. Il punto interrogativo conserva un tipo preciso ma sconosciuto in quel punto del sorgente. Possiamo leggere un elemento come Object oppure passare la pila a un metodo generico che cattura e mantiene quel tipo nella propria firma. Non possiamo invece inserirvi qualunque oggetto, come faremmo in una Pila<Object>. La wildcard dichiara il limite della nostra conoscenza; un cast non lo elimina senza richiedere un controllo ulteriore.

Proviamo a trasferire il modello a un catalogo di numeri. Un metodo che calcola la somma dei doubleValue() può ricevere List<? extends Number>, perché legge ogni valore come Number e non deve aggiungerne. Un metodo che deposita nuovi Integer può ricevere List<? super Integer>; una List<Number> e una List<Object> sono destinazioni valide. Se invece dobbiamo spostare lo stesso tipo preciso da una struttura a un'altra e restituire un elemento di quel tipo, <T> collega le posizioni meglio di due wildcard. Scrivere tre firme diverse non è una gara di sintassi: sono tre contratti diversi che il compilatore può far rispettare.

Il lettore può verificare la scelta introducendo volontariamente un'operazione vietata in ciascun metodo e leggendo l'errore. Se il metodo somma tenta di aggiungere un Double alla sorgente ? extends Number, il compilatore lo respinge; se il metodo che deposita in ? super Integer pretende di leggere direttamente un Integer, lo respinge ancora. La diagnostica è una spiegazione operativa del limite. Una volta compreso il perché, ricordare la sigla PECS diventa più facile e, soprattutto, meno pericoloso da applicare a memoria.

Per verificare

Compila DemoGenerics.java con javac --release 25 -Xlint:all -d build DemoGenerics.java ed esegui la classe. Esegui anche DemoPonte.java e usa javap -p sulla classe interna DepositoTitoli per individuare il metodo ponte generato dopo l'erasure. Poi prova ad assegnare List<Integer> a List<Number> in una copia del file e leggi la diagnostica. Infine modifica copiaNumeri affinché la sorgente sia List<? extends Integer>, il valore letto sia Integer e la destinazione sia List<? super Integer>; prova a passarle prima List<Number>, poi List<Object>. Entrambi i casi devono compilare, ma la scelta dei limiti cambia quali valori il corpo può leggere e aggiungere. Le attività di C15 useranno la pila per far emergere i vantaggi del controllo statico rispetto alla vecchia soluzione con Object e cast.

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 ↑