in Tutorial, Tutorial Python

Elementi di Python: utilizzi pratici dei metodi dunder

I metodi dunder, anche conosciuti come metodi speciali o magici, sono particolari metodi implementabili nelle classi Python3, che permettono di far sì che le nostre classi custom possano interagire con alcuni operatori e costrutti del linguaggio.

In questo articolo vedremo un insieme di casi d’uso pratici per questi metodi, assieme ad alcuni esempi di applicazioni.

Cos’è un metodo dunder?

Python contiene moltissimi costrutti basati su parole chiave o funzioni globali incluse nel linguaggio. Queste funzionalità rendono il linguaggio comodo da usare e diretto, offrendo una sintassi chiara ed intuitiva. 

Risulta inevitabile, quindi, che scrivendo applicazioni complesse ci ritroveremo a desiderare di potere usare questi costrutti su oggetti di classi create da noi. 

Questo è possibile tramite i metodi dunder, che sono metodi di una classe Python che possiedono un identificatore che inizia e finisce con due caratteri di underscore, come ad esempio __str__, o  __iter__ e __next__, di cui avevo parlato nel precedente articolo di questa serie.

Rappresentare un oggetto come stringa: __str__ e __repr__

Entrando nei dettagli di __str__, implementare questo metodo permette di rendere disponibile una rappresentazione di un oggetto come stringa. A dire il vero, ci sono due metodi dunder che hanno questo obiettivo: l’altro è __repr__.

La differenza tra i due sta nel fatto che __repr__ dovrebbe ritornare una rappresentazione che sia la meno ambigua possibile, anche a scapito della leggibilità, mentre __str__ può essere pensato come un metodo d’aiuto per avere una veloce descrizione sotto forma di stringa.

Se implementati da una classe custom, chiamare le funzioni builtin reprstr darà come risultato la stringa ritornata dai metodi. Qui di seguito un esempio esplicativo:

class Arduino:
    def __init__(self, nome, microcontr):
        self._nome = nome
        self._microcontr = microcontr
    
    def __str__(self):
        return f"Arduino {self._nome}"
    
    def __repr__(self):
        return f"Arduino(nome={self._nome}, microcontr={self._microcontr})"



uno = Arduino("Uno", "ATmega328")
nano = Arduino("Nano", "ATmega328p")

print("print usando __str__", uno, "-", nano)
print("print usando __repr__", repr(uno), "-", repr(nano))

# Utilizzo implicito di repr e str con str.format

print("print usando __str__ {} - {}".format(uno, nano))
print("print usando __repr__ {!r} - {!r}".format(uno, nano))

# Utilizzo implicito di repr e str con le f-string (python >= 3.6)

print(f"print usando __str__ {uno} - {nano}")
print(f"print usando __repr__ {uno!r} - {nano!r}")

Ridefinire l’uguaglianza tra oggetti: __eq__ e __hash__

Nel caso in cui volessimo la possibilità di utilizzare l’operatore == per testare l’uguaglianza di due istanze di una classe, è possibile ridefinire il metodo __eq__. Bisogna però tenere conto di alcuni dettagli e ripercussioni che quest’operazione porta con sé:

  • di base, ogni classe ha un’implementazione di __eq__, che ha come risultato il ritornare True solo se un oggetto è paragonato a sé stesso.
  • ogni classe custom python ha di base un metodo __hash__ implementato, che può essere considerato come un modo di verificare un’uguaglianza in maniera molto veloce, utilizzando la funzione builtin hash
  • implementare un __eq__ personalizzato implica che il metodo dunder __hash__ di default venga automaticamente disabilitato.

L’ultimo punto ha delle conseguenze piuttosto importanti, in quanto perdere la possibilità di calcolare l’hash di un oggetto implica che quell’oggetto non sia più hashable, ossia non può essere utilizzato come chiave di dizionari o all’interno di un set

Scrivere una buona funzione di hashing è parte di un argomento molto complesso ed è qualcosa che è sempre preferibile evitare di fare. Se si ha proprio bisogno di una classe hashable. con uguaglianza personalizzata, una maniera di agire è quella di considerare degli attributi di classe che sono immutabili e basare il calcolo di __eq__ e di __hash__ sul loro valore.  

L’importante è ricordarsi sempre che due oggetti uguali secondo __eq__ devono avere hash uguale.

class Raspberry:
    def __init__(self, modello):
        self._modello = modello
    
    def __repr__(self):
        return f"Raspberry(modello={self._modello})"

    def __eq__(self, other):
        if not isinstance(other, Raspberry):
            return NotImplemented
        return self._modello == other._modello

    def __hash__(self):
        return hash(self._modello)

In questo esempio possiamo notare un paio di cose interessanti:

  • il metodo __eq__ ha come parametro other, che è l’oggetto a destra dell’operatore ‘==’; se creo due oggetti di tipo Raspberry (ad esempio assegnandoli alle variabili pi3 e pi4), e scrivo pi3 == pi4, pi4 corrisponderà ad other. 
  • all’inizio del metodo __eq__, effettuiamo un’operazione che va a controllare che l’oggetto other sia istanza della nostra classe, ritornando NotImplemented invece di False.
  • l’attributo modello viene utilizzato come base per l’uguaglianza tra istanze della nostra classe.
  • il metodo metodo __hash__ utilizza lo stesso attributo di __eq__ per calcolare l’hash e si appoggia a sua volta sull’hash di modello.

I Context Manager: __enter__ ed __exit__

Un ultimo esempio che voglio trattare è quello dei context manager, che costituiscono una maniera semplice e pulita di acquisire e rilasciare risorse. Un esempio classico in cui ci si imbatte spessissimo in python è quello dell’aprire un file:

with open("file", "r") as dump:
    cont = dump.read()
    print(cont)

Il costrutto with … as costituisce uno dei modi in cui utilizzare i context manager e, dietro le quinte, richiama due metodi dunder __enter__ ed __exit__. Il primo inizializza la risorsa da utilizzare e ne ritorna un riferimento, mentre il secondo la chiude e gestisce eventuali eccezioni.

Per implementarli, bisogna fare attenzione a far sì che __enter__ ritorni sempre un riferimento alla risorsa, e che __exit__ abbia la giusta intestazione, come di seguito:

def __enter__(self)
def __exit__(self, exc_typeexc_valuetraceback)

dove exc_type è il tipo dell’eccezione verificatasi, exc_value il suo valore ed il terzo parametro contiene il traceback relativo all’errore. Tutti e tre saranno None se nessuna eccezione si sarà verificata.

Conclusioni

Abbiamo visto tre aspetti pratici dell’utilizzo dei metodi dunder di Python3, ma ce ne sono un’altra miriade da utilizzare ed esplorare per coprire le casistiche più disparate, come quelle dell’utilizzo degli operatori aritmetici.

Tutto il codice trattato è interamente reperibile al seguente indirizzo, copiate e sperimentate!

Per questo articolo è tutto, per eventuali approfondimenti o richieste di chiarimenti vi invito come al solito a commentare nella sezione qui sotto!

Scrivi un commento

Commento