Il concetto di ereditarietà


Una classe definisce struttura e funzionalità di una collezione di oggetti.

In molti casi occorre definire una classe i cui oggetti hanno una struttura più ricca di quella di una classe già definita, oppure che realizzano delle funzionalità aggiuntive.

Nel paradigma di programmazione orientato ad oggetti, l'ereditarietà è un meccanismo fondamentale sia per il riutilizzo del codice che per lo sviluppo incrementale di programmi. Questo meccanismo permette infatti di estendere e potenziare classi già esistenti, senza ridefinire variabili e metodi d'istanza già presenti.

  • superclasse: la classe più "generale" (quella preesistente);
  • sottoclasse: la nuova classe più "specializzata" (oggetti più ricchi);
  • si dice che la sottoclasse eredita dalla superclasse i membri preesistenti;
  • si dice che la sottoclasse estende la superclasse con i nuovi membri.

La sintassi per estendere una (super)classe ereditandone struttura e funzionalità è:

public class <nuova_(sotto)classe>
       extends <vecchia_(super)classe> { 

      <nuove_variabili_istanza> 

      <nuovi_metodi> 
} 




Un esempio: la classe Persona

 
public class Persona { 
    String nome; 
    String indirizzo; 
    
    public Persona() {  
        this("John Doe","ignoto");
    } 

    public Persona(String nome) {  
        this(nome,"ignoto");
    } 
    
    public Persona(String nome, String indirizzo) {  
        this.nome = nome; 
        this.indirizzo = indirizzo; 
    } 

    public String getNome() { 
        return nome; 
    } 
    
    public String getIndirizzo() { 
        return indirizzo; 
    } 
    
    public void visualizza() { 
        System.out.println("Nome: " + nome + "\nIndirizzo: " + indirizzo); 
    } 

    public boolean omonimo(Persona p) { 
        return this.nome.equalsIgnoreCase(p.nome); 
    }  
    ...
} 

Le variabili d'istanza NON sono state dichiarate private: vedremo più avanti perché.




La sottoclasse Studente


Vogliamo definire una classe Studente, che rappresenti  gli studenti iscritti ad un corso di laurea. Ogni studente è descritto dal nome, dall'indirizzo, dal numero di matricola e dal piano di studio.

Uno Studente è un tipo particolare di  Persona.

L'ereditarietà ci consente di definire questa classe senza ripetere la descrizione di tutte le variabili e i metodi di Persona, ma procedendo invece in modo incrementale:

public class Studente extends Persona { 
    // Studente eredita variabili e metodi da Persona
    
    int matricola;        // Nuova variabile istanza 
    String pianoDiStudio; // Nuova variabile istanza 
    
    // Nuova variabile statica (di classe)
    static int nextMatricola = 1; 
    
    // Costruttore
    public Studente(String nome, String indirizzo) { 
        this.nome = nome;
        this.indirizzo = indirizzo;  
        this.matricola = nextMatricola++; 
        this.pianoDiStudio = ""; 
    } 
    
    // Nuovo metodo 
    public String getPdS() {
        return pianoDiStudio; 
    } 
    
    // Nuovo metodo 
    public void modificaPdS(String nuovoPdS) { 
        pianoDiStudio += nuovoPdS + "\n"; 
    }     
    ...
}

La parola chiave extends significa che

  • Studente è una sottoclasse o classe derivata di Persona
  • Persona è una superclasse o classe genitrice di Studente



Istanze di Studente


Un'istanza di Studente avrà quattro variabili di istanza:

  • nome e indirizzo, ereditate dalla classe Persona
  • matricola e pianoDiStudio, definite nella classe Studente

Notazione grafica:

 





Ereditarietà come "inclusione"

Concettualmente, possiamo intrepretare una classe come un insieme, i cui elementi sono le sue istanze, e la relazione extends come la relazione di inclusione tra insiemi. Quindi
    Studente extends Persona
vuol dire che ogni istanza di Studente è anche un'istanza di Persona. Di conseguenza:
  • su di un'istanza di Studente possono essere invocati anche i metodi della superclasse Persona;

  • più in generale, è possibile utilizzare un'istanza di una sottoclasse (Studente) dovunque sia richiesto un oggetto della superclasse (Persona), come in un assegnamento o nel passaggio di parametri.
    Persona tizio = new Studente("Mario Rossi""Pisa"); 

    /* corretto: adesso su tizio posso invocare i
       metodi di Persona, grazie all'ereditarieta' */ 

    tizio.visualizza(); 

    Studente pippo = new Studente("Pinco Pallino""Empoli");      ...      if ( tizio.omonimo(pippo) )            ...      /* corretto: posso passare pippo come parametro        attuale, anche se era richiesta una Persona */



Cast esplicito



