Il concetto di ereditarietà

Nel contesto del paradigma di programmazione orientato ad oggetti, l'ereditarietà è un meccanismo fondamentale sia per il riutilizzo del codice che per supportare lo sviluppo incrementale di programmi.

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.

In questi casi non c'è bisogno di ridefinire variabili e metodi d'istanza già definiti, ma si può definire la nuova classe come sottoclasse della precedente.




Un esempio: la classe Tempo

class Tempo {
   protected int ore, minuti, secondi;  // <== protected!!
   static public char separatore = ':';

   public Tempo () {   // costruttore
      ore = minuti = secondi = 0;
   }
   public Tempo (int ora, int minuto, int secondo) {
                       // costruttore
      this();
      assegnaTempo (ora, minuto, secondo);
   }
   public int assegnaTempo (int ora, 
                            int minuto, int secondo){
      if (ora >= 0 && ora < 24 && minuto >= 0 &&
          minuto < 60 && secondo >= 0 && secondo < 60 ){
         ore = ora;
         minuti = minuto;
         secondi = secondo;
         return 0;
      } else
        return -1;
   }
   public int leggiOra () {
      return ore;
   }
   public int leggiMinuti () {
      return minuti;
   }
   public int leggiSecondi () {
      return secondi;
   }
   public void aggiungiOre (int numOre) {
      ore = ore + numOre;
      while (ore > 23)
         ore = ore - 24;
   }
   public void visualizza (boolean ritornoACapo) {
      System.out.print("" + ore + separatore 
                       + minuti + separatore);

/* La stringa vuota ("") come argomento di System.out.print  serve per forzare l'interpretazione di + come operatore di concatenazione di stringhe */

      if (ritornoACapo)
         System.out.println (secondi);
      else
         System.out.print (secondi); 
   }
}

  • Il costruttore Tempo() inizializza le variabili d'istanza a 0.
  • Nel secondo costruttore,  this() ha l'effetto di invocare il costruttore senza argomenti: questo garantisce che le variabili d'istanza vengano inizializzate anche se assegnaTempo fallisce.
  • Le variabili d'istanza sono state dichiarate protected (e non private): vedremo più avanti perché.



La sottoclasse Tempo2

Vogliamo definire una classe Tempo2 che rappresenti il tempo con maggior precisione, cioè con i centesimi di secondo. L'ereditarietà ci consente di definire questa classe senza ripetere la descrizione di tutte le variabili e i metodi di Tempo, ma in modo incrementale:
 
 
class Tempo2 extends Tempo {

   private int centesimi;

   public void assegnaCentesimi (int cent) {
      centesimi = cent;
   }

   public int leggiCentesimi () {
      return centesimi;
   }

}

La parola chiave extends significa che

  • Tempo2 è una sottoclasse o classe derivata di Tempo
  • Tempo è una superclasse o classe genitrice di Tempo2



Istanze di Tempo2


Notazione grafica UML  [Unified Modeling Language] per oggetti.


Un'istanza di Tempo2 avrà quattro variabili di istanza:
  • ore, minuti e secondi da Tempo
  • centesimi da Tempo2
Su di essa possono essere invocati anche i metodi della superclasse.

 




Gerarchie di classi

La definizione di sottoclassi può essere iterata a piacere: si possono definire delle gerarchie di classi arbitrariamente complesse.

Ogni classe è una classe derivata, in modo diretto o indiretto, dalla classe Object. Questo spiega perché su ogni oggetto Java possono essere invocati metodi come equals e toString, definiti nella classe Object.

Diagramma di classi (notazione UML [Unified Modeling Language])

  • Rettangoli rappresentano classi
  • Frecce con punta "vuota" indicano relazione di ereditarietà



Ereditarietà come "inclusione"

E' possibile utilizzare un'istanza di una sottoclasse (Tempo2) dovunque sia richiesto un oggetto della superclasse (Tempo), come in un assegnamento o nel passaggio di parametri.
 
 
Tempo arrivo = new Tempo2();

/* corretto: su arrivo posso invocare tutti i metodi di Tempo, grazie all'ereditarieta' */


