mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 13/39

13. Interfacce, classi astratte e polimorfismo

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 →

Quando la stessa domanda riguarda oggetti diversi

Nel capitolo sull'ereditarietà abbiamo potuto trattare un'automobile come un veicolo. La classe base forniva una parentela reale e una parte dell'implementazione. Adesso immaginiamo di voler chiedere un valore numerico tanto a un termometro quanto a un contatore di prestiti. Nessuno dei due è una variante dell'altro. Non serve inventare una superclasse che accomuni «cose che hanno un numero»: ci interessa soltanto poter rivolgere a entrambi la domanda valore().

Un'interfaccia dichiara un tipo attraverso operazioni che le classi promettono di offrire. La classe scrive implements e fornisce il comportamento richiesto. Una variabile del tipo dell'interfaccia può riferirsi a oggetti di classi differenti che la implementano. Quando chiamiamo il metodo su quella variabile, viene eseguita l'implementazione dell'oggetto effettivo. Questo è il polimorfismo per sottotipo che useremo nel resto del libro: chi chiama dipende da un contratto comune e non deve conoscere ogni classe concreta.

Definizione – Contratto. Per un'interfaccia, il contratto comprende firme dei metodi, significato delle operazioni e condizioni che i chiamanti possono aspettarsi. Il compilatore controlla le firme, ma non può dimostrare da solo che valore() rappresenti davvero la stessa idea in tutte le implementazioni. Nomi identici con significati incompatibili non costruiscono una buona astrazione.

Una pila di int può sembrare troppo specifica: se sostituiamo il tipo degli elementi con Object, accoglie qualunque oggetto. Questa scelta perde la verifica dei tipi: il chiamante deve eseguire cast e può scoprire un errore soltanto durante l'esecuzione. Qui separiamo i problemi. L'interfaccia serve a descrivere che cosa si può fare; i generics del capitolo 15 serviranno a descrivere il tipo degli elementi senza rinunciare al controllo del compilatore. La Pila di C08 rimane un esempio concreto di stato e invarianti, e non diventa magicamente generica perché implementa un'interfaccia.

Seguiamo le operazioni di una pila prima di scegliere come dichiararne il contratto. Con una capacità di due posti, la sequenza push(7), push(9), pop() lascia 7 nella pila e restituisce 9: l'ultimo elemento entrato è il primo a uscire. Per un'altra pila che conservi titoli vorremmo la stessa regola, ma il metodo pop() dovrebbe restituire una String, non un int. Se dichiariamo Object pop() nell'interfaccia, entrambi gli oggetti possono promettere la stessa firma; però il chiamante che riceve Object non sa se il risultato sia un numero o un titolo. Dopo Object dato = pila.pop(), un cast a String compila anche quando la pila contiene un Integer; l'errore appare soltanto quando il cast viene eseguito. L'interfaccia ha reso uniforme il nome dell'operazione, ma ha perso una relazione essenziale fra l'argomento di push e il risultato di pop.

Non basta dire che tutti gli oggetti estendono Object. Quella parentela permette di conservare riferimenti eterogenei, non promette che qualunque oggetto estratto sia del tipo desiderato dal chiamante. Né pop() dovrebbe restituire lo zero quando la pila è vuota: zero potrebbe essere un elemento legittimo e non distinguerebbe un risultato da un errore. In C08 abbiamo scelto un'eccezione per il caso vuoto; in C15 useremo Pila<T> per mantenere insieme il tipo di ingresso e quello di uscita. Il passaggio storico dall'interfaccia con Object alla soluzione generica resta importante: mostra quale problema risolve il parametro di tipo, anziché presentare le parentesi angolari come una semplice novità grafica.

Concetto chiave – Due generalizzazioni diverse. La variabile di tipo Misurabile consente a una funzione di lavorare con più classi che condividono un comportamento. Il parametro T di Pila<T> le consentirà di mantenere una relazione fra più posizioni del contratto. Possiamo usare interfacce e generics insieme, ma l'uno non sostituisce l'altro.

Il primo contratto

