OOP in Java: Ereditarietà e Polimorfismo — Guida Completa con Esempi

OOP in Java: Ereditarietà e Polimorfismo

L’ereditarietà e il polimorfismo in Java sono due pilastri fondamentali della programmazione orientata agli oggetti (OOP). Questa guida approfondisce questi concetti con un taglio pratico e avanzato. In particolare, vedremo extends, la keyword super, override di metodi, polimorfismo, classi astratte, interfacce multiple, casting, instanceof, Liskov Substitution Principle (LSP) e i pattern Template Method e Strategy, con molto codice di esempio.

ereditarietà e polimorfismo in Java - diagramma gerarchia classi
Diagramma UML della gerarchia di classi e polimorfismo in Java

1. Ripasso rapido: classi, oggetti e gerarchie nell’ereditarietà Java

In Java, ogni classe (ad eccezione di Object) ha una superclasse. Se non specifichi nulla, viene estesa implicitamente java.lang.Object. Pertanto, l’ereditarietà ti permette di creare gerarchie in cui le classi figlie riutilizzano e specializzano il comportamento delle classi genitore.

// Classe base (superclasse)
public class Veicolo {
    private String targa;
    public Veicolo(String targa) {
        this.targa = targa;
    }
    public void muovi() {
        System.out.println("Il veicolo si muove");
    }
    public String getTarga() {
        return targa;
    }
}
// Classe derivata (sottoclasse)
public class Auto extends Veicolo { // <-- extends
    public Auto(String targa) {
        // Richiama il costruttore della superclasse
        super(targa);
    }
}

2. Ereditarietà Java con extends e uso corretto di super

extends definisce una relazione “è-un” (is-a). In altre parole, la keyword super viene usata per accedere a costruttori e membri della superclasse. Per questo motivo, è fondamentale comprenderne le regole per evitare errori sottili.

2.1 Ereditarietà Java: costruttori e chiamate a super(…)

Se la superclasse non ha un costruttore di default (senza argomenti), sei obbligato a chiamare esplicitamente uno dei suoi costruttori tramite super(...) come prima istruzione del costruttore della sottoclasse. Di conseguenza, questo vincolo garantisce che l’oggetto genitore sia sempre inizializzato correttamente.

public class Veicolo {
    private final String targa;
    public Veicolo(String targa) {
        if (targa == null || targa.isBlank()) {
            throw new IllegalArgumentException("Targa non valida");
        }
        this.targa = targa;
    }
}
public class Auto extends Veicolo {
    private int numeroPorte;
    public Auto(String targa, int numeroPorte) {
        // Deve essere la prima istruzione
        super(targa);
        if (numeroPorte <= 0) {
            throw new IllegalArgumentException("Numero porte non valido");
        }
        this.numeroPorte = numeroPorte;
    }
}

2.2 Accesso a metodi e campi della gerarchia Java con super

super può essere usato anche per richiamare metodi della superclasse. Inoltre, ad esempio quando vuoi estendere (e non sostituire totalmente) un comportamento esistente, super ti permette di riutilizzare il codice della superclasse.

public class Veicolo {
    private final String targa;
    public Veicolo(String targa) {
        this.targa = targa;
    }
    public void muovi() {
        System.out.println("Veicolo " + targa + " in movimento");
    }
}
public class Auto extends Veicolo {
    public Auto(String targa) {
        super(targa);
    }
    @Override
    public void muovi() {
        // Aggiungo comportamento prima
        System.out.println("Controllo cintura di sicurezza...");
        // Richiamo il comportamento base
        super.muovi();
        // Aggiungo comportamento dopo
        System.out.println("L'auto sta accelerando.");
    }
}

3. Override di metodi in Java e annotazione @Override

L’override ti permette di ridefinire un metodo ereditato per specializzarne il comportamento. L’annotazione @Override (documentazione ufficiale Oracle) non è obbligatoria ma è fortemente raccomandata perché fa controllare al compilatore la correttezza dell’override.

public class Veicolo {
    public void muovi() {
        System.out.println("Il veicolo si muove genericamente");
    }
}
public class Moto extends Veicolo {
    // Override corretto
    @Override
    public void muovi() {
        System.out.println("La moto si piega in curva");
    }
    // Esempio di override overloadato (non è override, è overload!)
    public void muovi(int velocita) {
        System.out.println("La moto accelera a " + velocita + " km/h");
    }
}

