Introduzione all'Ereditarietà in Java

Concetti Chiave
  • L'ereditarietà in Java consente di creare gerarchie di classi, promuovendo la riusabilità del codice e la specializzazione delle classi.
  • Una superclasse definisce tratti comuni, mentre una sottoclasse estende la superclasse aggiungendo attributi unici.
  • Una variabile di riferimento di una superclasse può fare riferimento a un oggetto di sottoclasse, ma l'accesso ai membri è limitato al tipo della variabile di riferimento.
  • I membri dichiarati come private in una superclasse non sono accessibili dalle sottoclassi.
  • L'ereditarietà è un concetto fondamentale della programmazione orientata agli oggetti, che consente la creazione di classificazioni gerarchiche e la specializzazione delle classi.

L'ereditarietà è uno dei pilastri della programmazione orientata agli oggetti perché consente la creazione di classificazioni gerarchiche.

Usando l'ereditarietà, è possibile creare una classe generale che definisce tratti comuni a un insieme di elementi correlati. Questa classe può quindi essere ereditata da altre classi più specifiche, ognuna delle quali aggiunge ciò che le è unico.

Nella terminologia di Java, una classe che viene ereditata è chiamata superclasse. La classe che effettua l'ereditarietà è chiamata sottoclasse. Pertanto, una sottoclasse è una versione specializzata di una superclasse. Essa eredita tutti i membri definiti dalla superclasse e aggiunge i propri elementi unici.

Fondamenti dell'ereditarietà

Per ereditare una classe, è sufficiente incorporare la definizione di una classe in un'altra usando la parola chiave extends.

Per comprenderne il funzionamento, prendiamo in esame un breve esempio. Il programma seguente crea una superclasse chiamata A e una sottoclasse chiamata B. Si noti come la parola chiave extends venga utilizzata per creare una sottoclasse di A.

// Un semplice esempio di ereditarietà.

// Creare una superclasse.
class A {
    int i, j;

    void mostraIJ() {
        System.out.println("i e j: " + i + " " + j);
    }
}

// Creare una sottoclasse estendendo la classe A.
class B extends A {
    int k;

    void mostraK() {
        System.out.println("k: " + k);
    }

    void somma() {
        System.out.println("i+j+k: " + (i + j + k));
    }
}

class EreditarietaSemplice {
    public static void main(String[] args) {
        A superOg = new A();
        B subOg = new B();

        // La superclasse può essere utilizzata da sola.
        superOg.i = 10;
        superOg.j = 20;
        System.out.println("Contenuto di superOg: ");
        superOg.mostraIJ();
        System.out.println();

        /* La sottoclasse ha accesso a tutti i membri pubblici
           della sua superclasse. */
        subOg.i = 7;
        subOg.j = 8;
        subOg.k = 9;
        System.out.println("Contenuto di subOg: ");
        subOg.mostraIJ();
        subOg.mostraK();
        System.out.println();

        System.out.println("Somma di i, j e k in subOg:");
        subOg.somma();
    }
}

Output del programma:

Contenuto di superOg:
i e j: 10 20

Contenuto di subOg:
i e j: 7 8
k: 9

Somma di i, j e k in subOg:
i+j+k: 24

Come si può vedere, la sottoclasse B include tutti i membri della sua superclasse A. Ciò spiega perché subOg può accedere a i e j e chiamare mostraIJ(). Inoltre, all'interno di somma(), i e j possono essere richiamati direttamente, come se facessero parte di B.

Anche se A è una superclasse per B, rimane comunque una classe indipendente e autonoma. Essere una superclasse per una sottoclasse non implica che la superclasse non possa essere usata da sola. Inoltre, una sottoclasse può diventare a sua volta superclasse di un'altra sottoclasse.

La forma generale di una dichiarazione di classe che eredita da una superclasse è mostrata qui:

class nomeSottoclasse extends nomeSuperclass {
    // corpo della classe
}

È possibile specificare una sola superclasse per ogni sottoclasse che creiamo.

Java non supporta l'ereditarietà di più superclassi in un'unica sottoclasse, chiamata anche ereditarietà multipla.

È però possibile creare una gerarchia di ereditarietà in cui una sottoclasse diventa superclasse di un'altra sottoclasse. Nessuna classe, tuttavia, può essere superclasse di sé stessa.

Accesso ai membri ed ereditarietà

Sebbene una sottoclasse includa tutti i membri della sua superclasse, non può accedere a quei membri della superclasse che sono stati dichiarati come private. Per esempio, si consideri la seguente semplice gerarchia di classi:

/* In una gerarchia di classi, i membri private
   rimangono privati alla loro classe.

   Questo programma contiene un errore e non verrà
   compilato.
*/

// Creare una superclasse.
class A {
    int i;            // accesso predefinito
    private int j;    // privato per A

    void impostaIJ(int x, int y) {
        i = x;
        j = y;
    }
}

// j di A non è accessibile qui.
class B extends A {
    int totale;

    void somma() {
        totale = i + j;   // ERRORE, j non è accessibile qui
    }
}

class Accesso {
    public static void main(String[] args) {
        B sottoOg = new B();

        sottoOg.impostaIJ(10, 12);

        sottoOg.somma();
        System.out.println("Totale è " + sottoOg.totale);
    }
}

Questo programma non verrà compilato perché l'uso di j all'interno del metodo somma() di B provoca una violazione di accesso. Poiché j è dichiarato private, è accessibile solo dagli altri membri della sua stessa classe. Le sottoclassi non vi hanno accesso.

Nota

private e l'ereditarietà

Un membro di classe dichiarato private rimane privato per la sua classe. Non è accessibile da alcun codice esterno alla classe, incluse le sottoclassi.

Un esempio pratico

Esaminiamo ora un esempio più concreto che aiuta a illustrare la potenza dell'ereditarietà.

In questa sezione, l'ultima versione della classe Scatola sviluppata nelle lezioni precedenti verrà estesa per includere una quarta componente chiamata peso. La nuova classe conterrà quindi larghezza, altezza, profondità e peso di una scatola.

// Questo programma usa l'ereditarietà per estendere Scatola.
class Scatola {
    double larghezza;
    double altezza;
    double profondita;

    // costruttore di copia
    Scatola(Scatola ob) {        // passa oggetto al costruttore
        larghezza  = ob.larghezza;
        altezza    = ob.altezza;
        profondita = ob.profondita;
    }

    // costruttore usato quando tutte le dimensioni sono specificate
    Scatola(double l, double a, double p) {
        larghezza  = l;
        altezza    = a;
        profondita = p;
    }

    // costruttore usato quando non viene specificata alcuna dimensione
    Scatola() {
        larghezza  = -1;   // usa -1 per indicare
        altezza    = -1;   // stato non inizializzato
        profondita = -1;   // della scatola
    }

    // costruttore usato quando viene creato un cubo
    Scatola(double lato) {
        larghezza = altezza = profondita = lato;
    }

    // calcola e restituisce il volume
    double volume() {
        return larghezza * altezza * profondita;
    }
}

// Qui, Scatola è estesa per includere il peso.
class ScatolaPeso extends Scatola {
    double peso;   // peso della scatola

    // costruttore per ScatolaPeso
    ScatolaPeso(double l, double a, double p, double m) {
        larghezza  = l;
        altezza    = a;
        profondita = p;
        peso       = m;
    }
}

class DemoScatolaPeso {
    public static void main(String[] args) {
        ScatolaPeso miaScatola1 = new ScatolaPeso(10, 20, 15, 34.3);
        ScatolaPeso miaScatola2 = new ScatolaPeso(2, 3, 4, 0.076);
        double vol;

        vol = miaScatola1.volume();
        System.out.println("Volume di miaScatola1 è " + vol);
        System.out.println("Peso di miaScatola1 è " + miaScatola1.peso);
        System.out.println();

        vol = miaScatola2.volume();
        System.out.println("Volume di miaScatola2 è " + vol);
        System.out.println("Peso di miaScatola2 è " + miaScatola2.peso);
    }
}

Output del programma:

Volume di miaScatola1 è 3000.0
Peso di miaScatola1 è 34.3

