mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 10/39

10. Object, classi wrapper ed enumerazioni

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 antenato comune, tre domande diverse

Nel capitolo 7 abbiamo visto che due variabili possono riferirsi allo stesso oggetto. Nel capitolo 9 abbiamo introdotto una gerarchia di classi. Alla radice delle classi Java c'è Object: anche quando non scriviamo extends Object, una classe senza altra superclasse lo estende implicitamente. Questo dà a ogni istanza metodi come equals, hashCode e toString. Non significa però che questi metodi rispondano sempre alla domanda che interessa al nostro programma.

== fra riferimenti chiede se identificano lo stesso oggetto. equals chiede se due oggetti sono uguali secondo il criterio della loro classe. Il comportamento ereditato direttamente da Object considera uguali soltanto i riferimenti allo stesso oggetto; una classe può ridefinirlo. toString produce una rappresentazione testuale utile a leggere un oggetto. La sua implementazione di base non è una descrizione del suo contenuto: se vogliamo mostrarlo con un significato chiaro, la ridefiniamo.

Nel programma completo modelliamo una piccola Etichetta che vale per il testo che contiene. Ne costruiamo due con il medesimo testo, ma in due espressioni new distinte. Il confronto di identità è false; il confronto di valore è true. La figura 10.1 lega le tre domande alle tre espressioni, senza suggerire che un hash sia un'identità.

Identità, uguaglianza e hash
Figura 10.1 – Due etichette diverse possono avere lo stesso valore. Un codice hash uguale è richiesto per oggetti uguali; da solo non prova che gli oggetti siano uguali.

Dare un significato a equals e hashCode

L'esempio usa una classe immutabile: dopo la costruzione, il suo campo testo non cambia. Questo rende più facile rispettare il contratto di uguaglianza. Il metodo equals controlla prima l'identità, poi verifica che l'altro oggetto sia un'Etichetta; infine confronta i testi con String.equals.

@Override
public boolean equals(Object altro) {
    if (this == altro) return true;
    if (!(altro instanceof Etichetta etichetta)) return false;
    return testo.equals(etichetta.testo);
}

@Override
public int hashCode() {
    return testo.hashCode();
}

La firma riceve Object perché è quella del metodo che ridefiniamo. Il pattern instanceof, incontrato nel capitolo 9, evita un cast separato. Due Etichetta con lo stesso testo ottengono lo stesso hash perché lo calcoliamo dal medesimo dato usato da equals. Se ridefinissimo soltanto equals, una collezione basata su hash potrebbe cercare oggetti uguali in posizioni diverse e comportarsi in modo inatteso.

Concetto chiave – Il contratto di uguaglianza. equals deve essere riflessivo, simmetrico, transitivo e coerente finché i dati confrontati non cambiano. Se a.equals(b) è vero, a.hashCode() e b.hashCode() devono essere uguali. L'inverso non è richiesto: due oggetti diversi possono produrre lo stesso numero hash. La specifica di Object espone il contratto completo.

Due punti con le stesse coordinate

Costruiamo new Punto(10, 10) due volte. Prima di ridefinire equals, i due punti sono istanze distinte e il metodo ereditato da Object restituisce false. Dopo una ridefinizione che confronta x e y, il risultato diventa true. A questo punto dobbiamo considerare hashCode: se i due punti sono uguali secondo equals, devono produrre lo stesso codice hash. La nostra Etichetta segue esattamente questa progressione, usando una sola componente per rendere visibile il contratto.

Il codice hash non è un numero identificativo univoco dell'oggetto. Restituisce un int, quindi molti valori possibili del mondo reale devono condividere qualche risultato, e due oggetti diversi possono avere una collisione. Una struttura basata su hash usa il numero per restringere la ricerca e poi deve verificare l'uguaglianza. Anche la rappresentazione predefinita di toString, che mostra il nome della classe seguito da @ e da una forma esadecimale del codice hash, non dimostra che due oggetti siano identici o diversi. La sequenza non va interpretata come l'indirizzo di memoria dell'istanza.

