Skip to content

Solution du problème timings

Vous trouverez ici la solution pour l’exercice suivant :

timings

Un petit mot sur timeit

Comme il a été dit dans l’énoncé de l’exercice, il existe un module en python qui permet de chronométrer simplement des bouts de code : timeit.

L’utilisation principale est la suivante :

>>> import timeit
>>> timeit.timeit("[x**2 for x in range(25)]")
13.74110952099727
>>> timeit.timeit("(x**2 for x in range(25))")
0.8063442959974054

Voici un fichier d’exemple qui vous montrera comment utiliser le module timeit pour voir la différence entre différentes versions d’un même algo : Démo Timeit

Exercice de base

Il s’agit ici d’écrire un décorateur de fonction, mais comment cela fonctionne-t-il ?

Un décorateur de fonction est une manière s’augmenter les capacités d’une fonction avec du code qui peut être réutilisé pour plusieurs fonctions. Ceci peut être très utile par exemple pour faire en sorte de mettre en cache le retour d’une fonction complexe ou alors appelée un très grand nombre de fois (Fibonacci). On peut aussi ajouter des traces d’exécution pour afficher les appels aux fonctions décorées ainsi que leurs paramètres. Ou bien comme ici, permettre de connaître le temps d’exécution d’une fonction.

Tout d’abord, voyons comment utiliser un décorateur : il suffit de rajouter son nom précédé de @ sur la ligne avant la définition de la fonction à décorer:

@decorator
def my_decorated_function():
    return

Maintenant, voyons comment écrire un décorateur de fonction. Il s’agit simplement d’une fonction prenant la fonction à décorer comme paramètre et retournant un objet appelable (callable) comme valeur de retour (cela veut dire que l’on peut utiliser () sur cet objet). Afin d’augmenter les possibilités de la fonction passée, nous devons passer par un wrapper qui sera notre callable.Son rôle sera d’appeler notre fonction tout en ajoutant le nécessaire afin de connaître son temps d’exécution.

Voici le code pour un décorateur qui ne fait rien :

def decorator(function):
    def wrapper():
        function()
    return wrapper

Pour chronométrer un bout de code, il est possible d’utiliser le module time qui possède une méthode time() -> float qui renvoie le temps en secondes depuis EPOCH (1/1/1970) sous forme de nombre à virgule flottante. Donc, en regardant la différence entre avant et après l’appel de la fonction à chronométrer, on peut connaître le nombre de secondes qui ont été prises pour exécuter la fonction.

Ainsi, le décorateur timeit_decorator peut s’écrire :

import time

def timeit_decorator(function):
    def wrapper():
        start_time = time.time()
        function()
        duration = time.time() - start_time
    return wrapper

Il faut aussi un moyen de pouvoir récupérer dans le code client la durée d’exécution de la fonction décorée. Pour cela il nous était demandé de faire une fonction get_duration() qui retournera la durée calculée dans le wrapper.

Nous allons stocker la durée dans une variable globale afin de pouvoir la changer dans le wrapper, et la retrouver dans get_duration:

import time

duration = 0

def get_duration():
    return duration

def timeit_decorator(function):
    def wrapper():
        global duration
        start_time = time.time()
        function()
        duration = time.time() - start_time
    return wrapper

Il ne faut pas oublier de faire en sorte de retourner le bon code de retour de la fonction décorée. Pour cela rien de compliqué, si nous n’avions pas de traitement après l’appel de la fonction, il suffirait de faire return function(). Malheureusement pour nous, il faut faire des choses après l’appel de function; il suffit de stocker ce retour le temps de faire notre traitement.

Et voila donc la solution :

import time

duration = 0

def get_duration():
    return duration

def timeit_decorator(function):
    def wrapper():
        global duration
        start_time = time.time()
        result = function()
        duration = time.time() - start_time
        return result
    return wrapper

Et voila pour l’exercice de base, rien de réellement sorcier, et les décorateurs peuvent être vraiment très utiles.

Petite note en passant : on peut ajouter autant de décorateurs que l’on veut sur une même fonction (et même sur la définition d’un décorateur pourquoi pas). Ils seront évalués de bas en haut:

@decorator3
@decorator2
@decorator1
def function():
    pass

L’interpréteur appliquera d’abord decorator1 sur function, puis decorator2 sur le résultat, et enfin decorator3 sera appliqué sur l’ensemble.

Bonus 1

Nous savons qu’en python il y a 2 manières de passer des paramètres à une fonction en Python. La première consiste à placer les paramètres les uns après les autres : on parle de passer des paramètres par position.

>>> def my_function(param1, param2):
...     pass

>>> my_function(value_for_param1, value_for_param2)

Si nous voulons que notre fonction décorée puisse utiliser des paramètres, il suffit que notre wrapper prenne des paramètres lui aussi:

def decorator(function):
    def wrapper(param_1, param_2):
        function(param_1, param_2)
    return wrapper

Le problème, c’est que notre décorateur ne fonctionnera qu’avec les fonctions qui prenne 2 paramètres… Heureusement pour nous, la Fondation Python a pensé à nous en créant l’opérateur splat (« étoile » *). Celui-ci permet de faire de nombreuses choses par exemple transformer les paramètres d’une fonction en liste, on appelle cela du paramétrage dynamique. Autrement dit cela permet à une fonction de prendre un nombre variable de paramètres (comme c’est le cas pour print par exemple):

>>> def my_function(*args):
...     return sum(args)
>>> my_function(1)
1
>>> my_function(1, 10, 100)
111

Voila qui va bien nous aider, on peut ainsi s’arranger pour que notre wrapper prenne une quantité variable de paramètres (pour info, cela fonctionne aussi s’il n’y a pas de paramètre du tout).

Un autre super pouvoir de l’opérateur * c’est l’unpacking. Cela consiste à faire croire à une fonction qu’une liste doit être interprétée comme si ses valeurs étaient des paramètres passés par position.

>>> def my_function(param_1, param_2):
...     print(param_1 + param_2)
>>> params = [1, 5]
>>> my_function(params)
6

Parmi les autres super pouvoirs l’opérateur *, on peut noter qu’il permet de récupérer la suite des éléments :

>>> first_element_of_list, second_one, *other_elements_in_list = [1, 2, 3, 4, 5]
>>> first_element_of_list
1
>>> other_elements_in_list
[3, 4, 5]

Ou encore qu’il permet de faire de l’unpacking directement dans des littéraux:

>>> [1, 2, *range(3), *range(2)]
[1, 2, 0, 1, 2, 0, 1]
>>> (*range(1), 4)
(0, 4)

Bref, pour en revenir à notre problème, en conjuguant les deux usages de l’opérateur splat, unpacking et parametrage dynamique, on obtient :

import time

duration = 0

def get_duration():
    return duration

def timeit_decorator(function):
    def wrapper(*args):
        global duration
        start_time = time.time()
        result = function(*args)
        duration = time.time() - start_time
        return result
    return wrapper

Et voila qui solution le second bonus.

Bonus 2

La seconde façon de passer des paramètres en Python consiste à les passer par leur nom, ce sont des keyword parameters. C’est ce mécanisme qui permet de conserver toutes les valeurs par défauts aux paramètres d’une fonction sauf ceux qu’on veut passer :

>>> def my_function(param_1=0, param_2=12):
...     print(param_1, param_2)
>>> my_funciton(param_2=0)
0 0

Hé bien, pour que ceci fonctionne avec notre wrapper il va falloir utiliser des super pouvoirs de *, légèrement différents des précédents, mais qui ont le même objectif :

import time

duration = 0

def get_duration():
    return duration

def timeit_decorator(function):
    def wrapper(*args, **kargs):
        global duration
        start_time = time.time()
        result = function(*args, **kargs)
        duration = time.time() - start_time
        return result
    return wrapper

L’opérateur double * fait la même chose que l’opérateur simple *, mais au lieu de fonctionner sur une base de liste, il fonctionne sur une base de dictionnaire. Ainsi **kargs lorsqu’il est mis en définition de paramètre d’une fonction récupère les paramètres nommés pour les mettre dans un dictionnaire; quant à **kargs que l’on donne à une fonction, il fait comme si chacune des clefs – valeurs du dictionnaire avait été passée avec son nom comme paramètre de la fonction.

Petite (ou pas tant que ça) note en passant : * peut servir à séparer les paramètres qu’on peut passer par position, et ceux qu’on veut absolument passer par nom. À quoi cela pourrait bien servir ? Si un paramètre est réellement très particulier, on peut vouloir faire en sorte que le code client doive le nommer pour l’utiliser. Par exemple, imaginons une fonction qui prend un nom et un mot de passe pour se connecter à un service, mais peut aussi prendre un paramètre qui soit un nom de proxy (c’est très spécifique si c’est utilisé, et demande que le code client l’explicite). Voila comment cela serait codé par quelqu’un qui ne connaît pas cet usage de splat :

>>> connect(login="me", password="formule1", proxy=None):
...     print(login)
>>> connect()
"me"
>>> connect(1, 2, 3)
1

Pour réussir cela, il suffit de séparer les paramètres normaux des paramètres que l’on veut être passés que par nom avec un *:

>>> connect(login="me", password="formule1", *, proxy=None):
...     print(proxy)
>>> connect()
None
>>> connect(1, 2, 3)
Error
>>> connect(proxy=123)
123

Bonus 3

En python l’introspection est très importante. On peut obtenir des informations très importantes sur tous les objets, à condition que le développeur pense à y faire attention. Il est recommandé (exigé) de faire attention à documenter ses objets (que ce soit une classe, une fonction, etc). Mais si le développeur ne fait pas attention, il peut introduire des conditions où on perd ces informations qu’un autre développeur s’est décarcassé à ajouter…

Voyons un peu ce qui arrive pour une fonction sans décorateur :

>>> def my_function(param1=0, param2=1):
        """ My beautiful function documentation. """
        return param1, param2
>>> my_function.__doc__
" My beautiful function documentation. "

Et avec notre décorateur, la documentation est perdue:

>>> @timings_decorator
    def my_function(param1=0, param2=1):
        """ My beautiful function documentation. """
        return param1, param2
>>> my_function.__doc__
None

Les développeurs consciencieux refuseront d’utiliser notre décorateur (et a raison!) car cela fait perdre des informations importantes au code client. Que dire par exemple s’il y a du doctest dans la documentation ? Les tests unitaires ne seront même plus tournés.

Il est assez facile de conserver la documentation de notre fonction, il suffit de recopier la documentation originale a notre wrapper :

(...)
def timings_decorator(function):
    def wrapper(*args, **kargs):
        global last_timing
        (...)
        return result
    wrapper.__doc__ = function.__doc__
    return wrapper

Ceci suffit a faire en sorte que le troisième bonus passe au vert, mais j’aimerais vous montrer une autre façon qui permet de conserver beaucoup plus d’informations : functools.wraps. Comme le dit la documentation, ce décorateur permet de conserver la documentation de la fonction sur le wrapper, mais aussi de conserver son nom qui sans cela deviendrait wrapper!

Pour l’instant cela ne gère que le nom et la documentation, mais en utilisant ce décorateur au lieu de le faire à la main, cela permettra de toujours conserver les bonnes informations, même si l’interpréteur python change.

Voici donc ma solution pour le problème donné :

import time
import functools


last_timing = 0


def get_duration():
    return last_timing


def timings_decorator(function):
    @functools.wraps(function)
    def wrapper(*args, **kargs):
        global last_timing
        start_time = time.time()
        result = function(*args, **kargs)
        last_timing = time.time() - start_time
        return result
    return wrapper

Super bonus

En python il existe depuis la version 2.6 la notion de management de contexte. Cela permet de gérer plus facilement du code qui sans cela utiliserait des blocs try: ... finally .... Le premier usage que j’ai vu de ces contextes concerne les fichiers que l’on ouvre pour lire et/ou écrire. Tout d’abord la version sans contexte:

file = open('file_path', 'w')
try:
    file.write('hello world')
finally:
    file.close()

Le même comportement mais avec un contexte :

with open('file_path', 'w') as file:
    file.write('hello world !')

En fait, le close() est géré par le contexte, lorsque l’on sort du bloc.

Pour notre notion de chronométrage cela permet de faire une mesure du temps pris par le code dans le bloc du contexte. Lorsqu’on commence le with on stocke le temps de départ ; et lorsqu’on sort du contexte, on calcule la durée depuis le temps de départ.

Un contexte manager est une classe avec 2 fonctions bien précises : l’entrée dans le contexte et la sortie du contexte. En dehors de cela la classe peut contenir tous les attributs, et méthodes qu’on souhaite…

Je reviendrai plus en détail sur les contextes lors d’un prochain exercice, mais ici, le code peut s’écrire :

class timings_context(object):
    def __init__(self):
        """ A simple constructor that initialize some properties """
        self.__start_time = None
        self.__duration = None

    def __enter__(self):
        """ Function called when entering the context """
        self.__start_time = time.time()
        return self

    def __exit__(self, exception_type, exception_value, traceback):
      """ Function called when context ends """
        self.__duration = time.time() - self.__start_time

    def get_duration(self):
        """ Retrieve duration of the context block execution """
        return self.__duration

Dans le constructeur nous initialisons 2 propriétés: le temps de départ, et la durée (afin de pouvoir la récupérer par la suite).

La méthode __enter__ est appelée lorsqu’on rentre dans le contexte (le with dans le code). Notez que pour pouvoir stocker notre objet dans une variable (with XXX as <variable>) il faut que la méthode enter retourne notre objet.

Enfin la méthode __exit__ sera appelée lors de la sortie du bloc. Si le code se passe bien, alors tous les paramètres seront vides. Si une exception est levée durant l’exécution du bloc, alors python appelle la méthode __exit__ du manager de contexte avec comme paramètres le type d’exception, l’exception en elle-même, et un objet traceback qui permet de récupérer une call stack. Dans notre cas, dans __exit__ nous calculons et stockons la durée afin que la méthode get_duration puisse fonctionner.

Et voila, rendez-vous bientôt pour un nouvel exercice.