15.3. Refactorisation

Le meilleur avec des tests unitaires exhaustifs, ce n’est pas le sentiment que vous avez quand tous vos cas de test finissent par passer, ni même le sentiment que vous avez quand quelqu’un vous reproche d’avoir endommagé leur code et que vous pouvez véritablement prouver que vous ne l’avez pas fait. Le meilleur, c’est que les tests unitaires vous permettent la refactorisation continue de votre code.

La refactorisation est le processus par lequel on fait d’un code qui fonctionne un code qui fonctionne mieux. Souvent, «mieux» signifie «plus vite», bien que cela puisse aussi vouloir dire «avec moins de mémoire», «avec moins d’espace disque» ou simplement «de manière plus élégante». Quoi que cela signifie pour vous, pour votre projet, dans votre environnement, la refactorisation est importante pour la santé à long terme de tout programme.

Ici, «mieux» veut dire «plus vite». Plus précisément, la fonction fromRoman est plus lente qu’elle ne le devrait, à cause de cette énorme expression régulière que nous utilisons pour valider les nombres romains. Cela ne vaut sans doute pas la peine de se priver complètement de l’expression régulière (cela serait difficile et ne serait pas forcément plus rapide), mais nous pouvons rendre la fonction plus rapide en précompilant l’expression régulière.

Exemple 15.10. Compilation d’expressions régulières

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')               1
<SRE_Match object at 01090490>
>>> compiledPattern = re.compile(pattern) 2
>>> compiledPattern
<SRE_Pattern object at 00F06E28>
>>> dir(compiledPattern)                  3
['findall', 'match', 'scanner', 'search', 'split', 'sub', 'subn']
>>> compiledPattern.search('M')           4
<SRE_Match object at 01104928>
1 C’est la syntaxe que nous avons déjà vu : re.search prend une expression régulière sous forme de chaîne (motif) et une chaîne dont on va tester la correspondance ('M'). Si le motif reconnaît la chaîne, la fonction retourne un objet correspondance qui peut être interrogé pour savoir exactement ce qui a été reconnu et comment.
2 C’est une nouvelle syntaxe : re.compile prend une expression régulière sous forme de chaîne et retourne un objet motif. Notez qu’il n’y a pas ici de chaîne à reconnaître. Compiler une expression régulière n’a rien à voir avec la reconnaissance d’une chaîne en particulier (comme 'M'), cela n’implique que l’expression régulière elle-même.
3 L’objet motif compilé retourné par re.compile a plusieurs fonctions qui ont l’air utile, parmi lesquelles plusieurs (comme search et sub) sont directement disponible dans le module re.
4 Appeler la fonction search de l’objet motif compilé avec la chaîne 'M' accomplit la même chose qu’appeler re.search avec l’expression régulière et la chaîne 'M'. Mais c’est beaucoup, beaucoup plus rapide. (En fait, la fonction re.search se contente de compiler l’expression régulière et d’appeler la méthode search de l’objet motif résultant pour vous.)
NOTE
A chaque fois que vous allez utiliser une expression régulière plus d’une fois, il vaut mieux la compiler pour obtenir un objet motif et appeler ses méthodes directement.

Exemple 15.11. Expressions régulières compilées dans roman81.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage8/ du répertoire des exemples.

Si vous ne l’avez pas déjà fait, vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

# toRoman and rest of module omitted for clarity

romanNumeralPattern = \
    re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$') 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not romanNumeralPattern.search(s):                                    2
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
1 Cela à l’air très similaire mais en fait beaucoup a changé. romanNumeralPattern n’est plus une chaîne, c’est un objet motif qui est retourné par re.compile.
2 Cela signifie que nous pouvons appeler des méthodes de romanNumeralPattern directement. Cela sera beaucoup plus rapide que d’appeler re.search à chaque fois. L’expression régulière est compilée une seule fois et est stockée dans romanNumeralPattern quand le module est importé pour la première fois, puis, à chaque fois que nous appelons fromRoman, nous pouvons immédiatement tester la correspondance de la chaîne d’entrée avec l’expression régulière, sans que des étapes intermédiaire interviennent en coulisse.

Mais à quel point est-ce plus rapide de compiler notre expression régulière ? Voyez vous-même :

Exemple 15.12. Sortie de romantest81.py avec roman81.py

.............          1
----------------------------------------------------------------------
Ran 13 tests in 3.385s 2

OK                     3
1 Juste une note en passant : cette fois, j’ai lancé le test unitaire sans l’option -v, donc au lieu d’avoir la doc string complète pour chaque test, nous avons un point pour chaque test qui passe. (Si un test échouait, nous aurions un F (failed) et si il y avait une erreur, nous aurions un E. Nous aurions quand même la trace de pile pour chaque échec ou erreur de manière à pouvoir localiser les problèmes.)
2 Nous avons exécuté 13 tests en 3,385 secondes, au lieu de 3,685 secondes sans précompilation de l’expression régulière. C’est une amélioration de 8% et rappelez-vous que la plus grande partie du temps passé dans le test unitaire est consacré à d’autres chose. (J’ai testé séparément l’expression régulière et j’ai découvert que sa compilation accélère la fonction search de 54% en moyenne.) Pas mal pour une modification aussi simple.
3 Oh, au cas ou vous vous le demandiez, la précompilation de l’expression régulière n’a rien endommagé et nous venons de le prouver.