Il programma completo dichiara Misurabile e Descrivibile dentro una classe dimostrativa, così tutti i pezzi necessari restano in un unico file. In un'applicazione più grande, le interfacce pubbliche avrebbero file propri. La dichiarazione centrale è piccola:

interface Misurabile {
    int valore();

    default boolean supera(int soglia) {
        return valore() > soglia;
    }

    static int differenza(Misurabile primo, Misurabile secondo) {
        return Math.abs(primo.valore() - secondo.valore());
    }
}

Il metodo valore() non ha corpo: è implicitamente public e abstract. Una classe concreta che implementa Misurabile deve fornire un metodo public int valore(). Non basta avere per coincidenza un metodo con lo stesso nome: la classe deve dichiarare la relazione con implements. Un'interfaccia non possiede campi di istanza né costruttori, dunque non crea direttamente oggetti. Può dichiarare costanti, che sono implicitamente public static final, ma una costante pubblica va scelta perché appartiene davvero al contratto e non per nascondere stato condiviso.

supera è un metodo default: offre un comportamento comune basato sul metodo astratto. Se il valore effettivo cambia, supera usa sempre il valore restituito dall'oggetto corrente. differenza è invece static: si invoca come Misurabile.differenza(a, b) e non attraverso un'istanza. Il suo compito è un calcolo utile accanto al contratto; non viene ridefinito dalle classi che implementano l'interfaccia. La specifica Java 25 sulle interfacce descrive anche i metodi private, utili per condividere un dettaglio fra metodi default o statici senza esporlo ai chiamanti.

La figura 13.1 distingue la relazione fra tipo e implementazioni. Le frecce indicano che le due classi soddisfano lo stesso contratto, non che una erediti lo stato dell'altra.

Due implementazioni dello stesso contratto
Figura 13.1 – Un'interfaccia permette di chiedere valore() a oggetti non imparentati per classe. Temperatura e ContatorePrestiti possono anche implementare Descrivibile senza rinunciare alla loro diversa struttura interna.

Nel programma, Temperatura è un record; il suo componente valore genera un accessore valore() che soddisfa l'interfaccia. Non abbiamo bisogno di scrivere un secondo metodo identico. ContatorePrestiti eredita invece valore() da una classe astratta. Le due strade sono diverse, ma il chiamante che riceve un Misurabile usa la medesima operazione. Eseguendo il programma leggiamo 27 gradi, true, 1 prestiti e 26: la differenza usa i valori 27 e 1. Il programma mostra anche un limite dell'astrazione: sottrarre gradi e numero di prestiti è matematicamente possibile ma semanticamente discutibile. In un progetto reale il nome del contratto e i tipi dovrebbero impedire di confrontare grandezze che non hanno lo stesso significato. L'esempio serve proprio a far notare che la sola compatibilità sintattica non basta.

Nota bene – Il compilatore non misura il senso. Misurabile.differenza accetta entrambi gli oggetti perché entrambi implementano l'interfaccia. Se il dominio consente soltanto differenze fra temperature, occorre un contratto più specifico, oppure una funzione che accetti soltanto Temperatura. Generalizzare troppo presto trasferisce errori di significato dal compilatore ai lettori e agli utenti.

Classe astratta o interfaccia?

La classe Contatore del programma conserva un campo privato e implementa due operazioni: aumenta() e valore(). È dichiarata abstract perché vogliamo usarla come base di specializzazioni e non crearla direttamente. ContatorePrestiti eredita sia lo stato sia il comportamento, aggiungendo la descrizione. Una classe Java può estendere una sola classe, astratta o concreta, ma può implementare più interfacce. Per questo il record Temperatura e ContatorePrestiti dichiarano entrambi implements Misurabile, Descrivibile.

Una classe astratta è adatta quando le sottoclassi condividono stato, invarianti o una sequenza di operazioni che la base può governare senza forzature. Un'interfaccia è adatta quando vogliamo esprimere una capacità utilizzabile da classi altrimenti indipendenti. Non è una regola che si applica contando soltanto i metodi: anche un'interfaccia può avere metodi default, e anche una classe astratta può non avere campi. La domanda più utile è quale relazione promettiamo a chi userà il tipo e quale evoluzione futura vogliamo rendere possibile.

