Metodologie di Programmazione: Lezione 4

Riccardo Silvestri

Ereditarietà

Uno degli aspetti che possono maggiormente influire sulla qualità dello sviluppo software è la possibilità di riusare codice precedentemente scritto. Il riuso può ridurre o evitare del tutto la nefasta duplicazione di codice e può favorire una buona organizzazione del codice. Il principale strumento offerto dai linguaggi orientati agli oggetti come Java è l'ereditarietà che non solo agevola il riuso ma dà un aiuto anche sul fronte della separazione interfaccia-implementazione.

Estendere e condividere

A volte accade che una classe implementi delle funzionalità che vorremmo poter usare anche in un'altra classe. Ma non vorremmo dover implementare di nuovo queste funzionalità nella nuova classe. Si pensi, ad esempio, a una classe Persona che permette di gestire tramite opportuni metodi le generalità di una persona (nome, cognome, data di nascita), l'indirizzo e magari altro ancora. Dovendo definire una o più classi per gestire i dati relativi ai dipendenti di una azienda sarebbe conveniente poter riusare la classe Persona. Un dipendente è anche una persona e i dati gestiti dalla classe Persona devono essere gestiti dall'archivio. Potremmo definire la nostra classe Dipendente in modo che relativamente ai dati in comune con quelli gestiti dalla classe Persona contiene una copia della corrispondente implementazione e interfaccia (campi e metodi). Ma se non abbiamo il codice sorgente della classe Persona? E anche se avessimo il codice sorgente, nella maggior parte delle situazioni, non è conveniente replicare il codice.

Sempre continuando con il nostro esempio dell'archivio dei dipendenti, sicuramente avremo bisogno di gestire i dati di dipendenti che rivestono ruoli diversi. Ad esempio, potrebbero esserci dei dipendenti nel ruolo di dirigenti. In relazione ad un dirigente si dovranno gestire delle informazioni ulteriori, ad esempio, la denominazione del reparto diretto, eventuali responsabilità di progetto, ecc. Allora diventa naturale definire una nuova classe chiamata appunto Dirigente. Però un dirigente è anche un dipendente e così tutti i dati gestiti dalla classe Dipendente dovranno essere gestiti anche dalla classe Dirigente. Cosa facciamo? Replichiamo il codice della classe Dipendente nella classe Dirigente? Chiaramente questa non è la soluzione ottimale.

Per fortuna quasi tutti i linguaggi orientati agli oggetti come Java, forniscono un meccanismo che permette di definire una nuova classe estendendo una classe esistente. La nuova classe, così definita, eredita tutti i campi e i metodi (accessibili) della classe originale senza bisogno di replicarne il codice. Quindi la classe Dirigente può essere definita come un'estensione della classe Dipendente (e la classe Dipendente può, a sua volta, estendere la classe Persona). La classe Dirigente necessiterà solamente dell'implementazione dei campi e metodi che servono per gestire i dati che sono di esclusiva pertinenza di un dirigente ma non di un dipendente generico. Così la classe Dirigente eredita, e quindi condivide, l'implementazione e l'interfaccia della classe Dipendente. È anche vero che la classe Dirigente estende la classe Dipendente perché definisce metodi e campi che la classe Dipendente non possiede. Inoltre, il tipo definito da una classe che estende un'altra classe diventa un sotto-tipo del tipo definito da quest'ultima. Così il tipo Dirigente diventa un sotto-tipo di Dipendente. Questo significa che ovunque si può usare un oggetto di tipo Dipendente si può anche usare un oggetto di tipo Dirigente. Questo è in accordo con l'intuizione perché qualsiasi operazione che posso applicare a un oggetto Dipendente (generico) la posso applicare anche a un Dipendente particolare che è un Dirigente1.

Una classe che ne estende un'altra non solo eredita l'interfaccia e l'implementazione ma può anche modificare il comportamento dei metodi che eredita. Ad esempio, la classe Dirigente può ridefinire il metodo stipendio in modo che ritorni lo stipendio del dirigente, mantenendo la stessa interfaccia. Questo, insieme al fatto che il tipo Dirigente è trattato come un sotto-tipo di Dipendente, è un esempio di ciò che viene chiamato polimorfismo e sarà spiegato tra poco.

Ereditarietà e polimorfismo

La sintassi di Java per definire una classe che ne estende un'altra è molto semplice e consiste nell'uso della parola chiave extends nell'intestazione della classe. Come primo esempio consideriamo la definizione di una classe Dirigente per rappresentare i dirigenti della nostra ipotetica azienda per cui abbiamo già definito la classe Dipendente. Ogni dirigente è anche un dipendente e quindi è naturale che la classe Dirigente estenda la classe Dipendente. Per ora supponiamo che l'unica caratteristica che distingue un dirigente è un bonus sullo stipendio:

package mp;

/** Un oggetto {@code Dirigente} rappresenta un dirigente dell'azienda */
public class Dirigente extends Dipendente {
    /** Crea un dirigente con il dato nome e cognome e bonus.
     * @param nomeCognome  nome e cognome del dirigente
     * @param bonus  bonus del dirigente */
    public Dirigente(String nomeCognome, double bonus) {
        super(nomeCognome);
        this.bonus = bonus;
    }

    /** @return il bonus di questo dirigente */
    public double getBonus() { return bonus; }

    /** Imposta un nuovo bonus per questo dirigente.
     * @param b  l'importo del nuovo bonus */
    public void setBonus(double b) { bonus = b; }