La mutabilità rende più difficile questa promessa. Se inseriamo un oggetto in una collezione basata su hash e poi cambiamo un campo usato sia da equals sia da hashCode, la ricerca potrebbe non trovarlo più dove lo aveva collocato. Per questo Etichetta conserva un testo immutabile. Non è l'unica soluzione possibile, ma riduce un rischio concreto che un esempio troppo piccolo potrebbe nascondere.

Per classi con più campi, Objects.equals e Objects.hash della libreria standard possono semplificare il codice. Non aggiungiamo automaticamente tutti i campi: scegliamo prima che cosa rende due istanze equivalenti nel dominio. Se l'uguaglianza deve basarsi su tutti i componenti di un semplice valore immutabile, un record come quello del capitolo 7 fornisce già equals, hashCode e toString coerenti con i componenti. Se un componente è un array, il confronto generato dal record usa comunque l'uguaglianza di quel componente: la progettazione del valore resta responsabilità nostra.

toString nell'esempio restituisce Etichetta[Java], così una stampa o un messaggio diagnostico sono leggibili. Non dovrebbe rivelare dati riservati: il testo stampato da un oggetto può finire in log e segnalazioni d'errore.

getClass() restituisce un oggetto Class che descrive la classe effettiva dell'istanza durante l'esecuzione. Può servire per diagnostica o per API che esaminano tipi, ma la normale logica del programma dovrebbe preferire contratti, metodi e interfacce alla continua interrogazione della classe concreta. clone() esiste in Object, ma la sua combinazione con Cloneable e con oggetti che contengono altri oggetti richiede attenzione: non è un comando universale per ottenere una copia indipendente. Per un valore semplice è spesso più chiaro costruire un nuovo oggetto o usare un metodo di copia con un contratto esplicito.

Sull'oggetto Class ottenuto da un Punto possiamo chiamare getSimpleName(), getName() e getPackageName(). La differenza si vede con una classe nel package esempi.geometria: il nome semplice può essere Punto, mentre il nome completo comprende il package. Queste informazioni possono aiutare un sistema che registra eventi o carica componenti per nome, ma non dicono quali valori contenga una particolare istanza di Punto. La reflection permette di esaminare membri e, nei limiti consentiti, invocare operazioni; non riscrive a piacere il codice sorgente o l'interfaccia pubblica di una classe già caricata, come si potrebbe credere da una descrizione troppo generica. La sua potenza va accompagnata da regole di accesso, moduli e controlli sugli errori.

Object dichiara anche metodi legati alla coordinazione fra thread, come wait e notify. Non li useremo per imparare l'uguaglianza: appartengono al tema della concorrenza e richiedono di comprendere monitor, possesso del lock e condizioni di attesa. Il fatto che un metodo sia ereditato da ogni oggetto non significa che sia opportuno chiamarlo in qualsiasi capitolo.

Tre metodi, tre prove diverse

Consideriamo due punti con coordinate (10, 10). Senza una ridefinizione, primo.equals(secondo) eredita il comportamento di Object e restituisce false se abbiamo usato due istruzioni new, benché le coordinate coincidano. Dopo una ridefinizione che considera soltanto x e y, il risultato diventa true. A quel punto dobbiamo ridefinire anche hashCode usando gli stessi dati. Non serve ottenere un numero hash «bello» o diverso per ogni punto: serve garantire che due punti considerati uguali producano lo stesso numero. Se questa regola manca, il difetto potrebbe rimanere invisibile finché non inseriamo i punti in una raccolta basata su hash.

La terza prova riguarda toString. Stampare System.out.println(primo) invoca una rappresentazione testuale dell'oggetto. Possiamo decidere di restituire Punto[x=10, y=10], che aiuta la diagnosi; questa scelta non modifica né l'identità né l'uguaglianza. Due punti possono avere lo stesso toString e restare distinti, oppure essere uguali secondo il contratto e avere una rappresentazione destinata a cambiare per ragioni editoriali. Non scriviamo un programma che confronta oggetti attraverso la loro stringa di stampa: le tre domande devono rimanere separate.

