Bien comprendre les décorateurs en Python

Vous les avez sans doute déjà vus quelque part, vous les avez peut-être même déjà utilisés sans trop savoir ce que c’est, ni comment cela fonctionne : je parle ici des décorateurs. Il s’agit de ces « mots-clés » préfixés par un @ qui précèdent certaines fonctions ou méthodes en Python.

Je vais tenter dans cet article d’expliquer le fonctionnement des décorateurs. Nous verrons ensuite comment créer nos propres décorateurs, et les possibilités qui nous sont offertes par cette fonctionnalité.

Quelques exemples pour démarrer

Si vous êtes habitué à la programmation orientée objet en Python (ce qui un plus pour la compréhension de cet article, mais pas indispensable), alors vous avez déjà certainement utilisé l’un des décorateurs suivants : @classmethod ou @staticmethod, permettant respectivement de déclarer une méthode de classe et une méthode statique (je ne détaillerai pas la différence entre les deux).

Ces décorateurs sont fournis par le langage, et s’utilisent comment ceci :

class MaJolieClasse:

    @classmethod
    def ma_methode_de_classe(cls):
        print("Je suis une méthode de la classe", cls.__name__)

    @staticmethod
    def ma_methode_statique():
        print("Je suis une méthode statique")

Il en existe d’autres bien entendu, et pour mieux comprendre comment ils fonctionnent, l’idéal serait de créer notre propre décorateur.

Les décorateurs décryptés

Je vais lever très rapidement le voile sur les décorateurs : il ne s’agit ni plus ni moins que de simples fonctions.

Cela signifie que lorsque vous voyez ceci dans un programme …

@mon_decorateur
def ma_fonction():
    pass

… quelque part dans le code il y a quelque chose qui ressemble à cela (pas exactement mais on y vient) :

def mon_decorateur(...):
    ...

Pour être plus précis, un décorateur est une fonction qui se doit de respecter deux critères pour être considéré comme tel :

  • la fonction doit prendre une fonction en paramètre ;
  • la fonction doit retourner une fonction.

Ok, dit comme ça cela peut paraître un peu bizarre. Mais nous allons détailler cela.

Pour reprendre l’exemple précédent, la fonction que mon_decorateur va prendre en paramètre ici est ma_fonction. Et celle qu’il va retourner est la fonction qui va remplacer ma_fonction.

Prenez le temps de lire et relire cela tranquillement.

On y vient donc …

Un décorateur permet de remplacer une fonction.

Appliquer un décorateur à une fonction, cela revient donc à faire cela :

def ma_fonction():
    pass

ma_fonction = mon_decorateur(ma_fonction)

Cette écriture équivaut à celle-ci, qui est un sucre syntaxique :

@mon_decorateur
def ma_fonction():
    pass

Les fonctions : des objets comme les autres

Pour mieux comprendre ce que nous avons fait jusqu’ici, il est important de poser une base importante : les fonctions sont des objets. Et quand je dis objet, j’entends par là programmation orientée objet bien sûr.

Si j’affirme qu’il n’y a aucune différence structurelle entre une list, un str, une MaJolieClasse, un int et une fonction en Python ai-je tort ? Pas vraiment. Vous le savez peut-être, mais en Python, absolument tout est objet. Et les fonctions n’y échappent pas : ce sont des instances d’un type function.

Revenons à nos moutons. Dans le point précédent, nous avions défini qu’un décorateur devait prendre une fonction en paramètre. Ça, c’est la partie « facile » des décorateurs : étant donné que les paramètres ne sont pas typés en Python, il suffit simplement de déclarer un paramètre au décorateur, que l’on nommerait fonction par exemple. Ou bien ancienne_fonction, je pense que c’est mieux. Oui, on va l’appeler comme ça : ancienne_fonction. On reste là-dessus.

def mon_decorateur(ancienne_fonction):
    ...

On a déjà coché la première case ✅

À présent, mon_decorateur doit retourner une fonction. Là, ça se complique un petit chouïa. Mais pas de panique. Rappelez-vous : les fonctions ne sont rien de plus que des objets. Donc au lieu de retourner un int ou une list, on va retourner une fonction.

On pourrait retourner une lambda :

def mon_decorateur(ancienne_fonction):
    return lambda x: x ** 2

Et là on coche toutes les cases. Mais cet exemple n’a pas vraiment de sens. L’idéal serait d’avoir une vraie fonction, qui fasse plus d’une ligne.

