Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Classi Astratte e Polimorfismo

Nel capitolo sull’ereditarietà abbiamo visto che una classe derivata può reimplementare (overriding) un metodo ereditato, cambiandone il comportamento. In questo capitolo portiamo questo concetto alle sue conseguenze naturali, introducendo due idee strettamente collegate: il polimorfismo e le classi astratte.

Polimorfismo

La parola polimorfismo viene dal greco e significa, letteralmente, molte forme. In OOP indica la possibilità di chiamare lo stesso metodo su oggetti di classi diverse, ottenendo risultati diversi a seconda del tipo reale dell’oggetto.

Partiamo da un esempio concreto. Abbiamo già incontrato Cerchio, Quadrato e TriangoloRettangolo negli esercizi precedenti. Tutte e tre queste classi hanno un metodo calcolaArea(). Proviamo a metterle insieme in una lista:

import math

class Cerchio:
    def __init__(self, raggio):
        self.raggio = raggio

    def calcolaArea(self):
        return math.pi * self.raggio ** 2

class Quadrato:
    def __init__(self, lato):
        self.lato = lato

    def calcolaArea(self):
        return self.lato ** 2

class TriangoloRettangolo:
    def __init__(self, base, altezza):
        self.base = base
        self.altezza = altezza

    def calcolaArea(self):
        return self.base * self.altezza / 2

# -------------------------------------------

if __name__ == "__main__":
    forme = [Cerchio(5), Quadrato(4), TriangoloRettangolo(6, 3)]

    for forma in forme:
        print("Area:", forma.calcolaArea())

Output:

Area: 78.53981633974483
Area: 16
Area: 9.0

Su ogni oggetto della lista chiamiamo calcolaArea(): Python capisce da solo, a runtime, di che classe è quell’oggetto e quale versione del metodo chiamare. È il polimorfismo in azione.

Note

Polimorfismo

Il polimorfismo è la capacità di chiamare lo stesso metodo su oggetti di classi diverse, ottenendo un comportamento specifico per ogni classe. Python lo gestisce automaticamente grazie all’ereditarietà e all’overriding.

Duck Typing

Python porta il polimorfismo anche oltre: il codice sopra funziona anche se Cerchio, Quadrato e TriangoloRettangolo non hanno nessuna relazione di ereditarietà fra loro. L’unica cosa che conta è che tutte e tre abbiano il metodo calcolaArea().

Questo principio si chiama duck typing e si riassume con una frase famosa:

“Se cammina come un’anatra e starnazza come un’anatra, allora è un’anatra.”

In Python, se un oggetto ha il metodo calcolaArea(), può essere usato ovunque ci si aspetti una “forma con area”, a prescindere dalla sua classe di appartenenza. L’interprete non controlla il tipo dell’oggetto, ma solo se il metodo richiesto esiste.

class Rombo:
    def __init__(self, d1, d2):
        self.d1 = d1
        self.d2 = d2

    def calcolaArea(self):
        return (self.d1 * self.d2) / 2

# Rombo non eredita da niente di speciale, eppure funziona benissimo nella lista di prima!
forme = [Cerchio(5), Quadrato(4), Rombo(6, 4)]

for forma in forme:
    print("Area:", forma.calcolaArea())

Il duck typing è comodo, ma ha un limite: non garantisce nulla a priori. Se dimenticate di implementare calcolaArea() in una classe e la inserite nella lista, il programma sembrerà funzionare fino al momento in cui Python arriva a quell’oggetto… e lì si ferma con un AttributeError.

class Trapezio:
    def __init__(self, b1, b2, h):
        self.b1 = b1
        self.b2 = b2
        self.h = h

    # ops... mi sono dimenticato di implementare calcolaArea()!

forme = [Cerchio(5), Trapezio(4, 6, 3)]

for forma in forme:
    print("Area:", forma.calcolaArea())  # AttributeError su Trapezio!

Come si risolve questo problema in maniera sistematica? Con le classi astratte.

Classi Astratte

Una classe astratta è una classe che definisce un contratto: stabilisce quali metodi le classi derivate devono implementare, senza implementarli essa stessa.

Se una classe derivata non implementa tutti i metodi richiesti, Python si rifiuta di istanziarla e genera un errore immediatamente, molto prima che il programma arrivi a chiamare il metodo mancante.

Per usare le classi astratte in Python si importa il modulo abc (Abstract Base Class):

from abc import ABC, abstractmethod
  • ABC è la classe base da cui deve derivare la nostra classe astratta;
  • @abstractmethod è il decoratore che marca un metodo come obbligatorio per le classi derivate.

Riscriviamo l’esempio delle forme geometriche in modo sicuro:

from abc import ABC, abstractmethod
import math

class Forma(ABC):               # Forma deriva da ABC: diventa una classe astratta

    @abstractmethod
    def calcolaArea(self):
        """Calcola e ritorna l'area della forma"""
        pass

    @abstractmethod
    def calcolaPerimetro(self):
        """Calcola e ritorna il perimetro della forma"""
        pass

Adesso Forma definisce il contratto: chiunque voglia essere una Forma deve implementare calcolaArea() e calcolaPerimetro().