getClass() risponde a una quarta domanda: qual è la classe effettiva dell'istanza che stiamo osservando? Se una variabile ha tipo Object ma indica un Punto, getClass() descrive Punto. Il risultato è un oggetto Class, non una stringa da confrontare a mano. getSimpleName() può produrre il solo nome semplice e getName() un nome più completo, utile nei messaggi di diagnostica; nessuno dei due ci dice se due punti abbiano coordinate uguali. Nel capitolo sull'ereditarietà abbiamo visto che lavorare attraverso un tipo generale è normale: ricorrere a getClass() per ogni decisione del dominio significherebbe spesso perdere il beneficio dei metodi comuni e della ridefinizione.

Un'ultima distinzione riguarda il codice hash. Il metodo hashCode() non promette di restituire l'indirizzo dell'oggetto e non promette un numero diverso per ogni istanza. Un HashSet può collocare due valori diversi nella stessa zona della propria struttura e poi distinguerli con equals. Il contratto obbliga soltanto gli oggetti uguali ad avere hash uguali durante il periodo in cui i dati rilevanti non cambiano. Per questo il nostro esempio evita di stampare numeri hash specifici come output atteso: il lettore deve verificare la relazione fra i valori, non imparare un numero prodotto da una particolare esecuzione.

Approfondimento – Objects.equals e il caso null. Se due campi possono essere null, Objects.equals(a, b) restituisce true quando sono entrambi nulli, false quando uno solo lo è, e altrimenti chiama a.equals(b). Riduce un controllo ripetitivo, ma non decide quali campi appartengano al significato del valore. Objects.hash(...) costruisce un hash dai valori forniti; anche qui la scelta dei valori deve restare coerente con equals. Il nome simile non autorizza a usare Objects.equals in equals e un campo diverso in hashCode.

Quando un primitivo serve come oggetto

I tipi primitivi, per esempio int, non sono istanze di Object. Le classi wrapper (Integer, Double, Boolean e le altre) rappresentano quei valori come oggetti. Nell'esempio, Integer.valueOf(42) produce un Integer; l'assegnazione a int valore estrae il primitivo attraverso l'unboxing. L'operazione inversa, dal primitivo al wrapper, si chiama boxing. Il compilatore può inserirle automaticamente quando il contesto lo richiede.

Integer numero = Integer.valueOf(42);
int valore = numero;
System.out.println(valore + 1);

La riga stampa 43. Se numero fosse null, l'unboxing non avrebbe un valore da estrarre e produrrebbe NullPointerException. Inoltre, == applicato a due riferimenti wrapper confronta l'identità e può sembrare dare risultati diversi per valori diversi a causa delle regole di caching. Per confrontare il valore, usa equals oppure, quando i valori sono validi e la conversione è appropriata, confronta i primitivi. La documentazione di java.lang elenca i wrapper; le collezioni che vedremo più avanti ne sono un caso d'uso comune.

Nota bene – Confronti misti. In numero == 42, uno dei due operandi è primitivo e Java può estrarre il valore dal wrapper; in numero == unAltroInteger, se entrambi sono riferimenti Integer, il confronto è d'identità. Scrivere tipi e conversioni esplicitamente mentre si impara evita di attribuire un significato unico a == in contesti differenti.

Perché esistono i wrapper

Una collezione come List<Integer> lavora con oggetti, mentre un array int[] conserva direttamente valori primitivi. I wrapper permettono di usare valori numerici nei contesti che richiedono riferimenti. La corrispondenza è byte/Byte, short/Short, int/Integer, long/Long, float/Float, double/Double, char/Character e boolean/Boolean. I nomi Integer e Character non si ottengono aggiungendo semplicemente una maiuscola all'inizio del primitivo: è bene riconoscerli quando compaiono nei messaggi del compilatore.

