logo le blog invivoo blanc

Les décorateurs Python démystifiés

11 mars 2021 | Design & Code, Python | 0 comments

Les décorateurs en Python sont un de ces outils que tout développeur Python croise dans sa carrière, mais qu’on peut se passer de vraiment comprendre pendant longtemps tout en parvenant à écrire des programmes assez complexes qui répondent parfaitement aux besoins des utilisateurs. Cela est dû au fait que ce design pattern est nativement intégré au langage, à travers un opérateur qui permet de le reconnaître au premier coup d’œil : le symbole « @ ».

Même en-dehors de tout module standard ou non, on finit par rencontrer tôt ou tard (pour peu qu’on fasse un peu de programmation objet) un des 3 décorateurs built-in :

  • @classmethod
  • @staticmethod
  • @property

On en rencontre plus ou moins selon le secteur dans lequel on évolue. Par exemple, quand on fait du scripting, même avancé, ou du calcul, on en croise plutôt rarement. Par contre dans le web c’est monnaie courante, car beaucoup de frameworks offrent des fonctionnalités sous cette forme. Les décorateurs sont aussi un sujet régulièrement abordé dans les entretiens d’embauche, et j’ai personnellement pu constater que le sujet est la plupart du temps méconnu chez les développeurs juniors et même confirmés. D’où ce billet de blog.

I. Présentation

Un décorateur sert à modifier le comportement d’une fonction. Prenons un exemple simple : on souhaite mesurer et afficher le temps passé dans une fonction. Notre fonction à décorer sera une simple multiplication de deux valeurs :

def multiply(x, y):
    return x * y

Supposons qu’un décorateur qui répond exactement à notre demande existe déjà et soit nommé measure_time, alors notre décoration s’écrira :

@measure_time
def multiply(x, y):
    return x * y

Et l’exécution donnerait par exemple, selon le message défini dans measure_time :

>>>> a = multiply(2, 3)
Time spent in multiply: 0.000 ms

Effectivement, une multiplication aussi simple se fait de manière quasiment instantanée. Si nous prenons un cas bien plus costaud, nous pouvons observer un temps non négligeable :

>>>> a = multiply(list(range(100)), 10**6)
Time spent in multiply: 234.318 ms

L’intérêt de ce décorateur est qu’il est réutilisable pour toutes les fonctions que comporterait notre programme. Supposons que nous définissions plus loin :

>>>> @measure_time
. . .     def my_complex_function_that_takes_so_long(data, *, algo):
. . .           sleep(10)
. . .           return 42

Alors nous aurons à l’exécution :

>>>> my_complex_function_that_takes_so_long(list(range(1000)), algo="RK4")
Time spent in my_complex_function_that_takes_so_long: 10013.261 ms
42

Ce n’est qu’un exemple, et beaucoup de bibliothèques proposent de rajouter ainsi certaines de leurs fonctionnalités autour de nos fonctions. Voyons maintenant comment cela fonctionne.

II. Explication

Le mécanisme est en réalité assez simple. Tout d’abord, il faut savoir que les fonctions pour les développements en Python sont des « objets de première classe », c’est-à-dire des objets comme les autres, manipulables comme des entiers, des chaînes de caractères, des listes ou tout objet de notre crû créé via une de nos classes. C’est bien connu, en Python tout est objet. Y compris les fonctions. Il est donc notamment tout à fait possible qu’une fonction retourne une autre fonction, et qu’on définisse des fonctions quand on se trouve déjà à l’intérieur d’une autre fonction. Illustration :

>>>> def f1():
. . .         print("Je suis dans f1")
. . .         def f2():
. . .             print("Je suis dans f2")
. . .         return f2
. . .
>>>> ma_fonction = f1()
Je suis dans f1
>>>> ma_fonction
<function f1.<locals>.f2 at 0x00000279E7EF7EE0>
>>>> ma_fonction.__name__
'f2'
>>>> ma_fonction()
Je suis dans f2

La fonction f1 retourne une fonction enregistrée dans la variable ma_fonction, dont on voit bien après qu’on peut la manipuler comme une fonction, c’est-à-dire ici accéder à son nom (__name__) et l’appeler avec l’opérateur ().