    private double bonus;
}

Super-classe/sotto-classe    Come si vede, la sintassi per definire una classe che ne estende un'altra usa la parola chiave extends seguita dal nome della classe da estendere. Come conseguenza tutti i membri accessibili (campi e metodi) della classe Dipendente sono ereditati dalla classe Dirigente. I membri privati e i costruttori non sono invece ereditati e i membri privati non sono neanche accessibili. Ad esempio, il metodo getStipendio fa automaticamente parte dei metodi pubblici di Dirigente, con l'implementazione definita nella classe Dipendente. In questo modo la classe Dirigente eredita sia l'interfaccia della classe Dipendente sia l'implementazione. Nella terminologia comune la classe che viene estesa è detta classe base o super-classe e la classe che estende è detta classe derivata o sotto-classe2. Così Dipendente è la classe base (o super-classe) della classe Dirigente e la classe Dirigente è una classe derivata da (o una sotto-classe di) Dipendente.

Costruttori e la parola chiave super    Siccome i costruttori non sono ereditati, una sotto-classe deve necessariamente definire almeno un costruttore. L'unica eccezione a questa regola si ha quando la super-classe ha un costruttore senza argomenti: in questo caso per la sotto-classe è implicitamente definito un costruttore di default senza argomenti che automaticamente invoca il costruttore senza argomenti della super-classe. Se però la super-classe, come nel caso di Dipendente, non ha un costruttore senza argomenti, allora la sotto-classe deve definire almeno un costruttore. Inoltre, ogni costruttore della sotto-classe deve invocare un costruttore della super-classe. Ciò è necessario perché un oggetto della sotto-classe è anche un oggetto della super-classe e così deve essere costruito in relazione ad entrambe le classi. Per effettuare la costruzione dell'oggetto rispetto alla super-classe si usa la parola chiave super che permette di invocare un costruttore della super-classe, ma come vedremo fra poco ha anche altri usi. L'invocazione del costruttore della super-classe deve sempre essere la prima istruzione. Nel nostro esempio il costruttore di Dirigente invoca super(nomeCognome) per costruire la parte dell'oggetto che riguarda la super-classe Dipendente. Un altro costruttore di Dirigente potrebbe invocare un altro costruttore di Dipendente.

Possiamo già usare la classe Dirigente è controllare che i membri pubblici di Dipendente sono ereditati:

Dirigente dir = new Dirigente("Carla Bianchi", 500);
out.print("Dirigente: "+dir.getNomeCognome());
out.println("  codice: "+dir.getCodice());
out.println("    stipendio:  " + dir.getStipendio());

Questo stampa,

Dirigente: Carla Bianchi  codice: 1
    stipendio:  0.0

Ridefinire (override)    Ovviamente, la sotto-classe può definire nuovi membri. Nel nostro esempio c'è il campo bonus e i metodi getBonus e setBonus. Inoltre può ridefinire (override) i metodi che eredita. La classe Dirigente dovrebbe ridefinire almeno il metodo getStipendio3:

/** @return lo stipendio di questo dirigente */
public double getStipendio() {
    return super.getStipendio() + bonus;
}

La parola chiave super permette di accedere al metodo della super-classe. Così super.getStipendio() invoca proprio il metodo getStipendio della super-classe Dipendente. Si osservi che in questa invocazione è necessario usare la parola chiave super4 perché altrimenti si sarebbe invocato il metodo stesso, cioè il metodo getStipendio di Dirigente, definendo così un metodo ricorsivo che in esecuzione produce un ciclo infinito.

Polimorfismo    Come si è detto, una sotto-classe eredita l'interfaccia della super-classe. Inoltre, il tipo definito dalla sotto-classe è considerato un sotto-tipo (subtype) del tipo definito dalla super-classe. Ciò significa che ovunque si può usare un oggetto della super-classe si può anche usare un oggetto della sotto-classe. Nel nostro esempio, il tipo Dirigente è un sotto-tipo di Dipendente e ovunque si può usare un oggetto di tipo Dipendente si può anche usare un oggetto di tipo Dirigente. Ad esempio, se un metodo richiede come parametro un oggetto di tipo Dipendente allora gli si può passare anche un oggetto di tipo Dirigente (il viceversa non è possibile perché, ad esempio, il metodo getBonus appartiene all'interfaccia del sotto-tipo Dirigente ma non appartiene all'interfaccia del super-tipo Dipendente). Questa caratteristica è detta polimorfismo. Un oggetto di tipo Dirigente può assumere diverse "forme" potendo essere usato sia come un oggetto Dirigente sia come un oggetto Dipendente. Il seguente semplice programma usa le classi Dipendente e Dirigente e mostra il polimorfismo degli oggetti di tipo Dirigente.

public class Tests {
    public static void stampaStipendi(Dipendente[] dd) {
        for (Dipendente d : dd)
            out.println("Stipendio di "+d.getNomeCognome()+" è "+d.getStipendio());
        out.println();
    }

    public static void main(String[] args) {
        Dirigente dir = new Dirigente("Carla Bianchi", 500);
        Dipendente[] dip = new Dipendente[3];
        dip[0] = new Dipendente("Mario Rossi", 1000);
        dip[1] = dir;
        dip[2] = new Dirigente("Ugo Gialli", 350);
        stampaStipendi(dip);
        dip[0].setStipendio(1200);
        dip[1].setStipendio(1300);
        stampaStipendi(dip);
        dir.setBonus(700);
        ((Dirigente)dip[2]).setBonus(600);    // Cast
        stampaStipendi(dip);
    }
}

