Sommario
< Home
Stampa

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.

Interfacce

Come abbiamo visto con il principio LSP possiamo usare una classe astratta con uno o più metodi astratti che impongono il vincolo alle sottoclassi di implementare quel metodo. Questo permette al consumatore di utilizzare solo la classe astratta che implementa quel metodo (o quei metodi) senza conoscere tutta la gerarchia.

Questo meccanismo anche se efficace presenta alcuni limiti:

  • il consumatore deve comunque conoscere l’esistenza della classe base. Ma la classe base resta pur sempre una classe. Se la gerarchia venisse modificata ad esempio creando una classe genitore della classe base, bisognerebbe riscrivere anche tutti i consumatori;
  • la dipendenza è sempre verso una certa classe di una certa gerarchia. In realtà il consumatore non dipende effettivamente da una classe specifica, ma da uno o più metodi.

Per questa ragione Java prevede il concetto di interfaccia. Una interfaccia è una dichiarazione di un tipo che prevede solo la dichiarazione di metodi astratti, è quindi molto più generica di una classe astratta.

Le interfacce hanno due caratteristiche fondamentali:

  • sono svincolate da una gerarchia di classi: una classe può estendere o meno un’altra classe, ma in modo indipendente può implementare una o più interfacce;
  • una interfaccia può essere implementata da classi di gerarchie diverse, a qualsiasi livello della gerarchia. E’ quindi possibile l‘implementazione multipla..

Facciamo un esempio:

package interfacce;

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());
    }
    
}

L’interfaccia è definita dalla parola chiave interface e contiene solo dichiarazioni di metodi. Per implementarla si usa la parola chiave implements.

Il vantaggio di usare le interfacce è che rende Il consumatore totalmente indipendente dalla gerarchia di classi, ma solo ed unicamente dall’interfaccia. Per questa ragione si può considerare l’interfaccia non come una classe da cui si dipende, ma come un contratto. Un contratto è un vincolo che impegna l’oggetto utilizzato ad implementare tutti i metodi dell’interfaccia, qualsiasi sia la gerarchia di classi a cui appartiene.

Per capirlo meglio proviamo a vedere un nuovo esempio.



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.

Principi di programmazione ad oggetti

Principio di sostituzione di Liskov (LSP)

Questo principio, di cui abbiamo visto esempi sopra, 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.

Lo abbiamo visto con l’esempio del conto corrente.

Dependency Inversion Principle (DIP)

Questo principaio afferma che un consumatore non deve dipendere da una classe, anche se base e/o astratta, ma da una interfaccia. Questo rende il consumatore completamente indipendente dalla gerarchia di classi che deve utilizzare.

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 e farle implementare solo quando servono. Questa flessibilità è inoltre utile proprio nell’utilizzo: il contratto che deve rispettare la classe che viene consumata si riduce solo allo stretto necessario.

Lo abbiamo visto con l’esempio delle due interfacce dell’area calcolabile e del perimetro calcolabile.

Conclusioni

Riassumiamo i contenuti della lezione.

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.