mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 8/39

8. Incapsulamento

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 →

Una regola che deve restare vera

Nel capitolo precedente abbiamo creato un Libro con un titolo e un contatore. Il campo prestiti è privato e si modifica tramite registraPrestito(). Questo evita che un altro pezzo di programma scriva direttamente un numero negativo nel campo, ma non dimostra ancora che l'oggetto sia progettato bene: se il metodo stesso sottraesse uno senza controllo, la regola sarebbe violata lo stesso. Incapsulare significa disegnare un confine attorno allo stato e alle operazioni che lo governano, così che dall'esterno si possano compiere soltanto azioni coerenti con il significato dell'oggetto.

Una regola che deve essere vera per tutta la vita utile di un oggetto si chiama invariante. In un registro di voti, per esempio, vogliamo almeno un voto, e ogni voto deve stare nell'intervallo da zero a trenta. Se la media è una operazione del registro, non può avere senso chiedergli di dividerla per zero perché qualcuno ha svuotato di nascosto l'array. Il problema concreto, dunque, non è «come nascondo i campi?» ma «come impedisco che si crei o diventi osservabile uno stato impossibile?».

Possiamo spiegare l'occultamento con la leva del cambio di un'automobile. L'analogia è utile finché ricorda che il guidatore usa comandi e non modifica direttamente gli ingranaggi. Una leva, però, non garantisce da sola che il cambio non possa guastarsi: anche l'implementazione deve rispettare le regole. Preferisco quindi mostrare quelle regole nel codice, dove possiamo verificarle.

Pensiamo alla differenza fra chiedere all'auto di inserire una marcia e scrivere direttamente il numero della marcia in una variabile. Nel primo caso un'operazione può controllare se il cambio ammette quella richiesta nella condizione corrente; nel secondo chi usa l'auto deve conoscere e rispettare regole che appartengono all'auto stessa. L'analogia ha un limite: il software non impedisce che un programmatore scriva un'implementazione sbagliata del metodo pubblico. Per questo, dopo aver scelto un confine, lo proviamo con chiamate lecite e illecite. La qualità dell'incapsulamento si misura nelle azioni consentite e nelle garanzie osservabili, non nel numero di parole private presenti nel file.

Approfondimento – Rappresentazione e contratto. La rappresentazione è il modo scelto dalla classe per conservare i dati: RegistroVoti usa un array, Pila un array e un contatore. Il contratto riguarda invece ciò che chi usa la classe può aspettarsi: quali valori sono ammessi, quali risultati ottiene e che cosa accade nei casi anomali. Se un giorno sostituiamo l'array con un'altra struttura, il codice dei chiamanti dovrebbe continuare a funzionare finché manteniamo il contratto. Questo è il vantaggio pratico del confine.

Un registro che custodisce i propri dati

Il programma seguente è disponibile anche come RegistroVoti.java. Salvalo con questo nome, compilalo con javac --release 25 -Xlint:all -d build RegistroVoti.java e avvialo con java -cp build RegistroVoti dalla cartella del file.

public final class RegistroVoti {
    private final String studente;
    private final int[] voti;

    public RegistroVoti(String studente, int[] voti) {
        if (studente == null || studente.isBlank()) {
            throw new IllegalArgumentException("studente mancante");
        }
        if (voti == null || voti.length == 0) {
            throw new IllegalArgumentException("serve almeno un voto");
        }
        for (int voto : voti) {
            if (voto < 0 || voto > 30) {
                throw new IllegalArgumentException("voto fuori intervallo");
            }
        }
        this.studente = studente;
        this.voti = voti.clone();
    }

    public RegistroVoti(String studente, int voto) {
        if (voto < 0 || voto > 30) {
            throw new IllegalArgumentException("voto fuori intervallo");
        }
        this(studente, new int[] {voto});
    }

    public String studente() {
        return studente;
    }

    public int[] voti() {
        return voti.clone();
    }

    public double media() {
        int somma = 0;
        for (int voto : voti) {
            somma += voto;
        }
        return (double) somma / voti.length;
    }

    public static void main(String[] args) {
        int[] originali = {24, 27, 30};
        RegistroVoti registro = new RegistroVoti("Ada", originali);
        originali[0] = 0;
        int[] ricevuti = registro.voti();
        ricevuti[1] = 0;
        System.out.println(registro.studente() + ": " + registro.media());
    }
}

L'output è Ada: 27.0. Nel main modifichiamo due array fuori dal registro: prima quello consegnato al costruttore, poi quello ottenuto da voti(). Se il registro avesse conservato o restituito direttamente il proprio array, la media potrebbe cambiare senza passare da alcuna operazione che controlli i voti. Le due chiamate a clone() creano invece copie dell'array; per un array di int, copiare gli elementi significa copiare i valori primitivi. La figura 8.1 mostra le due direzioni del confine.

Copie difensive del registro
Figura 8.1 – Il registro copia l'array all'ingresso e all'uscita. Chi lo usa può modificare le proprie copie senza cambiare i voti custoditi dall'oggetto. Il disegno descrive il contratto, non la disposizione fisica della memoria.