Note

Classi astratte

Una classe astratta è una classe che non può essere istanziata direttamente. Serve solo come modello per le classi derivate, imponendo loro di implementare i metodi marcati con @abstractmethod.

Se proviamo ad istanziare direttamente Forma:

f = Forma()   # TypeError: Can't instantiate abstract class Forma
              # with abstract methods calcolaArea, calcolaPerimetro

Python ci ferma subito con un messaggio di errore chiaro: la classe è astratta e non può essere istanziata.

Adesso implementiamo le classi concrete che derivano da Forma:

class Cerchio(Forma):
    def __init__(self, raggio):
        self.raggio = raggio

    def __str__(self):
        return f"Cerchio di raggio {self.raggio}"

    def calcolaArea(self):
        return math.pi * self.raggio ** 2

    def calcolaPerimetro(self):
        return 2 * math.pi * self.raggio


class Quadrato(Forma):
    def __init__(self, lato):
        self.lato = lato

    def __str__(self):
        return f"Quadrato di lato {self.lato}"

    def calcolaArea(self):
        return self.lato ** 2

    def calcolaPerimetro(self):
        return 4 * self.lato


class TriangoloRettangolo(Forma):
    def __init__(self, base, altezza):
        self.base = base
        self.altezza = altezza

    def __str__(self):
        return f"Triangolo rettangolo di base {self.base} e altezza {self.altezza}"

    def calcolaArea(self):
        return self.base * self.altezza / 2

    def calcolaPerimetro(self):
        ipotenusa = math.sqrt(self.base**2 + self.altezza**2)
        return self.base + self.altezza + ipotenusa

E adesso se dimentico di implementare un metodo, Python me lo dice subito:

class Trapezio(Forma):
    def __init__(self, b1, b2, h):
        self.b1 = b1
        self.b2 = b2
        self.h = h

    def calcolaArea(self):
        return (self.b1 + self.b2) * self.h / 2

    # ho dimenticato calcolaPerimetro()!

t = Trapezio(4, 6, 3)   # TypeError: Can't instantiate abstract class Trapezio
                         # with abstract method calcolaPerimetro

L’errore arriva al momento della creazione dell’oggetto, non quando si chiama il metodo mancante. Molto meglio!

Adesso il codice che usa le forme è sia comodo che sicuro:

if __name__ == "__main__":
    forme = [Cerchio(5), Quadrato(4), TriangoloRettangolo(6, 3)]

    area_totale = 0
    for forma in forme:
        print(forma)
        print(f"  area: {forma.calcolaArea():.2f}")
        print(f"  perimetro: {forma.calcolaPerimetro():.2f}")
        area_totale += forma.calcolaArea()

    print(f"\nArea totale: {area_totale:.2f}")

Output:

Cerchio di raggio 5
  area: 78.54
  perimetro: 31.42
Quadrato di lato 4
  area: 16.00
  perimetro: 16.00
Triangolo rettangolo di base 6 e altezza 3
  area: 9.00
  perimetro: 15.71

Area totale: 103.54

Metodi concreti in una classe astratta

Una classe astratta può avere anche metodi normali (non astratti), che vengono ereditati da tutte le classi derivate. Questo è utile quando una parte del comportamento è comune a tutte le sottoclassi, mentre solo alcune parti variano.

class Forma(ABC):

    @abstractmethod
    def calcolaArea(self):
        pass

    @abstractmethod
    def calcolaPerimetro(self):
        pass

    def descrivi(self):
        # metodo CONCRETO: non è astratto, funziona uguale per tutte le forme
        print(f"Area: {self.calcolaArea():.2f}, Perimetro: {self.calcolaPerimetro():.2f}")

Tutte le classi che derivano da Forma ereditano descrivi() gratuitamente, senza doverla reimplementare:

c = Cerchio(5)
c.descrivi()   # Area: 78.54, Perimetro: 31.42

Note

Classe astratta: cosa può contenere

  • Metodi astratti (@abstractmethod): nessuna implementazione, obbligatori per le classi derivate.
  • Metodi concreti: implementati normalmente, ereditati da tutte le classi derivate.
  • Variabili di classe e @classmethod / @staticmethod: tutto ciò che abbiamo visto nel capitolo precedente funziona normalmente.

Esercizio svolto: gerarchia Veicolo

Definiamo una gerarchia di veicoli con la classe astratta Veicolo, che impone a tutte le sottoclassi di definire il metodo consumo(km) (consumo in litri per percorrere km chilometri) e il metodo tipo() (descrizione testuale del veicolo).

from abc import ABC, abstractmethod

class Veicolo(ABC):
    def __init__(self, marca, modello):
        self.marca = marca
        self.modello = modello

    def __str__(self):
        return f"{self.tipo()}: {self.marca} {self.modello}"

    @abstractmethod
    def tipo(self):
        """Ritorna una stringa che descrive il tipo di veicolo"""
        pass

    @abstractmethod
    def consumo(self, km):
        """Ritorna il consumo in litri per percorrere km chilometri"""
        pass

    def costo_viaggio(self, km, prezzo_litro):
        """Metodo concreto: calcola il costo del viaggio in euro"""
        return self.consumo(km) * prezzo_litro