Il valore rappresentato da un wrapper non cambia dopo la sua creazione. Se scriviamo Integer numero = 10; numero = numero + 1;, la seconda istruzione non modifica internamente l'oggetto che rappresentava 10: calcola un nuovo valore e assegna alla variabile un riferimento appropriato per 11. Il compilatore può nascondere boxing e unboxing in una sintassi breve, ma le conversioni esistono e possono avere costi o fallire se un wrapper da estrarre vale null. Per la conversione esplicita da testo a numero useremo metodi come Integer.parseInt("42"), distinguendo l'errore di testo non numerico dalla semplice presenza di un oggetto wrapper.

La tabella dei wrapper rende evidente la corrispondenza, senza suggerire che Boolean e Character appartengano alla gerarchia numerica.

Valore primitivo Oggetto wrapper Un uso possibile
byte, short, int, long Byte, Short, Integer, Long valore intero in un'API che richiede oggetti
float, double Float, Double valore in virgola mobile in una raccolta
char Character unità UTF-16 trattata come oggetto
boolean Boolean esito logico in un contesto che ammette null

La tabella accorpa le coppie per funzione, ma ciascun primitivo ha il proprio wrapper. Integer non contiene un int da cambiare tramite setter. Per ottenere un int da un testo usiamo Integer.parseInt, che restituisce un primitivo; Integer.valueOf restituisce invece un oggetto Integer. Entrambi possono rifiutare un testo non numerico con NumberFormatException. Se dobbiamo rappresentare l'assenza di un valore, Integer può contenere null come riferimento, ma questo impone una scelta esplicita prima dell'unboxing. Un valore mancante non dovrebbe essere confuso automaticamente con zero.

La figura 10.2 mostra la gerarchia dei wrapper: i wrapper dei numeri fanno capo a Number, mentre Boolean e Character sono separati. Il disegno non dice che tutti i valori numerici siano intercambiabili senza conversioni; mostra soltanto il rapporto fra le classi wrapper.

Gerarchia dei wrapper
Figura 10.2 – Boolean e Character non sono sottoclassi di Number. Ogni primitivo ha un wrapper, ma la gerarchia delle classi non coincide con una graduatoria di conversioni numeriche.

Per capire – Un errore nascosto dalla sintassi. Considera Integer voto = null; int prossimo = voto + 1;. La seconda riga sembra una normale addizione, ma richiede prima di estrarre un int da voto. Poiché non c'è un oggetto da cui estrarre il valore, si ottiene NullPointerException. Il messaggio non dice che l'operatore + sia difettoso: ci invita a risalire alla provenienza del riferimento nullo e a decidere che significato abbia nel contratto.

Seguire boxing e unboxing senza magie

Immagina di aggiungere un voto a una List<Integer>. La raccolta conserva oggetti, mentre il valore che hai calcolato può essere un int. In una chiamata come voti.add(27), il compilatore inserisce il boxing necessario per ottenere un Integer. Quando leggi int primo = voti.get(0);, inserisce l'unboxing. Il codice è breve, ma una lettura accurata ricorda che get(0) restituisce un riferimento e che quel riferimento potrebbe essere nullo se la raccolta ammette e contiene null. Il fatto che il compilatore faccia una conversione non cambia il contratto dei dati che hai scelto di memorizzare.

Il confronto è un altro punto in cui la sintassi corta inganna. Integer a = 100; Integer b = 100; può far apparire vero a == b, perché alcune istanze sono condivise secondo le regole dell'API. Cambiare i valori può far apparire falso lo stesso confronto fra variabili che contengono numeri uguali. Un libro non dovrebbe insegnare il confine della cache come regola per confrontare numeri: dovrebbe insegnare a porre la domanda giusta. Se vuoi sapere se due valori wrapper rappresentano lo stesso numero, usa a.equals(b) dopo aver definito come trattare null. Se vuoi lavorare solo con numeri presenti e scegliere un confronto primitivo, estrai consapevolmente i valori. == fra due riferimenti rimane un confronto di identità.

