mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 7/39

7. Definizione di classi e oggetti

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 →

Dal modello al programma

Nel capitolo 2 abbiamo immaginato una biblioteca fatta di libri, lettori e prestiti. Finora erano soprattutto nomi con cui ragionare. Come diventa una di quelle idee un programma Java? Partiamo da un libro che conosce il proprio titolo e sa contare quante volte è stato preso in prestito. È un esempio piccolo: ci permetterà di vedere una classe, creare un oggetto e chiamare un metodo senza confondere le tre operazioni.

Una classe è una dichiarazione: descrive quali dati e operazioni avranno gli oggetti creati secondo quella definizione. Un oggetto, o istanza, è un'entità concreta del programma. Possiamo dichiarare la classe Libro una volta e creare più oggetti Libro, ciascuno con il proprio titolo e il proprio contatore. La differenza era già comparsa nella definizione del capitolo 2; adesso la vediamo nel codice.

public class Libro {
    private final String titolo;
    private int prestiti;

    public Libro(String titolo) {
        this.titolo = titolo;
    }

    public void registraPrestito() {
        prestiti++;
    }

    public String descrizione() {
        return titolo + " - prestiti: " + prestiti;
    }

    public static void main(String[] args) {
        Libro primo = new Libro("Java Mattone dopo Mattone");
        Libro secondo = primo;
        primo.registraPrestito();
        System.out.println(secondo.descrizione());
        System.out.println(primo == secondo);
    }
}

Salva il file come Libro.java. Nella cartella che lo contiene, javac --release 25 -d build Libro.java produce la classe compilata e java -cp build Libro la avvia. Le due righe stampate sono Java Mattone dopo Mattone - prestiti: 1 e true. Se il risultato ti sorprende, torna alla riga Libro secondo = primo: è il centro dell'esempio.

Campi e metodi

Dentro Libro, titolo e prestiti sono campi: conservano dati associati a ogni istanza. Il titolo viene assegnato quando nasce il libro e, grazie a final, non viene riassegnato più tardi. Il contatore invece cambia quando registriamo un prestito. La parola private impedisce a codice esterno di accedere direttamente a questi campi; nel prossimo capitolo vedremo perché questo aiuta a mantenere regole valide, e perché da solo non basta.

registraPrestito e descrizione sono metodi. Un metodo dà un nome a un'operazione e contiene le istruzioni necessarie a eseguirla. Il primo non restituisce un valore, perciò dichiara void. Il secondo restituisce una String: l'istruzione return consegna la descrizione al punto in cui il metodo è stato chiamato. In secondo.descrizione(), il punto seleziona un metodo dell'oggetto a cui si riferisce secondo. La stringa descrive lo stato al momento della chiamata; se registrassimo un altro prestito, una nuova chiamata produrrebbe un testo diverso.

Il testo fra parentesi nella dichiarazione di un metodo è l'elenco dei parametri formali. Nel costruttore Libro(String titolo), il parametro formale è il nome locale che riceve il valore passato da new Libro("Java Mattone dopo Mattone"); quella stringa è l'argomento della chiamata. Distinguere i due nomi aiuta a leggere il codice: il parametro appartiene alla dichiarazione del metodo, l'argomento alla singola chiamata.

Approfondimento – Un numero variabile di argomenti. Un parametro scritto String... titoli permette di chiamare un metodo con zero o più stringhe. All'interno del metodo, titoli si usa come un array: titoli.length dice quanti valori sono arrivati. Il parametro variabile deve essere l'ultimo della lista e ce ne può essere soltanto uno. È utile quando il numero dei valori cambia davvero; non serve a evitare di progettare un insieme di dati coerente. La specifica dei metodi definisce questa sintassi.

Il blocco main è anch'esso un metodo, ma dichiara static: il comando java può usarlo come punto di ingresso senza che noi dobbiamo prima creare esplicitamente un Libro. Invece registraPrestito lavora sul contatore di un particolare libro. Non possiamo chiamarlo come Libro.registraPrestito() perché non saprebbe quale oggetto aggiornare. La distinzione fra membro di classe e membro di istanza è più importante della parola chiave da memorizzare.

Leggere una dichiarazione di metodo per intero

Prendiamo public String descrizione(). public indica che il metodo può essere chiamato anche dal codice che usa la classe; String indica il tipo del valore restituito; descrizione è il nome; le parentesi vuote dicono che la chiamata non richiede argomenti. Il corpo comincia con { e termina con }. Se promettiamo un risultato di tipo String, ogni percorso normale che completa il metodo deve produrre un valore compatibile tramite return. Scrivere soltanto return; non basterebbe. In public void registraPrestito(), invece, void dice che non c'è un valore da consegnare.

Nella dichiarazione Libro(String titolo) compare un tipo seguito dal nome del parametro. Nella chiamata new Libro("Java Mattone dopo Mattone") compare l'argomento concreto. Un metodo può ricevere più parametri separati da virgole, per esempio prestaA(String lettore, int giorni). Ogni argomento deve essere compatibile con il parametro che occupa la stessa posizione. Il nome giorni non obbliga il chiamante a passare un numero sensato: se i valori negativi non hanno significato, il metodo dovrà controllarli.

Il numero di argomenti richiesti da un metodo è chiamato arietà. Con String... argomenti dichiariamo un metodo a numero variabile di argomenti: il chiamante può passare zero o più stringhe. Dentro il metodo la variabile si usa come un array, ma chi chiama può scrivere gli argomenti separatamente. Il vantaggio è reale quando il numero dei valori cambia; la difficoltà è che una lista lunga e anonima non dice quale ruolo abbia ogni elemento. Per un prestito, un oggetto che raccoglie i dati del lettore e della durata è spesso più chiaro di molti argomenti posizionali.