Nota bene – final non congela un array. private final int[] voti impedisce al codice della classe di riassegnare il campo a un altro array dopo l'inizializzazione. Non impedisce di cambiare gli elementi dell'array. L'invariante dipende quindi dalla validazione e dalle copie, oltre che dalla visibilità del campo. Se gli elementi fossero oggetti modificabili, clone() copierebbe i riferimenti e potrebbe non essere sufficiente: servirebbe valutare copie degli elementi o un diverso modello.

Verificare le due porte del registro

Seguiamo la costruzione una riga per volta. originali contiene tre valori validi. Il costruttore verifica prima il nome, poi l'esistenza di almeno un voto, poi l'intervallo di ciascuno. Solo dopo assegna i campi e copia l'array. Se trovasse 31 durante la verifica, interromperebbe la costruzione: il chiamante non riceverebbe un RegistroVoti apparentemente utilizzabile ma con un dato illegittimo nascosto al suo interno. Questa distinzione è importante perché un controllo eseguito soltanto da media() arriverebbe troppo tardi e lascerebbe altri metodi liberi di osservare uno stato errato.

La prima porta è l'ingresso. Se sostituissimo this.voti = voti.clone() con this.voti = voti, l'array posseduto dal chiamante e quello conservato dal registro sarebbero lo stesso oggetto. La riga originali[0] = 0 cambierebbe la media da 27.0 a 19.0, benché nessun metodo del registro abbia approvato la modifica. La seconda porta è l'uscita. Anche lasciando la copia in ingresso, se voti() restituisse semplicemente voti, la riga ricevuti[1] = 0 cambierebbe la media da 27.0 a 18.0. Servono entrambe le copie perché i due percorsi di accesso sono indipendenti. I numeri rendono visibile la violazione: non stiamo facendo una copia «per prudenza» senza sapere quale problema risolva.

Un array di int è un buon primo esempio perché i suoi elementi sono valori primitivi. Se il registro conservasse un array di oggetti Valutazione modificabili, copiare soltanto l'array creerebbe un nuovo contenitore ma lascerebbe condivise le istanze al suo interno. Un chiamante potrebbe ancora modificare un voto attraverso un riferimento che possiede. La soluzione dipende dal modello: creare copie degli elementi, scegliere oggetti immutabili oppure esporre soltanto viste e operazioni che mantengano il contratto. Copia difensiva significa proteggere davvero il confine, non applicare clone() automaticamente a qualsiasi struttura.

L'anagrafica clienti

Consideriamo una classe Cliente per un negozio, con nome, cognome e telefono privati e getter e setter per ogni campo. Questa forma rende visibile che un campo privato non può essere modificato direttamente dal codice esterno, ma non basta a proteggere uno stato valido. Un cliente appena creato potrebbe esistere senza nome e cognome; un setter potrebbe accettare un testo vuoto. Anche aggiungere un controllo su null richiede attenzione: se il setter del cognome controllasse per errore il nome, la validazione riguarderebbe il dato sbagliato. Bisogna dare all’oggetto una regola coerente fin dalla costruzione e verificarla in ogni operazione che la potrebbe cambiare.

Riprendiamo lo stesso dominio nel sorgente Cliente.java. Un cliente nasce con nome, cognome e telefono; il nome completo è leggibile, mentre il telefono può essere aggiornato attraverso un'operazione che convalida il nuovo dato. In questa piccola versione nome e cognome non cambiano dopo la costruzione. È una scelta del modello, non un limite dei getter e setter. Se nel negozio esistesse una procedura per correggere il cognome, potremmo aggiungere un metodo con le regole di quella procedura.

public final class Cliente {
    private final String nome;
    private final String cognome;
    private String numeroDiTelefono;

    public Cliente(String nome, String cognome, String numeroDiTelefono) {
        this.nome = testoObbligatorio(nome, "nome");
        this.cognome = testoObbligatorio(cognome, "cognome");
        aggiornaTelefono(numeroDiTelefono);
    }

    private static String testoObbligatorio(String testo, String campo) {
        if (testo == null || testo.isBlank()) {
            throw new IllegalArgumentException(campo + " mancante");
        }
        return testo.strip();
    }

    public String nomeCompleto() {
        return nome + " " + cognome;
    }

    public String numeroDiTelefono() {
        return numeroDiTelefono;
    }

    public void aggiornaTelefono(String nuovoNumero) {
        this.numeroDiTelefono = testoObbligatorio(nuovoNumero, "telefono");
    }

    public static void main(String[] args) {
        Cliente cliente = new Cliente(" Ada ", " Rossi ", " 123 ");
        System.out.println(cliente.nomeCompleto());
        cliente.aggiornaTelefono(" 456 ");
        System.out.println(cliente.numeroDiTelefono());
    }
}

Il programma stampa Ada Rossi e 456. Il metodo privato testoObbligatorio concentra il controllo comune: rifiuta null e testo formato soltanto da spazi, poi elimina gli spazi ai margini. Il metodo pubblico aggiornaTelefono esprime una modifica ammessa dal dominio; non offre al chiamante un accesso senza regole al campo. La classe è final, quindi il suo costruttore può richiamare quel metodo senza il rischio che una sottoclasse ne esegua una ridefinizione su un oggetto ancora incompleto. Quando studieremo l'ereditarietà vedremo perché chiamare un metodo ridefinibile da un costruttore è spesso pericoloso.

