Python e l’Ereditarietà – Quando gli oggetti sono simili

Python - L'Ereditarietà header

In questo articolo vedremo uno dei concetti principe della programmazione Object-Oriented: l’ereditarietà (inheritance). L’ereditarietà in Python è un concetto fondamentale della programmazione orientata agli oggetti, consentendo la creazione di gerarchie di classi. Quando gli oggetti condividono attributi e comportamenti simili, è possibile utilizzare l’ereditarietà per creare una classe base (superclasse) dalla quale derivano classi più specifiche (sottoclassi). Questo approccio favorisce la riutilizzabilità del codice e permette alle sottoclassi di ereditare e/o sovrascrivere i membri della superclasse. Attraverso esempi pratici, esploreremo come Python gestisce l’ereditarietà, l’overriding e l’Ordine di Risoluzione dei Metodi (MRO), fornendo una base per la creazione di strutture di classi flessibili e organizzate.

L’ereditarietà di base (Basic Inheritance)

L’ereditarietà è un concetto potente che consente di strutturare il codice in modo gerarchico, facilitando la creazione di classi specializzate che ereditano le caratteristiche di classi più generali. Questo approccio promuove la modularità e la facilità di manutenzione del codice.

Una delle chiavi dell’ereditarietà è il concetto di “is-a relationship” (relazione di tipo “è-un”). Ad esempio, possiamo dire che un Cane è un tipo di Animale, e lo stesso vale per il Gatto. Questo concetto riflette la gerarchia naturale presente nel mondo reale e rende più intuitivo il design delle classi. Vediamolo con un semplice esempio, in cui definiremo una classe Animale e due classi Cane e Gatto che erediteranno dalla prima alcune caratteristiche, come il metodo emetti_suono.

class Animale:
    def __init__(self, nome):
        self.nome = nome

    def emetti_suono(self):
        pass

class Cane(Animale):
    def emetti_suono(self):
        return "Woof!"

class Gatto(Animale):
    def emetti_suono(self):
        return "Meow!"

# Utilizzo delle classi
fido = Cane("Fido")
whiskers = Gatto("Whiskers")

print(fido.nome)            
print(fido.emetti_suono())   

print(whiskers.nome)        
print(whiskers.emetti_suono())  

In questo esempio, abbiamo una classe base chiamata Animale che ha un costruttore __init__ e un metodo emetti_suono che è dichiarato come astratto (con pass). Le classi derivate Cane e Gatto ereditano dalla classe Animale e forniscono una specifica implementazione del metodo emetti_suono.

Eseguendo il codice otterremo:

Fido
Woof!
Whiskers
Meow!

Quando creiamo un’istanza di Cane o Gatto, possiamo accedere sia agli attributi specifici della classe derivata (come nome) che ai metodi ereditati dalla classe base.

L’ereditarietà in Python segue la sintassi class Figlio(ClasseGenitore), dove Figlio è la classe che eredita da ClasseGenitore. La classe figlio può estendere o sovrascrivere i metodi della classe genitore, fornendo così una specifica implementazione.

Tuttavia, è importante utilizzare l’ereditarietà con attenzione. Un uso eccessivo o improprio dell’ereditarietà può portare a una struttura gerarchica complessa e difficile da comprendere. In alcuni casi, la composizione (utilizzo di oggetti di altre classi senza ereditare) può essere una scelta migliore.

Un’altra considerazione importante è la sovrascrittura dei metodi. Quando una classe figlio eredita un metodo da una classe genitore, può sovrascrivere quel metodo per fornire una sua implementazione specifica. Questo consente una maggiore flessibilità nell’adattare il comportamento delle classi figlio alle esigenze specifiche del programma.

Classe, Superclasse e Sottoclasse

Ecco alcune ulteriori definizioni per riconoscere il ruolo delle classi nell’ambito dell’ereditarietà:

Classe: Una classe in programmazione orientata agli oggetti è un modello o un prototipo per creare oggetti. Le classi definiscono attributi (variabili di istanza) e metodi (funzioni) che rappresentano il comportamento degli oggetti creati dalla classe.