Maintenant, puisqu’une fonction est un objet comme un autre, je peux également la passer comme argument à une autre fonction. J’ai donc la liberté de modifier le code précédent comme ceci :

>>>> def f3():
. . .           print("Je suis dans f3.")
. . .
>>>> def f1(func_arg):
. . .           print("Je suis au début de f1.")
. . .           print(f"J’ai reçu comme argument func_arg la fonction {func_arg.__name__}.")
. . .           def f2():
. . .                 print("Je suis au début de f2.")
. . .                 result = func_arg()
. . .                 print("Je suis à la fin de f2.")
. . .                 return result
. . .           print("Je suis à la fin de f1.")
. . .           return f2
. . .
>>>> ma_fonction = f1(f3)
Je suis au début de f1.
J’ai reçu comme argument func_arg la fonction f3.
Je suis à la fin de f1.
>>>> ma_fonction
<function f1.<locals>.f2 at 0x000001C51DD0E430>
>>>> ma_fonction.__name__
'f2'
>>>> ma_fonction()
Je suis au début de f2.
Je suis dans f3.
Je suis à la fin de f2.

Dans ce code, je choisis de faire de f1 une fonction qui exige un argument, et je choisis également que cet argument doit être une autre fonction qui ne demande aucun argument, puisque c’est ainsi que je l’utilise quand j’écris func_arg(). Si je ne respecte pas ce contrat, ce dernier appel renverra tout simplement une erreur, comme à chaque fois qu’on appelle mal une fonction.

Quel sens a ce code ? J’ai défini la fonction f1 comme un moyen de construire une fonction à partir d’une autre, les deux ayant exactement la même signature : f2 comme f3 ne demandent aucun argument et f2 renvoie exactement le résultat de l’appel à f3 (même si dans ce cas précis, ce résultat est un None implicite). Autrement dit, la fonction construite par f1 se manipule exactement comme la fonction qu’elle a reçue en entrée. On peut aussi voir les choses ainsi : j’ai construit une version modifiée de la fonction de départ. La décoration consiste essentiellement en l’ajout de fonctionnalité(s) ou une légère modification du comportement global, on peut également dire que la fonction f1 renvoie une fonction f3 décorée (qui est f2), les fonctionnalités supplémentaires se résumant ici à des affichages via print. À ce stade, la fonction f1 est donc déjà ce qu’on appelle un décorateur.

Il ne nous reste plus qu’une dernière étape pour faire le lien avec la notation « @ » : la substitution. Cela consiste tout simplement à réutiliser la variable f3, qui contient au départ la fonction du même nom, pour lui assigner sa version décorée par f1 (c’est-à-dire f2) en écrivant :

>>>> f3 = f1(f3)
Je suis au début de f1.
J’ai reçu comme argument func_arg la fonction f3.
Je suis à la fin de f1.
>>>> f3
<function f1.<locals>.f2 at 0x0000018B834CE3A0>
>>>> f3()
Je suis au début de f2.
Je suis dans f3.
Je suis à la fin de f2.

Désormais, la fonction f3 originale n’est plus accessible. Rassurez-vous, c’est bien le but. L’objectif d’un décorateur Python est d’ajouter des fonctionnalités à une fonction existante sans modifier la façon dont celle-ci sera appelée par la suite. C’est cette étape que réalise le symbole « @ » :

>>>> @f1
. . .     def f3():
. . .           print("Je suis dans f3.")
. . .
Je suis au début de f1.
J’ai reçu comme argument func_arg la fonction f3.
Je suis à la fin de f1.
>>>> f3
<function f1.<locals>.f2 at 0x0000018B834CE5E0>
>>>> f3()
Je suis au début de f2.
Je suis dans f3.
Je suis à la fin de f2.

Il applique le décorateur f1 à la fonction définie juste en-dessous, puis substitue, dans la variable f3 qui suit le mot-clé def, cette fonction d’origine par le résultat de sa décoration.