static void useTempo(Tempo tmp){
// metodo che fa qualche operazione su tmp 
}

...

Tempo2 scadenza = new Tempo2();
...
useTempo(scadenza);

/* corretto: posso passare scadenza come parametro attuale, anche se era richiesto un Tempo */
 




Cast e instance of

Attenzione: Non è possibile invocare un metodo di una sottoclasse su di un identificatore di una superclasse.
 
 
 
...

Tempo arrivo = new Tempo2();
arrivo.assegnaCentesimi(10); 
        // errore a tempo di compilazione
 

Infatti abbiamo chiamato su arrivo (dichiarata di classe Tempo) un metodo della sottoclasse Tempo2.

In certe situazioni è utile/necessario invocare su di un identificatore un metodo di una sottoclasse [vedremo un esempio concreto più avanti con la classe VettoreOrdinato]: si può usare l'operazione di cast.
 

 
...

Tempo arrivo = new Tempo2();
((Tempo2) arrivo).assegnaCentesimi(10); 
        // compila senza errori
...
 

Esempio: TestTempo.java


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).

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

Si può controllare la classe di appartenenza di un oggetto prima del cast, come in
 

 
...

if (arrivo instanceof Tempo2

      ((Tempo2) arrivo).assegnaCentesimi(10); 
...

La condizione (obj instanceof Classe) restituisce true se e solo se obj è una istanza della classe Classe.




Overriding

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

Ad esempio, se invochiamo visualizza su un'istanza di Tempo2, verranno stampati solo i valori delle prime tre variabili d'istanza. Possiamo sovrascrivere (override) visualizza aggiungendo a Tempo2:
 
 


   public void visualizza (boolean ritornoACapo) {

      System.out.print("" + ore + separatore + minuti +

                     separatore + secondi + separatore);

      if (ritornoACapo)

         System.out.println (centesimi);

      else

         System.out.print (centesimi); 

   }
 

Il comando t.visualizza(true) invocherà il metodo visualizza della classe Tempo se t è un'istanza di Tempo, ma invocherà il nuovo metodo che stampa anche i centesimi se t è un'istanza di Tempo2.




Binding dinamico

Il meccanismo che determina quale metodo deve essere invocato in base alla classe di appartenenza dell'oggetto si chiama binding.
  • 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 = console.readInt();

Tempo tmp;

if (cond > 0) tmp = new Tempo(10, 10, 10);

else {

   tmp = new Tempo2 ();

   tmp.assegnaTempo(10, 10, 10);

   ((Tempo2) tmp).assegnaCentesimi(10);

   };

tmp.visualizza();
 

Nell'ultimo comando, il compilatore non può sapere se a tmp sarà associato un'istanza di Tempo o di Tempo2, 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 è concettualmente molto diverso da quello di overloading, 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, invece, consente di ridefinire un metodo in una sottoclasse: il metodo originale e quello che lo ridefinisce hanno necessariamente la stessa firma, e solo a tempo di esecuzione si determina quale dei due deve essere eseguito.




Protected e private

Se nella classe Tempo avessimo dichiarato le variabili d'istanza private, il metodo visualizza di Tempo2 avrebbe causato un errore in compilazione, tentando di accedere a variabili private. Il modificatore protected consente invece l'accesso alle variabili d'istanza a tutte le sottoclassi.



This e super

Come sappiamo, la variabile this  fa riferimento, nel corpo di un metodo o di un costruttore, 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.

Ad esempio, riscriviamo il metodo visualizza per Tempo2 in mododa riutilizzare il metodo visualizza di Tempo.
 
 

 
   public void visualizza (boolean ritornoACapo) {

    super.visualizza(false);

      System.out.print(separatore);

      if (ritornoACapo)

         System.out.println (centesimi);

      else

         System.out.print (centesimi); 

   }

Analogamente, il seguente costruttore per Tempo2  richiama un costruttore per Tempo al suo interno [l'invocazione di super() deve essere la prima istruzione].
 
 
 

 
   public Tempo2(int ora, int minuto, 
                 int secondo, int centesimo){

    super (ora, minuto, secondo);

      centesimi = centesimo;

   }





Esempi di ereditarietà