Già da questo semplicissimo esempio si può notare come il polimorfismo aiuti a trattare in modo uniforme oggetti di tipo diverso. Infatti, si può creare un oggetto di tipo Dirigente e assegnarlo ad una variabile di tipo Dipendente e il metodo stampaStipendi tratta senza distinzioni oggetti di tipo Dipendente e di tipo Dirigente senza però sacrificarne le differenze. Infatti la stampa degli oggetti, grazie al polimorfismo, usa automaticamente per ogni oggetto l'implementazione appropriata. Ecco il risultato dell'esecuzione del programma:

Stipendio di Mario Rossi è 1000.0
Stipendio di Carla Bianchi è 500.0
Stipendio di Ugo Gialli è 350.0

Stipendio di Mario Rossi è 1200.0
Stipendio di Carla Bianchi è 1800.0
Stipendio di Ugo Gialli è 350.0

Stipendio di Mario Rossi è 1200.0
Stipendio di Carla Bianchi è 2000.0
Stipendio di Ugo Gialli è 600.0

Dynamic binding    Quando il metodo getStipendio è invocato, nel metodo stampaStipendi, l'implementazione che deve essere usata (quella di Dipendente o quella di Dirigente) è determinata in base all'identità dell'oggetto su cui è invocato il metodo. L'identità di un oggetto contiene infatti anche il nome della classe a cui l'oggetto appartiene. La possibilità di determinare l'implementazione di un metodo durante l'esecuzione è detta dynamic binding (cioè, legame dinamico) e il meccanismo che la realizza si chiama selezione dinamica del metodo (dynamic method lookup). Il dynamic binding è in contrapposizione con lo static binding (legame statico) che invece determina l'implementazione staticamente, al momento della compilazione. Il polimorfismo richiede necessariamente l'uso del dynamic binding.

Cast e instanceof    Quando un oggetto Dirigente è contenuto in una variabile del super-tipo Dipendente si può usare un cast per invocare su di esso metodi che appartengono a Dirigente ma non al super-tipo (nel nostro caso il metodo setBonus). La sintassi del cast è semplice, il nome del tipo forzato, tra parentesi tonde, deve immediatamente precedere l'oggetto a cui si applica,

(AltroTipo)exprObj

dove exprObj è una qualsiasi espressione che ha come valore il riferimento ad un oggetto. Il cast è una direttiva che dice al compilatore che il tipo di un oggetto non deve essere quello inferito dal compilatore ma quello specificato nel cast. Però il compilatore richiede il rispetto di alcuni vincoli. Se il tipo di exprObj inferito dal compilatore è CT, allora il compilatore segnalerà un errore se AltroTipo non è o uguale a CT o un sotto-tipo di CT o un super-tipo di CT. Ad esempio, il seguente cast che tenta di forzare il tipo String per un oggetto contenuto nella variabile dir di tipo Dirigente,

(String)dir          // ERRORE in compilazione: cast non valido

provoca un errore in compilazione perché String non è uguale al tipo Dirigente, né è un sotto-tipo di Dirigente e nè è un super-tipo di Dirigente. Se AltroTipo soddisfa i vincoli imposti dal compilatore ma non è uguale al tipo attuale5 dell'oggetto riferito da exprObj o a un suo super-tipo, allora l'errore si manifesta in esecuzione con il lancio di ClassCastException. Ad esempio, il seguente cast

(Dirigente)dip[0]    // ERRORE in esecuzione: ClassCastException

non fallisce in compilazione perché il tipo inferito di dip[0], cioè Dipendente, è un super-tipo di Dirigente ma fallisce in esecuzione perché il tipo attuale dell'oggetto in dip[0] è Dipendente e Dirigente non è un super-tipo di Dipendente. Per evitare che un cast fallisca in esecuzione si può usare l'operatore instanceof. L'espressione

exprObj instanceof Tipo

ha valore true se e solo se il tipo attuale dell'oggetto exprObj è un sotto-tipo di Tipo o è uguale a Tipo. Così se prima di effettuare il cast (Dirigente)dip[0] facciamo il controllo dip[0] instanceof Dirigente possiamo evitare l'errore in esecuzione. Ritorneremo su instanceof più avanti.

Annotazione @Override    A questo punto è necessario un approfondimento circa la ridefinizione di un metodo. Supponiamo di voler gestire per ogni dipendente anche un supervisore che è un dipendente, spesso ma non sempre un dirigente, al quale il dipendente si deve rapportare. Quindi nella classe Dipendente introduciamo:

/** @return il supervisore di questo dipendente */
public Dipendente getSupervisore() { return supervisore; }

/** Imposta il supervisore di questo dipendente.
 * @param s  il supervisore */
public void setSupervisore(Dipendente s) { supervisore = s; }

private Dipendente supervisore;             // Inizialmente è null

Siccome non può accadere che il supervisore di un dirigente sia un dipendente, conviene ridefinire il metodo setSupervisore in Dirigente per fare un controllo che il supervisore impostato sia un dirigente. Quindi nella classe Dirigente introduciamo

/** Imposta il supervisore di questo dirigente.
 * @param s  il supervisore
 * @throws IllegalArgumentException se il supervisore non è un dirigente */
public void setSupervisore(Dipendente s) {
    if (!(s instanceof Dirigente))
        throw new IllegalArgumentException("Il supervisore di un dirigente " +
                "deve essere un dirigente");
    super.setSupervisore(s);
}