Un numero di telefono reale richiede regole più attente di «testo non vuoto»: prefissi, spazi, estensioni e convenzioni internazionali dipendono dal contesto. Qui il controllo è volutamente minimo e dichiarato. Inventare una regola che accetta soltanto cifre farebbe rifiutare numeri validi in molti casi. La lezione sull'incapsulamento non è sapere validare ogni numero del mondo, ma mettere la validazione nel punto in cui l'oggetto protegge il proprio significato. Se cambiamo la regola, chi chiama aggiornaTelefono continua a usare la stessa operazione, mentre il suo contratto documentato ci dice quali nuovi input sono ammessi.

Ritrovare la pila, con regole verificabili

La pila incontrata nel capitolo 7 conserva elementi in ordine LIFO: l'ultimo numero inserito è il primo estratto. La classe completa Pila usa un array privato e un contatore dimensione. L'invariante è 0 ≤ dimensione ≤ elementi.length: il contatore non può essere negativo né superare la capacità. Il costruttore rifiuta una capacità non positiva; inserisci controlla che ci sia spazio; estrai controlla che esista un elemento. Quando una di queste operazioni non può essere compiuta, segnala il motivo con un'eccezione, che leggeremo nel capitolo 12.

public void inserisci(int valore) {
    if (dimensione == elementi.length) {
        throw new IllegalStateException("pila piena");
    }
    elementi[dimensione] = valore;
    dimensione++;
}

public int estrai() {
    if (dimensione == 0) {
        throw new IllegalStateException("pila vuota");
    }
    dimensione--;
    return elementi[dimensione];
}

La figura 8.2 ferma la pila dopo due inserimenti in una capacità di tre. Il programma DemoPila stampa 2, poi 12 e 7: prima osserva il numero di elementi, poi estrae nell'ordine opposto all'inserimento.

Pila LIFO con due elementi
Figura 8.2 – Nella pila di capacità tre, il prossimo valore estratto è 12. Il posto libero resta disponibile per un successivo inserimento; non è un elemento della pila.

Concetto chiave – Un contratto, non solo un array privato. Chi usa Pila conosce le operazioni e gli errori previsti, non l'indice interno del prossimo posto libero. L'array privato impedisce modifiche dirette, mentre i controlli mantengono valido il rapporto fra capacità e dimensione. Non restituiamo l'array: un chiamante non deve poter aggirare inserisci ed estrai.

Seguire la pila senza guardare dentro l'array

Partiamo da una pila nuova di capacità tre. Il suo metodo dimensione() restituisce zero. Dopo inserisci(7), la dimensione è uno; dopo inserisci(12), è due. La prima estrai() restituisce 12 e la dimensione torna a uno; la seconda restituisce 7 e la dimensione torna a zero. Questa sequenza insegna l'ordine LIFO senza chiedere a chi usa l'oggetto di conoscere gli indici interni. L'invariante 0 ≤ dimensione ≤ capacità vale dopo ciascuna operazione completata correttamente. È utile fermarsi dopo ogni riga e verificare sia il valore restituito sia lo stato osservabile.

Che cosa accade se chiamiamo ancora estrai()? La pila è vuota. Restituire zero sarebbe ambiguo, perché zero è un valore che avremmo potuto inserire legittimamente. La classe sceglie di segnalare pila vuota, senza diminuire il contatore sotto zero. Che cosa accade se inseriamo un quarto elemento senza estrarne alcuno? La pila di capacità tre è piena: il metodo rifiuta l'operazione prima di scrivere oltre i limiti dell'array. Questi due controlli non servono soltanto a evitare eccezioni prodotte dall'array; esprimono il contratto della struttura dati con messaggi che parlano di pila piena o vuota.

Una Pila può essere scritta con nomi e comportamenti diversi. Per non confondere i contratti, seguiamo una storia crescente: nel capitolo 7 abbiamo nominato le operazioni, in questo capitolo fissiamo le regole, nel capitolo 12 osserviamo che cosa succede quando una chiamata non le rispetta. In questo modo il lettore non deve imparare che pop() ritorna zero in una pagina e che estrai() lancia un'eccezione in un'altra come se fossero la stessa API. Una versione che restituisce zero quando la pila è vuota resta utile come controesempio: ci fa vedere perché il nuovo contratto è migliore.

L'eccezione IllegalArgumentException dice a chi chiama il costruttore che l'argomento non rispetta il contratto. Qui serve una spiegazione minima: throw interrompe la costruzione e segnala il problema, evitando di consegnare un registro non valido. Nel capitolo 12 distingueremo con calma eccezioni di questo tipo, errori e casi in cui un programma può recuperare. Un messaggio come voto fuori intervallo è più utile di un'eccezione senza contesto, ma un'applicazione reale potrebbe includere anche il valore rifiutato quando non contiene dati sensibili.

Visibilità e responsabilità

Java offre quattro livelli rilevanti per i membri di una classe. private limita l'accesso alla classe che dichiara il membro; senza modificatore, l'accesso è nel package; protected comprende il package e, con regole ulteriori, le sottoclassi in altri package; public permette l'accesso dove la classe e il suo modulo sono accessibili. La specifica del controllo di accesso precisa i dettagli, soprattutto per protected. Un sottopackage non è lo stesso package del nome che lo precede, come abbiamo visto in C06.