Nota bene – Un campo non è una variabile locale. prestiti appartiene a ogni istanza di Libro. Se crei due libri, ciascuno ha il proprio contatore. Una variabile dichiarata nel corpo di descrizione, invece, nasce per quella chiamata e non è un campo condiviso con le chiamate successive. Il fatto che entrambi siano scritti con un tipo e un nome non li rende intercambiabili.

Dal metodo isolato alla classe completa

Partiamo dalla forma generale di un metodo, poi collochiamolo nella classe: leggere la dichiarazione è più utile che imparare un elenco di simboli a memoria. In Java una dichiarazione di metodo si trova nel corpo di una classe, di un'interfaccia o di un'altra struttura che può contenerla: non possiamo mettere descrizione() da sola, fuori da ogni tipo, come faremmo con una funzione libera in altri linguaggi. Dentro il corpo di Libro possono convivere campi, costruttori e metodi. Le parentesi graffe esterne delimitano la classe; quelle interne delimitano il corpo del singolo metodo. Quando il compilatore incontra un'istruzione, il suo posto nel sorgente contribuisce quindi a stabilire quali nomi possa usare.

Una classe è un modello comune, ma i valori dei campi di istanza non sono comuni a tutti gli oggetti. Se scriviamo Libro manuale = new Libro("Java"); e poi Libro romanzo = new Libro("Il sentiero");, il metodo descrizione() è definito una sola volta nel sorgente, mentre il campo titolo di ciascuna istanza ha un valore diverso. È utile chiedersi, per ogni dichiarazione, se descrive ciò che ogni libro possiede o ciò che la classe intera condivide. Il primo caso porta normalmente a un campo o metodo di istanza; il secondo può giustificare static. La differenza non dipende dal numero di variabili che useremo per raggiungere l'oggetto: due variabili possono indicare lo stesso libro.

Nota bene – var ha un posto preciso. Nei campi di Libro scriviamo il tipo, per esempio private int prestiti;: var non si usa per dichiarare un campo. In un metodo possiamo invece scrivere var copia = new Libro("Java");, perché l'inizializzatore permette di dedurre il tipo della variabile locale. var assente = null; non compila: da null da solo il compilatore non può inferire il tipo richiesto. Se vogliamo rappresentare l'assenza di un libro, dichiariamo esplicitamente Libro assente = null; e poi decidiamo se quel valore è lecito nel contratto. Riunire i campi all'inizio della classe è una convenzione che spesso aiuta la lettura, perché facilita la ricerca dei membri; Java non impone quell'ordine, e la coerenza del progetto conta più di una posizione fissa.

Il valore restituito da un metodo è distinto dai messaggi che il metodo stampa. descrizione() consegna una String al chiamante: quest'ultimo può stamparla, salvarla, confrontarla o passarla a un altro metodo. Un metodo che eseguisse internamente System.out.println(...) produrrebbe invece un effetto sul flusso di uscita, ma non darebbe automaticamente al chiamante il testo da riusare. Nella prima stesura di un programma le due forme sembrano equivalenti perché vediamo la stessa riga sullo schermo; appena dobbiamo comporre una pagina, fare una prova automatica o mostrare il risultato in un'interfaccia grafica, la differenza diventa decisiva.

Per capire – Argomenti e parametri non sono gli stessi nomi. In prestaA("Ada", 14), "Ada" e 14 sono gli argomenti della chiamata. In prestaA(String lettore, int giorni), lettore e giorni sono i parametri formali disponibili nel corpo. Il chiamante può aver ottenuto quei valori da variabili con nomi del tutto diversi. Prima di leggere il corpo, accoppia ogni argomento al parametro nella stessa posizione e chiediti se il tipo e il significato sono compatibili.

Un metodo a numero variabile di argomenti conserva la medesima logica. Se stampaTitoli(String... titoli) riceve tre titoli, nel corpo titoli è un array di lunghezza tre; se non ne riceve, la lunghezza è zero. La forma con i puntini permette anche una chiamata con un array già esistente. Il vantaggio è una chiamata comoda per una serie omogenea di valori, non l'eliminazione delle regole sui tipi. Per esempio, stampaTitoli("Java", 7) non diventa valida perché il metodo accetta un numero variabile di argomenti: 7 non è una String.

Nell'esempio storico DemoElementiMultipli, il ciclo for (String argomento : arg) stampava ogni elemento ricevuto. Letto con la regola appena vista, il metodo non possiede una magia per leggere argomenti illimitati: riceve un array e lo attraversa. Se la chiamata è stampaTitoli("Java", "JVM"), il ciclo compie due iterazioni e stampa due righe; con stampaTitoli() non entra nel corpo del ciclo. Se occorre un parametro obbligatorio prima della parte variabile, una dichiarazione come stampaTitoli(String prefisso, String... titoli) lo esprime. I puntini devono restare sull'ultimo parametro, così il compilatore sa quali argomenti riunire nell'array. Per una lista da conservare o trasformare in più passaggi, può essere più chiaro passare una raccolta esplicita.

Un libro che si sfoglia

Immaginiamo un Libro con un numero di pagine e una pagina corrente. I metodi descrivono azioni dell’oggetto, non soltanto formule che restituiscono numeri. Diamo a queste azioni una regola chiara: le pagine sono numerate da 1 a numeroPagine; quando siamo all'ultima, sfogliaAvanti() non va oltre, e quando siamo alla prima, sfogliaIndietro() non scende a zero. Il contratto ci permette di prevedere il comportamento prima di guardare i dettagli del codice.