Un solido, due responsabilità

Un solido rende concreta questa distinzione: per conoscerne il peso occorrevano un peso specifico e un volume; per conoscere il volume, invece, bisognava sapere quale forma concreta avessimo davanti. Seguiamo il ragionamento in un programma completo. SolidoMisurabile dichiara soltanto le domande volume() e superficie(). La classe astratta Solido conserva una densità, controlla che sia positiva e finita e offre massa(). Cubo conosce il lato e realizza le due misure geometriche. Se un'altra classe, senza essere sottoclasse di Solido, potesse calcolare volume e superficie, potrebbe comunque implementare SolidoMisurabile: è questa la libertà che il contratto concede.

Il programma stampa 8.0, 24.0 e 24.0. Per un lato lungo 2 unità, il volume del cubo è 2 × 2 × 2 = 8 unità cubiche; la superficie è la somma delle sei facce quadrate, ciascuna di area 2 × 2, quindi 6 × 4 = 24 unità quadrate. Nell'esempio la densità è 3 unità di massa per unità di volume; la massa risulta 3 × 8 = 24 unità di massa. La geometria non è il tema del capitolo, ma le unità rendono evidente una questione di progetto: volume() e superficie() restituiscono entrambi double, eppure rappresentano grandezze diverse. Il tipo numerico da solo non impedisce di sommarle per errore. I nomi, la documentazione e, nei sistemi che lo richiedono, tipi di dominio distinti completano il contratto.

Nel main, il riferimento SolidoMisurabile misurabile = cubo espone soltanto le due misure geometriche. misurabile.massa() non compilerebbe: massa() appartiene alla classe astratta e quindi al tipo concreto Cubo, non all'interfaccia. L'oggetto a cui punta il riferimento è sempre il medesimo cubo; cambiare il tipo della variabile non cambia l'oggetto, ma cambia le operazioni che il compilatore permette di chiamare. cubo.massa() funziona e richiama volume() implementato da Cubo. Questa è una prova concreta del legame fra classe astratta e metodo ridefinito.

Nota bene – La formula ha ipotesi. densita × volume calcola la massa solo se la densità è omogenea e le unità sono compatibili. Se la densità varia dentro il solido, quel metodo non rappresenta più il fenomeno; non lo salva né un'interfaccia né un double più preciso. Il contratto di un metodo comprende anche le ipotesi entro cui il risultato ha senso.

Per le interfacce possiamo parlare di «ereditarietà multipla di tipo». È vero che una classe può implementarne più di una e che un'interfaccia può estenderne più di una. Non significa che Java erediti liberamente lo stato di più classi. Questa distinzione evita di immaginare conflitti di campi o costruttori che appartengono ad altri modelli di linguaggio.

Quando due metodi default hanno lo stesso nome

Se due interfacce forniscono lo stesso metodo default e una classe le implementa entrambe, Java chiede alla classe di risolvere il conflitto. Nel secondo programma, Primo e Secondo dichiarano entrambi nome(). Scelta scrive un proprio nome() e richiama esplicitamente Primo.super.nome() e Secondo.super.nome(). L'output è primo+secondo.

Il compilatore non può scegliere sulla base dell'ordine in implements: quell'ordine non esprime una priorità semantica. La classe può scegliere una sola implementazione, combinarle o fornire un comportamento completamente nuovo, purché rispetti il contratto che promette. Se una classe base fornisce già un metodo d'istanza compatibile, quello prevale sul default dell'interfaccia. La scelta è parte del progetto della classe, non un dettaglio da affidare al caso.