Bravo, vous savez maintenant comment écrire et utiliser vos propres décorateurs ! Cela vous permettra également de mieux comprendre ceux de certaines bibliothèques que vous utiliseriez et dont vous auriez besoin d’aller voir le code. Et si malgré tout vous avez besoin d’une aide d’experte, renseignez-vous sur notre page expertise Python et prenez contact avec nous !

Il reste cependant des choses intéressantes à dire sur le sujet.

III. Remarques importantes

A. La liberté de faire tout autre chose

Si vous découvrez cette mécanique et que vous n’avez pas d’expérience avec les design pattern, il se peut que vous soyez un peu dérouté. Je pense qu’il peut être important pour certains d’insister sur un fait : comme pour tout code, le design pattern décorateur repose sur une intention que vous avez la charge de mettre en œuvre. En effet, comme on l’a vu la seule partie « magique », gérée automatiquement par Python, à savoir le symbole « », ne fait finalement pas grand-chose. L’essentiel du travail est effectué par la fonction f1 dans laquelle nous pouvons techniquement faire tout ce que bon nous semble, pourvu qu’elle prenne pour seul argument une fonction.

Pour faire un décorateur, le rôle de cette fonction doit être de fabriquer une autre fonction, f2, qui soit suffisamment proche de f3 pour pouvoir se faire passer pour elle, avec la même signature et un comportement très similaire (on rajoute simplement deux print autour de la fonction originale). Mais techniquement, rien ne nous empêche d’utiliser l’opérateur @ en ne respectant pas tout à fait, voire pas du tout, ce contrat implicite qui permet de qualifier f1 de décorateur. Voici un exemple extrême où on décide de faire n’importe quoi :

>>>> def f1(func_arg):
. . .           print("Je suis au début de f1.")
. . .           print(f"J’ai reçu comme argument func_arg la fonction {func_arg.__name__}.")
. . .           def f2(x, y):
. . .                 """Calcul de l'aire d'un rectangle de côtés x et y."""
. . .                 return x * y
. . .           print("Je suis à la fin de f1.")
. . .           return f2
. . .
>>>> @f1
. . .     def f3():
. . .           print("Je suis dans f3.")
. . .
Je suis au début de f1.
J’ai reçu comme argument func_arg la fonction f3.
Je suis à la fin de f1.
>>>> f3()
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: f2() missing 2 required positional arguments: 'x' and 'y'
>>>> f3(3, 4)
12

Nous voyons encore mieux ici que l’effet de l’opérateur @ revient simplement à remplacer une fonction par une autre (en ayant appliqué au préalable une fabrique de fonctions, ici f1, à la fonction décorée pour créer sa remplaçante). Il nous permet ici de détourner complètement l’usage et le but de la fonction f3. Désormais f3 nécessite deux arguments et retourne le résultat d’un calcul géométrique.

Or nous avons présenté la notion de « décoration » comme un ajout de fonctionnalité(s) ou une légère modification du comportement global d’une fonction, mais pas comme un changement complet, un remplacement par quelque chose qui n’a rien à voir. C’est une qualification aux frontières assez floues et donc relativement subjective. Mais dans notre exemple extrême nous voyons bien que l’idée n’est pas du tout respectée, puisque l’argument func_arg qui contient la fonction à modifier n’est même pas utilisé. Par conséquent, cette dernière version de f1 n’est tout simplement pas un décorateur. C’est une bizarrerie, du code qui n’a a priori aucun sens.

B. Une imitation plus complète

Telle que nous l’avons écrite au début, la fonction f2 ressemble bel et bien beaucoup à f3. La ressemblance est toutefois perfectible, puisque la fonction décorée reste identifiable à f2 à travers certains attributs automatiques :

>>>> def f1(func_arg):
. . .           """Ceci est un décorateur."""
. . .           print(f"J’ai reçu comme argument func_arg la fonction {func_arg.__name__}.")
. . .           def f2():
. . .                 """Fonction remplaçante"""
. . .                 print("Je suis un log supplémentaire totalement inutile.")
. . .                 return func_arg()
. . .           print("Décoration terminée !")
. . .           return f2
. . .
>>>> @f1
. . .     def f3():
. . .           """Affichage d'un message."""
. . .           print("Je suis dans f3.")
. . .
J'ai reçu comme argument func_arg la fonction f3.
Décoration terminée !
>>>> f3()
Je suis un log supplémentaire totalement inutile.
Je suis dans f3.
>>>> f3.__name__
'f2'
>>>> f3.__doc__
'Fonction remplaçante'
>>> help(f3)
Help on function f2 in module __main__:

f2()
    Fonction remplaçante

Dans la pratique, ce n’est pas toujours gênant. Il arrive néanmoins que ces attributs soient utilisés par d’autres parties du programme. Typiquement, un système de chronométrage du temps passé dans certaines fonctions ou de profilage émettra des logs faisant référence à la fonction par son attribut __name__. Ou certains outils iront chercher les docstrings (attribut __doc__) pour diverses raisons. Pour pallier à ce problème, on pourrait réassigner chaque attribut à la main dans f1, juste après la création de f2. Mais la bibliothèque standard nous propose un décorateur qui fera ce travail à notre place : la fonction wraps du module functools. Illustration :

>>>> import functools
>>>>
>>>> def f1(func_arg):
. . .           """Ceci est un décorateur."""
. . .           print(f"J’ai reçu comme argument func_arg la fonction {func_arg.__name__}.")
. . .           @functools.wraps(func_arg)
. . .           def f2():
. . .                 """Fonction remplaçante"""
. . .                 print("Je suis un log supplémentaire totalement inutile.")
. . .                 return func_arg()
. . .           print("Décoration terminée !")
. . .           return f2
. . .
>>>> @f1
. . .     def f3():
. . .           """Affichage d'un message."""
. . .           print("Je suis dans f3.")
. . .
J'ai reçu comme argument func_arg la fonction f3.
Décoration terminée !
>>>> f3.__name__
'f3'
>>>> f3.__doc__
"Affichage d'un message."
>>>> help(f3)
Help on function f3 in module __main__:

f3()
    Affichage d'un message.

C. Cascades de décorateurs Python

On peut facilement appliquer plusieurs décorateurs à une même fonction. Créons-en deux :

>>>> def d1(func):
. . .           def wrapper(*args, **kwargs):
. . .                 print("d1")
. . .                 return func(*args, **kwargs)
. . .           return wrapper
. . .
>>>> def d2(func):
. . .           def wrapper(*args, **kwargs):
. . .                 print("d2")
. . .                 return func(*args, **kwargs)
. . .           return wrapper
. . .
>>>> @d1
. . .     @d2
. . .     def f():
. . .           print("Je suis dans f.")
. . .
>>> f()
d1
d2
Je suis dans f.
>>>> @d2
. . .     @d1
. . .     def f():
. . .           print("Je suis dans f.")
. . .
>>> f()
d2
d1
Je suis dans f.

J’ai inséré au passage dans cet exemple deux petits détails intéressants :

  • le fait de nommer la fonction qui va remplacer la fonction décorée « wrapper » (en français : emballage, enveloppe), puisque généralement il s’agit bien d’une surcouche (on rajoute des instructions autour de la fonction décorée) dont le nom n’a pas d’importance, d’autant plus qu’il peut être remplacé dans l’attribut __name__ par functools.wraps, comme on l’a vu,
  • nos décorateurs ne faisant qu’afficher un message supplémentaire, on peut les rendre applicables à n’importe quelle fonction en leur faisant accepter tous types d’arguments (qui seront passés tels quels à la fonction décorée) en donnant au wrapper une signature générique : (*args, **kwargs) en entrée et le retour de la fonction décorée en sortie.

Comment ça marche ? Il s’agit simplement de composition de fonctions, au sens mathématique. Décomposons les premières étapes :

>>>> def f() :
. . .           print("Je suis dans f.")
. . .
>>>> f1 = d1(f)
>>>> f1()
d1
Je suis dans f.

La fonction intermédiaire f1 a exactement la même signature que l’originale, f. On peut donc tout à fait la décorer avec d1 ou avec tout autre décorateur pouvant agir sur une telle fonction, ce qui est le cas également de d2 et d3.

>>>> f2 = d2(f1)
>>>> f2()
d2
d1
Je suis dans f.

Donc au final, si on remplace f1 par sa définition d1(f) et qu’on donne à f2 le nom f comme le feraient les deux appels successifs à l’opérateur @, on peut écrire l’équivalent du second test :

>>>> f = d2(d1(f))
>>>> f()
d2
d1
Je suis dans f.

D. Décorateurs Python configurables (factories)

Tous les décorateurs que nous avons écrits se comportent toujours de la même façon, en ajoutant les mêmes choses autour de la fonction décorée, quelle qu’elle soit. Or nous pourrions vouloir avoir des décorateurs configurables. En réalité, rien ne nous empêche de le faire. À quoi cela pourrait-il ressembler ? À ceci par exemple :

>>>> @some_decorator(**options)
. . .     def some_function():
. . .           # Do something
. . .           pass
. . .

Comparons avec l’écriture que nous avons vue jusqu’à présent :

>>>> @another_decorator
. . .     def some_function():
. . .           # Do something
. . .           pass
. . .

Nous pouvons en déduire que pour que les deux écritures soient équivalentes, il suffit que notre fonction some_decorator retourne une fonction se comportant comme another_decorator. Reprenons l’idée simple de notre premier décorateur qui rajoutait des appels à print avant et après la fonction décorée, et généralisons-le au passage à tout type de fonction. Pour rendre ce procédé configurable, nous pourrions par exemple rajouter la possibilité de passer en arguments les messages à afficher. Voilà à quoi ça pourrait ressembler :

 >>>> def add_prints(first_message, second_message) :
. . .            def decorator(func_arg):
. . .                  def wrapper(*args, **kwargs):
. . .                        print(first_message)
. . .                        result = func_arg(*args, **kwargs)
. . .                        print(second_message)
. . .                        return result
. . .                  return wrapper
. . .            return decorator
. . .
>>>> @add_prints("avant", "apres")
. . .     def f3():
. . .           print("Je suis dans f3.")
. . .
>>>> @add_prints("Calcul de l'aire d'un rectangle...", "Calcul terminé !")
. . .     def compute_area(x, y):
. . .           return x * y
. . .
>>>> f3()
avant
Je suis dans f3.
apres
>>>> area = compute_area(3, 5)
Calcul de l'aire d'un rectangle...
Calcul terminé !
>>>> area
15

E. Décoration Python de classes

Le symbole @, comme on l’a vu, applique une fonction à une autre située juste en-dessous et réassigne le résultat à la variable qui contenait auparavant la fonction décorée. En réalité, cette opération peut s’effectuer sur tout objet supportant l’opérateur d’appel () (tout objet appelable comme une fonction, c’est-à-dire avec des parenthèses et éventuellement des arguments à l’intérieur). Cette catégorie d’objets « pouvant être appelés » est appelée callables. Il s’agit des fonctions et des classes (ainsi que de, pour être rigoureux, toute instance définissant la méthode __call__). Par conséquent, il est possible d’appliquer l’opérateur @ pour décorer une classe au lieu d’une fonction. Voici comment on pourrait procéder :

>>>> def add_birthday(cls):
. . .           class Wrapper(cls):
. . .                 def celebrate_birthday(self):
. . .                       print("Happy birthday!")
. . .                       if hasattr(self, "age"):
. . .                            self.age += 1
. . .           return Wrapper
. . .
>>>> @add_birthday
. . .     class Student:
. . .           def __init__(self, name, age):
. . .                 self.name = name
. . .                 self.age = age
. . .
>>>> jj20 = Student("Jean Jacques", 20)
>>>> jj20.celebrate_birthday()
Happy birthday!
>>>> jj20.age
21

Dans cet exemple, la classe Wrapper va remplacer la classe Student tout en conservant son comportement par héritage (Wrapper hérite des comportements de Student). Une telle décoration peut servir à rajouter des méthodes et des attributs aux classes décorées.

F. Décorateurs Python configurables sous forme de classe

Nous avons vu qu’il était possible de créer des fonctions générant des décorateurs qui s’appliqueront à d’autres fonctions (ou à des classes), ce qui conduit à des expressions telles que :

>>>> @some_decorator(**options)
. . .     def some_function():
. . .           # Do something
. . .           pass
. . .