Dichiarazione del membro Nella classe Stesso package Sottoclasse fuori package Altri package
private sì no no no
nessun modificatore sì sì no no
protected sì sì sì, secondo le regole dell'accesso tramite sottoclasse no
public sì sì sì sì, se classe e package sono accessibili

La tabella aiuta a orientarsi, ma non autorizza a dichiarare public un campo soltanto perché qualcuno dovrà leggerlo. studente() offre il nome, media() offre un risultato calcolato, e voti() offre una copia: ciascun metodo dichiara che cosa il registro consente di osservare. Un setter generico che accetti qualunque array farebbe saltare il controllo se non applicasse di nuovo l'intero contratto. È spesso più chiaro offrire un'operazione del dominio, come registraVoto, che verifichi il singolo voto prima di aggiornare uno stato progettato per cambiare.

Che cosa vede davvero chi usa una classe

Prendiamo private final int[] voti. Un'altra classe non può scrivere registro.voti[0], perché il nome del campo è privato. Se rendessimo il campo public, l'espressione diventerebbe accessibile dove la classe è accessibile, e chiunque potrebbe assegnare -5 a una posizione: il controllo fatto nel costruttore non servirebbe più a mantenere l'invariante nel tempo. Rendere pubblico un metodo è diverso. registro.media() espone il risultato di un calcolo, lasciando al registro il controllo della rappresentazione. registro.voti() è ancora più delicato: restituisce dati, ma li restituisce come copia proprio per non aprire una via indiretta al campo privato.

Nel package, una dichiarazione priva di modificatore di accesso è raggiungibile dalle altre classi dello stesso package. Il nome biblioteca.catalogo e il nome biblioteca.catalogo.interno indicano però due package distinti; il secondo non acquisisce automaticamente accesso ai membri del primo. protected aggiunge la possibilità d'uso nelle sottoclassi, con regole precise anche quando la sottoclasse sta in un altro package. Non leggerlo come «pubblico per tutte le sottoclassi e per chiunque le usi»: nel capitolo sull'ereditarietà vedremo perché conta anche l'espressione attraverso cui si tenta l'accesso.

Per le classi di primo livello le possibilità non sono identiche a quelle dei membri: una classe può essere public oppure avere accesso di package, ma non si dichiara private come classe di primo livello. Una classe annidata, invece, è un membro della classe esterna e può essere private. Questo dettaglio permette di nascondere anche il tipo di supporto, non soltanto i suoi campi. L'accessibilità del modulo resta un confine ulteriore: una classe pubblica in un package non esportato non diventa accessibile da ogni altro modulo.

Il caso delicato di protected

Supponiamo che biblioteca.base.Registro dichiari un campo protected int revisioni e che biblioteca.speciale.RegistroStorico estenda Registro. Nel codice della sottoclasse è lecito usare il campo dell'istanza corrente, per esempio this.revisioni. Non basta però trovarsi dentro la sottoclasse per leggere revisioni attraverso qualunque riferimento a Registro proveniente dall'altro package. Il linguaggio protegge l'accesso che serve alla sottoclasse per costruire il proprio comportamento; non apre il campo come se fosse pubblico. All'interno dello stesso package della classe base, invece, l'accesso protected è disponibile anche a classi che non la estendono. Per questo la frase «visibile ai figli» è troppo corta e può far prevedere erroneamente la compilazione.

La decisione progettuale viene prima della tabella. Se una sottoclasse ha bisogno del numero di revisioni, possiamo offrirle un metodo protected che esponga il dato o, meglio, un'operazione che rispetti la regola della classe base. Rendere modificabile un campo per facilitare una sottoclasse lega l'implementazione dei due tipi: una modifica del significato del campo può rompere codice che non controlliamo più. private è spesso un buon punto di partenza per i campi, ma la scelta di public e protected per i metodi richiede di conoscere i veri utilizzatori del contratto.

Per verificare – Package e sottopackage. Disegna tre file: catalogo.Libro, catalogo.Prova e catalogo.test.Prova. Se Libro ha un metodo senza modificatore, soltanto catalogo.Prova può chiamarlo grazie al package condiviso. La parola catalogo nel nome del terzo package non gli conferisce alcun privilegio. Poi cambia il metodo in public e osserva che la visibilità del tipo Libro e l'eventuale esportazione del package restano condizioni da considerare.

Costruttori e delega

RegistroVoti ha due costruttori con parametri diversi: uno riceve un array, l'altro un solo voto. Questa è una forma di sovraccarico: il nome resta lo stesso, ma la lista dei parametri permette al compilatore di scegliere quale chiamare. Il secondo usa this(studente, new int[] {voto}) per delegare la costruzione completa al primo. Evitiamo così due procedure indipendenti che potrebbero divergere nella validazione. I metodi possono essere sovraccaricati allo stesso modo; la scelta dipende dai parametri dichiarati, non dal solo tipo di ritorno.

