Polimorfismo
Dipendenza tra classi
Nella lezione sull’ereditarietà abbiamo visto che le classi possono essere realizzate utilizzando una gerarchia in cui da una classe genitore di creano classi figlie e che a loro volta possono essere estese a loro volta. Questa gerarchia è quindi una struttura ad albero.
Ad un certo punto però questa gerarchia deve essere usabile dall’esterno, e quindi devono essere previste classi che conoscono la gerarchia e la utilizzano, dette anche consumatori. Questa relazione di dipendenza genera quindi una struttura di oggetti a forma di grafo, come in questo esempio.
UML
Questo grafo viene chiamato grafo delle dipendenze. Per rappresentarlo possiamo usare UML ed il diagramma di classe.
UML è un linguaggio visuale che è stato introdotto per la fase di analisi e progettazione, che consente di realizzare diversi diagrammi che rappresentano sia la struttura dell’applicazione, sia il suo comportamento. Nella figura vediamo il diagramma di classe, dove vengono rappresentate le classi e le loro dipendenze (extends ed use), ciascuna con le proprietò (parte superiore) e i metodi (parte inferiore). Il simbolino + indica che la proprietà o il metodo è pubblico, il simbolino – indica che è privato.

Vediamo un esempio:
package polimorfismo;
import java.util.Scanner;
class ContoBancario {
protected String numeroConto;
protected double saldo;
public ContoBancario(String numeroConto) {
this.numeroConto = numeroConto;
this.saldo = 0;
}
public void versa(double quantita) {
this.saldo += quantita;
}
public void preleva(double quantita) {
this.saldo -= quantita;
}
public double getSaldo() {
return this.saldo;
}
}
class ContoDeposito extends ContoBancario{
private double interesse;
public ContoDeposito(String numeroConto) {
super(numeroConto);
}
}
class ContoCorrente extends ContoBancario {
public ContoCorrente(String numeroConto) {
super(numeroConto);
}
public void bonifico(double quantita, String numeroConto) {
// logica per gestire bonifico
super.preleva(quantita);
}
}
public class ContoApplication {
public static void stampaSaldo(ContoBancario conto) {
System.out.println(conto.getSaldo());
}
public static void main(String[] args) {
int tipoConto;
Scanner input = new Scanner(System.in);
System.out.print("Inserisci codice 1 per deposito, 2 per conto corrente: ");
tipoConto = input.nextInt();
input.close();
ContoBancario nuovoConto;
if (tipoConto == 1) {
nuovoConto = new ContoDeposito("123456"); //
} else {
nuovoConto = new ContoCorrente("123456"); //
}
stampaSaldo(nuovoConto);
}
}
In questo esempio il consumatore (ovvero Application) :
- chiede all’utente che conto vuole;
- dichiara una variabile come classe genitore (ContoBancario);
- assegna alla variabile una nuova istanza della classe figlia in base alla richiesta dell’utente (ad esempio il conto deposito o il conto corrente).
- richiama un metodo (getSaldo) che riceve come argomento ContoBancario.
Come si può vedere non è necessario quindi ridefinire stampaSaldo() per ogni tipo di conto, è sufficiente usare la classe padre, perché usiamo un metodo definito nella classe padre. Restando generici è possibile quindi usare una qualsiasi sottoclasse senza riscrivere il metodo, perché tanto al metodo non interessa quale sottoclasse sta usando, perché deve usare una funzionalità comune a tutta la gerarchia.
Override
Una classe figlia può non solo aggiungere funzionalità ad una classe padre, ma può anche ridefinire il comportamento di uno o più metodi. Questa possibilità si chiama override.
Abbandoniamo il mondo bancario e passiamo ad un esempio di geometria.
abstract class FiguraGeometrica {
public double area() {
return 0;
}
}
class Rettangolo extends FiguraGeometrica {
double base;
double altezza;
public Rettangolo(double base, double altezza) {
this.base = base;
this.altezza = altezza;
}
public double area() {
return this.base * this.altezza;
}
}
class Cerchio extends FiguraGeometrica {
double raggio;
public Cerchio(double raggio) {
this.raggio = raggio;
}
public double area() {
return this.raggio * this.raggio * Math.PI;
}
}
public class FigureGeometriche {
public static void main(String[] args) {
FiguraGeometrica cerchio = new Cerchio(4);
FiguraGeometrica rettangolo = new Rettangolo(6, 4);
System.out.println(cerchio.area());
System.out.println(rettangolo.area());
}
}
Con l’override Java va a cercare prima se il metodo area() è definito nella classe Cerchio e solo se non è definito risale la gerarchia fino a trovare una classe che lo implementa.
Questo meccanismo si chiama polimorfismo, ovvero possiamo cambiare il comportamento delle classi derivate senza che i consumatori ne siano a conoscenza. Ancora una volta, al consumatore non interessa come l’oggetto utilizzato si comporta, basta che il metodo sia presente con quella firma.
Metodi astratti
Nell’esempio sopra indicato dichiariamo nella classe FiguraGeometrica un metodo area() per fare in modo che il consumatore possa utilizzarlo in modo generico senza sapere quale oggetto è stato effettivamente istanziato. Java infatti come detto sopra risale la gerarchia finché non trova un metodo che corrisponde a quanto richiesto.
Ma se una sottoclasse non reimplementa il metodo, cioè usa il metodo di FiguraGeometrica, non abbiamo errori, tuttavia il programma si comporta in modo errato.
Per obbligare le sottoclassi a reimplementare il metodo lo possiamo rendere abstract e non implementarlo. Quindi scriveremo:
abstract class FiguraGeometrica {
public abstract double area();
}Un metodo astratto è dichiarato ma non implementato. Sarà compito delle sottoclassi impegnarsi a farlo. Come si vede spostiamo il controllo della correttezza dal runtime al build time, evitando un errore di dimenticanza.
Possiamo usare la definizione di metodi astratti di una classe astratta per imporre il vincolo alle sottoclassi di implementare quel metodo. Ad esempio se una classe astratta VeicoloAMotore dichiara un metodo AccendiMotore() come astratto, sarà obbligo delle sottoclassi concrete implementarlo.
Questo però porta il vantaggio alle classi che usano il VeicoloAMotore di non doversi preoccupare “quale” sia il VeicoloAMotore effettivo che sarà usato. Il compilatore garantisce che il metodo AccendiMotore sarà implementato a runtime.
In altri termini la classe consumatore può dipendere solo dalla classe astratta ma non dalle sue sottoclassi, e senza conoscere tutta la gerarchia. Viene quindi garantito disaccoppiamento tra gerarchie di classi.
Interfacce
Questo meccanismo anche se efficace (ed implementato da quasi tutti i linguaggi di programmazione ad oggetti) presenta due limiti:
il consumatore deve comunque conoscere l’esistenza della classe base.
Tuttavia la classe astratta di base resta pur sempre una classe di una gerarchia: e se la gerarchia venisse modificata ad esempio creando una classe genitore della classe base, bisognerebbe riscrivere anche tutti i consumatori. Questo ha grossi impatti nei progetti di grandi dimensioni, perché non è possibile scrivere librerie di classi generiche che si possano evolvere nel tempo in modo indipendente dalle gerarchie (o i programmi) che le usano.
Per fare un esempio poniamo di crerare la classe DispositivoAMotore genitore di VeicoloAMotore, e di inserire li il metodo astratto AccendiMotore(), e poi contemporaneamente togliere VeicoloAMotore, Allora tutte le classi consumatore dovranno essere riscritte per gestire questa dipendenza.
La dipendenza è sempre verso una specifica certa gerarchia.
Il consumatore è vincolato sempre a quella gerarchia, anche se alla fine lui deve solo eseguire AccendiMotore, senza sapere non solo come viene implementato il metodo, ma anche la natura stessa della gerarchia che lo implementerà. E’ un accoppiamento parecchio vincolante, perché costringe a progettare gerarchie di classi dipendenti.
Ma se ci pensiamo bene il consumatore non dipende effettivamente da una classe specifica, ma solo da un metodo con una specifica firma. Non deve importargli nè come nè chi lo implementa, è una associazione che verrà fatta da qualche altra classe.
Questo concetto si chiama “architettura a plugin” e per capirlo è sufficiente pensare ad una presa elettrica. Chi la costruisce si limita a disporre una uscita con una certa tensione ed un attacco compatibile con uno specifico standard. Non deve conoscere come verrà usata la corrente elettrica, e tantomeno chi la usa.
Per questa ragione Java prevede il concetto di interfaccia. Una interfaccia è una dichiarazione di tipo che che prevede solo metodi astratti. Non è vincolata ad una gerarchia di classi, e non implementa nulla.
Essa ha lo scopo di definire “un contratto di utilizzo” tra il fornitore di un servizio (cioè chi implementa il metodo) e il consumatore (cioè chi lo utilizza).
E’ quindi possibile scrivere classi (astratte o concrete) che implementano una interfaccia, e classi che la usano. Nessuna di queste conosce l’altra. E’ inoltre possibile implementare la stessa interfaccia in gerarchie indipendenti tra loro, ed il consumatore non è tenuto a sapere chi userà concretamente.
Infine una classe può implementare più interfacce, ed essere usata in contesti indipendenti.
L’interfaccia è definita dalla parola chiave interface e contiene solo dichiarazioni di metodi. Per implementarla si usa la parola chiave implements.
Facciamo un esempio:
interface AreaCalcolabile {
double area();
}
class Rettangolo implements AreaCalcolabile {
double base;
double altezza;
public Rettangolo(double base, double altezza) {
this.base = base;
this.altezza = altezza;
}
public double area() {
return this.base * this.altezza;
}
}
class Cerchio implements AreaCalcolabile {
double raggio;
public Cerchio(double raggio) {
this.raggio = raggio;
}
public double area() {
return this.raggio * this.raggio * Math.PI;
}
}
public class FigureGeometricheApplication {
public static void main(String[] args) {
AreaCalcolabile cerchio = new Cerchio(4);
AreaCalcolabile rettangolo = new Rettangolo(6, 4);
System.out.println(cerchio.area());
System.out.println(rettangolo.area());
}
}
Vediamolo con più interfacce:
interface AreaCalcolabile {
double area();
}
interface PerimetroCalcolabile {
double perimetro();
}
class Rettangolo implements AreaCalcolabile, PerimetroCalcolabile {
double base;
double altezza;
public Rettangolo(double base, double altezza) {
this.base = base;
this.altezza = altezza;
}
public double area() {
return this.base * this.altezza;
}
public double perimetro() {
return 2 * (this.base + this.altezza);
}
}
class Cerchio implements AreaCalcolabile, PerimetroCalcolabile {
double raggio;
public Cerchio(double raggio) {
this.raggio = raggio;
}
public double area() {
return this.raggio * this.raggio * Math.PI;
}
public double perimetro() {
return this.raggio * 2 * Math.PI;
}
}
public class InterfacceApplication {
public static void main(String[] args) {
Cerchio cerchio = new Cerchio(4);
Rettangolo rettangolo = new Rettangolo(6, 4);
System.out.println(cerchio.area());
System.out.println(cerchio.perimetro());
System.out.println(rettangolo.area());
System.out.println(rettangolo.perimetro());
}
}
Usare interfacce differenti è utile quando non vogliamo che le classi siano vincolate ad implementarle tutte. Ad esempio potrebbe non essere necessario, per alcune applicazioni, dover implementare una funzione che calcola il perimetro di una figura. Realizzando una interfaccia specifica per ogni funzionalità non siamo più obbligati ad implementarla se non serve.
Classi anonime
Partiamo da questo esempio:
Cerchio[] cerchi = {
new Cerchio(3),
new Cerchio(1.5),
new Cerchio(4),
new Cerchio(2.2),
new Cerchio(5),
new Cerchio(0.8),
new Cerchio(3.7),
new Cerchio(1),
new Cerchio(2.9),
new Cerchio(4.4)
};
Possiamo ordinare gli array usando Arrays.sort(), ma siccome sono oggetti non c’è modo di compararli direttamente, ci serve un criterio. Per questo dobbiamo utilizzare un suo overload, che usa un comparatore:
Arrays.sort(array, comparator);dove comparator è un oggetto di una classe che deve implementare questa interfaccia:
interface Comparator {
int compare(Object a, Object b);
}Di norma quindi dovremmo scrivere una classe che implementa questa interfaccia e permette di comparare due cerchi fra di loro, come questa:
import java.util.Comparator;
public class ComparatoreCerchi implements Comparator{
@Override
public int compare(Object a, Object b) {
return Double.compare((Cerchio)a).area(), (Cerchio)b).area());
}
}Tuttavia è una classe che useremmo solo una volta. In questo senso Java prevede la creazione di classi anonime, direttamente dove devono essere usate:
Arrays.sort(oggetti, new Comparator() {
@Override
public int compare(Object a, Object b) {
return Double.compare((Cerchio)a).area(), (Cerchio)b).area());
}
});Il codice è lo stesso ma ci evita di creare una classe in più per un solo utilizzo.
Espressioni Lambda
Nei linguaggi funzionali le funzioni sono oggetti, e quindi possono essere memorizzate in variabili, restituite da altre funzioni, ecc. Infatti in questi linguaggi alla fine l’ordinamento è più semplice da scrivere perché basta passare una funzione di comparazione alla funzione di ordinamento, mentre in Java bisogna passare una istanza di una classe (per carità, anonima!) che implementa una interfaccia che contiene una funzione, con uno schema ridondante e prolisso.
Nelle ultime versioni di Java i progettisti si sono resi conto di questo problema, e pur senza introdurre il concetto di funzione come oggetto, è stato trovato un compromesso.
E’ stato infatti introdotto il concetto di interfaccia funzionale, ovvero di una interfaccia che ha un solo metodo, proprio come il comparatore. Per questo tipo di interfacce, viene reso possibile di passare direttamente una funzione detta “funzione Lambda”, nome preso a prestito dai linguaggi funzionali.
La lambda ha questa struttura
(argomenti) -> codice di una sola rigaIn questo caso quindi possiamo scrivere quindi:
Arrays.sort(oggetti, (a,b) -> Double.compare(((Cerchio)a).area(). ((Cerchio)b).area()));
Le funzioni Lambda permettono quindi di risparmiare tempo e rendono il codice molto più leggibile, senza “rompere” i principi di programmazione ad oggetti a cui Java è strettamente legato.
Principi di programmazione ad oggetti
Principio di sostituzione di Liskov (LSP)
Il principio per cui possiamo usare una classe figlia al posto del padre viene chiamato “Principio di sostituzione di Liskov”.
Questo principio afferma che possiamo sostituire sempre una classe figlia al posto della classe padre, in quanto ogni classe figlia eredita dal padre tutte le caratteristiche.
E’ un principio fondamentale della programmazione ad oggetti, perché ci consente di rendere generici i consumatori, senza che dipendano dalla gerarchia di classi. Questo significa anche che è possibile modificare la gerarchia di classi, ad esempio aggiungendone di nuove, senza dover per forza modificare i consumatori.
Dependency Inversion Principle (DIP)
Questo principio afferma che un consumatore non deve dipendere da una classe ma da una interfaccia. Viene chiamata “inversione della dipendenza” perché non è la classe consumatore che dipende dalla classe fornitore, ma è la classe consumatore che indica tramite una interfaccia cosa può fare, e sarà la classe fornitore a dover dipendere da questa.
Per capirlo con un concetto fuori dall’informatica, un guidatore impara ad usare l’auto tramite volante, pedali, cambio, ecc., è cioé addestrato ad usare una interfaccia di funzionamento di qualsiasi oggetto dotato di volante, pedali, ecc.
E’ il costruttore di auto a dover costruire una auto che dispone di questi comandi, non è il guidatore che si deve adeguare ad una auto/modello specifico. E’ appunto una inversione delle dipendenze.
Lo abbiamo visto con l’esempio dell’area calcolabile di una figura geometrica, in questo caso è possibile riscrivere la gerarchia introducendo nuove figure geometriche, o addirittura modificarla creandone una nuova, senza dover modificare il consumatore.
Inoltre l’uso delle interfacce ci permette di non avere dipendenze circolari, quelle cioè dove una classe A usa un metodo della classe B, ma anche la classe B ha un metodo della classe A. Lo vedremo nel dettaglio nella prossima lezione.
Interface Segregation Principle (ISP)
Questo principio da come indicazione di usare sempre interfacce specifiche per l’uso effettivo che se ne dovrà fare. In pratica anzichè creare una interfaccia che contiene molti metodi diversi tra loro, che vincolano poi le classi a doverli implementare tutti, è molto più convieniente e semplice creare interfacce specifiche per ogni singola funzione e farle implementare solo quando servono. Questa flessibilità è inoltre utile proprio nell’utilizzo: il contratto che deve rispettare la classe che la implementa si riduce solo allo stretto necessario.
Ad esempio un’auto a marce automatiche implementerà volante e pedali, ma non il cambio manuale. Per chi non conosce la guida col cambio manuale sarà ancora usabile.
Lo abbiamo visto con l’esempio delle due interfacce dell’area calcolabile e del perimetro calcolabile.
Conclusioni
In una applicazione reale non si hanno solo gerarchie di classi, ma si realizza un grafo di dipendenze dove alcune classi dette consumatori usano altre classi. E’ importante quindi capire che questo tipo di struttura può diventare molto complessa e vincolante.
Possiamo però utilizzare relazioni con la classe base di una gerarchia, perché grazie al principio LSP è sempre possibile una classe figlia, visto che ne eredita tutte le funzioni.
E’ stato introdotto il polimorfismo, una caratteristica della OOP che consente ad una classe figlia di reimplementare uno o più metodi della classe base grazie all’override dei metodi. Java ricerca all’indietro nella gerarchia delle classi il primo metodo che implementa quanto richiesto.
Ma il polimorfismo trova piena applicazione nell’uso delle interfacce. Queste rappresentano dei contratti che rendono indipendente il consumatore dalla gerarchia di classi utilizzata, che può essere estesa e rimodellata senza ritoccare le sue dipendenze. Questo principio è noto come DIP. Abbiamo infine visto che è sempre più utile segregare le interfacce in modo specifico, in modo da non obbligare una classe ad implementare tutti i metodi di una interfaccia, se non strettamente necessari.
SRP, OCP, LSP, ISP e DIP formano l’acronimo SOLID, e sono i cinque principi fondamentali della programmazione ad oggetti.