Il sorgente completo dichiara due campi e tre operazioni pubbliche. numeroPagine viene fissato dal costruttore; paginaCorrente può cambiare. La parola final sul primo campo impedisce una nuova assegnazione dopo la costruzione, mentre l'assenza di final sul secondo consente di avanzare e tornare indietro. Il costruttore rifiuta un libro con zero pagine: non avrebbe una pagina 1 da cui iniziare.

public final class LibroDaSfogliare {
    private final int numeroPagine;
    private int paginaCorrente;

    public LibroDaSfogliare(int numeroPagine) {
        if (numeroPagine <= 0) {
            throw new IllegalArgumentException("serve almeno una pagina");
        }
        this.numeroPagine = numeroPagine;
        this.paginaCorrente = 1;
    }

    public int paginaCorrente() {
        return paginaCorrente;
    }

    public void sfogliaAvanti() {
        if (paginaCorrente < numeroPagine) {
            paginaCorrente++;
        }
    }

    public void sfogliaIndietro() {
        if (paginaCorrente > 1) {
            paginaCorrente--;
        }
    }

    public static void main(String[] args) {
        LibroDaSfogliare libro = new LibroDaSfogliare(3);
        System.out.println(libro.paginaCorrente());
        libro.sfogliaAvanti();
        System.out.println(libro.paginaCorrente());
        libro.sfogliaIndietro();
        System.out.println(libro.paginaCorrente());
    }
}

Le tre righe stampate sono 1, 2 e 1. Il primo println osserva lo stato iniziale; la seconda stampa segue un avanzamento; la terza segue il ritorno alla prima pagina. Se chiami sfogliaIndietro() ancora una volta, il numero resta 1. Se ripeti sfogliaAvanti() fino all'ultima pagina e oltre, resta 3. Questo comportamento è una scelta del nostro modello, non una proprietà automatica di tutti i libri Java. Avremmo potuto segnalare un tentativo di uscire dai limiti con un'eccezione, purché il contratto lo dicesse. Nel capitolo 8 vedremo come questi vincoli diventano parte dell'incapsulamento.

La classe contiene un metodo main soltanto per renderla eseguibile come esercizio. Le operazioni di sfoglio non dipendono da main e possono essere usate da un altro programma che crea un LibroDaSfogliare. Questa separazione è utile: il punto di ingresso serve alla dimostrazione, mentre campi e metodi descrivono la responsabilità dell'oggetto. Se avessimo scritto tutti i calcoli direttamente in main, avremmo stampato le stesse tre righe senza aver imparato a modellare un libro.

Costruire un'istanza

La riga new Libro("Java Mattone dopo Mattone") crea un'istanza e invoca il costruttore Libro(String titolo). Un costruttore ha il nome della classe e non dichiara un tipo di ritorno, neppure void. Qui riceve il titolo e lo assegna al campo. La variabile primo conserva il riferimento all'oggetto creato, non una copia di tutti i suoi campi. Il capitolo 8 approfondirà validazione, costruttori alternativi e invarianti; per ora ci basta vedere come la dichiarazione della classe diventi un'istanza utilizzabile.

Nel costruttore i due nomi titolo indicano cose diverse. this.titolo è il campo dell'oggetto corrente; il titolo a destra è il parametro ricevuto. this è il riferimento con cui un metodo o un costruttore indica la propria istanza. Quando non c'è ambiguità, come in prestiti++, Java consente di ometterlo. Non è un secondo oggetto nascosto: è un modo esplicito di dire «questo libro».

Nomi, ambito e this: seguiamo la ricerca

Immagina una classe Punto con i campi x e y e un metodo sposta(int x, int y). Dentro il metodo, i nomi semplici x e y indicano i parametri, perché sono i nomi più vicini nel loro ambito. Scrivere x = x; assegnerebbe il parametro a sé stesso: il campo resterebbe invariato. Le istruzioni this.x = x; e this.y = y; indicano invece i campi dell'oggetto corrente a sinistra, e i parametri a destra. È la stessa regola già vista nel costruttore di Libro, applicata a due dati.

Una variabile dichiarata dentro un blocco interno è visibile dove le regole di ambito lo consentono, fino alla fine di quel blocco. Un parametro è disponibile nel corpo del metodo che lo dichiara. Un campo è accessibile dai metodi della classe, secondo le regole di accesso e di contesto. Questi sono confini del nome nel sorgente: non dicono che la memoria di un oggetto venga liberata appena una parentesi graffa si chiude. Se un riferimento all'oggetto è conservato altrove, l'oggetto può continuare a essere raggiungibile.

Possiamo scrivere this.descrizione() da un metodo di istanza di Libro oppure, se non c'è ambiguità, descrizione(). In entrambi i casi chiediamo l'operazione allo stesso oggetto. this può anche essere passato come argomento o restituito da un metodo quando c'è un motivo preciso per consegnare il riferimento all'istanza corrente. Non si può invece usare this nel corpo di un metodo static, perché quel metodo non è legato a una particolare istanza. La specifica delle classi distingue questi contesti.

Consideriamo un esperimento in cui un campo e una variabile locale si chiamano entrambi appoggio. È un caso utile perché obbliga a leggere i nomi con attenzione. Nel programma DemoAmbito, il campo parte da 5, la variabile locale da 3 e il parametro valore riceve 2. Prima di eseguirlo, prova a seguire le tre assegnazioni e a scrivere i tre numeri che saranno stampati.

public class DemoAmbito {
    private int appoggio = 5;

    public void somma(int valore) {
        int appoggio = 3;
        appoggio = appoggio + valore;
        System.out.println("locale: " + appoggio);

        this.appoggio = this.appoggio + valore;
        System.out.println("campo: " + this.appoggio);

        this.appoggio = this.appoggio + appoggio;
        System.out.println("campo finale: " + this.appoggio);
    }