Ora potremmo pensare che sia ancora meglio se lo ridefiniamo imponendo già nel tipo del parametro che sia un Dirigente,

public void setSupervisore(Dirigente s) . . .

Ma se facciamo così il metodo setSupervisore di Dirigente non ridefinisce più quello di Dipendente. Infatti, un metodo ridefinisce un metodo della super-classe solo se ha lo stesso nome, la stessa sequenza dei tipi dei parametri e il tipo ritornato deve essere o lo stesso o un sotto-tipo di quello del metodo da ridefinire. Quindi se introducessimo la definizione sopra, la classe Dirigente avrebbe due metodi setSupervisore, uno che prende in input un oggetto Dipendente e l'altro che prende in input un oggetto Dirigente. Sicuramente non vogliamo questo. Allora per essere certi che un metodo sia stato dichiarato in modo giusto per ridefinire un metodo della super-classe, si può annotare la definizione con l'annotazione @Override:

@Override
public void setSupervisore(Dipendente s) . . .

Così facendo il compilatore farà un controllo che effettivamente il metodo che abbiamo annotato con @Override ridefinisca un metodo di una super-classe. Se tentassimo di annotare con @Override il metodo setSupervisore(Dirigente s) il compilatore si lamenterebbe.

Le annotazioni sono generalmente sfruttate da strumenti (come un compilatore) che analizzano il codice sorgente per controllare la validità di certe proprietà con lo scopo di prevenire errori o mantenere una specifica struttura del codice. Ma possono essere usate anche al runtime. Discuteremo le annotazioni in dettaglio più avanti.

final

A volte non si vuole che un metodo possa essere ridefinito perché esegue delle operazioni critiche o ritorna un valore che non deve essere in alcun modo modificato, ecc. Ad esempio, nella classe Dipendente potremmo non volere che una sotto-classe ridefinisca il metodo getCodice, per garantire che il metodo ritorni sempre il codice corretto su qualsiasi oggetto (di tipo Dipendente, Dirigente o di un altro sotto-tipo di Dipendente) sia invocato. A tale scopo c'è il modificatore final che applicato alla definizione di un metodo impedisce che possa essere ridefinito. Nel nostro caso,

public final long getCodice() . . .

Così se si tenta di ridefinire getCodice il compilatore si lamenta. Il modificatore final può anche essere applicato a una classe

public final NomeClasse { . . . }

impedendo che la classe possa essere estesa da una sotto-classe. Molte classi della piattaforma Java sono final, ad esempio tutte quelle che corrispondono ai tipi primitivi e String.

Sotto-tipi e array

In questo primo esempio abbiamo visto una classe (Dirigente) che ne estende un'altra (Dipendente). Dovrebbe essere chiaro che la classe che estende può a sua volta essere estesa da un'ulteriore classe. Invero, non c'è nessun limite sul numero di classi che possono esserci in una catena in cui ogni classe estende quella che la precede. Consideriamo, ad esempio, la seguente catena con tre classi:

class A { . . . }
class B extends A { . . . }
class C extends B { . . . }

Quindi la classe C estende la classe B che a sua volta estende la classe A. La terminologia delle super-classi/sotto-classi è usata anche in relazione a classi che non sono direttamente l'una l'estensione dell'altra. Così si dice che C è una sotto-classe di A (oltre a essere una sottoclasse di B) e che A è una super-classe di C (oltre a essere anche una super-classe di B). L'importante concetto di sotto-tipo si applica parimenti a tutte le sotto-classi dirette o indirette di una classe. Quindi C è, al pari di B, un sotto-tipo di A. Ovunque si può usare un oggetto di tipo A si può anche usare un oggetto di tipo C.

La relazione di sotto-tipo si estende anche agli array, cioè è preservata dagli array. Se Type è un qualsiasi tipo e Subtype è un sottotipo di Type allora Subtype[] è un sottotipo di Type[]. Così B[] e C[] sono sottotipi di A[] e C[] è un sottotipo di B[]. Ecco alcuni esempi:

A[] arrA;
B[] arrB;
C[] arrC = new C[10];
arrA = arrC;       // OK, C[] è sotto-tipo di A[]
arrB = arrC;       // OK, C[] è sotto-tipo di B[]
arrB = arrA;       // ERRORE in compilazione, A[] non è sotto-tipo di B[]
arrA = (B[])arrC;  // OK, C[] è sotto-tipo di B[] che è sotto-tipo di A[]

La relazione di sotto-tipo si estende naturalmente anche agli array di array:

A[][] matA;
B[][] matB;
C[][] matC = new C[5][10];
matA = matC;     // OK, C[][] è sotto-tipo di A[][]
matB = matC;     // OK, C[][] è sotto-tipo di B[][]
matB = matA;     // ERRORE in compilazione, A[][] non è sotto-tipo di B[][]
matA = (B[][])matC;  // OK, C[][] è sotto-tipo di B[][] che è sotto-tipo di A[][]

Si noti che C[][] è un sotto-tipo di A[][] perché C è un sotto-tipo di A e questo implica che C[] è un sotto-tipo di A[] che a sua volta implica che C[][] è un sotto-tipo di A[][].

In termini tecnici si dice che gli array in Java sono covarianti. Questa proprietà è conveniente ma può essere insidiosa. Ad esempio il frammento di codice

Dirigente[] dirigenti = new Dirigente[5];
Dipendente[] dip = dirigenti;   // Permesso dalla covarianza degli array
dip[0] = new Dipendente("Mario Rossi");  // ERRORE in esecuzione