De plus, le but d’un décorateur n’est pas vraiment de remplacer la fonction décorée, mais plutôt de la modifier. De ce fait, la fonction qui sera retournée par le décorateur, et qui vise à remplacer la fonction décorée (pour rappel, ancienne_fonction), devrait se comporter au moins de la même manière que la fonction qu’elle remplace, plus lui ajouter des comportements supplémentaires : d’où la notion de décoration. On garde la base, et on lui ajoute des fonctionnalités supplémentaires.

Pour ce faire, nous allons réaliser une opération peu habituelle : déclarer une fonction dans une fonction. Oui, en Python on peut. Étant donné qu’elle remplacera l’ancienne fonction, on l’appellera nouvelle_fonction.

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction():
        ...
    return nouvelle_fonction

On n’y est pas encore mais on s’en rapproche. On déclare nouvelle_fonction dans mon_decorateur : remarquez bien le niveau d’indentation. On s’occupera du contenu de nouvelle_fonction plus tard, mais j’aimerais que l’on se concentre sur la dernière instruction.

C’est là que ça devient intéressant : mon_decorateur retourne l’objet nouvelle_fonction. Pas le retour de nouvelle_fonction, non. La nouvelle_fonction en tant qu’objet de type function. Remarquez bien qu’il n’y a pas de parenthèse après nouvelle_fonction, car on ne souhaite pas retourner le résultat de son exécution, on ne souhaite même pas l’exécuter tout court (pas maintenant du moins).

Pour rappel, comme on l’a vu dans le point précédent, voici ce qui va se passer ensuite :

@mon_decorateur
def ma_fonction():
    print("Je suis une fonction sympa")

# Équivaut à :
# ma_fonction = mon_decorateur(ma_fonction)

On affecte à ma_fonction le retour de mon_decorateur. Donc, en d’autres termes, l’objet fonction nouvelle_fonction. Donc, ma_fonction va se comporter désormais exactement comme nouvelle_fonction, et perdre son comportement initial. Il nous suffit de faire un sorte que nouvelle_fonction exécute ancienne_fonction afin de conserver le comportement initial. Donc dans cet exemple, le comportement de ma_fonction. Vous suivez ?

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction():
        return ancienne_fonction()
    return nouvelle_fonction

Et voilà. Pour le moment, on a un décorateur qui n’applique aucune modification, mais ne pas avoir de régression est déjà une réussite en soi. Désormais, si on exécute ma_fonction (pour rappel, qui est la fonction décorée), il ne se passera rien de plus (ni de moins) que si on ne l’avait pas décorée.

Prendre en compte tous les scénarios

L’idée d’un décorateur, sauf cas particulier, est d’être le plus générique possible. Créer un décorateur qui sera appliqué à une ou deux fonctions tout au plus n’a aucun intérêt : les décorateurs sont utilisés dans un but de factorisation de code (mais pas que), et non pas pour le complexifier. Nous verrons à la fin de l’article quelques cas d’utilisations concrets de décorateurs, mais de manière générale, ils s’avèrent pratiques afin d’éviter des redondances de code dans plusieurs fonctions.

Reprenons l’exemple précédent. Que se passe-t-il si l’on applique mon_decorateur à une_autre_fonction, qui attend deux paramètres ?

@mon_decorateur
def ma_fonction():
    print("Je suis une fonction sympa")


@mon_decorateur
def une_autre_fonction(a, b):
    return a ** b


une_autre_fonction(5, 3)

Nous avons une erreur lorsqu’on appelle une_autre_fonction :

C’est normal : le comportement de une_autre_fonction a été remplacé par celui de nouvelle_fonction. Et nouvelle_fonction attend 0 paramètre. D’où l’erreur. L’astuce pourrait être de faire en sorte que nouvelle_fonction attende 2 paramètres, mais le problème surviendrait alors lorsqu’on appelle ma_fonction.

Le décorateur devant être générique, nous allons faire en sorte que nouvelle_fonction puisse être appelée avec un nombre indéterminé d’arguments :

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args):
        return ancienne_fonction(*args)
    return nouvelle_fonction


@mon_decorateur
def ma_fonction():
    print("Je suis une fonction sympa")


@mon_decorateur
def une_autre_fonction(a, b):
    return a ** b


ma_fonction()
une_autre_fonction(5, 3)

Voilà qui est mieux.

La nouvelle_fonction peut recevoir n’importe quel nombre d’arguments, puis les transmet en entrée de ancienne_fonction. Ici, on indique ancienne_fonction(*args), en précisant bien l’astérisque devant *args, car on ne souhaite pas transmettre les arguments sous la forme d’un tuple, mais en tant qu’arguments individuels. Je ne rentrerai pas plus que ça dans le détail sur ce point.