Prima della delega, il secondo costruttore controlla voto. Questa disposizione è permessa dai corpi di costruttore flessibili, diventati stabili in Java 25: si possono eseguire istruzioni prima di this(...) o super(...) rispettando i vincoli sul riferimento all'istanza in costruzione. Nel nostro prologo leggiamo soltanto il parametro; non chiamiamo metodi dell'oggetto ancora incompleto. La guida ufficiale illustra anche casi più avanzati, fra cui l'assegnazione anticipata di un campo prima di super(...). Qui non ne abbiamo bisogno: introdurre una possibilità nuova non rende buona ogni costruzione complicata.

Versione Java – Costruttori flessibili. La forma stabile arriva in Java 25. Se compili il secondo costruttore del nostro esempio per una release precedente, il controllo prima di this(...) non è valido. Puoi usare la forma tradizionale spostando la validazione nel costruttore delegato; il comportamento richiesto al registro resta lo stesso. Il riepilogo ufficiale delle modifiche distingue questa forma stabile dalle sue versioni preview.

Seguire new senza inventare un modello di memoria

Quando scriviamo new RegistroVoti("Ada", originali), Java crea una nuova istanza e ne esegue l'inizializzazione. Prima che il costruttore sia terminato correttamente, il chiamante non riceve un registro pronto da usare. Se la validazione lancia IllegalArgumentException, l'espressione new non restituisce l'istanza al chiamante. Questo è il motivo per cui il controllo iniziale è così importante: non vogliamo consegnare un oggetto con un array vuoto e sperare che media() si accorga più tardi del problema.

Il paragone fra new e malloc suggerisce una sequenza concreta di caricamento, allocazione e assegnazione di this. Questo può aiutare a intuire che serve spazio per un nuovo oggetto, ma nasconde differenze decisive. new invoca le regole Java di creazione e inizializzazione; malloc riserva memoria grezza e non conosce i costruttori Java. La posizione fisica dei dati e le ottimizzazioni della JVM non fanno parte del contratto che il nostro programma deve usare. Ci interessa invece una proprietà verificabile: due espressioni new RegistroVoti(...) producono due istanze distinte, ciascuna con il proprio stato.

Se non dichiari alcun costruttore, il compilatore ne fornisce uno senza parametri secondo le regole della classe. Ma appena ne dichiari uno tu, non devi aspettarti che esista ancora automaticamente quello senza parametri. Nel nostro registro esistono due costruttori espliciti e new RegistroVoti() non compila: mancano i dati necessari per rispettare l'invariante. Un costruttore non ha un tipo di ritorno, nemmeno void. Non puoi richiamarlo in seguito come registro.RegistroVoti(...): per cambiare un oggetto già creato serve un normale metodo che dichiari la modifica consentita.

Due modi di inizializzare, un solo contratto

Il costruttore con un singolo voto potrebbe copiare le tre assegnazioni del costruttore con array. Sembra poco lavoro, ma alla prima nuova regola dovremmo ricordarci di modificare entrambi. La delega this(studente, new int[] {voto}) fa attraversare a entrambe le forme lo stesso controllo. È il motivo per cui la delega migliora l'affidabilità, non soltanto la brevità del file. Il costruttore chiamato dalla delega non crea una seconda istanza: completa la costruzione della stessa.

Nella forma tradizionale di Java la chiamata a this(...) o super(...) doveva precedere ogni altra istruzione del costruttore. Java 25 permette un prologo entro limiti precisi, come nel controllo del parametro voto del nostro esempio. Il costruttore non può usare liberamente l'istanza incompleta in quel prologo. Se stai leggendo un progetto compilato per Java 17 o 21, la forma con controllo prima della delega non è disponibile come sintassi stabile: sposta il controllo nel costruttore delegato oppure in un'espressione di validazione compatibile con la release del progetto.

Perché il costruttore non è un metodo qualsiasi

Un metodo si può chiamare più volte su un oggetto già creato; un costruttore partecipa alla nascita di quell'oggetto. Il suo nome coincide con quello della classe e non compare un tipo di ritorno. Se scrivessimo void RegistroVoti(...), avremmo dichiarato un metodo ordinario con un nome insolito, non un costruttore. La distinzione spiega anche perché l'oggetto non può essere «ricostruito» chiamando di nuovo il costruttore dopo averlo ottenuto: un cambiamento successivo richiede un metodo con un contratto proprio.

Quando due costruttori sono sovraccaricati, la lista dei parametri deve permettere di scegliere la firma. RegistroVoti(String, int[]) e RegistroVoti(String, int) sono distinguibili dal secondo parametro. Cambiare soltanto il nome locale del parametro, oppure aggiungere un diverso tipo di ritorno, non crea una nuova firma valida. Il chiamante sceglie una forma in base ai dati che possiede; la classe deve far rispettare lo stesso invariante in entrambe. La delega this(...) è una maniera di garantire questa unità, ma occorre evitare una catena circolare in cui due costruttori si richiamano a vicenda senza arrivare a inizializzare l'oggetto.

L'accesso al costruttore è parte del contratto. Se è public, i chiamanti che vedono la classe possono usarlo; se è private, soltanto il codice della classe può chiamarlo direttamente. Una fabbrica statica può scegliere quale istanza restituire o validare i dati prima di costruirla, ma il nome «fabbrica» non obbliga a usare un Singleton. Se un dominio richiede un registro per ogni studente, condividere una sola istanza sarebbe un errore di modello anche se la sintassi di un costruttore privato lo permette. Le parole chiave governano l'accesso; il significato dell'oggetto decide la scelta.

