Interfacce e Metodi di Default in Java

Concetti Chiave
  • Le interfacce in Java possono definire metodi predefiniti, che forniscono un'implementazione di base per i metodi dell'interfaccia.
  • I metodi predefiniti consentono di evolvere le interfacce senza rompere il codice esistente, poiché le classi che implementano l'interfaccia possono scegliere di utilizzare l'implementazione predefinita o fornire una propria implementazione.
  • I metodi predefiniti sono dichiarati con la parola chiave default e possono essere utilizzati per fornire funzionalità opzionali o comuni tra diverse implementazioni di interfacce.
  • I metodi predefiniti possono essere utilizzati per risolvere conflitti di nomi tra metodi di interfacce diverse, seguendo regole specifiche per la risoluzione dei conflitti.

Metodi di Default nelle Interfacce

Come spiegato in precedenza, prima di JDK 8, un'interfaccia non poteva definire alcuna implementazione.

Questo significava che per tutte le versioni precedenti di Java, i metodi specificati da un'interfaccia erano astratti, privi di corpo. Questa è la forma tradizionale di un'interfaccia ed è il tipo di interfaccia che le lezioni precedenti hanno utilizzato.

Il rilascio di JDK 8 ha cambiato questo introducendo una nuova capacità alle interfacce chiamata metodo di default o metodo predefinito.

Un metodo predefinito consente di definire un'implementazione predefinita per un metodo di interfaccia. In altre parole, tramite un metodo predefinito, è possibile per un metodo di interfaccia fornire un corpo, piuttosto che essere astratto.

Un'importante motivazione per l'introduzione dei metodi predefiniti era fornire un mezzo con cui le interfacce potessero essere estese senza interrompere il codice esistente.

Si ricordi che devono esserci implementazioni per tutti i metodi definiti da un'interfaccia. In passato, se un nuovo metodo veniva aggiunto a un'interfaccia ampiamente utilizzata, allora l'aggiunta di quel metodo avrebbe rotto il codice esistente perché non sarebbe stata trovata un'implementazione per quel nuovo metodo. Il metodo predefinito risolve questo problema fornendo un'implementazione che sarà utilizzata se non viene esplicitamente fornita un'altra implementazione. Pertanto, l'aggiunta di un metodo predefinito non causerà la rottura del codice preesistente.

Un'altra motivazione per i metodi predefiniti era il desiderio di specificare metodi in un'interfaccia che fossero, essenzialmente, opzionali, a seconda di come l'interfaccia viene utilizzata.

Ad esempio, un'interfaccia potrebbe definire un gruppo di metodi che agiscono su una sequenza di elementi. Uno di questi metodi potrebbe essere chiamato rimuovi(), e il suo scopo è rimuovere un elemento dalla sequenza.

Tuttavia, se l'interfaccia è intesa per supportare sia sequenze modificabili che non modificabili, allora rimuovi() è essenzialmente opzionale perché non verrà utilizzato da sequenze non modificabili. In passato, una classe che implementava una sequenza non modificabile avrebbe dovuto definire un'implementazione vuota di rimuovi(), anche se non era necessaria. Oggi, un'implementazione predefinita per rimuovi() può essere specificata nell'interfaccia che non fa nulla (o genera un'eccezione).

Fornendo questa implementazione predefinita, una classe utilizzata per sequenze non modificabili non deve definire la propria versione segnaposto di rimuovi(). Pertanto, fornendo un metodo predefinito, l'interfaccia rende l'implementazione di rimuovi() da parte di una classe opzionale.

Nota

Differenze fondamentali tra interfacce e classi

È importante sottolineare che l'aggiunta dei metodi predefiniti non cambia un aspetto chiave delle interfacce: la loro incapacità di mantenere informazioni di stato.

Un'interfaccia non può avere variabili di istanza, ad esempio. Quindi, la differenza caratteristica tra un'interfaccia e una classe è che una classe può mantenere informazioni di stato, ma un'interfaccia no.

Inoltre, non è ancora possibile creare un'istanza di un'interfaccia da sola. Deve essere implementata da una classe. Pertanto, anche se a partire da JDK 8, un'interfaccia può definire metodi predefiniti, l'interfaccia deve comunque essere implementata da una classe se si desidera creare un'istanza.

Un'ultima osservazione: come regola generale, i metodi predefiniti costituiscono una funzionalità speciale. Le interfacce che vengono create verranno ancora utilizzate principalmente per specificare cosa fare e non come. Tuttavia, l'inclusione del metodo predefinito offre una maggiore flessibilità.

Fondamenti dei Metodi di Default

Un metodo predefinito di interfaccia è definito in modo simile a come un metodo è definito da una classe. La differenza principale è che la dichiarazione è preceduta dalla parola chiave default. Per esempio, si consideri questa semplice interfaccia:

public interface MiaIF {
    // Questa è una dichiarazione di metodo di interfaccia "normale".
    // NON definisce un'implementazione predefinita.
    int ottieniNumero();

    // Questo è un metodo predefinito. Si noti che fornisce
    // un'implementazione predefinita.
    default String ottieniStringa() {
        return "Stringa Predefinita";
    }
}

MiaIF dichiara due metodi. Il primo, ottieniNumero(), è una dichiarazione di metodo standard di interfaccia. Non definisce alcuna implementazione. Il secondo metodo è ottieniStringa(), e fornisce un'implementazione predefinita.

In questo caso, restituisce semplicemente la stringa "Stringa Predefinita". Prestare particolare attenzione al modo in cui ottieniStringa() è dichiarato. La sua dichiarazione è preceduta dal modificatore default. Questa sintassi può essere generalizzata. Per definire un metodo predefinito, la dichiarazione deve iniziare con default.

Poiché ottieniStringa() include un'implementazione predefinita, non è necessario che una classe implementatrice la sovrascriva. In altre parole, se una classe che implementa non fornisce una propria implementazione, verrà utilizzata quella predefinita. Ad esempio, la seguente classe MiaIFImp è perfettamente valida:

// Implementare MiaIF.
class MiaIFImp implements MiaIF {
    // Solo ottieniNumero() definito da MiaIF deve essere implementato.
    // ottieniStringa() può rimanere come predefinito.
    public int ottieniNumero() {
        return 100;
    }
}

Il seguente codice crea un'istanza di MiaIFImp e la utilizza per chiamare sia ottieniNumero() che ottieniStringa().

// Usare il metodo predefinito.
class DemoMetodoPredefinito {
    public static void main(String[] args) {

        MiaIF obj = new MiaIFImp();

        // Può chiamare ottieniNumero()
        // perché è implementato esplicitamente da MiaIFImp:
        System.out.println(obj.ottieniNumero());

        // Può anche chiamare ottieniStringa()
        // grazie all'implementazione predefinita:
        System.out.println(obj.ottieniStringa());
    }
}

L'output è il seguente:

100
Stringa Predefinita

Come si può vedere, è stata utilizzata automaticamente l'implementazione predefinita di ottieniStringa(). Non era necessario che MiaIFImp la definisse. Pertanto, per ottieniStringa(), l'implementazione da parte di una classe è opzionale. (Ovviamente, l'implementazione da parte di una classe sarà necessaria se la classe utilizza ottieniStringa() per uno scopo che va oltre quello supportato dalla sua versione predefinita.)

È sia possibile che comune per una classe implementatrice definire la propria implementazione di un metodo predefinito. Ad esempio, MiaIFImp2 sovrascrive ottieniStringa():

class MiaIFImp2 implements MiaIF {
    // Qui vengono fornite implementazioni
    // sia per ottieniNumero() che per ottieniStringa().
    public int ottieniNumero() {
        return 100;
    }

    public String ottieniStringa() {
        return "Questa è una stringa diversa.";
    }
}

Ora, quando si chiama ottieniStringa(), viene restituita una stringa diversa.

Un Esempio Pratico

Sebbene quanto precede mostri il funzionamento meccanico dell'uso dei metodi predefiniti, non ne illustra l'utilità in un contesto pratico.

A tal fine, riprendiamo ancora una volta l'interfaccia StackInteri mostrata nelle lezioni precedenti.

Ai fini della discussione, si assuma che StackInteri sia ampiamente utilizzata e che molti programmi vi facciano affidamento. Si supponga inoltre di voler aggiungere un metodo a StackInteri che svuoti la pila, consentendo alla pila di essere riutilizzata. Pertanto, si vuole evolvere l'interfaccia StackInteri in modo che definisca nuove funzionalità, ma senza voler rendere invalido alcun codice preesistente.

In passato, ciò sarebbe stato impossibile, ma con l'inclusione dei metodi predefiniti, ora è facile da fare. Ad esempio, l'interfaccia StackInteri può essere migliorata in questo modo:

interface StackInteri {
    void push(int elemento); // memorizza un elemento
    int pop(); // recupera un elemento

    // Poiché cancella() ha un valore predefinito,
    // non è necessario che venga implementato da
    // una classe preesistente che utilizza StackInteri.
    default void cancella() {
        System.out.println("cancella() non implementato.");
    }
}

Qui, il comportamento predefinito di cancella() mostra semplicemente un messaggio che indica che non è implementato.

Questo è accettabile perché nessuna classe preesistente che implementa StackInteri chiamerebbe mai cancella(), poiché non era definito dalla versione precedente di StackInteri.

Tuttavia, cancella() può essere implementato da una nuova classe che implementa StackInteri. Inoltre, cancella() deve essere definito da una nuova implementazione solo se viene utilizzato.

Pertanto, il metodo predefinito fornisce:

  • un modo per far evolvere le interfacce in modo graduale nel tempo, e
  • un modo per fornire funzionalità opzionali senza richiedere che una classe fornisca un'implementazione segnaposto quando tale funzionalità non è necessaria.
Consiglio

Eccezioni per metodi non implementati

Un altro punto: nel mondo reale, cancella() potrebbe lanciare un'eccezione, piuttosto che mostrare un messaggio di errore.

Le eccezioni sono descritte nel prossimo capitolo. Dopo aver lavorato con le eccezioni, si potrebbe voler cambiare il corpo predefinito di cancella() in modo che lanci un'eccezione chiamata UnsupportedOperationException.

Problemi con l'Ereditarietà Multipla

Come spiegato in precedenza, Java non supporta l'ereditarietà multipla delle classi.

Ora che un'interfaccia può includere metodi predefiniti, ci si potrebbe chiedere se un'interfaccia possa fornire un modo per aggirare questa restrizione. La risposta è, essenzialmente, no. Si ricordi che c'è ancora una differenza fondamentale tra una classe e un'interfaccia: una classe può mantenere informazioni di stato (specialmente attraverso l'uso di variabili di istanza), mentre un'interfaccia no.

Detto ciò, i metodi predefiniti offrono comunque un aspetto che si può normalmente associare con il concetto di ereditarietà multipla. Ad esempio, si potrebbe avere una classe che implementa due interfacce. Se ognuna di queste interfacce fornisce metodi predefiniti, allora parte del comportamento è ereditato da entrambe. Quindi, in misura limitata, i metodi predefiniti supportano l'ereditarietà multipla di comportamento. Come si può immaginare, in una situazione del genere, è possibile che si verifichi un conflitto di nomi.

Ad esempio, si supponga che due interfacce chiamate Alfa e Beta siano implementate da una classe chiamata MiaClasse. Cosa succede se sia Alfa che Beta forniscono un metodo chiamato azzera() per il quale entrambe dichiarano un'implementazione predefinita? Viene utilizzata la versione di Alfa o quella di Beta da parte di MiaClasse? Oppure si consideri una situazione in cui Beta estende Alfa. Quale versione del metodo predefinito viene utilizzata? Oppure, cosa succede se MiaClasse fornisce la propria implementazione del metodo?

Per gestire questi e altri tipi simili di situazioni, Java definisce una serie di regole per risolvere tali conflitti.

  1. In tutti i casi, l'implementazione fornita da una classe ha la precedenza su un'implementazione predefinita di interfaccia. Quindi, se MiaClasse fornisce un override del metodo predefinito azzera(), viene utilizzata la versione di MiaClasse. Questo vale anche se MiaClasse implementa sia Alfa che Beta. In tal caso, entrambi i metodi predefiniti vengono sovrascritti dall'implementazione di MiaClasse.

  2. In casi in cui una classe implementa due interfacce che hanno entrambe lo stesso metodo predefinito, ma la classe non esegue l'override del metodo, allora si verificherà un errore. Continuando con l'esempio, se MiaClasse implementa sia Alfa che Beta, ma non esegue l'override di azzera(), allora si verificherà un errore.

  3. Nei casi in cui un'interfaccia eredita un'altra, entrambe definendo un metodo comune, viene utilizzata la versione del metodo dell'interfaccia che eredita. Pertanto, continuando con l'esempio, se Beta estende Alfa, allora verrà utilizzata la versione di azzera() di Beta.

È possibile fare riferimento esplicito a un'implementazione predefinita in un'interfaccia ereditata utilizzando la forma super. La forma generale è la seguente:

NomeInterfaccia.super.NomeMetodo()

Ad esempio, se Beta vuole fare riferimento al metodo predefinito di Alfa per azzera(), può usare questa istruzione:

Alfa.super.azzera();