    public static void main(String[] args) {
        new DemoAmbito().somma(2);
    }
}

La prima assegnazione usa il nome semplice appoggio, che nel corpo di somma indica la variabile locale: 3 più 2 dà 5. Il campo è ancora 5. La seconda assegnazione nomina esplicitamente il campo con this.appoggio: 5 più 2 dà 7. La terza somma al campo 7 il valore locale 5 e ottiene 12. L'output è quindi locale: 5, campo: 7, campo finale: 12. Le parole davanti ai numeri non sono decorative: ci impediscono di attribuire per errore una stampa al dato sbagliato.

Se togliamo this. dalla seconda assegnazione, non otteniamo un modo più corto di aggiornare il campo. Aggiorniamo ancora la variabile locale e cambiamo il risultato dell'esperimento. La presenza di un campo non rende impossibile usare lo stesso nome per una variabile locale, ma l'ombreggiamento aumenta il carico di lettura. A volte è naturale, come this.titolo = titolo in un costruttore; in altri casi è più chiaro scegliere nomi diversi. L'esperimento insegna la regola del linguaggio e, insieme, perché vale la pena non sfruttarla senza motivo.

Il campo continua a esistere nello stato dell'oggetto dopo che somma è terminato. La variabile locale appoggio non è invece accessibile da una successiva chiamata: ciascuna esecuzione del metodo ha le proprie variabili locali. Se invochiamo somma(2) una seconda volta sulla stessa istanza, la variabile locale riparte da 3 mentre il campo riparte da 12, il risultato della chiamata precedente. Se creiamo un nuovo DemoAmbito, il suo campo parte da 5. Questa variante distingue l'ambito di un nome dalla durata dello stato dell'oggetto, due idee che non dobbiamo sovrapporre.

La figura 7.1 rappresenta il punto più importante del programma. Dopo l'assegnazione, primo e secondo si riferiscono allo stesso oggetto; chiamare un metodo attraverso uno dei due nomi può quindi produrre un risultato visibile attraverso l'altro.

Due riferimenti allo stesso libro
Figura 7.1 – primo e secondo indicano la stessa istanza di Libro. La figura è un modello logico dei riferimenti: non prescrive in quale area fisica della memoria la JVM debba collocare i dati.

primo == secondo è true perché i due riferimenti indicano la stessa istanza. Questa prova riguarda l'identità. Due libri creati con due espressioni new diverse possono avere lo stesso titolo ma essere oggetti distinti; nel capitolo 10 useremo equals per definire, quando serve, l'uguaglianza del contenuto. Non sostituire automaticamente == con equals senza chiederti quale domanda vuoi fare.

Una variabile di riferimento può contenere null, che significa «nessun oggetto indicato». Libro assente = null; è una dichiarazione valida; assente.descrizione() fallisce durante l'esecuzione perché non esiste un libro su cui chiamare il metodo. Un campo di tipo riferimento non inizializzato esplicitamente riceve il valore iniziale null; una variabile locale deve invece essere assegnata prima di essere letta. Nel disegno di stack e heap, distinguiamo prima le due regole di inizializzazione: la posizione disegnata per il dato non deve nasconderle. Nel capitolo 12 vedremo come leggere l'eccezione prodotta da un accesso a null.

Un array può conservare più riferimenti: Libro[] scaffale = {primo, secondo}; contiene due elementi, ma nell'esempio entrambi indicano lo stesso libro. La lunghezza dell'array è due; il numero di oggetti Libro distinti indicati dai suoi elementi è uno. Per l'uso degli indici e la forma dell'array puoi tornare al capitolo 4.

Quattro dichiarazioni, quattro domande

Confrontiamo int copie = 2;, Libro libro;, Libro[] scaffale; e Libro[] ripiani = new Libro[3];. La prima variabile contiene direttamente un valore primitivo. La seconda è una variabile di tipo riferimento, ma la sola dichiarazione locale non crea un Libro e non permette ancora di leggere libro: prima occorre assegnarle un valore. La terza dichiara un riferimento ad array senza creare l'array. Solo la quarta costruisce un array di tre posti; ciascun elemento di tipo Libro inizia con null, perché gli elementi dell'array ricevono i valori iniziali del loro tipo. Nessuno di quei tre posti contiene già un libro. Per ottenere tre libri servono tre creazioni, o assegnazioni di riferimenti a libri creati altrove.

La figura 7.2 separa il valore primitivo dal riferimento che permette di raggiungere un oggetto. Le due dichiarazioni possono apparire simili nel sorgente, ma rispondono a domande diverse quando leggiamo il valore di una variabile.

Primitivo e riferimento
Figura 7.2 – int contiene un valore numerico; Libro contiene un riferimento. Il modello riguarda i valori osservabili e non stabilisce una collocazione fisica obbligata nella JVM.

Questa lettura corregge un equivoco favorito da alcuni vecchi disegni della memoria. Il tipo int ha una dimensione definita dal linguaggio, ma da ciò non segue che la variabile locale copie debba trovarsi fisicamente in un'area chiamata heap, né che una variabile di riferimento sia un puntatore manipolabile come in C. La specifica descrive valori e comportamento osservabile; la JVM sceglie come rappresentarli. Per ragionare sul programma ci bastano tre domande: quale valore ha la variabile, quale oggetto può raggiungere attraverso quel valore e quali operazioni il suo tipo permette di scrivere. Una figura che disegna frecce fra nomi e oggetti risponde alla seconda domanda; non è una fotografia della memoria.