Si on souhaite vraiment couvrir tous les cas de figure, il faudrait également gérer le cas des paramètres nommés :

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args, **kwargs):
        return ancienne_fonction(*args, **kwargs)
    return nouvelle_fonction


@mon_decorateur
def ma_fonction():
    print("Je suis une fonction sympa")


@mon_decorateur
def une_autre_fonction(a, b):
    return a ** b


ma_fonction()
une_autre_fonction(b=3, a=5)

Et maintenant, on fait quoi ?

À présent, le champ des possibles s’ouvre devant nous. Commençons par quelque chose de simple : faire un print des arguments de la fonction décorée avant de l’exécuter.

Et voilà :

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args, **kwargs):
        print(f"Arguments : {args} {kwargs}")
        return ancienne_fonction(*args, **kwargs)
    return nouvelle_fonction

On peut faire de même avec le retour de la fonction. Cependant, étant donné qu’on ne peut rien écrire après un return, il faudra stocker le résultat dans une variable avant de le retourner.

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args, **kwargs):
        print(f"Arguments : {args} {kwargs}")
        resultat = ancienne_fonction(*args, **kwargs)
        print(f"Résultat : {resultat}")
        return resultat
    return nouvelle_fonction

L’avantage du décorateur, en tant que fonction englobante, est qu’il a accès à toutes les informations à la fois de ancienne_fonction, et à la fois de nouvelle_fonction.

On peut par exemple afficher le nom de la fonction dans les print précédents :

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args, **kwargs):
        print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
        resultat = ancienne_fonction(*args, **kwargs)
        print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
        return resultat
    return nouvelle_fonction

Si on appelle ma_fonction et une_autre_fonction, on aurait un résultat comme celui-ci :

Il est important de préciser que les instructions que nous avons ajouté seront exécutées lors de l’appel aux fonctions décorées (donc en l’occurence ici, ma_fonction et une_autre_fonction). Il y aura autant de print des arguments de une_autre_fonction que d’appels à une_autre_fonction dans le programme.

Que se passe-t-il à présent si l’on déclare des instructions en dehors de nouvelle_fonction (directement dans mon_decorateur) ?

def mon_decorateur(ancienne_fonction):
    print("Une instruction déclarée dans mon_decorateur")
    def nouvelle_fonction(*args, **kwargs):
        print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
        resultat = ancienne_fonction(*args, **kwargs)
        print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
        return resultat
    return nouvelle_fonction

Cette instruction-là serait exécutée lors de l’appel à mon_decorateur. Et à quel moment mon_decorateur est appelé ? Tout simplement à la déclaration de ma_fonction et une_autre_fonction, c’est-à-dire lorsqu’on applique ce décorateur à ces fonctions.

# Le décorateur est exécuté ici
@mon_decorateur
def ma_fonction():
    print("Je suis une fonction sympa")


# Le décorateur est exécuté ici
@mon_decorateur
def une_autre_fonction(a, b):
    return a ** b

On pourrait écrire le print après la déclaration de nouvelle_fonction, ça revient quasiment au même :

def mon_decorateur(ancienne_fonction):
    def nouvelle_fonction(*args, **kwargs):
        print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
        resultat = ancienne_fonction(*args, **kwargs)
        print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
        return resultat
    print("Une instruction déclarée dans mon_decorateur")
    return nouvelle_fonction

Ajouter une couche dans le sandwich

On va encore un peu plus pousser le truc. Si vous êtes vraiment habitué à Python, vous avez alors peut-être déjà vu quelque chose comme cela :

@mon_decorateur(10)
def ma_fonction():
    print("Je suis une fonction sympa")

Un décorateur qui prend un ou plusieurs arguments. Cela peut être des int, des str, ou même si on veut aller encore plus loin … une autre fonction (on verra un exemple dans le point suivant).

Avant de mettre cela en place, voyons à quoi ça correspond. L’exemple précédent équivaut à celui-ci :

def ma_fonction():
    print("Je suis une fonction sympa")

ma_fonction = mon_decorateur(10)(ma_fonction)

Je conçois que ce n’est pas une écriture très habituelle. Mais analysons cela un peu plus en détail. mon_decorateur est une fonction qui attend (a priori) un int en paramètre, et retourne une fonction. La fonction retournée attend une fonction en paramètre, et retourne également une fonction. C’est cette fonction retournée que l’on affecte à ma_fonction.

Si on décompose cette instruction en deux instructions, peut-être que ce sera un peu plus lisible :