Volume di miaScatola2 è 24.0
Peso di miaScatola2 è 0.076

ScatolaPeso eredita tutte le caratteristiche di Scatola e vi aggiunge la componente peso. Non è necessario che ScatolaPeso ricrei tutte le funzionalità presenti in Scatola; può semplicemente estendere Scatola per i propri scopi.

Un vantaggio fondamentale dell'ereditarietà consiste nel fatto che, una volta creata una superclasse che definisce gli attributi comuni a un insieme di oggetti, la si può utilizzare per generare un numero qualsiasi di sottoclassi più specifiche. Ogni sottoclasse può modellare con precisione la propria classificazione. Per esempio, la classe seguente eredita Scatola e aggiunge un attributo colore:

// Qui, Scatola è estesa per includere il colore.
class ScatolaColore extends Scatola {
    int colore; // colore della scatola

    ScatolaColore(double l, double a, double p, int c) {
        larghezza  = l;
        altezza    = a;
        profondita = p;
        colore     = c;
    }
}
Consiglio

L'essenza dell'ereditarietà

L'ereditarietà consente di creare una gerarchia di classi in cui una superclasse definisce gli attributi comuni, e le sottoclassi estendono questa superclasse per aggiungere attributi specifici. Questo approccio promuove la riusabilità del codice e la specializzazione delle classi.

Una volta creata una superclasse che definisce gli aspetti generali di un oggetto, tale superclasse può essere ereditata per formare classi specializzate. Ogni sottoclasse aggiunge semplicemente i propri attributi unici. Questa è l'essenza dell'ereditarietà.

Una variabile di superclasse può fare riferimento a un oggetto di sottoclasse

Una variabile di riferimento di una superclasse può ricevere un riferimento a qualsiasi sottoclasse derivata da quella superclasse.

Questo aspetto dell'ereditarietà risulta molto utile in diverse situazioni. Per esempio, si consideri il frammento seguente:

class DemoRif {
    public static void main(String[] args) {
        ScatolaPeso scatolaPeso     = new ScatolaPeso(3, 5, 7, 8.37);
        Scatola     scatolaSemplice = new Scatola();
        double vol;

        vol = scatolaPeso.volume();
        System.out.println("Volume di scatolaPeso è " + vol);
        System.out.println("Peso di scatolaPeso è " + scatolaPeso.peso);
        System.out.println();

        // assegnare il riferimento ScatolaPeso a un riferimento Scatola
        scatolaSemplice = scatolaPeso;

        vol = scatolaSemplice.volume(); // OK, volume() definito in Scatola
        System.out.println("Volume di scatolaSemplice è " + vol);

        /* La seguente istruzione è non valida perché scatolaSemplice
           non definisce un membro peso. */
        // System.out.println("Peso di scatolaSemplice è " + scatolaSemplice.peso);
    }
}

In questo esempio, scatolaPeso è un riferimento a oggetti ScatolaPeso, mentre scatolaSemplice è un riferimento a oggetti Scatola. Poiché ScatolaPeso è una sottoclasse di Scatola, è lecito assegnare a scatolaSemplice il riferimento contenuto in scatolaPeso.

È importante comprendere che è il tipo della variabile di riferimento — non il tipo dell'oggetto a cui si riferisce — a determinare quali membri possano essere accessibili.

Quando un riferimento a un oggetto di sottoclasse viene assegnato a una variabile di riferimento di superclasse, si avrà accesso solo alle parti dell'oggetto definite dalla superclasse. Ecco perché scatolaSemplice non può accedere a peso anche se, in quel momento, fa riferimento a un oggetto ScatolaPeso.

Ciò ha senso, perché la superclasse non ha conoscenza di ciò che una sottoclasse vi aggiunge. Per questo motivo l'ultima istruzione del frammento precedente è commentata: non è possibile per un riferimento di tipo Scatola accedere al campo peso, poiché Scatola non lo definisce.

Sebbene l'esempio possa sembrare un po' astratto, presenta numerose applicazioni pratiche. In particolare è alla base del polimorfismo, che vedremo in dettaglio in una lezione successiva.