06: I Dunder Methods (Metodi Speciali o Magici) - Programmare in Python

Video Corso Programmazione a Oggetti Python 3

06: I Dunder Methods (Metodi Speciali o Magici)

Ciao a tutti! In questa lezione parliamo di Metodi Speciali.

Questi sono un insieme di metodi predefiniti che possiamo usare per arricchire il potenziale delle nostre Classi di Oggetti in Python, e sono molto facili da riconoscere in quanto il loro nome inizia e finisce con due trattini bassi, chiamati in inglese underscore.

Nel gergo di Python ci si riferisce spesso a questi metodi come **dunder methods**, metodi dunder, dove dunder sta per double under_score quindi doppio trattino basso. Come alcuni di voi avranno sicuramente notato, si, abbiamo già esplicitamente incontrato uno di questi metodi speciali, il metodo dunder init (__init__), che come abbiamo ampiamente spiegato durante questa miniserie sulla programmazione a oggetti, è il metodo che utilizziamo per creare oggetti a partire dalle nostre classi.

Alcuni programmatori fanno riferimento a questi metodi come metodi magici anche se l'utilizzo di questa terminologia viene spesso considerato fuorviante, in quanto questi metodi andrebbero considerati come una qualsiasi altra caratteristica di questo linguaggio di programmazione, invece di nasconderli dietro a una cortina di apparentemente esoterismo.

Ma allora vi starete chiedendo: che cos'hanno questo metodi dunder di così tanto speciale, e perché parli di metodi predefiniti?

La risposta è in verità abbastanza semplice: sono metodi che non vengono chiamati direttamente da noi come magari il nostro metodo scheda_personale(), ma la chiamata a questi metodi avviene diciamo dietro le quinte, è proprio Python a chiamarli per noi in determinate circostanze.

Facciamo un esempio. Supponiamo di avere un dizionario in cui abbiamo abbinato a ciascuna lettera dell'alfabeto la sua posizione:

alfabeto = {1: "a", 2: "b", 3: "c", 4: "d"}
    
>>> type(alfabeto)

Quindi chiaramente alfabeto è oggetto di tipo dict, dizionario, e sappiamo che possiamo interagire con questo tipo di dato attraverso tutta una serie di metodi e funzionalità predefinite, giusto? Sappiamo ad esempio che per ottenere un valore associato ad una chiave ci basta fare:

>>> alfabeto[4]
'd'
Bene questo è proprio uno di quei casi in cui Python chiama per noi uno di questi Metodi Speciali, __getitem__, da leggersi quindi dunder getitem. Possiamo infatti richiamare i valori tramite __getitem__, in maniera esplicita in questo modo :
>>> alfabeto.__getitem__(1)
'a'
        
>>> alfabeto.__getitem__(3)
'c'

Proviamo ora a richiamare la funzione help() e passarle proprio la nostra variabile alfabeto:

help(alfabeto)

    Help on dict object:
    class dict(object)
     |  dict() -> new empty dictionary
     |  dict(mapping) -> new dictionary initialized from a mapping object's
     |      (key, value) pairs
     |  dict(iterable) -> new dictionary initialized as if via:
     |      d = {}
     |      for k, v in iterable:
     |          d[k] = v
     |  dict(**kwargs) -> new dictionary initialized with the name=value pairs
     |      in the keyword argument list.  For example:  dict(one=1, two=2)
     |  
     |  Methods defined here:
     |  
     |  __contains__(self, key, /)
     |      True if D has a key k, else False.
     |  
     |  __delitem__(self, key, /)
     |      Delete self[key].
     |  
     |  __eq__(self, value, /)
     |      Return self==value.
     |  
     |  __ge__(self, value, /)
     |      Return self>=value.
     |  
     |  __getattribute__(self, name, /)
     |      Return getattr(self, name).
     |  
     |  __getitem__(...)
     |      x.__getitem__(y) <==> x[y]
     |  
     |  __gt__(self, value, /)
     |      Return self>value.
     |  
     |  __init__(self, /, *args, **kwargs)
     |      Initialize self.  See help(type(self)) for accurate signature.
     |  
     |  __iter__(self, /)
     |      Implement iter(self).
     |  
     |  __le__(self, value, /)
     |      Return self<=value.
     |  
     |  __len__(self, /)
     |      Return len(self).
     |  
     |  __lt__(self, value, /)
     |      Return self size of D in memory, in bytes
     |  
     |  clear(...)
     |      D.clear() -> None.  Remove all items from D.
     |  
     |  copy(...)
     |      D.copy() -> a shallow copy of D
     |  
     |  fromkeys(iterable, value=None, /) from builtins.type
     |      Returns a new dict with keys from iterable and values equal to value.
     |  
     |  get(...)
     |      D.get(k[,d]) -> D[k] if k in D, else d.  d defaults to None.
     |  
     |  items(...)
     |      D.items() -> a set-like object providing a view on D's items
     |  
     |  keys(...)
     |      D.keys() -> a set-like object providing a view on D's keys
     |  
     |  pop(...)
     |      D.pop(k[,d]) -> v, remove specified key and return the corresponding value.
     |      If key is not found, d is returned if given, otherwise KeyError is raised
     |  
     |  popitem(...)
     |      D.popitem() -> (k, v), remove and return some (key, value) pair as a
     |      2-tuple; but raise KeyError if D is empty.
     |  
     |  setdefault(...)
     |      D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D
     |  
     |  update(...)
     |      D.update([E, ]**F) -> None.  Update D from dict/iterable E and F.
     |      If E is present and has a .keys() method, then does:  for k in E: D[k] = E[k]
     |      If E is present and lacks a .keys() method, then does:  for k, v in E: D[k] = v
     |      In either case, this is followed by: for k in F:  D[k] = F[k]
     |  
     |  values(...)
     |      D.values() -> an object providing a view on D's values
     |  
     |  ----------------------------------------------------------------------
     |  Data and other attributes defined here:
     |  
     |  __hash__ = None

Ecco che otteniamo una lista di metodi e attributi associati a questo tipo di oggetto, e nello specifico possiamo soffermarci su:

|  __getitem__(...)
|  x.__getitem__(y) <==> x[y]

Per notare il parallelismo tra i due modi in cui è possibile ottenere il valore associato a una chiave, usando i dunder in maniera "esplicita" o "implicita".

In Python, i dunder stanno dietro anche a comportamenti molto utili come ad esempio il funzionamento polimorfico dell'operatore +, ovvero che si adatta al tipo di dato. Sappiamo molto bene che a seconda del tipo di dato che proviamo a sommare otteniamo infatti una somma matematica oppure una concatenazione:
>>> 5 + 5
10
        
>>> "Veni, Vidi, " + "Vici"
'Veni, Vidi, Vici'

In questo caso il metodo corrispondente che viene richiamato implicitamente è __add__:

# chiamandolo sulla classe "int", per somme matematiche:
int.__add__(5, 10)
15
        
# chiamandolo sulla classe "str", per concatenazioni di stringhe:
str.__add__("Veni, Vidi, ", "Vici")
'Veni, Vidi, Vici'
La cosa fenomenale è che l'implementazione di questi metodi nelle nostre classi ci consente quindi di ottenere funzionalità che sono proprie dei built-in types di Python. Volendo potrei quindi implementare __add__ anche nella nostra classe Studente e fargli fare un po quello che voglio, ad esempio concatenare il nome e il cognome di due studenti diversi, anche se questo esempio rappresenta un pò un palese abuso di questa tecnica.
class Studente:
    """ Una semplice classe Studente """

    def __init__(self, nome, cognome, corso_di_studi):
        self.nome = nome
        self.cognome = cognome
        self.corso_di_studi = corso_di_studi


    def scheda_personale(self):
        return f"""
            Scheda Studente \n
            Nome: {self.nome}
            Cognome: {self.cognome}
            Corso Di Studi: {self.corso_di_studi}
            """

    def __add__(self, other):
        """ Solo per fini didattici. Usare i dunder in maniera intelligente! """
        return self.nome + " " + other.cognome
>>> studente_uno = Studente("Peter", "Malkovich", "Psicologia")
>>> studente_due = Studente("John", "Snow", "Antropologia")
    
>>> print(studente_due + studente_uno)
'John Malkovich'

Facciamo invece un esempio che è sicuramente tra i più frequentemente utilizzati nelle casistiche reali, ed è proprio l'esempio che vorrei portaste con voi alla fine di questa lezione.

Se faccio:

>>> frase = "test su stringa"
>>> print(frase)
# Chiaramente ottengo in output la stringa associata alla variabile frase:
'test su stringa' 
        

>>> x = 5
>>> print(x)
# Allo stesso modo, chiaramente ottengo in output il valore associato alla variabile x:
5

Posso inoltre passare la variabile x alla funzione str():

>>> str(x)
# Ed ecco che ottengo la rappresentazione in stringa dell'intero associato a x
'5'

Ma che succederebbe invece se provassi a passare a print() o a str() un'istanza della classe Studente?

class Studente:
    """ Una semplice classe Studente """
    
    def __init__(self, nome, cognome, corso_di_studi):
        self.nome = nome
        self.cognome = cognome
        self.corso_di_studi = corso_di_studi
    
    
    def scheda_personale(self):
        return f"""
            Scheda Studente \n
            Nome: { self.nome }
            Cognome: { self.cognome }
            Corso Di Studi: { self.corso_di_studi }
            """
>>> studente_uno = Studente("Peter", "Malkovich", "Psicologia")
>>> print(studente_uno)
<__main__.Studente object at 0x7f7b21f314a8>

>>> print(str(studente_uno))
<__main__.Studente object at 0x7f0d593dd4a8>

Per quanto a noi che stiamo programmando, questo messaggio può tornare parecchio utile o interessante, si tratta in fondo dell'indirizzo di memoria dell'oggetto in CPython, possiamo essere certi che l'utilizzatore medio non tecnico non avrà lo stesso nostro livello di interesse.

Insomma, agli oggetti di tipo Studente manca la funzionalità di rappresentazione in stringa, e quindi andiamo ora a fornirgliela mediante l'implementazione dei metodi dunder appropriati!

Nello specifico definiremo il metodo __str__ e il metodo __repr__.

L'implementazione di entrambi ci fornirà un bel po di elasticità nel modo in cui questi oggetti vengono rappresentati in stringa in diversi scenari.

def __str__(self):
    pass
    
def __repr(self):
    pass

L'obbiettivo di __str__ è quello di fornire una rappresentazione in stringa dell'oggetto che sia leggibile e semplice. Dovrebbe restituire una rappresentazione che sia facilmente interpretabile anche dai non tecnici, dagli utilizzatori finali. Chi ha seguito uno dei miei corsi sul Web Framework Django, avrà notato che utilizziamo __str__ proprio per fornire una rappresentazione in stringa leggibile dei nostri Modelli.

L'obbiettivo di __repr__ è invece quello di essere esaustivo e non ambiguo. Dovrebbe inoltre preferibilmente fornirci la possibilità di ricreare l'oggetto a partire dalla stringa che restituisce, ed è orientato agli sviluppatori. Vediamo:

class Studente:
    """ Una semplice classe Studente """

    def __init__(self, nome, cognome, corso_di_studi):
        self.nome = nome
        self.cognome = cognome
        self.corso_di_studi = corso_di_studi


    def scheda_personale(self):
        return f"""
            Scheda Studente \n
            Nome: { self.nome }
            Cognome: { self.cognome }
            Corso Di Studi: { self.corso_di_studi }
            """

    def __str__(self):
        """Rappresentazione Leggibile - Per il Pubblico."""
        return f"Lo Studente { self.nome } { self.cognome }"

    def __repr__(self):
        """Rappresentazione Non Ambigua - Per Sviluppatori."""
        return f"Studente('{ self.nome }', '{ self.cognome }', '{ self.corso_di_studi }')"
studente_uno = Studente("Peter", "Malkovich", "Psicologia")
    
# Rappresentazione in stringa Comoda e Rapida
>>> print(studente_uno) 
'Lo Studente Peter Malkovich'

# Rappresentazione User Friendly
>>> print(str(studente_uno))
'Lo Studente Peter Malkovich'

# Rappresentazione non ambigua, per Sviluppatori
# Potremo utilizzarla per ricreare un oggetto di tipo Studente con le stesse caratteristiche
>>> print(repr(studente_uno))
'Studente('Peter', 'Malkovich', 'Psicologia')'

Tenete a mente che chiamarle in questa maniera corrisponde a chiamarle esplicitamente, come nel caso degli altri dunder, in questo modo:

print(Studente.__str__(studente_uno))
print(Studente.__repr__(studente_uno))

print(studente_uno.__str__())
print(studente_uno.__repr__())

La rappresentazione in stringa andrebbe integrata in tutte le nostre classi. Se siete indecisi o troppo pigri per definire sia __str__ che __repr__, fate forse bene a scegliere , in quanto ad esempio print(), se nota che non avete definito __str__, utilizzerà __repr__ come sostituta:

# Avendo commentato Studente.__str__()
>>> print(studente_uno)
'Studente('Peter', 'Malkovich', 'Psicologia')'

Spero di essere riuscito a darvi un'idea abbastanza chiara di cosa sono i dunder methods e di come possono tornarci utili per rendere le nostre classi più potenti e il nostro codice più Pythonico. Chiaramente questo tutorial era semplicemente introduttivo al concetto, e mi interessava fare in modo che quantomeno conosceste l'argomento!

Vi invito sicuramente a visitare la documentazione ufficiale riguardo al Python Data Model in cui trovate anche un bell'elenco dei vari metodi speciali e del loro scopo, per cui vi lascerò un link, anche perché l'argomento è ben più vasto e sopratutto ramificato di così.

Su python.it dovreste inoltre trovare la documentazione ufficiale tradotta al riguardo, quindi fateci un salto!

Io personalmente ne parlerò in maniera più approfondita in alcuni tutorial dedicati più avanzati, in un'altra serie.

Questo era tutto per questa lezione! Come al solito Happy Coding!
- Michele




Menu della Serie