La sintassi new Libro("Java").descrizione() usa un oggetto senza assegnare il riferimento a una variabile con un nome. L'oggetto esiste e il metodo può essere chiamato; semplicemente, dopo l'espressione non conserviamo in quel punto un riferimento con cui richiamarlo. Al contrario, new Libro("Java") da solo come istruzione crea un oggetto che il nostro codice non usa: può avere senso se il costruttore produce un effetto documentato, ma per il modello Libro sarebbe una scelta poco chiara. Non deduciamo da questa osservazione quando la JVM recupererà la memoria: la gestione automatica della memoria non promette un istante di raccolta visibile al programma.

L'esempio storico usava anche un oggetto Integer appena costruito per trasformare un numero in testo. L'idea di chiamare subito un metodo sull'espressione resta valida, ma oggi, per ottenere il testo di 10, è più diretto scrivere Integer.toString(10) o String.valueOf(10): non dobbiamo creare esplicitamente un wrapper. In questo caso il metodo appartiene alla classe e riceve il valore numerico come argomento. Quando torneremo sui wrapper nel capitolo 10, vedremo perché i loro vecchi costruttori non sono la via ordinaria e perché un Integer è comunque un oggetto diverso da un int.

Nota bene – Valori iniziali e assegnazione certa. Un campo int prestiti inizia da 0 e un campo Libro collegato da null se non hanno un inizializzatore esplicito. Una variabile locale, invece, deve avere un valore assegnato su ogni percorso che porta alla sua lettura. La distinzione rende visibile una dimenticanza durante la compilazione; non è un invito a lasciare i campi senza una scelta consapevole del loro valore iniziale.

L'array stesso è un oggetto, distinto dagli eventuali oggetti contenuti nei suoi elementi. Se scriviamo Libro[] primoScaffale = new Libro[2]; Libro[] secondoScaffale = primoScaffale;, abbiamo un array raggiungibile con due variabili. Assegnare primoScaffale[0] = new Libro("A") rende quel libro visibile anche attraverso secondoScaffale[0]. Se invece scriviamo secondoScaffale = new Libro[2], la seconda variabile comincia a indicare un altro array; il primo continua a contenere il libro. Questa è la stessa regola dei riferimenti applicata a un contenitore. Si possono quindi avere due livelli di condivisione: due variabili che indicano lo stesso array, oppure due array distinti che contengono riferimenti allo stesso Libro.

Un array creato con new Libro[2] non chiama due volte il costruttore di Libro. I suoi elementi partono da null e ogni libro va costruito esplicitamente, salvo che si assegnino riferimenti a libri già esistenti. Al contrario, new int[2] contiene subito due valori primitivi inizializzati a zero. La differenza non rende gli array di oggetti «meno veri» degli array primitivi: chiarisce che il tipo degli elementi determina che cosa si trova in ciascuna posizione. Un disegno dei riferimenti agli array deve mostrare separatamente la variabile, l'oggetto array e gli oggetti a cui gli elementi possono rimandare.

Per verificare – Conta oggetti, non nomi. Dopo Libro a = new Libro("A"); Libro b = a; Libro[] scaffale = {a, b};, quante variabili di riferimento abbiamo dichiarato? Quanti oggetti Libro sono stati costruiti? E quanti elementi contiene l'array? Le risposte sono tre variabili (a, b, scaffale), un libro e due elementi nell'array. Inoltre è stato creato l'oggetto array. Se cambi solo b = new Libro("B"), il secondo elemento di scaffale continua a indicare il primo libro: l'assegnazione a b non riscrive automaticamente il contenuto dell'array.

La figura 7.3 mostra l'array come un oggetto con due posti. Entrambi possono contenere riferimenti allo stesso libro, e il numero di posti non ci dice quanti libri distinti siano stati creati.

Array con due riferimenti allo stesso libro
Figura 7.3 – Due elementi dell'array possono indicare un solo oggetto Libro. La costruzione dell'array, da sola, lascia i due elementi a null; le frecce rappresentano una situazione successiva alle assegnazioni.

Una pila ci mostra che più operazioni possono appartenere allo stesso oggetto. Sviluppiamo l'esempio nel capitolo 8: inserisci aggiunge un numero e estrai restituisce l'ultimo inserito. Si dice LIFO, dall'inglese last in, first out. Prima di leggere come è costruita, puoi già descriverne la responsabilità: deve sapere quanti elementi contiene e non deve estrarre da una pila vuota. La stessa pila ci aiuterà a leggere una catena di chiamate nel capitolo 12.

Nota bene – Non confondere variabile e oggetto. Assegnare secondo = primo copia il valore del riferimento. Non duplica l'istanza e non invoca il costruttore. Se in seguito scrivi secondo = null, l'oggetto può ancora essere raggiunto tramite primo: l'assegnazione cambia un nome, non distrugge immediatamente l'oggetto.

Un riferimento passato a un metodo

La distinzione diventa ancora più utile quando passiamo un Libro a un altro metodo. Java passa i valori degli argomenti: per un parametro di tipo riferimento, il valore copiato è il riferimento all'oggetto. Se il metodo chiama libro.registraPrestito(), modifica lo stato della stessa istanza visibile al chiamante. Se invece assegna libro = new Libro("Altro titolo"), cambia soltanto il proprio parametro locale; la variabile del chiamante continua a indicare il libro iniziale. Dire semplicemente «l'oggetto è passato per riferimento» nasconde questa differenza e porta a previsioni sbagliate.

Con un int la prova è più intuitiva: il metodo riceve un valore numerico; assegnare un altro numero al parametro non cambia la variabile originale. Nel caso di Libro, il metodo riceve sempre un valore, ma quel valore consente di raggiungere e modificare un oggetto condiviso. Due frasi sono entrambe vere: la riassegnazione del parametro resta locale; la mutazione dell'oggetto può essere osservata fuori dal metodo.