Il y a une autre optimisation que je veux essayer. Etant donnée la complexité de la syntaxe des expressions régulières, il n’est pas étonnant qu’il y ait souvent plus d’une manière d’écrire la même expression. Après une discussion à propos de ce module sur comp.lang.python, quelqu’un m’a suggéré d’utiliser la syntaxe {m,n} pour des caractères répétés optionnels.

Exemple 15.13. roman82.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage8/ du répertoire des exemples.

Si vous ne l’avez pas déjà fait, vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

# rest of program omitted for clarity

#old version
#romanNumeralPattern = \
#   re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$')

#new version
romanNumeralPattern = \
    re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$') 1
1 Nous avons remplacé M?M?M?M? par M{0,4}. Les deux signifient la même chose : «reconnais de 0 à 4 M». De même, C?C?C? est devenu C{0,3}reconnais de 0 à 3 C») et ainsi de suite pour X et I.

Cette forme d’expression régulière est un petit peu plus courte (mais pas plus lisible). La question est, est-elle plus rapide ?

Exemple 15.14. Sortie de romantest82.py avec roman82.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 Dans l’ensemble, les tests unitaires sont accélérés de 2% avec cette forme d’expression régulière. Cela n’a pas l’air d’être grand chose mais rappelez-vous que la fonction search n’est qu’une petite partie de l’ensemble de nos tests unitaires, la plus grande partie du temps est passée à faire autre chose. (En testant séparément l’expression régulière, j’ai découvert que la fonction search est accélérée de 11% avec cette syntaxe.) En précompilant l’expression régulière et en en récrivant une partie, nous avons amélioré la performance de l’expression régulière de plus de 60% et amélioré la performance d’ensemble des tests unitaires de plus de 10%.
2 Plus important que tout bénéfice de performance est le fait que le module fonctionne encore parfaitement. C’est là la liberté dont je parlais plus haut : la liberté d’ajuster, de modifier ou de récrire n’importe quelle partie et de vérifier que rien n’a été endommagé durant ce processus. Ce n’est pas une autorisation de fignoler indéfiniment le code pour le plaisir, nous avions un objectif spécifique (rendre fromRoman plus rapide) et nous avons rempli cet objectif sans que subsiste le doute d’avoir introduit de nouveaux bogues.

Il y a une autre modification que j’aimerais faire et ensuite je promet que j’arrêterai de refactoriser ce module. Comme nous l’avons vu de manière répétée, les expressions régulières peuvent devenir emberlificotées et illisibles assez vite. Je voudrais pouvoir revenir à ce module dans six mois et être capable de le maintenir. Bien sûr les cas de tests passent, je sais donc qu’il fonctionne mais si je ne peux pas comprendre comment il fonctionne, je ne serai pas capable d’ajouter des fonctionnalités, de corriger de nouveaux bogues et plus généralement de le maintenir. Comme nous l’avons vu au Section 7.5, «Expressions régulières détaillées», Python fournit une manière de documenter vos expressions régulières ligne à ligne.

Exemple 15.15. roman83.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage8/ du répertoire des exemples.

Si vous ne l’avez pas déjà fait, vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

# rest of program omitted for clarity

#old version
#romanNumeralPattern = \
#   re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$')

#new version
romanNumeralPattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 M's
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
                        #            or 500-800 (D, followed by 0 to 3 C's)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
                        #        or 50-80 (L, followed by 0 to 3 X's)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
                        #        or 5-8 (V, followed by 0 to 3 I's)
    $                   # end of string
    ''', re.VERBOSE) 1
1 La fonction re.compile peut prendre un second argument optionnel, un ensemble d’un flag ou plus qui contrôle diverses options pour l’expression régulière compilée. Ici, nous spécifions le flag re.VERBOSE, qui signale à Python qu’il y a des commentaires à l’intérieur de l’expression régulière. Les commentaires ainsi que les espaces les entourant ne sont pas considérés comme faisant partie de l’expression régulière, la fonction re.compile les enlève purement et simplement lorsqu’elle compile l’expression. Cette nouvelle version documentée est identique à l’ancienne mais elle est beaucoup plus lisible.

Exemple 15.16. Sortie de romantest83.py avec roman83.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 Cette nouvelle version documentée s’exécute exactement à la même vitesse que l’ancienne. En fait, l’objet motif compilé est le même, puisque la fonction re.compile supprime tout ce que nous avons ajouté.
2 Cette nouvelle version passe tous les tests que passait l’ancienne. Rien n’a changé, sauf que le programmeur qui se penchera à nouveau sur ce module dans six mois aura une chance de comprendre le fonctionnement de la fonction.