Anche Double e Float richiedono attenzione diversa dagli interi: la virgola mobile ha valori speciali e arrotondamenti propri. Un wrapper non rende il calcolo esatto per magia. Quando ci occuperemo di importi di denaro, non basterà sostituire double con Double; dovremo scegliere una rappresentazione coerente con le regole dell'importo. Qui ci basta riconoscere che il wrapper risolve il problema del tipo di oggetto, non ogni problema matematico del valore primitivo.

Per verificare – Due errori di origine diversa. Integer.parseInt("venti") fallisce perché il testo non rappresenta un intero valido. Integer numero = null; int valore = numero; fallisce perché manca l'oggetto da cui estrarre il valore. Le due istruzioni non hanno lo stesso problema: nel primo caso controlliamo il formato dell'input, nel secondo la presenza del valore. Scrivi prima questa diagnosi, poi osserva i tipi delle eccezioni durante l'esecuzione.

Un insieme dichiarato di valori

Un'enumerazione, dichiarata con enum, dà un nome e un tipo a un insieme finito di costanti. Nel programma Stato ammette NUOVO e IN_PRESTITO; Stato.IN_PRESTITO è un valore di quel tipo, non una stringa libera soggetta a refusi. Possiamo usarlo in un switch e, se il dominio lo richiede, aggiungere campi, costruttori e metodi all'enumerazione. Qui consentePrestito() risponde true solo per NUOVO: la regola resta vicina ai valori che governa. Non va scelto per elenchi che cambiano secondo i dati caricati dall'esterno.

enum Stato {
    NUOVO, IN_PRESTITO;

    boolean consentePrestito() {
        return this == NUOVO;
    }
}

Le costanti enum sono istanze definite dalla dichiarazione. Per confrontarle si usa normalmente ==, perché ciascuna costante rappresenta un'identità unica nel proprio tipo. Questo caso non smentisce il criterio spiegato per le nostre Etichetta: sono modelli diversi. Il nome di una costante può essere letto con name(), ma non conviene usarlo come testo destinato all'interfaccia se quella descrizione potrà cambiare o essere tradotta.

Dai numeri dei mesi a un tipo che si controlla

Consideriamo dodici costanti intere per i mesi. Una variabile int mese poteva ricevere anche 20: il nome della costante aiutava a scrivere il valore corretto, ma non impediva di assegnarne uno fuori elenco. Con enum Mese { GENNAIO, FEBBRAIO, ... }, la variabile Mese mese può contenere una delle costanti dichiarate oppure null; un intero qualsiasi non è un valore di quel tipo. Non basta, però, trasformare in enum qualsiasi insieme di parole: se i valori arrivano da un archivio che l'utente può ampliare, un elenco fissato nel sorgente non descrive bene il problema.

Ogni enum dispone di values(), che restituisce un array delle costanti nell'ordine dichiarato. Possiamo attraversarlo con un for esteso, come nell'esempio dei mesi, ma non dovremmo salvare in un database la posizione numerica ottenuta da ordinal(): riordinare o inserire una costante cambierebbe quei numeri. Per un formato esterno serve un codice stabile deciso dal dominio. Il confronto mese == Mese.GENNAIO è sicuro anche se mese è null: restituisce false. mese.equals(Mese.GENNAIO), invece, lancerebbe NullPointerException in quel caso.

Un enum può avere un costruttore e campi per dati che appartengono stabilmente a ciascuna costante. Nel nostro Stato, il metodo consentePrestito() è una regola semplice associata ai valori. Un campo booleano potrebbe distinguere mesi «invernali»: quell'esempio va maneggiato con cautela, perché la stagione dipende anche dall'emisfero e dal criterio meteorologico o astronomico adottato. Un campo su ogni costante può essere corretto quando la classificazione è parte esplicita del contratto del programma; se dipende da luogo e data, serve un'operazione che riceva anche quel contesto.

Quando incontreremo le raccolte, EnumSet ci darà un insieme specializzato di costanti dello stesso enum, mentre EnumMap permetterà di associare un valore a ciascuna costante. Non servono per il primo switch del capitolo: ricordiamo soltanto che l'enum è un tipo vero, usabile anche dalle API, e non un elenco di numeri a cui abbiamo applicato nomi più leggibili.