Una chiamata osservata da entrambi i lati

Consideriamo un metodo presta(Libro ricevuto) che chiama ricevuto.registraPrestito() e poi assegna a ricevuto un nuovo Libro. Dal lato del chiamante, la variabile primo continua a indicare il libro iniziale. Il suo contatore è aumentato perché il metodo ha raggiunto lo stesso oggetto tramite una copia del riferimento. La nuova assegnazione, invece, cambia soltanto la variabile locale ricevuto; non torna indietro lungo la chiamata per sostituire il valore di primo. In termini di comportamento osservabile, il metodo può mutare l'oggetto raggiunto, ma non può riassegnare la variabile locale che il chiamante ha usato come argomento.

Per controllare la previsione senza una figura di memoria, annota due colonne. Prima della chiamata, primo e ricevuto indicano lo stesso libro solo dopo il passaggio del parametro; il contatore vale zero. Dopo registraPrestito(), il contatore di quell'unico libro vale uno. Dopo ricevuto = new Libro("Altro"), ricevuto indica un secondo libro, mentre primo indica ancora il primo, con contatore uno. Se il metodo restituisse ricevuto e il chiamante scrivesse primo = presta(primo), sarebbe una nuova assegnazione esplicita nel chiamante a cambiare primo. Il passaggio degli argomenti, da solo, non l'ha fatto.

Per capire – La parola «riferimento». Un riferimento è un valore che permette di raggiungere un oggetto; non è l'oggetto stesso e Java non offre su di esso l'aritmetica dei puntatori del C. La copia del valore di riferimento spiega perché due variabili possono osservare la stessa mutazione. La distinzione basta per risolvere molti esercizi sulle chiamate, senza attribuire alla specifica una disposizione fisica fissa di stack e heap.

Getter e setter hanno un contratto

Consideriamo una classe Macchina con campi come colore e targa e metodi getTarga() e setTarga(...). Un getter restituisce un'informazione, un setter accetta una richiesta di modifica. Avere entrambi per ogni campo, però, non è una regola obbligatoria. Se la targa identifica l'auto e non deve cambiare dopo la creazione, un setter pubblico romperebbe proprio l'invariante che volevamo proteggere. Se il colore può cambiare, un metodo vernicia(String nuovoColore) può raccontare l'azione meglio di un generico setColore.

Un metodo che modifica lo stato deve anche decidere che cosa fare davanti a un argomento non valido. Per una targa vuota possiamo rifiutare la chiamata, oppure usare una rappresentazione che non possa esprimere quel valore. Non basta spostare l'assegnazione da macchina.targa = ... a macchina.setTarga(...) se il setter accetta qualunque testo senza controllo. Nel capitolo 8 vedremo come definire le regole dell'oggetto e mantenerle sia nei costruttori sia nei metodi.

Stato, identità e uguaglianza

Lo stato osservabile del nostro Libro comprende almeno il titolo e il numero di prestiti registrati. Il comportamento dei metodi può dipendere da questi valori: descrizione() cambia dopo registraPrestito(). Non ogni campo che una classe potrebbe contenere deve entrare nella sua nozione di uguaglianza. Un contatore di accessi usato soltanto per diagnostica, per esempio, può cambiare senza cambiare il significato del libro per il catalogo. La scelta dipende dal contratto della classe, non da una formula universale «due oggetti sono uguali se tutti i campi coincidono».

L'operatore == su due riferimenti risponde alla domanda di identità: indicano la stessa istanza? Il metodo equals risponde invece secondo il contratto definito dalla classe. L'implementazione ereditata da Object usa l'identità; alcune classi, come String, ridefiniscono equals per confrontare il contenuto. Per questo new String("Java").equals(new String("Java")) è vero, mentre il confronto con == fra i due oggetti creati con new è falso. Non usiamo due letterali identici per dimostrarlo: l'internamento delle stringhe renderebbe l'esempio ingannevole. Quando definiremo l'uguaglianza dei nostri tipi, dovremo anche rispettare il contratto di hashCode.

Concetto chiave – Il significato prima del confronto. Un prestito può essere «lo stesso prestito» perché ha un identificatore, anche se lo stato è cambiato dopo la restituzione. Due edizioni con titolo e anno uguali possono invece essere considerate uguali come valori. Prima di scrivere equals, scegli la domanda del dominio a cui deve rispondere.

Membri della classe e punto di ingresso

Tutti gli oggetti Libro hanno un titolo proprio. Se però ci serve una costante identica per tutta la classe, possiamo dichiararla con static final. Un esempio è static final int MASSIMO_PRESTITI = 20;: il nome e il valore appartengono alla classe e si richiamano come Libro.MASSIMO_PRESTITI, se la visibilità lo consente. Un campo static modificabile, invece, contiene uno stato condiviso fra tutte le istanze: usarlo per il numero di prestiti di un singolo libro sarebbe un errore, perché ogni libro aggiornerebbe lo stesso contatore.

Il nome maiuscolo con parole separate da sottolineature segue la convenzione usata per le costanti. Aiuta il lettore a riconoscere un valore condiviso e non riassegnabile, ma non sostituisce i modificatori: MASSIMO_PRESTITI scritto in maiuscolo senza final potrebbe ancora essere riassegnato. Analogamente static final non rende immutabile in profondità un oggetto mutabile raggiunto dal campo. Per un limite numerico come 20, invece, il valore stesso non ha parti interne modificabili. Distinguere convenzione del nome, regola di assegnazione e mutabilità del valore evita tre promesse diverse nascoste nella stessa parola «costante».