Oggetti annidati, blocchi e istanze uniche

Classi interne, classi annidate statiche, classi locali, blocchi di inizializzazione e Singleton richiedono attenzione anche al di fuori dell'esempio del registro. Sono strumenti reali, ma non sono passaggi obbligatori per proteggere l'array del registro. Una classe annidata è dichiarata dentro un'altra classe; una classe interna non statica ha un legame con un'istanza della classe esterna, mentre una classe annidata static non richiede quell'istanza. La differenza diventa utile quando un tipo di supporto appartiene davvero all'implementazione dell'oggetto esterno. Le classi locali e anonime torneranno nella parte sulle interfacce, dove avranno un problema concreto da risolvere.

Un blocco di inizializzazione esegue istruzioni quando viene inizializzata la classe o una sua istanza. Se un costruttore esprime meglio il lavoro, il blocco aggiunge soltanto un passaggio da cercare. L'ordine della costruzione fra classe base e sottoclasse sarà provato nel capitolo 9; i blocchi statici e quelli di istanza richiedono inoltre di distinguere inizializzazione della classe da creazione dell'oggetto.

Un esperimento sull'ordine di inizializzazione

Stampiamo un messaggio da un blocco static, uno da un blocco di istanza e uno dal costruttore. Prima di eseguirlo, possiamo prevedere il risultato. All'inizializzazione della classe, i suoi inizializzatori statici vengono eseguiti nell'ordine previsto dal sorgente; in un semplice avvio con main, ciò avviene prima di costruire le istanze. Ogni volta che creiamo un oggetto, gli inizializzatori di istanza della sua classe partecipano alla costruzione prima del corpo del costruttore. Con due oggetti della stessa classe vedremo quindi il messaggio statico una volta, e i messaggi di istanza e del costruttore due volte ciascuno.

Non diciamo che il blocco statico venga eseguito «ogni volta che la classe viene caricata» come se caricamento e inizializzazione fossero lo stesso evento. La JVM distingue queste fasi, e una classe può essere caricata prima che sia inizializzata. Per il programma didattico ci basta osservare che il codice statico non è legato a una singola istanza. Se puoi inizializzare un campo con una semplice espressione o nel costruttore, preferisci la forma che rende più facile seguire il valore; un blocco separato si giustifica quando ha un lavoro riconoscibile.

Il programma DemoBlocchi propone una prova con due blocchi statici, due blocchi di istanza e due creazioni. Prima di eseguirlo, separa su carta ciò che appartiene alla classe da ciò che appartiene a ciascun oggetto.

public class DemoBlocchi {
    static {
        System.out.println("statico 1");
    }

    static {
        System.out.println("statico 2");
    }

    {
        System.out.println("istanza 1");
    }

    {
        System.out.println("istanza 2");
    }

    public DemoBlocchi() {
        System.out.println("costruttore");
    }

    public static void main(String[] args) {
        new DemoBlocchi();
        new DemoBlocchi();
    }
}

L'output comincia con statico 1 e statico 2, una sola volta nell'avvio mostrato. Segue istanza 1, istanza 2, costruttore per il primo oggetto; la stessa terna torna per il secondo. Non leggiamo il file semplicemente dall'alto verso il basso come una lista di istruzioni che viene eseguita una volta. Distinguiamo l'inizializzazione della classe, che precede l'uso attivo nel nostro caso, dalla costruzione ripetuta delle istanze. I blocchi di uno stesso tipo seguono l'ordine del sorgente insieme alle inizializzazioni dei campi pertinenti. Se una classe estende un'altra, interviene anche la sequenza di inizializzazione della base; il capitolo 9 la renderà osservabile con un esempio separato.

I blocchi di inizializzazione non sono una novità di Java 11: erano disponibili già prima di quella release. Saperlo è importante perché chi mantiene programmi precedenti a Java 11 potrebbe altrimenti credere di non poterli usare. Più importante della data è però il loro scopo. Se l'inizializzazione è una semplice assegnazione, scriverla accanto al campo rende il codice più facile da seguire. Un blocco con effetti complessi richiede una ragione che il lettore del file possa riconoscere.

Un costruttore private può impedire ad altro codice di creare direttamente istanze. È uno degli ingredienti del pattern Singleton, che offre una sola istanza condivisa. Nel registro dei voti sarebbe sbagliato: ogni studente deve avere il proprio registro. Una sola istanza non rende automaticamente sicuro o facile da verificare uno stato globale; useremo questa soluzione soltanto quando il problema la richiederà.

Il Singleton storico e la domanda sulla condivisione

Un Singleton può usare un campo statico inizialmente null e un metodo getInstance() che costruisce l'oggetto alla prima chiamata. In un programma eseguito da un solo thread la sequenza sembra chiara: la prima chiamata crea l'istanza, le successive la restituiscono. Con più thread, però, due chiamate possono osservare entrambe il campo ancora nullo e costruire due istanze. Il problema non si risolve rendendo soltanto private il costruttore: quella parola limita chi può invocarlo, non coordina le esecuzioni concorrenti. Il capitolo sulla concorrenza spiegherà gli strumenti per ragionare sull'ordine fra thread; qui evitiamo di stampare l'implementazione ingenua come modello sicuro da copiare.