è sintatticamente corretto, ma in esecuzione produce un errore con lancio di ArrayStoreException perché si tenta di assegnare all'array dip un oggetto il cui tipo (Dipendente) non è uguale né è un sotto-tipo del tipo attuale (Dirigente) dell'array.

La classe Object

Tutte le classi estendono automaticamente, direttamente o indirettamente, una classe speciale chiamata Object (in java.lang). Quindi tutte le classi sono sotto-classi dirette o indirette di questa classe. Quando una qualsiasi classe è definita, anche se non estende esplicitamente nessuna classe, implicitamente estende la classe Object. Se ad esempio definiamo una classe NomeClasse

class NomeClasse { . . . }

ciò equivale a

class NomeClasse extends Object { . . . }

Non solo tutte le classi definiscono quindi dei sotto-tipi del tipo Object ma anche qualsiasi tipo array è un sotto-tipo del tipo Object. Questo ha due importanti effetti il primo è che una variabile di tipo Object può mantenere il riferimento ad un qualsiasi oggetto sia esso un'istanza di una classe o di un array. Il secondo effetto è che tutti gli oggetti ereditano i metodi della classe Object. Dapprima discuteremo il primo effetto e poi il secondo.

Dipendente d = new Dipendente("Mario Rossi");
Object obj = d;        // OK perché d è di un sotto-tipo di Object
obj = "stringa";       // OK perché String è un sotto-tipo di Object
int[] interi = new int[10];
obj = interi;             // OK perché l'array è un sotto-tipo di Object
obj = new Dipendente[5];  // OK perché l'array è un sotto-tipo di Object
d = obj;       // ERRORE in compilazione, d non è di un super-tipo di Object
interi = obj;  // ERRORE in compilazione, interi non è di un super-tipo di Object

Si noti che il tipo Object[] (array di Object) è un super-tipo di tutti i tipi Type[] tali che Type è un sotto-tipo di Object. Quindi Object[] è il super-tipo di tutti i tipi array eccetto gli array di tipi primitivi, dato che Object non è un super-tipo di nessuno dei tipi primitivi. Vediamo alcuni esempi:

Object[] objArray;
objArray = new Dipendente[10];            // OK
String[][] matrixStr = new String[10[20];
objArray = matrixStr;                     // OK
int[] interi = new int[10];
objArray = interi;                        // ERRORE in compilazione
int[][] matrixInt = new int[20][30];
objArray = matrixInt;                     // OK

La ragione per cui Object[] è un super-tipo di String[][] è che Object è un super-tipo di String[]. Come si vede dall'errore segnalato per l'assegnamento objArray = interi, Object[] non è un super-tipo di int[] perché Object non è un super-tipo di int. Però Object[] è un super-tipo di int[][] dato che Object è un super-tipo di int[]. E questo spiega la ragione per cui l'assegnamento objArray = matrixInt è corretto.

Nonostante, come abbiamo detto, Object non è un super-tipo di nessuno dei tipi primitivi i seguenti assegnamenti sono corretti

Object obj = 15;      // OK
float v = 0.34;
obj = v;              // OK
obj = true;           // OK
char c = 'A';
obj = c;              // OK

La ragione è che il compilatore Java, in tutti i contesti come questi, esegue la conversione automatica boxing che converte il tipo primitivo in un oggetto del corrispondente tipo (Integer, Float, ecc.).

La classe Object introduce un modo generico e uniforme per riferirsi ad oggetti dei tipi più vari. Questo è utile per scrivere metodi che possono operare in modo uniforme su oggetti di tipo diverso. Come ad esempio i metodi binarySearch e sort della classe Arrays.

La classe Object ha parecchi metodi alcuni dei quali non siamo ancora pronti ad affrontare. Così limiteremo la discussione solamente a due metodi che sono anche quelli più usati: equals e toString.

Il metodo equals

L'intestazione del metodo è la seguente

public boolean equals(Object obj)

L'implementazione di default (cioè quella fornita direttamente dalla classe Object) non è di grande utilità perché semplicemente ritorna true se e solo se il riferimento di obj è uguale a quello dell'oggetto su cui il metodo è invocato. Ecco un semplice esempio,

class LPoint {           // Punto del piano con etichetta
    public final double x, y;
    public final String label;

    public LPoint(double x, double y, String label) {
        this.x = x;
        this.y = y;
        this.label = label;
    }
}

public class Tests {
    public static void main(String[] args) {
        LPoint p1 = new LPoint(0, 0, "origine");
        LPoint p2 = new LPoint(0, 0, "origine");
        if (p1.equals(p2))            // Sempre false, p1 e p2 sono oggetti diversi,
            out.println("Uguali");    // anche se hanno lo stesso valore
}

È chiaro quindi che se si vuole che gli oggetti di una classe implementino una versione significativa del metodo equals, cioè una versione che controlla l'uguaglianza di valore (non di identità), è necessario che il metodo sia ridefinito. Infatti molte classi della piattaforma Java lo ridefiniscono, come ad esempio la classe String. Consideriamo la ridefinizione di equals nella classe LPoint:

/** Ritorna true se l'oggetto dato non è null, è di tipo LPoint ed ha le stesse
 * coordinate e la stessa etichetta di questo punto.
 * @param o  un oggetto
 * @return true se l'oggetto è un LPoint con lo stesso stato di questo oggetto */
@Override
public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass()) return false;
    LPoint p = (LPoint)o;
    return (p.x == x && p.y == y && Objects.equals(p.label, label));
}

Quasi tutte le ridefinizioni del metodo equals seguono uno schema simile a quello appena dato. Per determinare se l'oggetto o è uguale a questo oggetto, per prima cosa si accerta che non sia null poi che abbia la stessa classe tramite il metodo getClass di Object. Tale metodo invocato su un oggetto o ritorna un oggetto c che rappresenta la classe di cui o è un'istanza. Si noti che l'oggetto ritornato c è sempre lo stesso per un qualsiasi oggetto di quella classe, per cui il confronto si può fare con il semplice operatore di uguaglianza == o !=. Dopo di ciò, essendo o della stessa classe (in questo caso LPoint), facciamo il cast per poter accedere allo stato dell'oggetto, cioè i suoi campi. Per confrontare valori di tipo riferimento è utile il metodo statico Objects.equals che tiene conto di possibili valori null. Più precisamente, per una qualsiasi coppia di oggetti x e y da confrontare invece di usare x.equals(y) (o y.equals(x)), conviene Objects.equals(x, y) perché quest'ultimo va bene anche quando x (o y) è null.

Una adeguata ridefinizione di equals non è sempre facile da fornire. Quando si è in dubbio è utile tenere in conto che la relazione di equivalenza tra oggetti indotta da equals dovrebbe essere simmetrica. Questo significa che se x e y sono due oggetti non null, allora x.equals(y) deve essere uguale a y.equals(x).

Il metodo toString

Il metodo toString dovrebbe ritornare una descrizione tramite stringa dell'oggetto sul quale è invocato. L'implementazione di default ritorna una stringa che contiene il nome della classe dell'oggetto e un numero in esadecimale (un codice diverso per ogni oggetto). Ad esempio su un oggetto LPoint il metodo toString (di default) ritorna qualcosa del tipo:

"LPoint@78308db1"

che non è molto informativo. Una descrizione ritornata da molte classi, anche della piattaforma Java, consiste nel nome della classe seguito, tra parentesi quadre, dai valori di alcuni dei campi più significativi dell'oggetto. Per la classe LPoint potremmo quindi ridefinirlo nel modo seguente

@Override
public String toString() {
    return getClass().getName()+"[x="+x+",y="+y+",label="+label+"]";
}

Invece di usare direttamente il nome della classe (LPoint) abbiamo usato il metodo getName dell'oggetto classe, così se il nome della classe dovesse cambiare non dobbiamo cambiare anche l'implementazione del metodo toString. Il metodo su p = new LPoint(0,0,"origine"), ritorna

"LPoint[x=0.0,y=0.0,label=origine]"

Per la classe Dipendente potremmo ridefinirlo così

@Override
public String toString() {
    return getClass().getName()+"[codice="+codice+",nomeCognome="+nomeCognome+"]";
}

E per la classe Dirigente possiamo anche non ridefinirlo. Infatti il seguente frammento di programma

Dipendente mr = new Dipendente("Mario Rossi");
Dirigente cv = new Dirigente("Carla Bianchi", 500);
out.println(mr);
out.println(cv);

stampa

mp.Dipendente[codice=1,nomeCognome=Mario Rossi]
mp.Dirigente[codice=2,nomeCognome=Carla Bianchi]

Grazie all'uso di getClass().getName() il nome della classe è corretto anche per le sotto-classi.

Esercizi

[ErroriTipi]    Il seguente programma contiene 4 errori (uno di questi si verifica solamente in esecuzione), trovarli e spiegarli.

class Point {
    public final double x, y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }
}
class LPoint extends Point {
    public final String label;

    public LPoint(double x, double y, String l) {
        label = l;
        super(x, y);
    }
}
public class Test {
    public static void main(String[] args) {
        Point[] pA = new Point[10];
        pA[0] = new LPoint(0, 0, "Roma");
        System.out.println(pA[0].label);
        LPoint[] lpA = new Point[5];
        Point[][] pM = new LPoint[5][];
        pM[0] = new Point[5];
    }
}

[Persona]    Nell'ipotetica azienda si vogliono gestire anche i dati di collaboratori esterni come i consulenti. Alcuni dei dati gestiti dalla classe Dipendente sono in comune con quelli gestiti da una nuova classe Consulente (dati anagrafici, contatti). Per evitare duplicazione di codice e gestire quindi in modo unitario tali dati si decide di introdurre un'ulteriore classe Persona per gestire tali dati, da cui saranno derivate le classi Dipendente e Consulente. La ristrutturazione richiede in particolare di muovere la classe annidata Contatti da Dipendente a Persona. Per i consulenti gestire anche il curriculum tramite un campo di tipo stringa con i relativi getter e setter.

[Persona+]    Con riferimento all'esercizio precedente, aggiungere alla classe Persona un campo per mantenere il codice fiscale e far sì che non sia possibile creare un oggetto Persona (direttamente o tramite una sotto-classe) senza specificare tale dato. Deve essere controllata la correttezza sintattica del codice fiscale secondo quanto specificato qui e se non è corretto si deve lanciare un'opportuna IllegalArgumentException. Tenendo conto che il codice fiscale identifica in modo univoco le persone, come va ridefinito il metodo equals? In modo che sia ben definito anche per tutte le sotto-classi.