Un metodo static può usare direttamente altri membri statici, ma non un campo di istanza senza conoscere quale istanza. Può comunque ricevere un Libro come parametro e chiamarne i metodi: il divieto non riguarda gli oggetti in generale, riguarda l'assenza di un this implicito. Questa distinzione spiega sia perché main è tradizionalmente statico sia perché main può creare e usare oggetti.

Il punto di ingresso della forma tradizionale è public static void main(String[] args). Il comando java avvia il programma a partire dalla classe che indichiamo; se più classi dichiarano un metodo main adatto, scegliamo quale avviare nominando quella classe. L'array args contiene le parole passate dopo il nome della classe, come abbiamo verificato nel capitolo 3. main non rende l'intera applicazione un oggetto: è un metodo di avvio. Nel seguito incontreremo anche la forma compatta introdotta in Java 25, ma la forma tradizionale rimane importante per leggere progetti esistenti.

Leggere main come un normale metodo, con un ruolo speciale

Se avvii java -cp build Libro Ada 3, le parole dopo Libro diventano elementi dell'array args: args[0] contiene "Ada" e args[1] contiene "3". Il secondo elemento è ancora testo; per ottenere un numero, un programma dovrebbe convertirlo e gestire un eventuale input non valido. Se avvii il programma senza argomenti, l'array è vuoto e leggere args[0] provocherebbe un errore. Prima di usarlo, controlliamo quindi args.length. Questo piccolo passaggio collega la firma del punto di ingresso alle regole sugli array già studiate e mostra che un parametro non crea dati che il chiamante non ha fornito.

static non significa che il metodo lavori «prima di ogni oggetto» o che non possa creare istanze. Significa che il metodo è associato alla classe e può essere chiamato senza un ricevente Libro. Nel corpo di main possiamo scrivere new Libro(...), conservare il riferimento e invocare registraPrestito(). Possiamo anche chiamare un altro metodo statico della classe direttamente. Se tentassimo di leggere titolo senza indicare un libro, il compilatore ci fermerebbe: di quale delle possibili istanze dovrebbe prendere il titolo? In questo modo il messaggio del compilatore rinforza il modello classe/istanza, anziché sembrare una regola arbitraria sulle parole chiave.

Il nome args è convenzionale, ma non è una parola riservata. Possiamo chiamare il parametro argomenti; conta il tipo e la forma del punto di ingresso. Nella dichiarazione più nota, String[] indica un array di stringhe e void indica che il metodo non consegna un valore al proprio chiamante. Stampare una riga e restituire un valore sono ancora operazioni diverse: main può produrre messaggi con System.out, ma la sua firma non dichiara un risultato del programma. Un eventuale codice di uscita del processo segue un altro meccanismo e non va confuso con return di un metodo che restituisce int.

System: uscita, errori e proprietà

La riga System.out.println(...) contiene due passaggi. System.out è un campo statico della classe System che fornisce un flusso di uscita; println è un metodo dell'oggetto flusso. Non è quindi corretto chiamare println un metodo statico di System. System.err è un altro flusso, convenzionalmente usato per i messaggi di errore. Anche se entrambi possono apparire nello stesso terminale, strumenti e script possono indirizzarli in luoghi diversi. Useremo System.exit(int) soltanto quando il programma deve terminare il processo con un codice di uscita; chiamarlo da un normale metodo di libreria sorprenderebbe chi usa quella libreria.

Il metodo statico System.getProperty("java.version") restituisce una proprietà del runtime, oppure null se la chiave richiesta non è definita. Altre chiavi utili sono os.name per il nome del sistema, user.dir per la directory di lavoro e line.separator per il separatore di riga. I valori cambiano da una macchina all'altra: un elenco di percorsi Windows 2000 descriverebbe soltanto la macchina e l'epoca in cui è stato prodotto, non un output da aspettarsi oggi. Prova a stampare soltanto java.version e user.dir, poi confrontali con java --version e con la directory da cui hai avviato il programma.

Nota bene – Proprietà non significa variabile d'ambiente. Le proprietà di sistema sono coppie chiave e valore accessibili dall'API Java. Le variabili d'ambiente sono fornite al processo dal sistema operativo e si leggono con System.getenv. Alcune informazioni possono assomigliarsi, ma i due meccanismi non sono identici. La documentazione di System elenca metodi e proprietà standard.

Leggere una proprietà senza trasformarla in una promessa

Le proprietà di sistema descrivono l’ambiente in cui il programma viene eseguito; un elenco di valori raccolti su una sola macchina diventa presto una fotografia datata. Se scriviamo String versione = System.getProperty("java.version");, la variabile riceve un testo che descrive la versione del runtime con cui il programma sta effettivamente girando. Compilare con --release 25 e avviare con un altro runtime sono operazioni distinte: la proprietà riguarda l'esecuzione. System.getProperty("user.dir") descrive la directory di lavoro del processo, che può essere diversa dalla cartella in cui si trova il sorgente o il file .class. È facile verificarlo avviando lo stesso programma da due directory diverse con un classpath adatto.

Un secondo esperimento mette in relazione System.out e System.err. Stampa "risultato" sul primo e "problema" sul secondo. In un terminale puoi vedere entrambe le righe, ma non concludere che siano lo stesso flusso: l'ambiente può separarle, come fanno molti strumenti di esecuzione e verifica. La scelta del flusso fa parte del significato del messaggio. L'uscita ordinaria è adatta a un risultato che un altro programma potrebbe leggere; l'uscita degli errori a una diagnosi che non deve confondersi con quel risultato. Nel nostro Libro stampiamo una descrizione su out perché fa parte dell'esito normale dell'esempio.