Approfondimento – Che cosa vuol dire atomico. Un'operazione è atomica rispetto agli altri thread quando il suo effetto viene osservato come un solo passaggio indivisibile. Nel Singleton ingenuo, «controlla se il campo è null» e «assegna l'istanza appena costruita» sono due passaggi distinti: un altro thread può intervenire fra i due. Il costruttore privato non rende atomica la coppia. Per progettare l'accesso concorrente occorrono regole di sincronizzazione o una forma di inizializzazione che offra già la garanzia necessaria; la sola lettura della sequenza nel sorgente non basta a provarla.

Se il dominio richiede davvero un'unica istanza di un servizio senza parametri di configurazione per ogni uso, un enum a una costante può rappresentare quella scelta con una forma semplice. Anche in quel caso, il servizio può avere stato modificabile, e l'unicità dell'istanza non rende automaticamente corrette le modifiche concorrenti. Un'altra possibilità è creare esplicitamente un oggetto nel punto di avvio dell'applicazione e passarlo ai componenti che ne hanno bisogno. Chi legge la firma dei costruttori vede così la dipendenza, e una prova può consegnare un'istanza diversa. Questa pratica è spesso chiamata iniezione delle dipendenze: il termine indica qui il passaggio esplicito di un collaboratore, senza richiedere alcun framework.

Concetto chiave – Una sola istanza non è sempre un'invariante. Il registro di un cliente, la pila di un calcolo e il libro sfogliato nel capitolo precedente devono poter esistere più volte. Prima di scegliere un Singleton, formula la regola del dominio che richiederebbe esattamente una istanza e chiediti se deve valere per il processo, per una sessione, per una richiesta o per un utente. Se la risposta cambia con il contesto, una variabile globale nascosta dietro getInstance() rende il modello meno chiaro.

Un costruttore protected è ancora un'altra scelta: permette l'uso nel package e nelle sottoclassi secondo le regole di accesso, senza aprire la costruzione indiscriminatamente. Serve quando il tipo è progettato per essere esteso e la sottoclasse deve inizializzare la parte ereditata. Non scegliamo il modificatore per imitare il Singleton; lo scegliamo dopo aver deciso chi può creare o estendere il tipo. Nel prossimo capitolo vedremo questa decisione nella classe base Veicolo.

Tre forme di classe annidata, tre legami diversi

Una classe interna membro non statica è associata a un'istanza della classe esterna. Immagina una Biblioteca che definisca al proprio interno Scansione: ogni oggetto Scansione potrebbe riferirsi alla biblioteca a cui appartiene. Dentro Scansione, this indica la scansione corrente, mentre Biblioteca.this indica l'istanza esterna associata. Questa distinzione fra i due this è essenziale per leggere le autoreferenze. Se due biblioteche creano ciascuna una scansione, i riferimenti esterni sono diversi; non esiste una sola biblioteca globale implicita.

Il legame di un'istanza interna con quella esterna non vieta di dichiarare membri static nel tipo interno. Il divieto di dichiarare metodi statici in una classe interna apparteneva alle versioni anteriori a Java 16, non a Java 25. Un metodo static dichiarato nella classe interna appartiene al tipo e non può usare implicitamente Biblioteca.this come farebbe un metodo di istanza. La JLS §8.1.3 distingue precisamente le due cose. Nel nostro esempio Dettaglio usa l'istanza esterna; Formato è invece un tipo annidato statico perché non ha bisogno di quel legame.

La figura 8.3 ridisegna quel rapporto con due istanze esterne. Ogni dettaglio conosce la biblioteca che lo ha creato; non c'è una sola biblioteca condivisa da tutti i dettagli.

Due classi interne associate a istanze esterne diverse
Figura 8.3 – Ogni istanza interna Dettaglio è legata alla propria istanza esterna. Una classe annidata static non conserva questo legame implicito.

Una classe annidata statica appartiene alla struttura del sorgente della classe esterna. Le sue istanze non ricevono un riferimento implicito a un oggetto esterno. Nel programma che segue, Formato è dichiarata dentro la classe della biblioteca ma lavora soltanto sui dati che riceve. Il legame fra i due tipi è visibile nel codice, senza obbligare ogni Formato a ricordare una particolare biblioteca. Possiamo creare più oggetti di quel tipo: static qui non significa «istanza unica».

Una classe locale viene dichiarata dentro un blocco, per esempio nel corpo di un metodo. Il suo nome è disponibile nell'ambito di quel blocco. Se usa una variabile locale del metodo che la contiene, quella variabile deve essere final oppure effettivamente final: può cioè essere assegnata una volta e non modificata dopo. Java accetta una variabile non modificata anche senza il modificatore final: ciò che conta è che sia effettivamente final. Le classi anonime sono un'altra forma locale che useremo quando incontreremo le interfacce e avremo un esempio che ne motivi l'uso.