[Prodotti]    Si immagini una situazione in cui si deve gestire un archivio di prodotti merceologici di varia natura come elettrodomestici, televisori, capi di abbigliamento, ecc. Ogni oggetto dell'archivio dovrebbe rappresentare una specifica tipologia di prodotto. Ad esempio, un televisore di una certa marca e modello o una camicia di un certa marca, taglia e colore. Chiaramente ci sono degli attributi (o proprietà) che sono comuni a tutti i prodotti: prezzo e produttore. Altri attributi non sono comuni a tutti i prodotti ma appartengono a una certa categoria di prodotti. Ad esempio, il consumo in watt è comune a tutti i prodotti elettrici (elettrodomestici, televisori, ecc.). Possiamo organizzare proprio in base a queste comunanze e differenze le classi per la gestione di questo archivio. Definire una classe base Prodotto per la gestione degli attributi comuni a tutti i prodotti. Poi definire delle estensioni di tale classe per le diverse categorie specifiche di prodotti. Per mantenere l'esercizio semplice consideriamo solamente poche categorie: abbigliamento, frigoriferi e televisori. I capi di abbigliamento possono essere gestiti da una sola classe Abbigliamento. Quindi Abbigliamento sarà una sotto-classe di Prodotto. I frigoriferi e i televisori hanno alcuni attributi in comune (ad esempio, consumo in watt) però hanno anche delle differenze: la capacità ha senso solamente per i frigoriferi e la dimensione in pollici ha senso solamente per i televisori. Così conviene definire una classe intermedia AppElettr che accomuna tutti i prodotti elettrici o elettronici. È anch'essa una sotto-classe di Prodotto. Infine definire le classi Frigorifero e Televisore come sotto-classi di AppElettr. Queste classi costituiscono una piccola gerarchia che può essere visualizzata con il seguente diagramma:

                                Prodotto
                                    Λ
                  __________________|___________________
                  |                                    |
              AppElettr                          Abbigliamento
                  Λ 
      ____________|____________
      |                       |
  Frigorifero             Televisore

[Regioni]    Si vuole realizzare un archivio per mantenere i dati relativi alle regioni, provincie e capoluoghi di provincia. Per ogni regione si vuole gestire il nome, l'estensione (in Km quadrati), la popolazione e i collegamenti alle provincie. Per ogni provincia si vuole gestire il nome, l'estensione, la popolazione, il numero di comuni e il collegamento al capoluogo di provincia. Per ogni capoluogo di provincia si vuole gestire il nome, la popolazione, l'estensione e l'elenco dei nomi di tutte le circoscrizioni. Definire una gerarchia di classi per la rappresentazione dell'archivio secondo le seguenti indicazioni. Definire una classe base ElemGeo che gestisce i dati comuni ai diversi elementi (regioni, provincie e capoluoghi) e un codice numerico che identifica univocamente ogni elemento. Definire poi le sottoclassi Regione, Provincia e Capoluogo per gestire i dati specifici. Definire opportuni metodi per impostare i dati ed eventualmente leggerli. Ridefinire in modo opportuno i metodi toString e equals.

[FigureGeometriche]    Definire una classe Punto per rappresentare punti del piano a coordinate intere. Definire una gerarchia di classi per gestire figure geometriche del piano (cerchi, rettangoli e triangoli) a coordinate intere secondo le indicazioni seguenti.

  1. Definire una classe base Figura2D e le sotto-classi Cerchio, Rettangolo e Triangolo. Ogni oggetto di tipo Cerchio è determinato da un centro di tipo Punto e un raggio (intero). Ogni oggetto Rettangolo è determinato da due punti (di tipo Punto) rappresentanti lo spigolo in alto a sinistra e quello in basso a destra (il rettangolo ha i lati paralleli agli assi). Ogni oggetto Triangolo è determinato da tre punti (i vertici del triangolo). Definire un metodo area che ritorna l'area della figura geometrica. Definire un metodo minR che ritorna un oggetto Rettangolo che è il più piccolo rettangolo che contiene la figura geometrica. Definire inoltre un metodo isIn che prende in input un punto (di tipo Punto) e ritorna true o false a seconda che il punto cada all'interno o all'esterno della figura geometrica.
  2. Ridefinire in modo opportuno i metodi equals e toString per le classi definite.
  3. Definire un metodo statico maxArea della classe Figura2D che prende in input un array di Figura2D e ritorna la massima area delle figure geometriche dell'array.
  4. Definire un metodo statico minRettangolo della classe Figura2D che prende in input un array di Figura2D e ritorna un oggetto Rettangolo che è il più piccolo rettangolo che contiene tutte le figure geometriche dell'array.

[Biblioteca]    Si vuole gestire un archivio dei documenti (libri e DVD) di una biblioteca. Ogni documento ha una collocazione. Prima di tutto definire quindi una classe Collocazione per gestire appunto le collocazioni. Una collocazione è determinata da una stringa che specifica il nome di un reparto della biblioteca, un numero di scaffale che identifica uno scaffale del reparto e da un numero che indica una posizione nello scaffale. Poi, definire una gerarchia di classi con una classe base Documento e poi le sotto-classi Libro, DVD_Video e DVD_Audio. I dati comuni devono essere gestiti dalla classe Documento (come la collocazione). Definire opportuni costruttori e metodi getter e setter per i vari dati (autore, titolo, ecc.). Ridefinire in modo opportuno i metodi equals e toString. Definire un metodo statico cercaTitoli della classe Documento che prende in input un array arrD di oggetti Documento e una stringa str e ritorna in un array di oggetti Documento tutti i documenti dell'array arrD il cui titolo contiene la stringa str.

