Concepts Python avancés

Les décorateurs

GALODE Alexandre

Ne vous est-il jamais arrivé de souhaiter, pour diverses raisons, de modifier le comportement d'une fonction de manière temporaire ? Ou encore, de souhaiter un peu plus de dynamisme dans le fonctionnement de vos fonctions ?

Une fois de plus Python apporte une réponse via les décorateurs. Je vous invite donc à découvrir ce concept.

Commentez

- Titre : Concepts Python avancés

- Auteur : GALODE Alexandre

- Parution : 14 mars 2016

- Mise à jour : 14 mars 2016

- Public visé :

- Licence : Le contenu de cet article est rédigé par Alexandre GALODE et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- I. Introduction

- II. Exemple simplifié de décorateur

- III. Les décorateurs en vrai

- IV. Exemple d'application concrète

- V. Conclusion

- VI. Remerciements

I - Introduction

Introduits par la PEP 318, les décorateurs (decorator en anglais) ne sont ni plus ni moins que des fonctions permettant de modifier le comportement d'autres fonctions et/ou d'exécuter du code supplémentaire.

Ainsi, plutôt que de copier/coller bêtement du code afin de légèrement modifier le comportement d'une fonction, Python vous propose d'éviter la répétition de code, réduisant ainsi les problèmes de maintenance du code via la possibilité de créer des fonctions d'ordre supérieur.

Les décorateurs sont considérés, en Python, comme une notion assez évoluée. La possibilité de passer en paramètre, à une fonction, une autre fonction, ne semble en effet pas forcément automatique ou naturelle en programmation procédurale ou objet.

