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
nomeespecie(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
nomeeore_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 cometipo()ritorna"Bicicletta"; - il metodo astratto
velocita_massima()inVeicolo, che ritorna la velocità massima in km/h; - il metodo concreto
tempo_viaggio(km)inVeicolo, che ritorna il tempo minimo stimato in ore per percorrerekmchilometri 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.