Consideriamo un'interfaccia Autore che deve crescere senza rompere le implementazioni esistenti: dopo che AutoreDelLibro aveva già implementato due metodi, l'API pubblicata voleva aggiungerne un terzo senza obbligare tutti gli autori delle implementazioni a scriverlo. La nuova operazione deve avere senso per tutti gli autori. Se l'interfaccia promette nome() e dataNascita(), può aggiungere default String etichetta() { return nome() + " (" + dataNascita() + ")"; }. La vecchia classe continua ad avere un comportamento per etichetta() perché il metodo default si appoggia a due operazioni già richieste dal contratto. Una classe può comunque ridefinirlo, per esempio se la data non deve apparire in una ricevuta pubblica.

L'esempio storico aggiungeva invece getPeso() con il valore fisso 100.0. Una classe compilerebbe anche in quel caso, ma il contratto direbbe una cosa falsa per molti oggetti. È il punto più interessante dell'esercizio: la compatibilità della firma non garantisce la correttezza del significato. Se non esiste un'implementazione generale sensata, conviene una nuova interfaccia oppure un servizio separato, invece di insegnare a tutte le classi un metodo che restituisce un valore inventato. Anche la compatibilità binaria di un'aggiunta va valutata nel contesto dell'intera gerarchia: metodi già presenti in altre interfacce o classi possono introdurre conflitti. Il default aiuta a evolvere un'API, ma non dispensa dal provare le implementazioni esistenti.

Un metodo statico dell'interfaccia risolve un'esigenza diversa. Se vogliamo verificare che due misure siano entrambe non negative, un metodo static può lavorare sui valori ricevuti e restare vicino al contratto. La chiamata si scrive Misurabile.nomeDelMetodo(...); le classi che implementano l'interfaccia non lo ridefiniscono con il normale overriding. Un metodo private può servire come dettaglio condiviso fra più default o statici: il chiamante esterno non lo vede. È quindi possibile scegliere fra una regola comune che ogni oggetto eredita, un'operazione di utilità legata al tipo e un dettaglio interno, senza confondere i tre livelli di visibilità e dispatch.

Interfacce estese, classi anonime e confini chiusi

Un'interfaccia può estenderne altre: interface ArchivioLeggibile extends Descrivibile aggiungerebbe nuove operazioni pur mantenendo il contratto di descrizione. Quando il tipo di un parametro è l'interfaccia più piccola che basta al metodo, chi chiama rimane libero di passare implementazioni diverse. Dichiarare ogni parametro con l'interfaccia più generale possibile, però, non è sempre una virtù: deve ancora rappresentare chiaramente ciò che il metodo richiede.

Un'interfaccia può anche essere dichiarata dentro una classe o un'altra interfaccia. Il nome qualificato, per esempio Archivio.Lettore, comunica che il contratto appartiene al contesto di Archivio; non crea automaticamente un oggetto esterno per ogni implementazione. La visibilità dipende da dove si trova la dichiarazione e dai suoi modificatori. È una scelta utile se il contratto è davvero legato a quell'API, meno utile quando altre parti del programma devono usarlo come concetto autonomo.

Prima delle lambda, per fornire una piccola implementazione locale si usava spesso una classe anonima: una classe senza nome dichiarata nell'espressione new Interfaccia() { ... }. È ancora utile quando occorre implementare più metodi o conservare un piccolo stato locale; per le interfacce con un solo metodo astratto, nel capitolo 16 vedremo spesso una lambda più leggibile. Anche un'enumerazione può implementare un'interfaccia se i suoi valori condividono un'operazione. Queste forme non cambiano il significato di implements.

Una interfaccia sealed limita quali classi o interfacce possono implementarla direttamente, con una clausola permits. È utile quando il dominio è intenzionalmente chiuso e vogliamo poter ragionare su tutti i casi, per esempio in uno switch esaustivo. Una delle implementazioni può essere un record, purché sia ammessa dalla dichiarazione e rispetti le regole della gerarchia sealed. Se nuovi casi devono poter essere introdotti liberamente da altri moduli, chiudere il tipo sarebbe invece un ostacolo. Il capitolo 9 ha già mostrato questa scelta per le classi; il principio è lo stesso.

Nel programma DemoEsito.java, un prestito produce un Esito: o è Confermato e porta un codice, oppure è Rifiutato e porta un motivo. I due record implementano un'interfaccia sealed. Uno switch su Esito tratta entrambi i casi e restituisce una descrizione. Il compilatore sa che l'elenco dei casi ammessi è chiuso, perciò non serve un ramo default che mascheri dimenticanze.