Cependant, c'est ce que les décorateurs vont nous permettre de faire : via une fonction, manipuler d'autres fonctions (aussi bien en entrée qu'en sortie) et éventuellement impacter leur comportement. Pour ce faire, il suffira de passer le nom de la fonction à manipuler (sans les « () » ) comme n'importe quel autre paramètre.

Côté application, cela peut être fort utile pour avoir du code intelligent sachant s'adapter à divers environnements, générer des logs de l'exécution du code…

L'ensemble des codes de cet article sont écrits en Python 3.x. Par conséquent leur fonctionnement en Python 2.X n'est pas garanti. Pour rappel, Python 3.X est déclaré apte pour la production depuis la version 3.4 et il est fortement recommandé de ne plus utiliser que Python 3.X, sauf en cas de contrainte forte (bibliothèques non portées, serveur impossible à migrer…)

II - Exemple simplifié de décorateur

1. def mon_decorateur(fonction):

2. print(fonction.__name__ + ' appelée')

3. return fonction()

5. def ma_fonction():

6. print("hello_world")

8. if __name__ == "__main__":

9. mon_decorateur(ma_fonction)

Voici un exemple simplifié de décorateur. Dans le code ci-dessus, nous appelons notre fonction de décoration « mon_decorateur() » en lui passant en argument une fonction : ma_fonction(). Par rapport à la fonction seule (NDLR : ma_fonction), nous exécutons du code supplémentaire et par conséquent, modifions le comportement de la fonction. Nous sommes donc bien en présence d'un décorateur.

En réalité, nous ne modifions nullement le comportement du code de la fonction, puisque nous n'y touchons pas. C'est au niveau de l'appel de la fonction que tout se joue et ce sera le résultat final qui sera impacté.

Cependant cela a l'inconvénient que vous deviez appeler le décorateur et non pas la fonction à chaque fois que vous avez besoin de cette fonctionnalité supplémentaire.

Python permet de simplifier cette écriture via une syntaxe dédiée aux décorateurs, que l'on déclare avec le « @ ».

1. def mon_decorateur(fonction):

2. print(fonction.__name__ + ' appelée')

3. return fonction

5. @mon_decorateur

6. def ma_fonction():

7. print("hello_world")

9. if __name__ == "__main__":

10. ma_fonction()

Dans ce cas, nous voyons que nous appelons notre fonction comme si elle n'était pas décorée. Python se charge, de façon transparente pour nous, de lui substituer sa version décorée

Une question vous vient alors peut-être à l'esprit : et si on ne désire pas décorer notre fonction ? Eh bien il suffit de passer par une variable.

1. a_decorer = True

3. def mon_decorateur(fonction):

4. if a_decorer:

5. print(fonction.__name__ + ' appelée')

6. return fonction

8. @mon_decorateur

9. def ma_fonction():

10. print("hello_world")

12. if __name__ == "__main__":

13. ma_fonction()

Et voilà. Si a_decorer vaut True, alors notre décorateur jouera son rôle. Mais si a_decorer vaut False, alors nous récupérons notre fonction standard. Pour passer d'une version de débogage qui requiert le log des appels de fonction à une version de distribution, il vous suffira de changer cette valeur.

C'est sur cela que repose le fonctionnement de base d'un décorateur.

Il est possible d'enchaîner les décorateurs. Ainsi faire

1. mon_decorateur01(mon_decorateur02(ma_fonction))

s'écrira

1. @mon_decorateur01

2. @mon_decorateur02

3. ma_fonction():

4. ...

III - Les décorateurs en vrai

Ce que nous venons de voir précédemment est bien sympathique, néanmoins d'une utilité limitée. En effet, nous avons fait abstraction des potentiels arguments que nous voudrions passer à la fonction. Entrons dans le vif du sujet.

Dans sa version idiomatique, un décorateur n'exécutera jamais lui-même du code, même s'il le peut. En effet, il préférera créer une nouvelle fonction, décoration de la fonction passée en paramètre, et vous renverra la fonction décorée, laquelle sera alors interprétée par Python. C'est là le point important : un vrai décorateur renvoie toujours un « callable », c'est-à-dire une fonction.

Afin d'être plus clair, reprenons notre exemple précédent.

1. def mon_decorateur(fonction):

2. def ma_fonction_decoree():

3. print(fonction.__name__ + ' appelée')

4. fonction()

5. return ma_fonction_decoree

7. @mon_decorateur

8. def ma_fonction():

9. print("hello_world")

11. if __name__ == "__main__":

12. ma_fonction()

Basiquement, voici un véritable décorateur. « mon_decorateur » définit une fonction décorant la fonction passée en paramètre, puis renvoie cette fonction décorée.

Notez que le résultat est le même que précédemment. Maintenant continuons à monter en puissance, en décorant une fonction avec paramètres.

1. def mon_decorateur(fonction):

2. def ma_fonction_decoree(*args, **kwargs):

3. print(fonction.__name__ + ' appelée')

4. print('arguments non nommes passes en parametres: %s' % (args))

5. return fonction(*args, **kwargs)

6. return ma_fonction_decoree

8. @mon_decorateur

9. def ma_fonction(nom):

10. print('hello_' + nom)

11. return True

13. if __name__ == '__main__':

14. print(ma_fonction('developpez.com'))

Voici un exemple un peu plus proche de ce que vous serez amené à voir réellement. Les arguments, nommés ou non, sont attrapés par la fonction imbriquée dans notre décorateur, et transmis à la fonction appelée.

Dans cette fonction imbriquée, nous effectuons directement un « return » de la fonction appelée.

Notons également que dans cet exemple nous constatons que le décorateur a accès aux arguments passés en paramètres.

Voyons maintenant une petite particularité importante à connaître. Exécutez le code suivant.

1. def mon_decorateur(fonction):

2. print("Instance de decorateur en memoire")

3. def ma_fonction_decoree(*args, **kwargs):

4. print(fonction.__name__ + ' appelée')

5. return fonction(*args, **kwargs)

6. return ma_fonction_decoree

8. @mon_decorateur

9. def ma_fonction(nom):

10. print('hello_' + nom)

11. return True

13. @mon_decorateur

14. def ma_fonction2(website):

15. print('Visitez-nous sur ' + website)

17. if __name__ == '__main__':

18. print(ma_fonction('developpez.com'))

19. ma_fonction2('http://www.developpez.com')

Que constatez-vous dans le terminal d'exécution ? Chaque « @mon_decorateur » débouche sur la mise en mémoire, en cache, d'une instance de notre décorateur. Cela est assimilable au concept de programmation fonctionnelle appelée mémoïsation.

Cela est fort utile, car vous pourrez alors utiliser cela à votre avantage pour compter le nombre d'appels des fonctions décorées par exemple.

1. def mon_decorateur(fonction):

2. counter = 0

3. print("Instance de decorateur en memoire")

4. def ma_fonction_decoree(*args, **kwargs):

5. nonlocal counter

6. counter += 1

7. print("Appel N°: %s" % (counter))

8. print(fonction.__name__ + ' appelée')

9. return fonction(*args, **kwargs)

10. return ma_fonction_decoree

12. @mon_decorateur

13. def ma_fonction(nom):

14. print('hello_' + nom)

15. return True

17. if __name__ == '__main__':

18. print(ma_fonction('developpez.com'))

19. print(ma_fonction('developpez.com'))

20. print(ma_fonction('developpez.com'))

« nonlocal » est une nouveauté de la branche 3. Ce nouveau mot clé ne fonctionne que dans les cas de plusieurs fonctions imbriquées. Il permet de stipuler que la fonction imbriquée a le droit, dans son espace de noms propre, de modifier les variables ainsi déclarées dans l'espace de noms de sa fonction mère. Voici un code permettant de mettre en évidence l'usage de ce mot clé.

1. def fonction():

2. var1 = 0

3. var2 = 0

4. print("Valeurs initiales")

5. print("var1= %d" % (var1))

6. print("var2= %d" % (var2))

7. def demo():

8. nonlocal var1

9. var1 += 2

10. var2 = var1 + 3

11. print("*****************")

12. print("*****************")

13. print("var1= %d" % (var1))

14. print("var2= %d" % (var2))

15. demo()

16. print("*****************")

17. print("var1= %d" % (var1))

18. print("var2= %d" % (var2))

20. if __name__ == '__main__':

21. fonction()

On voit bien ici que la variable déclarée en « nonlocal » a bien été modifiée, mais pas la seconde variable.

IV - Exemple d'application concrète

L'exemple le plus basique que nous rencontrons le plus souvent est la mesure du temps d'exécution. Même s'il existe des bibliothèques dédiées, nous pouvons le faire facilement via un décorateur.

Reprenons notre exemple précédent.

1. import time

3. a_mesurer = True

5. def mon_decorateur(fonction):

6. if a_mesurer:

7. def mesure_execution():

8. start_time = time.time()

9. print('start_time: %.3f' % (start_time))

10. result = fonction()

11. stop_time = time.time()

12. print('stop_time: %.3f' % (stop_time))

13. total = stop_time - start_time

14. print("temps d'execution: %.3f" % (total))

15. return result

16. return mesure_execution

17. else:

18. return fonction

20. @mon_decorateur

21. def ma_fonction():

22. print('hello_world')

23. time.sleep(0.25)

25. if __name__ == '__main__':

26. ma_fonction()

Le plus grand piège ici est au niveau du décorateur. En effet, mal déclaré vous pourrez voir apparaître un certain nombre d'erreurs potentielles. Il ne faut pas oublier en effet qu'un décorateur doit toujours renvoyer une fonction, autrement dit un « callable ». Sans cela, une erreur sera signalée.

V - Conclusion

Comme nous venons de le voir, les décorateurs font partie des concepts Python qui peuvent rapidement se révéler fort utiles.

Très utilisés par les développeurs Python aguerris, les décorateurs permettent d'obtenir du code concis, limitant les répétitions au strict minimum, sans pour autant renoncer à une lisibilité importante.

J'espère que cette introduction aura su vous convaincre, et vous permettra à l'avenir d'être encore plus performant avec Python.

VI - Remerciements

- Lolo78

- LittleWhite

- Stendhal666

- ClaudeLELOUP