class Auto(Veicolo):
    def __init__(self, marca, modello, consumo_per_100):
        super().__init__(marca, modello)
        self.consumo_per_100 = consumo_per_100   # litri per 100 km

    def tipo(self):
        return "Auto"

    def consumo(self, km):
        return self.consumo_per_100 * km / 100


class Moto(Veicolo):
    def __init__(self, marca, modello, consumo_per_100):
        super().__init__(marca, modello)
        self.consumo_per_100 = consumo_per_100

    def tipo(self):
        return "Moto"

    def consumo(self, km):
        return self.consumo_per_100 * km / 100


class Camion(Veicolo):
    def __init__(self, marca, modello, consumo_per_100):
        super().__init__(marca, modello)
        self.consumo_per_100 = consumo_per_100

    def tipo(self):
        return "Camion"

    def consumo(self, km):
        return self.consumo_per_100 * km / 100


# -------------------------------------------

if __name__ == "__main__":
    veicoli = [
        Auto("Fiat", "Panda", 5.5),
        Moto("Honda", "CB500", 4.0),
        Camion("Iveco", "Daily", 14.0)
    ]

    distanza = 300
    prezzo = 1.85

    for v in veicoli:
        print(v)
        print(f"  consumo per {distanza} km: {v.consumo(distanza):.1f} L")
        print(f"  costo viaggio: {v.costo_viaggio(distanza, prezzo):.2f} €")

Output:

Auto: Fiat Panda
  consumo per 300 km: 16.5 L
  costo viaggio: 30.52 €
Moto: Honda CB500
  consumo per 300 km: 12.0 L
  costo viaggio: 22.20 €
Camion: Iveco Daily
  consumo per 300 km: 42.0 L
  costo viaggio: 77.70 €

Notate che costo_viaggio() è un metodo concreto definito in Veicolo che usa consumo(), il quale è astratto. Ogni classe derivata implementa consumo() a modo suo, e costo_viaggio() funziona correttamente per tutte senza doverla reimplementare.

Esercizi di comprensione


Esercizio 1501

Definire la classe astratta Forma con i metodi astratti calcolaArea() e calcolaPerimetro().

Derivare da essa le classi Cerchio, Quadrato e TriangoloRettangolo come mostrato nel capitolo.

Definire una lista con almeno un oggetto di ogni classe e, per ognuno, visualizzare area e perimetro.

Verificare che tentare di istanziare direttamente Forma produca un TypeError.


Esercizio 1502

Aggiungere alla gerarchia dell’esercizio precedente le classi Rettangolo e Rombo (il rombo ha come parametri le due diagonali).

Creare una lista eterogenea con almeno 6 forme diverse e calcolare l’area totale di tutte le forme presenti.


Esercizio 1503

Definire la classe astratta Animale con:

  • attributi nome e specie (forniti in __init__);
  • metodo astratto verso() che ritorna la stringa con il verso dell’animale;
  • metodo astratto muovi() che ritorna una stringa che descrive come si muove l’animale (es. “corre”, “vola”, “nuota”);
  • metodo concreto presentati() che ritorna la stringa "Sono {nome}, un {specie}. Faccio {verso()} e {muovi()}".

Derivare da Animale almeno 4 classi concrete a scelta (es. Cane, Gatto, Aquila, Delfino).

Creare una lista eterogenea di animali e chiamare presentati() su ciascuno.


Esercizio 1504

Definire la classe astratta Dipendente con:

  • attributi nome e ore_lavorate (inizialmente zero);
  • metodo astratto stipendio_mensile() che calcola lo stipendio;
  • metodo concreto lavora(ore) che aggiunge ore alle ore lavorate del mese;
  • metodo concreto fine_mese() che visualizza il riepilogo (ore lavorate e stipendio) e azzera le ore lavorate.

Derivare da Dipendente:

  • DipendenteOre: pagato a ore (riceve la tariffa oraria in __init__);
  • DipendenteStipendioFisso: riceve uno stipendio fisso mensile indipendentemente dalle ore lavorate.

Definire 2 dipendenti per tipo, far lavorare ciascuno un numero diverso di ore e chiamare fine_mese() su tutti.


Esercizio 1505: DIFFICILE

Riprendere la gerarchia Veicolo dell’esercizio svolto e aggiungere:

  • la classe Bicicletta, che non consuma carburante (consumo() ritorna sempre 0) e come tipo() ritorna "Bicicletta";
  • il metodo astratto velocita_massima() in Veicolo, che ritorna la velocità massima in km/h;
  • il metodo concreto tempo_viaggio(km) in Veicolo, che ritorna il tempo minimo stimato in ore per percorrere km chilometri alla velocità massima.

Implementare velocita_massima() in tutte le classi con valori realistici a scelta.

Creare una lista con tutti i tipi di veicoli e, per un viaggio di 500 km, visualizzare per ognuno: consumo, costo (con prezzo a scelta) e tempo stimato.