Il y a ici deux appels : some_decorator(**options) puis some_decorator(**options)(some_function). En partant du principe qu’un callable en vaut bien un autre, on pourrait tout aussi bien remplacer some_decorator par une classe. Si some_decorator était une classe, le premier appel correspondrait à sa méthode __init__ et la seconde à une méthode d’instance __call__ . Reprenons notre exemple de la section D et voyons que c’est en effet possible :

>>>> class add_prints:
. . .           def __init__(self, first_message, second_message) :
. . .                 self.first_message = first_message
. . .                 self.second_message = second_message
. . .            def __call__(self, func_arg):
. . .                  def wrapper(*args, **kwargs):
. . .                        print(self.first_message)
. . .                        result = func_arg(*args, **kwargs)
. . .                        print(self.second_message)
. . .                        return result
. . .                  return wrapper
. . .
>>>> @add_prints("avant", "apres")
. . .     def f3():
. . .           print("Je suis dans f3.")
. . .
>>>> @add_prints("Calcul de l'aire d'un rectangle...", "Calcul terminé !")
. . .     def compute_area(x, y):
. . .           return x * y
. . .
>>>> f3()
avant
Je suis dans f3.
apres
>>>> area = compute_area(3, 5)
Calcul de l'aire d'un rectangle...
Calcul terminé !
>>>> area
15

Il est bien entendu toujours possible de décorer une classe avec cette méthode, avec par exemple un code comme celui-ci :

>>>> class add_birthday:
. . .           def __init__(self, birthday_message="Happy birthday !") :
. . .                 self.birthday_message = birthday_message
. . .            def __call__(self, cls):
. . .                  birthday_message = self.birthday_message
. . .                  class Wrapper(cls):
. . .                        def celebrate_birthday(self):
. . .                              print(birthday_message)
. . .                              if hasattr(self, "age"):
. . .                                   self.age += 1
. . .                  return Wrapper
. . .
>>>> @add_birthday()
. . .     class Student:
. . .           def __init__(self, name, age):
. . .                 self.name = name
. . .                 self.age = age
. . .

IV. Conclusion

Le mécanisme des décorateurs en Python devrait dorénavant vous apparaître plus clair. La principale chose à retenir, c’est le rôle finalement assez limité que joue l’opérateur @, qui n’est utile que dans la phase d’application d’un décorateur, que nous avons la charge d’écrire (à moins bien sûr qu’il ne soit importé d’une bibliothèque externe). Nous pouvons le résumer ainsi : l’opérateur @ applique un callable à un autre et remplace le callable décoré par celui qui résulte du processus. Il s’agit alors de décoration statique, c’est-à-dire appliquée dès la création de la cible et permanente. Une décoration dynamique consisterait par exemple en l’appel du décorateur seulement lorsque c’est utile, donc sans utiliser l’opérateur @, sous la forme :

>>>> result = mon_decorateur(mon_callable_decore)(*args, **kwargs)

Ensuite, le rôle très partiel de cet opérateur nous permet, nous l’avons vu, certaines libertés. D’abord, celle de l’utiliser pour tout autre chose qu’une décoration, qui est définie comme la modification d’un objet visant à lui rajouter des fonctionnalités. Mais surtout, celles d’écrire ou de générer des décorateurs sous plusieurs formes, via une fonction d’usine (factory en anglais) ou une classe, et de chaîner les décorations assez facilement.

En tant que développeur Python, il est possible de travailler longtemps sans rencontrer le besoin de créer ses propres décorateurs. En revanche, on en croise assez tôt et plus ou moins souvent selon le secteur d’activité (en fonction des bibliothèques qui y sont utilisées). Il sera donc toujours utile de se sentir à l’aise avec cette notion. De plus, créer ses propres décorateurs peut parfois s’avérer être la meilleure façon d’isoler une partie de code : typiquement, pour rajouter une même fonctionnalité à une grande quantité de fonctions diverses et variées éparpillées dans tout un projet (comme par exemple, une mesure du temps passé dans ces fonctions ou une interaction avec toujours le même code tiers), mais pas seulement.