Superclasse (o Classe Genitore): Una superclasse è una classe più generale da cui altre classi (sottoclassi) possono ereditare attributi e metodi. La superclasse rappresenta concetti più ampi e generali.

Sottoclasse (o Classe Figlio): Una sottoclasse è una classe che eredita attributi e metodi da una superclasse. La sottoclasse può anche estendere o sovrascrivere i metodi della superclasse per adattare il comportamento alle sue esigenze specifiche.

Ora, un esempio più dettagliato:

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

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

class Auto(Veicolo):
    def __init__(self, marca, modello, porte):
        # Chiamiamo il costruttore della superclasse usando super()
        super().__init__(marca, modello)
        self.porte = porte

    # Sovrascriviamo il metodo descrizione
    def descrizione(self):
        return f"{super().descrizione()}, {self.porte}-porte"

# Creiamo un'istanza di Auto
mia_auto = Auto("Toyota", "Corolla", 4)

# Utilizziamo il metodo descrizione della sottoclasse
print(mia_auto.descrizione())

In questo esempio:

  • Veicolo è la superclasse che ha un costruttore e un metodo descrizione.
  • Auto è la sottoclasse che eredita da Veicolo e ha un attributo aggiuntivo porte. La sottoclasse ha anche sovrascritto il metodo descrizione per aggiungere informazioni specifiche delle auto.

La relazione è chiara: un’Auto è un tipo di Veicolo. Pertanto, Auto è la sottoclasse e Veicolo è la superclasse.

Eseguendo otterremo il seguente risultato:

Toyota Corolla, 4-porte

Ereditarietà multipla

In Python, è anche possibile ereditare da più classi, il che è noto come ereditarietà multipla. Questa funzionalità offre molte possibilità, ma va gestita con cura per evitare confusione e ambiguità nel codice.

Ecco un esempio che mostra l’ereditarietà multipla:

class Motore:
    def accendi(self):
        print("Motore acceso")

    def spegni(self):
        print("Motore spento")

class Elettronica:
    def accendi(self):
        print("Sistema elettronico acceso")

    def spegni(self):
        print("Sistema elettronico spento")

class Auto(Motore, Elettronica):
    def avvia_auto(self):
        print("Auto avviata")

# Creiamo un'istanza di Auto
mia_auto = Auto()

# Chiamiamo i metodi ereditati da entrambe le classi genitore
mia_auto.accendi() 
mia_auto.spegni()   
mia_auto.avvia_auto() 

In questo esempio, abbiamo due classi genitore, Motore e Elettronica, ognuna con i suoi metodi accendi e spegni. La classe Auto eredita sia da Motore che da Elettronica, sfruttando così l’ereditarietà multipla.

Quando chiamiamo i metodi accendi e spegni sull’oggetto mia_auto, Python utilizza il metodo della prima classe genitore specificato nella dichiarazione della classe. Quindi, mia_auto.accendi() chiama il metodo accendi della classe Motore, mentre mia_auto.spegni() chiama il metodo spegni della classe Elettronica.

Eseguendo otterremo il risultato seguente:

Motore acceso
Motore spento
Auto avviata

L’ereditarietà multipla può portare a situazioni in cui esistono ambiguità, specialmente se le classi genitore hanno metodi con lo stesso nome. In questi casi, è importante gestire attentamente l’ereditarietà multipla per evitare confusioni nel codice.

Ereditarietà multipla con la tecnica del Mixin

Il mixin è una tecnica in programmazione orientata agli oggetti che coinvolge l’uso di classi leggere e specializzate per aggiungere funzionalità a un’altra classe principale. L’obiettivo principale dei mixin è quello di fornire una modalità flessibile e modulare per estendere il comportamento di una classe senza dover utilizzare l’ereditarietà multipla in modo pesante.

Le classi mixin, infatti sono classi che forniscono specifiche funzionalità o comportamenti aggiuntivi. Sono progettate per essere leggere e indipendenti, concentrandosi su una singola responsabilità.