def ma_fonction():
    print("Je suis une fonction sympa")

traitement = mon_decorateur(10)
ma_fonction = traitement(ma_fonction)

Ce que l’on peut souligner ici, c’est que dans ce scénario, le décorateur n’est pas mon_decorateur … mais bien traitement. En effet, c’est cette fonction qui prend une fonction en paramètre et retourne une fonction. Afin d’éviter la confusion, je vais renommer mon_decorateur en afficher_args, cela sera un peu plus explicite.

Désormais, ce que l’on va devoir déclarer, c’est une fonction (afficher_args) qui attend un booléen en paramètre (il déterminera si l’on doit afficher ou non le résultat). afficher_args va déclarer une fonction qui est un décorateur (on va l’appeler traitement_deco). traitement_deco aura le comportement classique du décorateur comme vu précédemment (prendre une fonction en paramètre, déclarer une nouvelle fonction et retourner cette nouvelle fonction). Et enfin, afficher_args va retourner traitement_deco. D’où la couche supplémentaire dans le « sandwich », qui va prendre tout son sens à travers cet exemple :

def afficher_args(print_result):
    def traitement_deco(ancienne_fonction):
        @wraps(ancienne_fonctions)
        def nouvelle_fonction(*args, **kwargs):
            print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
            resultat = ancienne_fonction(*args, **kwargs)
            if print_result:
                print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
            return resultat
        return nouvelle_fonction
    return traitement_deco


@afficher_args(False)
def ma_fonction():
    print("Je suis une fonction sympa")


@afficher_args(True)
def une_autre_fonction(a, b):
    return a ** b

Gardez simplement en tête que désormais, le décorateur n’est pas afficher_args, mais le retour de afficher_args.

Emballer le sandwich

Nous avions attaché une grande importance au fait que notre décorateur soit le plus générique possible, et n’entraîne pas de régression. Pour autant, nous avons laissé un petit trou dans la raquette. Rien de dramatique, mais autant faire les choses bien jusqu’au bout.

Actuellement, si on tente d’afficher le nom des fonctions décorées dans la console, on aurait quelque chose comme cela :

print(ma_fonction.__name__)
print(une_autre_fonction.__name__)

C’est vraiment un détail, mais ce qu’il faut comprendre, c’est que non seulement le comportement des fonctions décorées a été remplacé par celui de nouvelle_fonction, mais également le nom (ainsi que tous les autres attributs spéciaux, que je ne détaillerai pas).

Deux possibilités pour corriger cela :

  • soit le faire manuellement, mais cela nécessite de connaître tous les attributs spéciaux de la fonction ;
  • soit utiliser … un décorateur.

Alors rassurez-vous, ce décorateur est déjà fourni par Python, il faut simplement l’importer depuis le module functools.

from functools import wraps


def afficher_args(print_result):
    def traitement_deco(ancienne_fonction):
        @wraps(ancienne_fonctions)
        def nouvelle_fonction(*args, **kwargs):
            print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
            resultat = ancienne_fonction(*args, **kwargs)
            if print_result:
                print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
            return resultat
        return nouvelle_fonction
    return traitement_deco

Le décorateur wraps (qui, si vous avez bien suivi, n’en est pas vraiment un), copie simplement tous les attributs spéciaux de ancienne_fonction dans nouvelle_fonction. Ainsi on conserve l’emballage d’origine, notamment le nom des fonctions décorées.

Quelques idées d’utilisation

On a fait le tour. Les décorateurs respectent globalement toujours la même structure : il s’agit d’un design pattern, une solution éprouvée à un problème donné.

À l’aide des décorateurs, vous pouvez mettre en place, par exemple :

  • un système de logs, à l’aide du module logging ;
  • compter le nombre d’appels effectués sur des fonctions ;
  • calculer le temps d’exécution de certaines fonctions, à l’aide du module time ;
  • mettre en place un type strict ;
  • et bien plus encore.

L’important est que les décorateurs apportent une solution, et ne soient pas une partie du problème.

Aller plus loin

Je n’ai pas abordé tous les aspects des décorateurs dans cet article. Il vous faudra pas mal d’entraînement et d’imagination pour en maîtriser tous les rouages.

Les décorateurs étant des fonctions englobantes, on aurait pu également parler de la notion de closure, permettant d’étendre les possibilités offertes par les décorateurs, en faisant conserver des états aux fonctions décorées.

On aurait pu également évoquer les annotations (type hints), ou bien encore l’utilisation conjointe de décorateurs et de context managers.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *