6. I Dunder Methods (Metodi Speciali)

In questa lezione parliamo di Metodi Speciali, un insieme di metodi predefiniti che possiamo usare per arricchire il potenziale delle nostre classi. Sono facili da riconoscere: 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, (in italiano metodi dunder), dove d-under sta per double under_score (in italiano doppio trattino basso). Abbiamo già incontrato il metodo dunder init (__init__), che è il metodo che utilizziamo per creare oggetti a partire dalle nostre classi.


Il metodo dunder __getitem__

I metodi dunder non vengono chiamati direttamente da noi, ma è Python a chiamarli in determinate circostanze. Ad 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]

# output
'd'

Questo è proprio uno di quei casi in cui Python chiama per noi uno di questi metodi speciali, __getitem__, da leggersi dunder getitem. Possiamo infatti richiamare i valori tramite __getitem__ in maniera esplicita in questo modo:

alfabeto.__getitem__(1)

# output
'a'
        
alfabeto.__getitem__(3)

# output
'c'

Proviamo ora a richiamare la funzione help() e passarle proprio la nostra variabile alfabeto. Otteniamo una lista di metodi e attributi associati a questo tipo di oggetto, e nello specifico possiamo soffermarci su:

help(alfabeto)

    Help on dict object:
    class dict(object)
    # ...

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

Ci sono quindi due modi in cui è possibile ottenere il valore associato a una chiave: usando i metodi dunder in maniera "esplicita" o "implicita".


Il metodo dunder __add__

In Python, i dunder method 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

# output
10
        
"Veni, Vidi, " + "Vici"

# output
'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)

# output
15
        
# chiamandolo sulla classe "str", per concatenazioni di stringhe:
str.__add__("Veni, Vidi, ", "Vici")

# output
'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. Possiamo quindi implementare __add__ anche nella nostra classe Studente e fargli concatenare il nome e il cognome di due studenti diversi, anche se questo esempio rappresenta un 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)

# output
'John Malkovich'


I metodi dunder __str__ e __repr__

Facciamo invece un esempio che è sicuramente tra i più frequentemente utilizzati nelle casistiche reali:

frase = "test su stringa"
print(frase)

# output
'test su stringa' 
        

x = 5
print(x)

# output
5

Possiamo passare la variabile x alla funzione str():

str(x)

# 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)
# output
<__main__.Studente object at 0x7f7b21f314a8>

print(str(studente_uno))
# output
<__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) 

# output
'Lo Studente Peter Malkovich'

# Rappresentazione User Friendly
>>> print(str(studente_uno))

# output
'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))

# output
'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)

# output
'Studente('Peter', 'Malkovich', 'Psicologia')'

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