static String descrivi(Esito esito) {
    return switch (esito) {
        case Confermato confermato -> "prestito " + confermato.codice();
        case Rifiutato rifiutato -> "rifiuto: " + rifiutato.motivo();
    };
}

L'output è prestito B-17 e rifiuto: non disponibile. Se aggiungiamo un terzo record consentito in permits, il compilatore ci chiede di aggiornare lo switch. Questo è un vantaggio quando il programma deve trattare ogni esito, non una ragione per chiudere tutte le interfacce. L'interfaccia Misurabile rimane volutamente aperta: una futura classe può offrire una misura senza chiedere di modificare la sua dichiarazione. La scelta fra aperto e chiuso nasce dalla stabilità del dominio, non dalla novità della sintassi.

Una classe senza nome e un enum con comportamento

Classi anonime ed enumerazioni possono entrambe realizzare un'interfaccia. Prima di scegliere la forma più breve, osserviamo che cosa viene dichiarato. Il programma DemoFormeLocali.java definisce Operazione, il cui metodo applica riceve due numeri e ne restituisce uno. Calcolo è un enum: ogni costante fornisce una propria implementazione del metodo. SOMMA restituisce la somma, PRODOTTO il prodotto. Nel main compare invece una classe anonima creata con new Operazione() { ... }; la sua implementazione sottrae uno sconto dal totale. I tre risultati stampati sono, nell'ordine, 6.0, 8.0 e 15.0.

La sequenza del primo calcolo merita di essere letta senza saltare passaggi: applica(5, 3) somma 5 e 3, poi sottrae lo sconto 2, ottenendo 6. Il metodo è nel corpo della classe anonima; l'espressione new Operazione() { ... } crea un'istanza di quella classe, che implementa l'interfaccia. Non stiamo creando direttamente un'istanza di un'interfaccia astratta. Non stiamo nemmeno usando un semplice «oggetto senza nome» nel senso generico di new String(...) passato senza assegnarlo a una variabile: qui è la classe a non avere un nome dichiarato nel sorgente.

La classe anonima può usare sconto perché quella variabile locale, dopo l'assegnazione iniziale, non viene più riassegnata: è effectively final. Se aggiungessimo sconto = 3; più avanti nel metodo, il compilatore rifiuterebbe la cattura, anche se la riassegnazione comparisse dopo la creazione dell'oggetto. La regola riguarda la variabile locale, non la possibilità astratta di modificare qualsiasi oggetto: un riferimento locale stabile può ancora puntare a un oggetto modificabile. Dire che final garantisce da sola purezza o assenza di effetti sarebbe sbagliato. Non c'è neppure una regola generale per cui aggiungere la parola final a ogni locale produce automaticamente ottimizzazioni impossibili con una variabile effectively final.

Per questa singola operazione, una lambda come (primo, secondo) -> primo + secondo - sconto sarebbe più breve. La useremo in C16, dove chiariremo il tipo bersaglio. La classe anonima rimane istruttiva perché mostra esplicitamente il metodo implementato e perché può dichiarare uno stato e più metodi quando il contratto lo consente. Una lambda richiede un'interfaccia funzionale, mentre una classe anonima può implementare anche un'interfaccia con più metodi astratti. Se l'implementazione merita un nome, va riusata in più luoghi o richiede spiegazioni proprie, una classe dichiarata è spesso più leggibile di un corpo anonimo sempre più grande.

L'enum mostra un'altra decisione. Calcolo.SOMMA e Calcolo.PRODOTTO sono due valori di un insieme chiuso, non due sottoclassi pubbliche che il chiamante debba costruire. La chiamata Calcolo.SOMMA.applica(5, 3) esegue il corpo associato a quella costante. Potremmo aggiungere SOTTRAZIONE, ma dovremmo anche darle una semantica coerente con Operazione. Se ogni costante finisse per contenere molti dettagli di stato o una politica che cambia durante l'esecuzione, un enum diventerebbe una gabbia troppo rigida; classi separate che implementano il contratto descriverebbero meglio quel dominio.

Per osservare. Sostituisci Operazione sottraiSconto = new Operazione() { ... }; con la lambda equivalente e controlla che le tre righe stampate rimangano uguali. Poi prova a riassegnare sconto prima di chiamare applica: la compilazione deve fallire, e la diagnostica riguarda la cattura della variabile locale. Infine aggiungi una costante SOTTRAZIONE all'enum: non basta scriverne il nome, perché il contratto richiede ancora il comportamento di applica.

Polimorfismi che non vanno confusi

Nel libro abbiamo incontrato più usi della parola polimorfismo. Il sovraccarico sceglie fra metodi con lo stesso nome e parametri diversi, in base alle firme disponibili al momento della compilazione. La ridefinizione permette a una sottoclasse o a una classe che implementa un'interfaccia di fornire il comportamento chiamato attraverso un tipo più generale. I generics permettono di scrivere un algoritmo o un contenitore parametrizzato su un tipo. L'idea di un comportamento comune resta utile, purché distinguiamo il controllo statico del tipo dalla scelta del metodo durante l'esecuzione.

Supponiamo di avere Misurabile misura = new Temperatura(27);. Il tipo dichiarato del riferimento è Misurabile, quindi possiamo chiamare soltanto i metodi che quel contratto rende disponibili. L'oggetto effettivo è Temperatura, e per valore() viene eseguito il comportamento fornito dal record. Non possiamo chiamare direttamente descrizione() attraverso misura, perché Descrivibile non è promesso dal tipo dichiarato, anche se quell'oggetto specifico lo implementa. Un cast o un pattern instanceof potrebbe recuperare l'altra capacità, ma se il metodo richiede sempre entrambe le capacità è più onesto progettare un tipo che le esprima entrambe.

La classe astratta presenta un altro confine utile. Contatore possiede un campo e il metodo aumenta(), ma non possiamo scrivere new Contatore(): la classe è incompleta come entità da esporre direttamente. Questo non significa che il suo costruttore, se definito, non venga eseguito quando nasce una sottoclasse. La parte di classe base viene comunque inizializzata prima che il costruttore della sottoclasse completi il lavoro, secondo le regole già viste in C09. Chiamare dal costruttore astratto un metodo ridefinibile è rischioso: il metodo potrebbe osservare campi della sottoclasse non ancora inizializzati.

Approfondimento – Un metodo private nell'interfaccia. Un'interfaccia con più metodi default può ripetere una piccola regola interna. Un metodo private dell'interfaccia consente di darle un nome e riusarla senza aggiungerla al contratto pubblico. Non è un metodo astratto da implementare nelle classi concrete. Questo è un esempio di come l'interfaccia moderna possa contenere comportamento condiviso, pur non diventando una classe con campi di istanza.

Tre prove sullo stesso riferimento

Per vedere davvero il polimorfismo non basta dichiarare due classi con implements. Torniamo a Misurabile misura = new Temperatura(27) e facciamo tre prove mentali. Prima chiamiamo misura.valore(): la compilazione riesce perché valore() è nel contratto di Misurabile, e la chiamata restituisce 27 dall'oggetto Temperatura. Poi assegniamo misura = new ContatorePrestiti() e chiamiamo ancora misura.valore(): il sorgente della chiamata è identico, ma ora l'implementazione eseguita appartiene al contatore. Infine tentiamo misura.descrizione(): la chiamata non compila, anche se entrambe le classi concrete hanno quella capacità, perché il tipo dichiarato della variabile non la promette.

Potremmo risolvere l'ultimo caso con un cast, ma prima dobbiamo chiedere che cosa sa il chiamante. Se ogni valore ammesso in quel punto deve essere anche descrivibile, un'interfaccia più specifica che estende entrambe le capacità rende il requisito esplicito. Se soltanto alcuni lo sono, un controllo instanceof Descrivibile descrivibile consente di trattare il caso senza assumere che tutti gli oggetti lo siano. La distinzione non serve a evitare un messaggio del compilatore: serve a non trasformare un'ipotesi non dichiarata in un errore a esecuzione avviata.

