Blog // Exirel.me

DRY, de la fonction au décorateur

Par Florian Strzelecki - 23:38 - 12.07.2017

Tags : Python, Programmation, Développement, decorator

Prenons un exemple de code python simpliste :

def more(number, i):
    """Add ``number`` and ``i``"""
    return number + i

print(more(5, 2))

Sans grande surprise, ceci affiche dans le terminal le nombre "7". Maintenant, mettons que j'ai envie d'afficher du texte avant de faire l'addition... mais seulement de temps en temps ?

La version simpliste, c'est d'écrire une autre fonction, comme ceci :

def more_verbose(number, i):
    """Print a sentence and return ``more(number, i)``"""
    print('Calling function `more`')
    return more(number, i)

print(more_verbose(5, 2))

Ce qui affiche :

Calling function `more`
7

Et maintenant, qu'est-ce qui se passerait si je voulais faire la même chose avec une autre fonction ?

Se répéter

Lorsque je travaille sur du code, je ne cherche jamais à faire du code DRY du premier coup : ce sera sale, ce sera brouillon, il y aura de la duplication de code, et parfois même le score pylint de mon code sera inférieur à 10. Ouais, carrément, peur de rien !

Dans la suite de l'exemple précédent, en avant la répétition :

def less(number, i):
    """Remove ``i`` from ``number``"""
    return number - i


def less_verbose(number, i):
    """Print a sentence and return ``less(number, i)``"""
    print('Calling function `less`')
    return less(number, i)


print(less_verbose(5, 2))

Ce qui affiche :

Calling function `less`
3

D'accord. Ce n'est toujours pas bien compliqué. Qu'est-ce qui se passe si je veux faire la même chose pour 250 fonctions ? Je répète la même chose 250 fois ?

Étape par étape

Autant je ne cherche pas à faire DRY du premier coup, autant faire 250 fois la même chose ça a tendance à me gêner assez rapidement. Pour résoudre le problème, j'y vais étape par étape.

Je vais continuer avec l'exemple du début, bien que cela reste du code très simpliste, c'est pour l'exercice - vous trouverez bien des exemples plus complexes dans votre quotidien.

Utiliser des variables internes

Pour commencer, on peut remplacer le print, en utilisant une variable :

func_name = 'more'
print('Calling function `{0}`'.format(func_name))

Et cette variable peut même être définie automatiquement :

func_name = more.__name__
print('Calling function `{0}`'.format(func_name))

Le but ici est de trouver comment factoriser les différentes fonctions : pour le moment, j'ai toujours deux fonctions qui se répètent, mais grâce à ces changements les comportements identiques sont plus facile à identifier - et donc à remplacer.

Fonction en paramètre

Une fonction peut être passée en paramètre d'une autre fonction, et on peut la manipuler à sa guise : l'appeler, l'assigner à une variable, lui demander ses attributs (ce que j'ai fait avec more.__name__ par exemple).

Commençons par créer une fonction générique qui prend, en premier paramètre, la fonction à appeler :

def more_verbose_and_dry(func, number, i):
    """Print a sentence and return ``func(number, i)``"""
    func_name = func.__name__
    print('Calling function `{0}`'.format(func_name))
    return func(number, i)


print(more_verbose_and_dry(more, 5, 2))
print(more_verbose_and_dry(less, 5, 2))

Ce qui affiche, très correctement :

Calling function `more`
7
Calling function `less`
3

Nous sommes passés à l'étape suivante : au lieu d'avoir une fonction outil par fonction utile, je n'en ai plus qu'une seule, paramétrable.

Signature de fonction

La signature d'une fonction en python peut prendre plusieurs formes. L'une d'entre elle permet notamment de récupérer tous les arguments d'une fonction, comme ceci :

def many_args(*args):
    """Return the number of arguments given."""
    return len(args)


print(many_args(1, 2, 3))  # display "3"
print(many_args("only one argument"))  # display "1"