[ErroriO1]    Il seguente programma contiene uno o più errori. Trovare gli errori e spiegarli. In particolare, dire per ogni errore se si verifica in compilazione o durante l'esecuzione.

class Pair {
    private String key, value;
    public Pair(String k, String v) {
        key = k;
        value = v;
    }
    public String getKey() { return key; }
}
public class Test {
    public static void main(String[] args) {
        Pair[] pp = {new Pair("K", "V"), new Pair("KK", "VV")};
        System.out.println(pp[0].toString());
        System.out.println(pp.toString());
        Object[] oA = pp;
        String k = oA[0].getKey();
        Object[] oB = new int[4];
    }
}

[ErroriO2]    Il seguente programma contiene uno o più errori. Trovare gli errori e spiegarli. In particolare, dire per ogni errore se si verifica in compilazione o durante l'esecuzione.

public class Test {
    public static void main(String[] args) {
        String[] sA = {"A", "B", "C"};
        double[] dA = {0.9, 1.2};
        System.out.println(sA.toString()+dA.toString());
        Object[] oA = dA;
        Object obj = sA;
        Object obj2 = dA;
        boolean[][] tab = new boolean[4][4];
        Object[] oB = tab;
        Object[][] oT = tab;
    }
}

[ErroriO3]    Il seguente programma contiene uno o più errori. Trovare gli errori e spiegarli. In particolare, dire per ogni errore se si verifica in compilazione o durante l'esecuzione.

public class Test {
    public static void main(String[] args) {
        if (args instanceof String) return;
        float[][] matrix = new float[5][];
        Object[] oA = matrix;
        if (oA instanceof float[]) return;
        oA = new int[10];
        Object[] oB = new int[5][4];
        oA = matrix;
        oA[0] = oB[0];
    }
}

[toString]    Definire un metodo String toString(Object o) pubblico e statico (preferibilmente nella classe Utils) che se o non è un array ritorna o.toString(), se invece è un array ritorna ciò che ritornano i metodi deepToString o toString di Arrays a seconda di quale sia applicabile e/o appropriato. Ad esempio,

toString(5)    -->    "5"
toString(new int[] {1,3,4})    -->    "[1, 3, 4]"
toString(new String[] {"A", "B"})    -->    "[A, B]"
toString(new char[][] {{'a'},{'b','c'},{'d'}})    -->    "[[a], [b, c], [d]]"
toString(new Object[][] {{1,2.5,"abc"},{true,null,'q'}})    -->    
                                             "[[1, 2.5, abc], [true, null, q]]"
toString(null)    -->    null

[infos]    Definire un metodo String infos(Object...objs) pubblico e statico (preferibilmente nella classe Utils) che ritorna una stringa che per ogni oggetto x passato in input ha una linea che riporta il nome semplice della classe di x seguito da ciò che ritorna il metodo toString dell'esercizio precedente. Ad esempio,

out.print(infos(4, .3, "A", null, new int[1], new Object[][] {{1,'a'},{""}}));

stampa

Integer  4
Double  0.3
String  A
null
int[]  [0]
Object[][]  [[1, a], []]

Per ottenere il nome semplice di una classe si può usare getSimpleName di Class.

[Info]    Definire un metodo String info(Object o) pubblico e statico (preferibilmente nella classe Utils) che se o non è un array ritorna il nome semplice della classe di o, se o è un array unidimensionale ritorna la stringa "Array of C", dove C è il nome semplice delle componenti dell'array o, se infine o è un array multidimensionale, ritorna la stringa "Array with d dimensions of C" dove d è il numero di dimensioni e C è il nome semplice della classe delle componenti finali dell'array o. Ad esempio,

info(null)                     -->  "null"
info(23)                       -->  "Integer"
info(new float[3])             -->  "Array of float"
info(new String[20][10])       -->  "Array with 2 dimensions of String"
info(new Dipendente[8][8][8])  -->  "Array with 3 dimensions of Dipendente"

Per determinare se un oggetto è un array si può usare il metodo isArray, per ottenere la classe delle componenti di un array si può usare getComponentType e per ottenere il nome semplice di una classe si può usare getSimpleName, tutti metodi di Class.

1 Mar 2016


  1. Se così non fosse, allora sarebbe sbagliato considerare un dirigente come un tipo particolare di dipendente.

  2. Questa terminologia può essere fuorviante. Una super-classe non è superiore a una sua sotto-classe, piuttosto l'inverso è più plausibile dato che una sotto-classe generalmente ha più funzionalità della super-classe. La motivazione dietro questa terminologia sta nell'interpretazione insiemistica: l'insieme degli oggetti di una sotto-classe è un sotto-insieme dell'insieme degli oggetti della super-classe.

  3. Si noti che dopo aver ridefinito in questo modo il metodo getStipendio per gli oggetti Dirigente il metodo setStipendio ereditato da Dipendente diventa incoerente. Infatti per i dirigenti getStipendio ritorna lo stipendio (da dipendente) più il bonus mentre setStipendio imposta solamente lo stipendio da dipendente.

  4. Diversamente da this, super non è il riferimento ad un oggetto ma una direttiva per aggirare il meccanismo di selezione dinamica dei metodi (dynamic method lookup) e invocare un altro metodo.

  5. Per tipo attuale di un'espressione che ha come valore (un riferimento ad) un oggetto intendiamo il tipo dell'oggetto che è il valore dell'espressione al runtime, cioè quando l'espressione è valutata durante l'esecuzione.