L’idea che oggetti diversi possano presentare operazioni comuni richiede un contratto riconoscibile. In Java quella forma è un tipo dichiarato, quindi il compilatore ne controlla la relazione. Non basta che una classe abbia casualmente un metodo chiamato valore: senza implements Misurabile non può essere usata come Misurabile. Questa caratteristica rende il contratto verificabile e permette alla documentazione di descrivere un significato comune. Il prezzo è una dichiarazione esplicita, che in una libreria ben progettata diventa anche una guida per chi legge.

Progettare un contratto che sopravvive alle implementazioni

Un'interfaccia utile non nasce copiando tutti i metodi di una classe esistente. Nasce da una domanda che più chiamanti devono poter porre senza conoscere il dettaglio della risposta. Immaginiamo un metodo che stampa una ricevuta di prestito. Se gli basta ottenere una descrizione, ricevere Descrivibile è più chiaro che ricevere un oggetto ContatorePrestiti soltanto perché la prima implementazione disponibile è quella. Ma se il metodo deve anche cambiare il numero dei prestiti, Descrivibile non promette abbastanza: occorre un contratto che esponga esplicitamente l'operazione ammessa.

La segregazione dei contratti significa evitare che un chiamante dipenda da operazioni che non usa. Non vuol dire creare un'interfaccia per ogni singolo metodo a prescindere dal contesto. Misurabile e Descrivibile sono separati perché chiedono cose diverse: una misura e una rappresentazione testuale. Una classe può implementarle entrambe, ma il metodo che richiede soltanto la misura non riceve per questo anche il diritto di descrivere o modificare l'oggetto. Questo restringe le aspettative e rende più facile sostituire un'implementazione.

Una sostituzione corretta richiede anche comportamento coerente. Se Misurabile.valore() promette una misura stabile durante una singola operazione, un'implementazione che cambia casualmente il valore a ogni lettura rompe quella promessa pur compilando. Se Descrivibile.descrizione() è destinata a una ricevuta per l'utente, un'implementazione che restituisce un codice interno incomprensibile rispetta la firma ma non l'uso. La documentazione del contratto deve quindi spiegare significato, eventuali valori ammessi, errori e stabilità del risultato, non solo mostrare int valore();.

Anche l'evoluzione merita attenzione. Aggiungere un nuovo metodo astratto a un'interfaccia pubblica può obbligare tutte le classi che la implementano a cambiare. Un metodo default può fornire un comportamento iniziale compatibile quando esiste una regola generale sensata, come supera che si basa su valore(). Non va usato per mascherare una domanda a cui alcune implementazioni non possono rispondere correttamente. Se il comportamento nuovo è opzionale o appartiene a una capacità diversa, un'altra interfaccia può essere un modello più fedele.

Infine, un'interfaccia non rende un oggetto immutabile né thread-safe. ContatorePrestiti può cambiare il proprio numero; due thread che lo aggiornano richiederebbero regole di concorrenza ulteriori. Il contratto può dichiarare queste garanzie se servono, ma la parola interface non le crea. Questa cautela prepara i capitoli sulle collezioni: List dice quali operazioni esistono, mentre la scelta dell'implementazione e il contratto dell'API stabiliscono proprietà pratiche come mutabilità, ordine e uso concorrente.

Per verificare

Compila i tre programmi con javac --release 25 -Xlint:all -d build DemoInterfacce.java DemoConflittoDefault.java DemoEsito.java. Prova poi a togliere nome() dalla classe Scelta: il compilatore deve rifiutare il conflitto dei default. Nel primo programma, cambia il parametro di supera da 25 a 30 e spiega perché cambia soltanto la riga booleana. Nel terzo, aggiungi un nuovo esito consentito e osserva la diagnostica dello switch prima di aggiornarlo. L'attività applicativa del blocco C13–C18 riprenderà questi contratti per introdurre un tipo generico senza cast.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