Il criterio di scelta non è «annidare rende sempre il file più corto». Una classe di supporto privata e piccola, usata soltanto dall'esterna, può rendere più chiaro il confine. Una classe che cresce, ha un contratto autonomo o viene usata da più parti del programma può meritare un file proprio. Anche le classi annidate partecipano alle regole di accesso e possono accedere ai membri privati dell'esterna secondo la loro forma; questo non autorizza a creare dipendenze difficili da seguire.

Per vedere i tre legami in esecuzione, il file DemoClassiAnnidate.java contiene un caso completo. I nomi sono volutamente semplici: qui osserviamo la relazione fra istanze, non stiamo ancora progettando una vera biblioteca.

public class DemoClassiAnnidate {
    private final int id;

    public DemoClassiAnnidate(int id) {
        this.id = id;
    }

    public class Dettaglio {
        public String descrizione() {
            return "biblioteca: " + DemoClassiAnnidate.this.id;
        }
    }

    public static class Formato {
        public String nome() {
            return "catalogo";
        }
    }

    public String etichettaLocale() {
        String prefisso = "id=";
        class Etichetta {
            String testo() {
                return prefisso + id;
            }
        }
        return new Etichetta().testo();
    }

    public static void main(String[] args) {
        DemoClassiAnnidate biblioteca = new DemoClassiAnnidate(7);
        Dettaglio dettaglio = biblioteca.new Dettaglio();
        Formato formato = new Formato();
        System.out.println(dettaglio.descrizione());
        System.out.println(formato.nome());
        System.out.println(biblioteca.etichettaLocale());
    }
}

La chiamata biblioteca.new Dettaglio() associa il nuovo Dettaglio a quella particolare biblioteca. Dentro la classe interna, DemoClassiAnnidate.this.id indica senza ambiguità il campo dell'istanza esterna. new Formato() non ha bisogno della biblioteca: Formato è annidata staticamente e il suo metodo non legge lo stato di una DemoClassiAnnidate. La classe locale Etichetta è visibile soltanto nel metodo etichettaLocale; legge prefisso, che non viene riassegnato ed è quindi effettivamente final. Il programma stampa, nell'ordine, biblioteca: 7, catalogo e id=7.

Nel disegno storico delle classi interne comparivano due istanze della classe esterna, ciascuna con il proprio oggetto interno. Possiamo ricostruire la situazione senza immaginare una variabile globale nascosta: se creiamo prima = new DemoClassiAnnidate(7) e seconda = new DemoClassiAnnidate(9), allora prima.new Dettaglio() produce un dettaglio che, tramite DemoClassiAnnidate.this, legge 7; seconda.new Dettaglio() ne produce uno che legge 9. La classe Dettaglio è una sola dichiarazione nel sorgente, ma ogni sua istanza interna è associata a una particolare istanza esterna. È proprio questa associazione a giustificare la forma non statica quando il comportamento deve usare lo stato esterno.

Se Formato deve soltanto restituire la parola catalogo, non ha ragione di portare con sé un legame implicito a una biblioteca. La forma annidata statica esprime meglio questa indipendenza. Non confondiamo però il modificatore con i campi statici studiati nel capitolo 7: possiamo creare molti oggetti Formato, ciascuno con il proprio eventuale stato di istanza. static qui descrive il rapporto della dichiarazione annidata con la classe esterna, non limita a uno il numero di oggetti.

La classe locale Etichetta risponde a una terza esigenza: serve soltanto mentre eseguiamo etichettaLocale(), quindi il suo nome rimane nel blocco del metodo. Il valore prefisso usato al suo interno è una variabile locale del metodo che la circonda. Java richiede che non venga riassegnata dopo la sua inizializzazione; altrimenti non potremmo leggere quel valore come una cattura stabile. Non occorre scrivere la parola final se il codice rispetta già la regola. Una classe locale molto lunga o riutilizzata in più metodi, tuttavia, rende difficile leggere il metodo: in quel caso vale la pena valutare un tipo membro o un file separato. La forma più interna non è automaticamente la più incapsulata nel senso utile al lettore.

Una piccola prova modifica solo prefisso dopo la dichiarazione, aggiungendo per esempio prefisso = "numero="; prima della classe locale. Il programma non compila più, perché la classe locale cattura una variabile che non è effettivamente finale. Se invece cambi l'identificatore passato al costruttore della biblioteca, cambiano le righe che leggono lo stato dell'istanza esterna, ma non catalogo. Questa differenza è il motivo per scegliere con attenzione fra classe interna e classe annidata statica.

Per verificare

Riprendi prima DemoClassiAnnidate: crea due biblioteche con identificatori diversi e un Dettaglio per ciascuna. Prevedi quali righe cambiano e spiega perché Formato continua a stampare catalogo. Poi riassegna prefisso prima della dichiarazione della classe locale e verifica che il compilatore rifiuti la cattura. Qui la prova deve distinguere il legame con l'istanza esterna da quello con una variabile locale.

Torna quindi alla copia difensiva degli array: togli una chiamata a clone() per volta e prevedi la media stampata dopo le modifiche agli array esterni. Quale direzione di copia manca in ciascun caso? L'attività C08 propone i comandi e i risultati con cui controllare la risposta. I due esperimenti richiedono lo stesso metodo di lettura: individua quale oggetto o valore resta raggiungibile, poi verifica la previsione eseguendo il programma.

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 ↑