Approfondimento – Chiavi e valori. Una proprietà si cerca tramite una chiave testuale. System.getProperty("java.version") chiede la versione dell’ambiente Java in esecuzione; se una chiave non è presente, getProperty restituisce null. Prima di chiamare un metodo sulla stringa ottenuta, decidiamo quindi come gestire l’assenza. Possiamo anche usare la forma con un valore predefinito, come System.getProperty("applicazione.porta", "8080"). Il risultato resta una stringa: per usarlo come porta numerica servono conversione e controllo dell’intervallo ammesso. La prova consiste nel leggere una proprietà nota e una chiave assente, verificando il valore e il caso da gestire. Interrogare l’ambiente in questo modo evita di prendere per universali valori che dipendono dall’installazione.

Una forma per dati semplici: i record

Il nostro Libro possiede un comportamento che cambia stato. A volte, però, serve soltanto raggruppare dati che vogliamo descrivere in modo trasparente, per esempio il titolo e l'anno di una particolare edizione. Per questo Java offre i record, classi speciali che dichiarano i propri componenti nell'intestazione. Un record come record Edizione(String titolo, int anno) { } mette a disposizione un costruttore, i metodi titolo() e anno() e metodi per confronto e rappresentazione testuale. Non dobbiamo scriverli tutti a mano per un semplice aggregato.

record Edizione(String titolo, int anno) { }

public class DemoRecord {
    public static void main(String[] args) {
        Edizione prima = new Edizione("Java Mattone dopo Mattone", 2026);
        System.out.println(prima.titolo() + " (" + prima.anno() + ")");
    }
}

Il file si chiama DemoRecord.java e stampa Java Mattone dopo Mattone (2026). La documentazione dei record descrive i membri generati. I campi dei componenti sono final, ma questo non rende automaticamente profondamente immutabile qualsiasi oggetto a cui un componente si riferisca. Torneremo su copie difensive e validazione nel capitolo 8. I record sono stabili da Java 16; non richiedono preview in Java 25.

Decidere quando il record esprime il modello

Per Edizione, titolo e anno descrivono un valore che ci interessa leggere e confrontare. Due istanze costruite con gli stessi componenti risultano uguali secondo i metodi generati dal record; due riferimenti a istanze distinte restano diversi secondo ==. Questa è la stessa distinzione che approfondiremo nel capitolo 10, ma qui mostra perché il record è comodo quando il significato del valore corrisponde ai suoi componenti. Se l'identità del libro dipendesse invece da un codice di catalogo assegnato dall'applicazione, o se dovessimo registrare e modificare continuamente prestiti, una classe ordinaria con un contratto esplicito potrebbe raccontare meglio il problema.

La brevità della dichiarazione non elimina la responsabilità di controllare i dati. new Edizione(null, -3) è sintatticamente costruibile con il record mostrato: il compilatore non sa che un titolo mancante e un anno negativo non hanno senso per il nostro dominio. Possiamo aggiungere un costruttore che convalidi i componenti. Nel capitolo successivo vedremo come la stessa domanda sugli invarianti si applica a ogni forma di classe. Il criterio per scegliere un record, quindi, non è «questa definizione occupa una riga»; è «i componenti esposti descrivono esattamente il valore che voglio modellare».

Un record può contenere un componente di tipo array, per esempio record Scaffale(Libro[] libri) {}. Il campo del componente non può essere riassegnato dall'esterno, ma l'array passato al costruttore e quello restituito dall'accessore possono comunque esporre i propri elementi a modifiche. Inoltre il confronto di due array non diventa automaticamente un confronto elemento per elemento perché si trovano dentro un record. È una ragione concreta per non chiamare «immutabile» ogni record senza esaminare ciò che custodisce. La domanda su riferimenti e oggetti è la stessa che abbiamo costruito fin dall'inizio del capitolo.

Un programma ancora più piccolo in Java 25

Forse ti sei chiesto perché il primo programma del libro richiedesse classe, public, static e array di argomenti solo per stampare una riga. Java 25 offre anche un file sorgente compatto: si può scrivere un metodo main senza una dichiarazione esplicita di classe. Il file CiaoCompatto.java dei materiali contiene:

void main() {
    IO.println("Ciao da Java 25!");
}

Il comando java CiaoCompatto.java stampa Ciao da Java 25!. In questo caso il linguaggio dichiara implicitamente una classe per il file e il comando avvia un metodo main di istanza. IO.println è una scorciatoia per l'uscita testuale disponibile in Java 25; il nostro System.out.println resta valido. Questa forma è utile per un esperimento breve, ma non elimina le classi dal linguaggio e non è la forma scelta per il modello Libro, dove nome, stato e metodi hanno un ruolo didattico preciso. La guida ufficiale sui file compatti ne descrive il comportamento.

Versione Java – Due forme stabili. I record sono permanenti da Java 16; i file sorgente compatti e i metodi main di istanza sono permanenti da Java 25. Un lettore con un JDK precedente deve usare la forma supportata dalla propria release. Il riepilogo ufficiale delle modifiche del linguaggio registra introduzione e stato delle funzionalità.

Per verificare

Prima di eseguire il programma Libro, prevedi che cosa cambierebbe se la riga Libro secondo = primo; diventasse Libro secondo = new Libro("Java Mattone dopo Mattone");. Le due variabili indicherebbero ancora la stessa istanza? Quale descrizione stamperebbe secondo dopo primo.registraPrestito()? Poi prova la modifica e spiega il risultato senza usare la parola «copia» in modo ambiguo. L'attività C07 riprende questa prova con un caso da risolvere in autonomia.

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 ↑