A differenza dell’ereditarietà multipla classica, in cui una classe può ereditare da più classi genitore, la tecnica del mixin favorisce la composizione. Le classi principali incorporano il comportamento dei mixin attraverso l’ereditarietà, ma senza creare una gerarchia complessa.

Ecco un esempio di come potrebbe apparire l’uso di mixin in Python:

# Classe mixin
class RegistrabileMixin:
    def registra(self):
        print(f"Registrazione: {self}")

# Classe principale che utilizza il mixin
class Utente:
    def __init__(self, nome):
        self.nome = nome

    def saluta(self):
        print(f"Ciao, sono {self.nome}")

# Classe che eredita da Utente e utilizza il mixin RegistrabileMixin
class UtenteRegistrabile(Utente, RegistrabileMixin):
    pass

# Creiamo un'istanza di UtenteRegistrabile
utente_registrabile = UtenteRegistrabile("Alice")

# Utilizziamo i metodi dalla classe principale e dal mixin
utente_registrabile.saluta()   
utente_registrabile.registra()  

In questo esempio, RegistrabileMixin è un mixin che fornisce il metodo registra. La classe UtenteRegistrabile eredita sia da Utente che da RegistrabileMixin, ottenendo così sia il comportamento di saluto che la capacità di registrazione.

Eseguendo il codice si otterrà il seguente risultato:

Ciao, sono Alice
Registrazione: <__main__.UtenteRegistrabile object at 0x0000021B52504090>

L’uso di mixin offre un modo flessibile per aggiungere funzionalità a classi esistenti senza dover creare gerarchie complesse di ereditarietà. Questo rende il codice più modulare e facile da gestire.

Il Problema del Diamante nell’ereditarietà multipla

Il “problema del diamante” è una situazione che può verificarsi in linguaggi di programmazione che supportano l’ereditarietà multipla, come Python. Questo problema è talvolta chiamato anche “ereditarietà ambigua” o “diamante della morte”. Si verifica quando una classe eredita da due classi che hanno una stessa classe genitore comune.

Per capire meglio il problema, considera il seguente scenario:

class A:
    def metodo(self):
        print("Metodo di classe A")

class B(A):
    def metodo(self):
        print("Metodo di classe B")

class C(A):
    def metodo(self):
        print("Metodo di classe C")

class D(B, C):
    pass

# Creiamo un'istanza della classe D
istanza_d = D()

# Chiamiamo il metodo della classe D
istanza_d.metodo()

In questo caso, la classe D eredita sia da B che da C, e entrambe queste classi ereditano da A. Se chiamiamo il metodo metodo sull’istanza di D, si verifica il problema del diamante. In Python, la risoluzione di attributi avviene seguendo l’Ordine di Risoluzione dei Metodi (MRO), che determina l’ordine in cui le classi base vengono esaminate durante la ricerca di un attributo.

L’output di questo esempio sarà:

Metodo di classe B

Questa è la risoluzione predefinita del problema del diamante in Python. L’ordine di risoluzione è determinato dalla sequenza in cui le classi sono elencate tra le parentesi nella dichiarazione della classe D (class D(B, C)). In questo caso, B viene prima di C, quindi il metodo di B ha la precedenza.

Per gestire il problema del diamante, Python utilizza un meccanismo chiamato C3 Linearization (o algoritmo C3) per definire l’ordine di risoluzione dei metodi. Questo algoritmo garantisce un ordine coerente e prevedibile per la risoluzione degli attributi durante l’ereditarietà multipla.

Se è necessario influenzare manualmente l’ordine di risoluzione, è possibile utilizzare il metodo __mro__ o la funzione mro(). Ad esempio:

print(D.__mro__)

Che si ottiene:

(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

Questo mostra l’ordine in cui le classi vengono considerate durante la risoluzione degli attributi.

Estendere le classi built-in

Un aspetto davvero interessante di ereditarietà è quello di aggiungere delle funzionalità a classi built-in. È possibile estendere le classi built-in in Python utilizzando l’ereditarietà per creare sottoclassi personalizzate. Questo consente di aggiungere funzionalità specifiche o modificare il comportamento delle classi built-in secondo le proprie esigenze.

Ecco un esempio in cui estendiamo la classe list:

class PilaPersonalizzata(list):
    def push(self, elemento):
        self.append(elemento)

    def pop(self):
        if not self:
            raise IndexError("La pila &egrave; vuota")
        return super().pop()

# Creiamo un'istanza della nostra classe personalizzata
mia_pila = PilaPersonalizzata()

# Utilizziamo i metodi della classe list e quelli aggiunti dalla nostra classe
mia_pila.push(1)
mia_pila.push(2)
mia_pila.push(3)

print("Pila:", mia_pila)  
elemento_rimosso = mia_pila.pop()
print("Elemento rimosso:", elemento_rimosso)  

print("Pila aggiornata:", mia_pila)  

In questo esempio, PilaPersonalizzata è una sottoclasse di list che aggiunge due metodi, push e pop, per aggiungere e rimuovere elementi dalla pila. La classe PilaPersonalizzata eredita tutti i metodi e attributi della classe list e ne aggiunge di nuovi.

Eseguendo il codice si ottiene il seguente risultato:

Pila: [1, 2, 3]
Elemento rimosso: 3
Pila aggiornata: [1, 2]

Puoi estendere altre classi built-in in modo simile, come dict, str, o qualsiasi altra classe incorporata in Python. Basta ereditare dalla classe desiderata e sovrascrivere o aggiungere i metodi necessari.

È importante notare che, quando si estendono le classi built-in, è possibile sfruttare molte delle funzionalità e degli operatori incorporati che sono già definiti per quelle classi. Ad esempio, la nostra PilaPersonalizzata può essere utilizzata con gli operatori di slicing e altre operazioni di liste come se fosse una lista comune.

Overriding e la funzione super()

L’overriding (sovrascrittura) è un concetto chiave nell’ereditarietà, che consente a una sottoclasse di fornire una propria implementazione di un metodo che è già definito nella sua superclasse. In altre parole, una sottoclasse può “sovrascrivere” il comportamento di un metodo ereditato dalla sua superclasse, fornendo una nuova implementazione più specifica alle sue esigenze.

Ecco un esempio di overriding in Python:

class Veicolo:
    def descrizione(self):
        return "Questo &egrave; un veicolo generico."

class Auto(Veicolo):
    def descrizione(self):
        return "Questa &egrave; un'auto."

# Creiamo un'istanza della classe Auto
mia_auto = Auto()

# Chiamiamo il metodo sovrascritto
print(mia_auto.descrizione())  # Output: Questa &egrave; un'auto.

In questo esempio, la classe Auto eredita dalla classe Veicolo e sovrascrive il metodo descrizione. Quando chiamiamo mia_auto.descrizione(), Python utilizza la versione del metodo definita nella classe Auto, ignorando la versione della classe Veicolo.

Eseguendo il codice si ottiene:

Questa è un'auto.

L’overriding è utile quando si desidera personalizzare il comportamento di un metodo ereditato per adattarlo alle esigenze specifiche della sottoclasse. Tuttavia, è importante rispettare la firma del metodo, cioè il nome e il numero di parametri, per garantire che l’overriding sia corretto e che le sottoclassi possano essere utilizzate in modo coerente con le superclassi.

Un altro punto importante è l’uso della funzione super() per chiamare il metodo della superclasse all’interno di una sottoclasse. Questo consente di estendere il comportamento della superclasse senza doverlo riscrivere completamente. Ad esempio:

class Veicolo:
    def descrizione(self):
        return "Questo &egrave; un veicolo generico."

class Auto(Veicolo):
    def descrizione(self):
        # Chiamiamo il metodo della superclasse usando super()
        return super().descrizione() + " Ma &egrave; anche un'auto."

# Creiamo un'istanza della classe Auto
mia_auto = Auto()

# Chiamiamo il metodo sovrascritto con la chiamata al metodo della superclasse
print(mia_auto.descrizione())

In questo modo, la sottoclasse Auto può estendere il comportamento della superclasse Veicolo senza ripetere completamente la sua implementazione. Eseguendo si ottiene il seguente risultato:

Questo è un veicolo generico. Ma è anche un'auto.

Lascia un commento