I Decoratori

I decoratori (in inglese decorator) sono uno strumento che ci consente di estendere e modificare il comportamento di funzioni e classi senza doverne alterare direttamente il codice sorgente.

Nella serie sulla programmazione a oggetti abbiamo utilizzato i decoratori @staticmethod() e @classmethod(), che consentono ai nostri metodi di trascendere quello che è il loro comportamento tradizionale, rendendolo più appropriato in certi determinati contesti.

Uno degli ambienti in cui mi trovo a lavorare spesso con I decoratori in Python è quello dei web framework: Django ad esempio fa un uso fantastico di questo strumento in molti di quei casi in cui le nostre funzioni hanno bisogno di alcune funzionalità extra, che sono tuttavia abbastanza generiche e quindi utilizzabili su più funzioni. Il decoratore @login_required() ad esempio, è uno di questi.

I decoratori sono molto potenti quindi, anche per il fatto che una volta definiti, possono essere utilizzati su più funzioni, rispettando in questa maniera il precetto DRY, Don’t Repeat Yourself, che tradotto significa: evita di scrivere lo stesso codice più volte di quanto non sia strettamente necessario.


Cosa sono i decoratori?

Un decoratore è una funzione che prende come parametro un’altra funzione, aggiunge delle funzionalità e restituisce un’altra funzione senza alterare il codice sorgente della funzione passata come parametro.

Questo è possibile per il fatto che in Python le funzioni sono first class object, il che significa che possono essere passate come parametro e restituite come qualsiasi altro valore, possono venire definite all’interno di altre funzioni (nel qual caso si parla di funzioni annidate) e assegnate a delle variabili.


Come creare ed utilizzare i decoratori

Creiamo una nuova funzione che accetta un'altra funzione come parametro per aggiungere alcune funzionalità, che in questo caso consistono nel mandare in print delle stringhe:

def funzione_decoratore(funzione_parametro): 
    def wrapper(): 
        """ nome convenzionale - wrapper significa 'incarto, confezione' """
        print("... codice da eseguire prima di 'funzione_parametro' ...") 					
        funzione_parametro()
        print("... codice da eseguire dopo di 'funzione_parametro' ...") 
    return wrapper

def mia_funzione(): 
    print("Hello World!")


mia_funzione = funzione_decoratore(mia_funzione) 
mia_funzione()

# output:
... codice da eseguire prima di funzione_parametro ...
Hello World!
... codice da eseguire dopo di funzione_parametro ...

Il messaggio Hello World! viene stampato a schermo "incartato" in mezzo a queste due nuove stringhe: per questo motivo per convenzione ci si riferisce alla funzione interna al decoratore come wrapper (che significa involucro).

Per quanto questa forma sia molto utile per spiegare il modo in cui un decoratore è strutturato, la sintassi tipica con cui vengono utilizzati I decoratori in Python è invece la seguente:

@funzione_decoratore
def mia_funzione(): 
    print("Hello World!") 
mia_funzione()


# output:
... codice da eseguire prima di funzione_parametro ...
hello world!
... codice da eseguire dopo di funzione_parametro ...

Quindi ciò che stiamo facendo coi decoratori, è sostanzialmente sostituire una funzione con un’altra: questo determina che alcuni dettagli riguardo a mia_funzione vadano persi. Facciamo un primo test senza utilizzare il decoratore:

def mia_funzione(): 
    print("Hello World!") 
    
>>> print(mia_funzione.__name__)

# output
mia_funzione

Otteniamo in output il nome della nostra funzione, ma facciamo ora un secondo test, decorando la funzione con @funzione_decoratore:

@funzione_decoratore
def mia_funzione(): 
    print("Hello World!") 

>>> print(mia_funzione.__name__)

# output
wrapper

Così facendo abbiamo perso delle informazioni che potrebber essere utili, ad esempio in fase di debugging. Per ovviare al problema e salvaguardare queste informazioni (chiamate in altri contesti metadata), possiamo utilizzare un decoratore apposito incluso con la standard library, il decoratore wraps, in questo modo:

from functools import wraps
    
def funzione_decoratore(funzione_parametro): 
    @wraps(funzione_parametro)
    def wrapper(): 
        """ nome convenzionale - wrapper significa 'incarto, confezione' """
        print("... codice da eseguire prima di 'funzione_parametro' ...") 					
        funzione_parametro()
        print("... codice da eseguire dopo di 'funzione_parametro' ...") 
    return wrapper 

@funzione_decoratore
def mia_funzione(): 
    print("Hello World!") 

>>> print(mia_funzione.__name__)

# output
mia_funzione


Come modificare il comportamento di una funzione tramite i decoratori

Come abbiamo detto all’inizio, oltre ad estendere possiamo anche modificare il comportamento delle nostre funzioni. Facciamo ora un esempio in cui vogliamo un decoratore @caps_lock, ovvero un decoratore che trasformi l’output delle nostre funzioni in output a lettere maiuscole:

def caps_lock(funzione_parametro):
    @wraps(funzione_parametro)
    def wrapper():
        """ wrapper significa 'incarto, confezione' """
        return funzione_parametro().upper()
    return wrapper
    
    
@caps_lock
def mia_funzione():
    return "hello world!"
    

>>> print(mia_funzione())

# output
HELLO WORLD!


Come decorare funzioni che accettano parametri

Vediamo ora come sia possibile decorare anche funzioni che accettano parametri, mediante l’utilizzo di *args e **kwargs. Per chi non avesse familiarità coi due, si tratta semplicemente di un modo che ci consente di passare un numero indefinito di parametri posizionali args, e parametri chiave-valore, quindi Key-Word, kwargs.

Teniamo inoltre a mente che una funzione annidata all’interno di un’altra funzione può ricordare lo scope (cioè l’ambito) della funzione in cui è definita. Ci riferiamo a queste come closure.

def caps_lock(funzione_parametro):
    @wraps(funzione_parametro)
    def wrapper(*args, **kwargs):
        """ wrapper significa 'incarto, confezione' """
        return funzione_parametro(*args, **kwargs).upper()
    return wrapper

    
@caps_lock
def echo(msg):
    return msg


>>> print(echo("I decoratori sono fantastici, e molto potenti!"))

# output
I DECORATORI SONO FANTASTICI, E MOLTO POTENTI!


Come concatenare più decoratori

Un’altra cosa da sapere riguardo ai decoratori, è che possiamo concatenare anche più di uno nella stessa funzione. Aggiungiamo un altro decoratore, @spam:

def spam(funzione_parametro):
    @wraps(funzione_parametro)
    def wrapper(*args, **kwargs):
        print("SPAM!")
        return funzione_parametro(*args, **kwargs)
    return wrapper

Ora per concatenarli ci basta fare:

@spam
@caps_lock
def echo(msg):
    return msg

>>> print(echo("eggs & bacon!"))

# output
SPAM!
EGGS & BACON!


Come creare decoratori a partire da classi

Infine, seppur forse non si tratta della tecnica più usata, è possibile creare decoratori anche a partire da classi. Ad esempio, questa è una possibile versione del decoratore @caps_lock, fatta con una classe:

class Caps_lock:
    
    def __init__(self, funzione_parametro):
        self.funzione_parametro = funzione_parametro

    def __call__(self, *args, **kwargs):
        return self.funzione_parametro(*args, **kwargs).upper()