Un'enumerazione è più di un insieme di nomi stampabili. Se dichiariamo enum Mese { GENNAIO, FEBBRAIO, MARZO } per un esperimento ridotto, Mese.GENNAIO ha tipo Mese: non si può assegnare direttamente alla variabile un int o la stringa "GENNAIO". Per trasformare un testo in una costante esiste Mese.valueOf(testo), ma il metodo richiede un nome esatto e segnala un nome sconosciuto con IllegalArgumentException. Se il testo proviene da una persona, dobbiamo decidere come gestire maiuscole, spazi e traduzioni prima della conversione. La sicurezza del tipo nel programma non rende automaticamente validi i dati esterni.

Quando un enum possiede dati e metodi, il suo costruttore non è un invito a creare nuove costanti durante l'esecuzione. L'elenco resta quello della dichiarazione. Per un mese potremmo conservare un numero stabile deciso dal dominio e offrire un metodo numero(), distinguendolo da ordinal(), che segue semplicemente l'ordine nel sorgente. Se l'ordine delle costanti cambia per rendere il codice più leggibile, il numero di dominio deve restare quello promesso ai dati già salvati. Questa distinzione è preziosa ogni volta che il programma scambia valori con file o database.

Un enum non può estendere liberamente una nostra classe base, ma può implementare interfacce e quindi rispettare un contratto condiviso con altri tipi. Lo vedremo più avanti quando le interfacce avranno un ruolo concreto nel programma. Per i nomi delle costanti si usano spesso lettere maiuscole, per esempio GENNAIO: è una convenzione riconoscibile anche oggi, non una regola che il compilatore impone. Scegliere nomi stabili e leggibili conta soprattutto se quei nomi sono serializzati o letti da altri componenti: rinominare una costante è allora anche una modifica del contratto esterno.

Un enum come piccolo tipo del dominio

Torniamo al prestito di un libro. Con una stringa libera potremmo scrivere "IN_PRESTITO", "in prestito" o "prestato" e poi ricordarci in ogni metodo che tutti e tre i testi significano la stessa cosa. Con enum Stato { DISPONIBILE, IN_PRESTITO }, il compilatore conosce i due valori ammessi dal programma. Un metodo puoEsserePrestato(Stato stato) può ricevere soltanto un valore di quel tipo o null; non può ricevere accidentalmente una String. Il controllo su null resta una nostra decisione: il tipo enum riduce gli stati rappresentabili, ma non elimina il riferimento nullo.

Supponiamo che Stato offra consentePrestito(). Quando il libro è DISPONIBILE, il metodo restituisce vero; quando è IN_PRESTITO, falso. Abbiamo messo la regola accanto al tipo che la descrive, ma la transizione del libro richiede ancora un'operazione: presta() può controllare lo stato corrente e cambiarlo. Avere un enum non impedisce a due parti del programma di modificare direttamente un campo pubblico stato senza rispettare le regole del prestito. L'incapsulamento del capitolo 8 e il tipo enum risolvono problemi diversi e lavorano bene insieme.

Possiamo attraversare i mesi con un for esteso, scritto come for (Mese mese : Mese.values()) { ... }: values() restituisce un array con le costanti nell'ordine in cui sono state dichiarate. È utile per costruire una scelta o verificare tutti i casi, ma non garantisce che quell'ordine sia un codice stabile per dati esterni. toString() può essere ridefinito per presentare una descrizione, mentre name() conserva il nome della costante dichiarata nel sorgente. Se un'interfaccia deve mostrare Gennaio in italiano e January in inglese, la traduzione appartiene all'interfaccia o a risorse di localizzazione, non al solo nome maiuscolo del valore enum.

Per verificare

Compila il sorgente completo con javac --release 25 -Xlint:all -d build DemoValore.java ed esegui java -cp build DemoValore. Prevedi prima le sette righe. Poi cambia solo il testo di seconda in "JVM": quali righe cambiano? L'attività C10 guida anche una piccola diagnosi del contratto fra equals e hashCode.

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 ↑