Pour les arguments nommés, il faut utiliser **kwargs (le nom kwargs est arbitraire, ce qui compte, c'est l'usage de ** devant le nom de l'argument).

Utilisons cette connaissance pour notre fonction :

def verbose_call(func, *args, **kwargs):
    """Print a sentence and return result of `func(*args, **kwargs)`"""
    print('Calling function `{0}`'.format(func.__name__))
    return func(*args, **kwargs)


print(verbose_call(more, 5, 2))
print(verbose_call(less, 5, i=2))

Ce qui affiche... la même chose qu'avant ! C'est à dire :

Calling function `more`
7
Calling function `less`
3

À ce stade là, vous avez déjà un code relativement DRY : à chaque fois que vous voudrez appeler une fonction en rajoutant un print, il suffit de passer par verbose_call(func, *args, **kwargs).

Et si je veux aller plus loin ? Si je dois appeler 10 fois la même fonction avec verbose_call, il n'y aurait pas un moyen plus lisible ?

Générer une fonction

Tout comme il est possible de passer une fonction en paramètre, il est possible de retourner une fonction. Si vous ajoutez que l'on peut définir une fonction au sein d'une autre fonction, vous pouvez aller beaucoup plus loin :

def make_verbose(func):
    sentence = 'Calling function `{0}`'.format(func.__name__)
    def wrapped(*args, **kwargs):
        print(sentence)
        return func(*args, **kwargs)
    return wrapped


more_made_verbose = make_verbose(more)
print(more_made_verbose(5, 2))

Il y a quelque chose que je n'ai pas expliqué : les variable sentence et func ne sont pas déclarées dans la définition de la fonction wrapped, et pourtant, cette dernière y a accès. C'est grâce au fait qu'en python, une fonction a accès au scope de son parent (voir aussi : les closures en JavaScript, et cet article plus complet Closures in python).

Vous noterez que j'assigne directement l'appel de make_verbose à une variable pour créer une nouvelle fonction. Il se trouve que make_verbose peut être utilisée autrement, pour un usage un peu différent.

@decorator

OK, je dois admettre quelque chose : le but de cet article est d'aborder les décorateurs en Python, et de faire disparaître toute la magie de leur fonctionnement en montrant, étape par étape, comment on passe d'un ensemble de fonctions à une syntaxe plus courte et plus lisible, qui rend quelques services intéressants. C'est un outil très utile en Python, et il serait dommage de s'en priver parce qu'il est difficile à appréhender de prime abord.

Reprenons la fonction make_verbose, et utilisons la syntaxe @decorator :

@make_verbose
def multiply(number, i):
    """Mutiply ``number`` by ``i``"""
    return number * i


print(multiply(5, 2))

Le résultat est le suivant :

Calling function `multiply`
10

La fonction multiply a été modifiée au moment de sa définition. La syntaxe @decorator revient à faire ceci :

def multiply(number, i):
    """Mutiply ``number`` by ``i``"""
    return number * i

multiply = make_verbose(multiply)

Comme vous pouvez le voir, il n'y a rien de "magique" : le décorateur peut être utilisé comme du code Python très classique. L'avantage, c'est le sucre syntaxique, qui, avec l'habitude, rend le code plus lisible.

@decorator mais avec paramètres

Vous savez maintenant qu'un décorateur, ce n'est jamais qu'une fonction qui en retourne une autre, et dont le premier argument est une fonction. Bien. Et si on veut passer des paramètres à ce décorateur au moment de son utilisation ?

Décomposons le problème :

On peut donc imaginer que si une fonction peut retourner une fonction, alors elle peut retourner un décorateur. Je sais que je me répète, mais c'est là toute l'élégance des décorateurs : ils utilisent les règles du langage sans créer d'exceptions à apprendre. Une fois que l'on a bien intégré les bases, les syntaxes avancées sont à portée de la main !

Passons à l'exemple concret :

def verbose(sentence_template):
    """This function returns a DECORATOR function."""

    # This is the decorator function that is returned by `@verbose(sentence)`
    def decorator(func):
        """This function returns the DECORATED version of the function"""

        # This is the decorated function that is returned at the end
        def decorated(*args, **kwargs):
            """This is the function that will replace the given `func`"""
            # This uses `sentence` from call to `verbose`
            print(sentence_template.format(func.__name__))
            # This uses `func` from call to `decorator`
            return func(*args, **kwargs)

        return decorated

    return decorator


@verbose('Yes, I am calling function {0}')
def multiply(number, i):
    """Mutiply ``number`` by ``i``"""
    return number * i


@verbose('I think I am calling function {0}')
def divide(number, i):
    """Divide ``number`` by ``i``"""
    return number / i


print(multiply(5, 2))
print(divide(10, 5))

Ce qui affiche :

Yes, I am calling function multiply
10
I think I am calling function divide
2.0

Et voilà ! L'exemple est un peu difficile à comprendre au début : c'est normal. Il faut du temps et de la pratique pour vraiment bien comprendre les décorateurs paramétrés. Et vous pouvez y arriver !

Les bases, toujours les bases

Dans cet article, vous avez pu apprendre :

Les décorateurs sont des outils très puissants - qui ne sont pas sans défauts - et il serait dommage de s'en priver. Je conseille toujours aux débutants de bien comprendre ce qu'est une fonction, et ce que l'on peut faire avec, avant de se lancer dans des choses plus complexes comme les décorateurs.

Cela ne veut pas pour autant dire que les décorateurs soient réservés à l'élite des programmeurs : avec un peu d'entraînement et d'habitudes, tout le monde peut en bénéficier.

Alors, qu'attendez-vous ?