Attenzione a non confondere override con overload: cambiare la firma (numero o tipo dei parametri) genera un nuovo metodo (overload), non un override. Dunque, si tratta di due concetti distinti che spesso creano confusione.

4. Polimorfismo in Java: late binding e dispatch dinamico

Il polimorfismo permette di trattare oggetti di classi diverse attraverso un’interfaccia comune (superclasse o interfaccia). Inoltre, demanda a runtime la scelta del metodo concreto da eseguire (dynamic dispatch), permettendo così un design più flessibile e estensibile.

public class Veicolo {
    public void muovi() {
        System.out.println("Veicolo generico");
    }
}
public class Auto extends Veicolo {
    @Override
    public void muovi() {
        System.out.println("Auto su strada");
    }
}
public class Barca extends Veicolo {
    @Override
    public void muovi() {
        System.out.println("Barca in mare");
    }
}
public class PolimorfismoDemo {
    public static void main(String[] args) {
        Veicolo v1 = new Auto();   // upcasting implicito
        Veicolo v2 = new Barca();  // upcasting implicito
        // Dispatch dinamico: viene scelto il metodo in base
        // al tipo reale dell'oggetto (Auto o Barca), non alla variabile (Veicolo)
        v1.muovi(); // "Auto su strada"
        v2.muovi(); // "Barca in mare"
        // Collezioni polimorfiche
        List<Veicolo> flotta = List.of(new Auto(), new Barca(), new Auto());
        for (Veicolo v : flotta) {
            v.muovi();
        }
    }
}

5. Classi astratte vs interfacce in Java (e interfacce multiple)

Una classe astratta è una classe che non può essere istanziata direttamente e può contenere sia metodi concreti che astratti. Un’interfaccia, invece, definisce un contratto di metodi (e, nelle versioni moderne di Java, anche metodi di default e statici). Infine, una classe può estendere una sola superclasse ma può implementare più interfacce.

5.1 Ereditarietà Java: esempio con classe astratta

public abstract class Forma {
    // Metodo astratto: deve essere implementato dalle sottoclassi
    public abstract double area();
    // Metodo concreto condiviso
    public void descrivi() {
        System.out.println("Sono una forma con area = " + area());
    }
}
public class Cerchio extends Forma {
    private final double raggio;
    public Cerchio(double raggio) {
        this.raggio = raggio;
    }
    @Override
    public double area() {
        return Math.PI * raggio * raggio;
    }
}
public class Rettangolo extends Forma {
    private final double base;
    private final double altezza;
    public Rettangolo(double base, double altezza) {
        this.base = base;
        this.altezza = altezza;
    }
    @Override
    public double area() {
        return base * altezza;
    }
}

5.2 Polimorfismo Java: esempio con interfacce multiple

Le interfacce multiple permettono di comporre comportamenti ortogonali. In particolare, in Java, questo è spesso preferibile all’ereditarietà multipla di implementazione (che Java non supporta).

public interface Ricaricabile {
    void ricarica();
}
public interface ConnessoRete {
    void connettiRete();
}
public class Smartphone implements Ricaricabile, ConnessoRete {
    @Override
    public void ricarica() {
        System.out.println("Ricarica tramite USB-C");
    }
    @Override
    public void connettiRete() {
        System.out.println("Connessione 5G attiva");
    }
}

6. Casting e polimorfismo in Java: upcasting, downcasting e instanceof

Upcasting: conversione da sottoclasse a superclasse, sempre sicura e implicita. Downcasting, invece, è conversione da superclasse a sottoclasse, potenzialmente insicura e richiede cast esplicito. Di conseguenza, l’operatore instanceof (Java Language Specification) permette di verificare a runtime il tipo reale prima di effettuare un downcast.

public class CastingDemo {
    public static void main(String[] args) {
        Veicolo veicolo = new Auto(); // upcasting implicito
        // Downcasting non sicuro se il tipo reale non è compatibile
        if (veicolo instanceof Auto auto) { // pattern matching (Java 16+)
            // Qui "auto" è già castato a Auto
            auto.muovi();
        } else {
            System.out.println("Veicolo non è un'Auto");
        }
        // Esempio di cast errato che lancia ClassCastException a runtime
        Veicolo altroVeicolo = new Barca();
        // Auto auto2 = (Auto) altroVeicolo; // <-- ClassCastException
    }
}

In codice ben progettato, il downcasting dovrebbe essere raro. Infatti, se ti ritrovi a farlo spesso, è un segnale che il design polimorfico può essere migliorato (es. aggiungendo metodi all’interfaccia comune). Pertanto, considera sempre se puoi risolvere il problema mediante polimorfismo.

7. Liskov Substitution Principle (LSP) nell’ereditarietà Java

Liskov Substitution Principle (LSP) afferma che “gli oggetti di un programma dovrebbero essere sostituibili con istanze delle loro sottoclassi senza alterare la correttezza del programma”. In altre parole, ogni sottoclasse deve poter essere usata al posto della sua superclasse senza rompere le aspettative del codice client.

7.1 Violazione LSP nell’ereditarietà Java: Rettangolo e Quadrato

public class Rettangolo {
    protected int larghezza;
    protected int altezza;
    public void setLarghezza(int larghezza) {
        this.larghezza = larghezza;
    }
    public void setAltezza(int altezza) {
        this.altezza = altezza;
    }
    public int getArea() {
        return larghezza * altezza;
    }
}
public class Quadrato extends Rettangolo {
    @Override
    public void setLarghezza(int larghezza) {
        this.larghezza = larghezza;
        this.altezza = larghezza; // vincolo lato uguale
    }
    @Override
    public void setAltezza(int altezza) {
        this.altezza = altezza;
        this.larghezza = altezza; // vincolo lato uguale
    }
}

Il seguente codice fallisce le aspettative del client quando usa un Quadrato al posto di un Rettangolo. Infatti, questo comportamento inaspettato mostra una violazione del principio LSP.

public void stampaAreaRettangolo(Rettangolo r) {
    r.setLarghezza(5);
    r.setAltezza(10);
    // Si aspetta area = 50
    System.out.println("Area = " + r.getArea());
}
// Uso
stampaAreaRettangolo(new Rettangolo()); // OK
stampaAreaRettangolo(new Quadrato());   // Sorpresa: area = 100

Questo è una violazione di LSP: la sottoclasse Quadrato non rispetta le aspettative dell’interfaccia di Rettangolo. Quindi, una soluzione migliore è modellare Rettangolo e Quadrato come due implementazioni distinte di una stessa interfaccia Forma, senza relazione di ereditarietà tra loro.

8. Template Method Pattern con classi astratte

Il Template Method è un pattern comportamentale in cui una classe astratta definisce lo scheletro di un algoritmo. Inoltre, delega ad alcune operazioni astratte (o hook) la responsabilità di dettagli specifici da implementare nelle sottoclassi.

public abstract class ElaborazioneFileTemplate {
    // Template method - definisce l'algoritmo
    public final void elabora(String percorso) {
        apri(percorso);
        leggiContenuto();
        processaDati();
        chiudi();
    }
    protected void apri(String percorso) {
        System.out.println("Apertura file: " + percorso);
    }
    protected abstract void leggiContenuto();
    protected abstract void processaDati();
    protected void chiudi() {
        System.out.println("Chiusura file");
    }
}
public class ElaborazioneCsv extends ElaborazioneFileTemplate {
    @Override
    protected void leggiContenuto() {
        System.out.println("Lettura righe CSV");
    }
    @Override
    protected void processaDati() {
        System.out.println("Parsing colonne e validazione");
    }
}
public class ElaborazioneJson extends ElaborazioneFileTemplate {
    @Override
    protected void leggiContenuto() {
        System.out.println("Lettura JSON da stream");
    }
    @Override
    protected void processaDati() {
        System.out.println("Deserializzazione oggetti");
    }
}

Uso polimorfico del Template Method. Dunque, il seguente esempio mostra come il pattern consente di definire una volta sola il flusso di elaborazione:

public class TemplateClient {
    public static void main(String[] args) {
        ElaborazioneFileTemplate csv = new ElaborazioneCsv();
        ElaborazioneFileTemplate json = new ElaborazioneJson();
        csv.elabora("dati.csv");
        json.elabora("dati.json");
    }
}

9. Strategy Pattern con interfacce

Lo Strategy pattern incapsula algoritmi intercambiabili all’interno di oggetti. Pertanto, consente di scegliere o cambiare la strategia a runtime senza modificare il codice client. In particolare, in Java, è tipicamente implementato tramite interfacce.

// Strategy
public interface Sconto {
    double applica(double prezzoOriginale);
}
// Concrete strategies
public class ScontoNessuno implements Sconto {
    @Override
    public double applica(double prezzoOriginale) {
        return prezzoOriginale;
    }
}
public class ScontoPercentuale implements Sconto {
    private final double percentuale; // es. 0.10 per 10%
    public ScontoPercentuale(double percentuale) {
        this.percentuale = percentuale;
    }
    @Override
    public double applica(double prezzoOriginale) {
        return prezzoOriginale * (1 - percentuale);
    }
}
public class ScontoFisso implements Sconto {
    private final double valore;
    public ScontoFisso(double valore) {
        this.valore = valore;
    }
    @Override
    public double applica(double prezzoOriginale) {
        return Math.max(0, prezzoOriginale - valore);
    }
}
// Context
public class Carrello {
    private final List<Double> prezzi = new ArrayList<>();
    private Sconto strategiaSconto = new ScontoNessuno();
    public void aggiungi(double prezzo) {
        prezzi.add(prezzo);
    }
    public void setStrategiaSconto(Sconto sconto) {
        this.strategiaSconto = Objects.requireNonNull(sconto);
    }
    public double totale() {
        double somma = prezzi.stream().mapToDouble(Double::doubleValue).sum();
        return strategiaSconto.applica(somma);
    }
}
public class StrategyDemo {
    public static void main(String[] args) {
        Carrello carrello = new Carrello();
        carrello.aggiungi(100);
        carrello.aggiungi(50);
        carrello.setStrategiaSconto(new ScontoPercentuale(0.10));
        System.out.println("Totale con 10% sconto: " + carrello.totale());
        carrello.setStrategiaSconto(new ScontoFisso(30));
        System.out.println("Totale con 30€ sconto fisso: " + carrello.totale());
    }
}

10. Best practice per ereditarietà e polimorfismo in Java

Alcune linee guida operative per un uso sano di ereditarietà e polimorfismo. Di seguito, troverai le best practice fondamentali:

  • Preferisci composizione su ereditarietà quando possibile (“favor composition over inheritance”).
  • Usa l’ereditarietà principalmente per modellare relazioni “è-un” stabili e ben definite.
  • Rendi le classi final quando non sono progettate per essere estese; definisci chiaramente i punti di estensione.
  • Progetta le tue API pensando a LSP: documenta le pre-condizioni e post-condizioni dei metodi e non infrangerle nelle sottoclassi.
  • Evita di usare il tipo concreto al posto dell’astrazione: programma contro interfacce o supertipi.
  • Riduci al minimo il downcasting; se necessario, valuta se una nuova operazione dovrebbe appartenere al tipo base.
  • Sfrutta pattern come Template Method e Strategy per incapsulare variazioni di comportamento.

Applicando questi principi, otterrai gerarchie di classi più manutenibili, estensibili e coerenti con i principi SOLID. Infine, sfruttando al meglio ereditarietà e polimorfismo in Java, il tuo codice sarà più robusto e sostenibile.

Commenti

5 risposte a “OOP in Java: Ereditarietà e Polimorfismo — Guida Completa con Esempi”

  1. […] OOP in Java: Ereditarietà e Polimorfismo […]

  2. […] OOP in Java: Ereditarietà e Polimorfismo […]

  3. […] OOP in Java: Ereditarietà e Polimorfismo […]

  4. […] OOP in Java: Ereditarietà e Polimorfismo […]

  5. […] OOP in Java Ereditarietà e Polimorfismo […]

Rispondi

Scopri di più da App Tutorial

Abbonati ora per continuare a leggere e avere accesso all'archivio completo.

Continua a leggere