Attenzione: Il compilatore non permette di invocare un metodo di una sottoclasse su di una variabile di una superclasse.

    ... 
    Persona tizio = new Studente("Mario Rossi""Pisa");  // Ok 
    tizio.modificaPdS("Algebra");  
        // errore a tempo di compilazione 
    ... 

Infatti abbiamo invocato su tizio (variabile dichiarata di classe Persona) un metodo della sottoclasse Studente.

In certe situazioni è utile/necessario invocare su di una variabile un metodo di una sottoclasse. In questi casi si può usare l'operazione di cast.

    ... 
    Persona tizio = new Studente("Mario Rossi""Pisa"); 
    ((Studente) tizio).modificaPdS("Algebra");    
       // compila senza errori 
    ... 

 


Si ricordi che avevamo già visto il cast per trasformare valori numerici:

    double y = 3.14; 
    int approxY = (int) y; // compila correttamente 
    System.out.println(approx); // stampa "3"

 


Il compilatore consente di effettuare un cast solo verso classi derivate o genitrici (ma in quest'ultimo caso il cast è superfluo).

    Persona tizio = ... ;
    ((Studente) tizio).modificaPdS("Algebra");
    // OK

    Studente caio = ... ;
    ((Persona) caio).visualizza();
    // OK ma inutile

    int x = ((String) caio).length();
    // tmp.java:9: inconvertible types
    // found   : Studente
    // required: java.lang.String




instanceof



Quando si valuta ((Studente) tizio) se tizio non si riferisce ad un'istanza di Studente, verrà generato un errore.

public class Test{
    public static void main (String [] a){
        Persona tizio = new Persona("Mario Rossi","Pisa");
        ((Studente) tizio).modificaPdS("Algebra");
    }
}

// Compila correttamente, ma fallisce l'esecuzione:
// Exception in thread "main" java.lang.ClassCastException: Persona
//        at Test.main(tmp.java:4)

Il predicato instanceof permette di controllare la classe di appartenenza di un oggetto prima del cast:

    ... 
    if (tizio instanceof Studente)  
          ( (Studente) tizio).modificaPdS("Algebra"); 
    ...

La condizione

(<un_oggetto> instanceof <Una_Classe>)

restituisce true se e solo se <un_oggetto> è una istanza della classe <Una_Classe> .




Overriding (sovrascrittura)


Una sottoclasse può anche modificare dei metodi della superclasse, ridefinendoli.

Ad esempio, se invochiamo il metodo visualizza() su di un'istanza di Studente, verranno stampati solo i valori delle prime due variabili d'istanza (nome e indirizzo).

Possiamo sovrascrivere (override) il metodo visualizza() aggiungendo a Studente il seguente metodo:

public class Studente extends Persona { 
    ...
    // Metodo sovrascritto
    public void visualizza() { 
        System.out.println("Nome: " + nome 
                           + "\nIndirizzo: " + indirizzo); 
        System.out.println("Matricola: " + matricola 
                           + "\nPiano di Studio: " + pianoDiStudio); 
    } 
    ...
}

A tempo di esecuzione, il comando p.visualizza() invocherà quest'ultimo metodo se in quel momento p è un riferimento a un'istanza di Studente, mentre invocherà il metodo visualizza() della classe Persona se p è un riferimento a un'istanza di Persona.




Binding dinamico


Il meccanismo che determina quale metodo deve essere invocato in base alla classe di appartenenza dell'oggetto si chiama binding (legame). Esiste una distinzione tra:
  • Binding statico o early binding: il metodo da invocare viene determinato a tempo di compilazione.
  • Binding dinamico o late binding:  il metodo viene determinato durante l'esecuzione.

Java adotta il binding dinamico
    int cond = Input.readInt(); 

    Persona tmp; 

    if (cond > 0)  
        tmp = new Persona("Mario Rossi""Pisa"); 
    else  
        tmp = new Studente("Mario Rossi""Pisa"); 

    tmp.visualizza(); 

Nell'ultimo comando, il compilatore non può sapere se a tmp sarà associato un'istanza di Persona o di Studente, perché questo dipenderà dal valore fornito dall'utente.
Il binding dinamico demanda la scelta del metodo da invocare all'interprete.




Overriding e overloading


Il meccanismo di overriding (sovrascrittura) è concettualmente molto diverso da quello di overloading (sovraccarico), e non deve essere confuso con esso.

L'overloading consente di definire in una stessa classe più metodi aventi lo stesso nome, ma che differiscano nella firma, cioè nella sequenza dei tipi dei parametri formali. È il compilatore che determina quale dei metodi verrà invocato, in base al numero e al tipo dei parametri attuali.

L'overriding consente di ridefinire un metodo in una sottoclasse: il metodo originale e quello che lo ridefinisce hanno necessariamente la stessa firma, e solo l'interprete, a tempo di esecuzione, determina quale dei due deve essere eseguito.




Variabili d'istanza protected e private



Se nella classe Persona avessimo dichiarato le variabili d'istanza private, il metodo visualizza() di Studente visto sopra avrebbe causato un errore in compilazione, tentando di accedere alle variabili private nome e indirizzo

Se nella classe Persona avessimo dichiarato le variabili d'istanza public, esse sarebbero state accessibili a tutti, in qualsiasi applicazione che usasse la classe Persona

Senza specificare il modificatore di visibilità, le variabili d'istanza di Persona sono accessibili solo nelle classi dello stesso package (in sistemi Linux, nella stessa directory). 

Dichiarando le variabili protected, si consentirebbe l'accesso alle variabili d'istanza non solo a tutte le classi del package, ma anche a tutte le sottoclassi della classe in questione, anche se appartenenti ad altri packages.




This e super


Come sappiamo, la variabile this fa riferimento, nel corpo di un metodo o di un costruttore, al parametro implicito, cioè all'oggetto che lo sta eseguendo.

La variabile super fa riferimento anch'essa all'istanza che sta eseguendo un metodo o un costruttore, ma costringe l'interprete a vedere l'oggetto come istanza della superclasse.

La variabile super viene usata tipicamente per accedere a metodi della superclasse che sono stati sovrascritti nella sottoclasse.

Ad esempio, possiamo riscrivere il metodo visualizza() di Studente in modo da riutilizzare il metodo visualizza() di Persona.

public class Studente extends Persona { 
      ...
    // Metodo sovrascritto
    public void visualizza() { 
        super.visualizza(); 
        System.out.println("Matricola: " + matricola 
                           + "\nPiano di Studio: " + pianoDiStudio); 
    } 
    ...
}


Come per this, esiste anche una versione con parametri di super, che permette di invocare un costruttore della superclasse.

Ad esempio, possiamo riscrivere il costruttore per Studente in modo da richiamare un costruttore per Persona al suo interno.

public class Studente extends Persona { 
    ...
    // Costruttore
    public Studente(String nome, String indirizzo) { 
        super(nome,indirizzo);  // Invoca costruttore della superclasse
        this.matricola = nextMatricola++; 
        this.pianoDiStudio = ""; 
    } 
    ...
}

Attenzione: come per this(), l'invocazione di super() deve essere la prima istruzione del costruttore!





La classe Studente:
versione finale

OSSERVAZIONE: In questa nuova versione, né il costruttore né il metodo visualizza() accedono direttamente alle variabili di istanza nome e indirizzo ereditate dalla superclasse. Nella superclasse Persona potremmo quindi dichiarare queste variabili private (che è spesso la scelta migliore).

public class Studente extends Persona { 
// Studente eredita variabili e metodi da Persona

    int matricola;        // Nuova variabile istanza 
    String pianoDiStudio; // Nuova variabile istanza 
    
    static int nextMatricola = 1; 
    // Nuova variabile statica (di classe)
    
    // Costruttore
    public Studente(String nome, String indirizzo) { 
        super(nome,indirizzo);  
        this.matricola = nextMatricola++; 
        this.pianoDiStudio = ""; 
    } 
    
    // Nuovo metodo 
    public String getPdS() {
        return pianoDiStudio; 
    } 
    
    // Nuovo metodo 
    public void modificaPdS(String nuovoPdS) { 
        pianoDiStudio += nuovoPdS + "\n"; 
    } 
    
    // Metodo sovrascritto
    public void visualizza() { 
        super.visualizza(); 
        System.out.println("Matricola: " + matricola 
                           + "\nPiano di Studio: " + pianoDiStudio); 
    } 
    ...
} 

 

 

 

 

 


Un altro esempio di ereditarietà:
la classe Professore


Vogliamo definire una classe Professore , che rappresenti  i docenti di un corso di laurea. Ogni professore è descritto dal nome, dall'indirizzo, dal ruolo, dallo stipendio e dai corsi affidati.

Un Professore è un tipo particolare di  Persona.

public class Professore extends Persona { 
    String ruolo; 
    int stipendio; 
    String corsiAffidati; 
        
    public Professore(String nome, String indirizzo, String ruolo) { 
        super(nome, indirizzo);  
        this.ruolo = ruolo; 
        this.corsiAffidati = ""; 
    } 
    
    public void setStipendio(int stipendio) { 
        this.stipendio = stipendio; 
    } 
    
    public void aumentaStipendio(int aumento) { 
        this.stipendio += aumento; 
    } 
    
    public void aggiungiCorso(String corso) { 
        corsiAffidati += corso + "\n"; 
    } 
    
    public void visualizza() { 
        super.visualizza(); 
        System.out.println("Ruolo: " + ruolo 
                           + "\nCorsi affidati: " + corsiAffidati); 
    ...
}