IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Plongez au coeur de Python ,De débutant à expert


précédentsommairesuivant

XV. Refactorisation

XV-A. Gestion des bogues

Malgré tous vos efforts pour écrire des tests unitaires exhaustifs, vous aurez à faire face à des bogues. Mais qu'est-ce que je veux dire par «bogue» ? Un bogue est un cas de test que vous n'avez pas encore écrit.

Exemple 15.1. Le bogue

 
Sélectionnez
>>> import roman5
>>> roman5.fromRoman("") ***1***
0

***1*** Vous vous rappelez que dans la section précédente nous avons vu à chaque fois qu'une chaîne vide était reconnue par l'expression régulière que nous utilisons pour vérifier la validité des nombres romains. En fait, c'est toujours vrai pour la version finale de l'expression régulière. Et c'est un bogue, nous voulons qu'une chaîne vide déclenche une exception InvalidRomanNumeralError comme toute autre séquence de caractères qui ne représente pas un nombre romain valide.

Après avoir reproduit le bogue et avant de le corriger, vous devez écrire un cas de test qui échoue, de manière à l'illustrer.

Exemple 15.2. Test du bogue (romantest61.py)

 
Sélectionnez
class FromRomanBadInput(unittest.TestCase):                                      

    # previous test cases omitted for clarity (they haven't changed)

    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, "") ***1***

***1*** C'est plutôt simple. On appelle fromRoman avec une chaîne vide et on s'assure qu'un exception InvalidRomanNumeralError est déclenchée. Le plus dur était de trouver le bogue, maintenant qu'on le connaît, le tester est facile.

Puisque notre code a un bogue et que nous avons maintenant un cas de test pour ce bogue, le cas de test va échouer :

Exemple 15.3. Sortie de romantest61.py avec roman61.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... FAIL
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

======================================================================
FAIL: fromRoman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage6\romantest61.py", line 137, in testBlank
    self.assertRaises(roman61.InvalidRomanNumeralError, roman61.fromRoman, "")
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
----------------------------------------------------------------------
Ran 13 tests in 2.864s

FAILED (failures=1)

Maintenant nous pouvons corriger le bogue.

Exemple 15.4. Correction du bogue (roman62.py)

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

 
Sélectionnez
def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s: ***1***
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not re.search(romanNumeralPattern, s):
        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*** Seulement deux lignes de code sont nécessaires : une vérification explicite de chaîne non nulle et une instruction raise.

Exemple 15.5. Sortie de romantest62.py avec roman62.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok ***1***
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 13 tests in 2.834s

OK ***2***

***1*** Le cas de test pour la chaîne vide passe maintenant, le bogue est donc corrigé.

***2*** Les autres cas de test passent toujours, ce qui veut dire que la correction du bogue n'a pas endommagé d'autre code. Tous les tests passent, on arrête d'écrire du code.

Programmer de cette manière ne rend pas la correction de bogues plus simple. Les bogues simples (comme ici) nécessitent des cas de tests simples, les bogues complexes de cas de tests complexes. Dans un environnement centré sur les tests, il peut sembler que la correction d'un bogue prend plus de temps puisque vous devez définir exactement par du code ce qu'est le bogue (pour écrire le cas de test) avant de corriger le bogue proprement dit. Puis, si le cas de test ne passe pas immédiatement, vous devez déterminer si la correction est erronée ou si le cas de test a lui-même un bogue. Cependant, à terme, ces aller-retours entre le code de test et le code testé est rentable car il rend plus probable la correction des bogues du premier coup. De plus, puisque vous pouvez facilement lancer tous les cas de tests en même temps que le nouveau, vous êtes beaucoup moins susceptibles d'endommager une partie de l'ancien code en corrigeant le nouveau. Les tests unitaires d'aujourd'hui sont les tests de non régression de demain.

XV-B. Gestion des changements de spécification

Malgré vos meilleurs efforts pour plaquer vos clients au sol et leur extirper une définition de leurs besoins grâce à la menace, les spécifications vont changer. La plupart des clients ne savent pas ce qu'ils veulent jusqu'à ce qu'ils le voient et même ceux qui le savent ne savent pas vraiment comment l'exprimer. Et même ceux qui savent l'exprimer voudront plus à la version suivante de toute manière. Préparez-vous donc à mettre à jour vos cas de test à mesure que vos spécifications changent.

Supposez, par exemple, que nous souhaitions élargir la portée de nos fonctions de conversion de chiffres romains. Vous vous rappelez de la règle disant qu'aucun caractère ne peut être répété plus de trois fois ? Et bien, les Romains faisaient une exception à cette règle pour permettre de représenter 4000 par 4 M. Si nous faisons cette modification, nous pourrons agrandir l'étendue de nombres que nous pouvons convertir de 1..3999 à 1..4999. Mais d'abord, nous devons modifier nos cas de test.

Exemple 15.6. Modification des cas de test pour prendre en charge de nouvelles spécifications (romantest71.py)

Ce fichier est disponible dans le sous-répertoire py/roman/stage7/ 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.

 
Sélectionnez
import roman71
import unittest

class KnownValues(unittest.TestCase):
    knownValues = ( (1, 'I'),
                    (2, 'II'),
                    (3, 'III'),
                    (4, 'IV'),
                    (5, 'V'),
                    (6, 'VI'),
                    (7, 'VII'),
                    (8, 'VIII'),
                    (9, 'IX'),
                    (10, 'X'),
                    (50, 'L'),
                    (100, 'C'),
                    (500, 'D'),
                    (1000, 'M'),
                    (31, 'XXXI'),
                    (148, 'CXLVIII'),
                    (294, 'CCXCIV'),
                    (312, 'CCCXII'),
                    (421, 'CDXXI'),
                    (528, 'DXXVIII'),
                    (621, 'DCXXI'),
                    (782, 'DCCLXXXII'),
                    (870, 'DCCCLXX'),
                    (941, 'CMXLI'),
                    (1043, 'MXLIII'),
                    (1110, 'MCX'),
                    (1226, 'MCCXXVI'),
                    (1301, 'MCCCI'),
                    (1485, 'MCDLXXXV'),
                    (1509, 'MDIX'),
                    (1607, 'MDCVII'),
                    (1754, 'MDCCLIV'),
                    (1832, 'MDCCCXXXII'),
                    (1993, 'MCMXCIII'),
                    (2074, 'MMLXXIV'),
                    (2152, 'MMCLII'),
                    (2212, 'MMCCXII'),
                    (2343, 'MMCCCXLIII'),
                    (2499, 'MMCDXCIX'),
                    (2574, 'MMDLXXIV'),
                    (2646, 'MMDCXLVI'),
                    (2723, 'MMDCCXXIII'),
                    (2892, 'MMDCCCXCII'),
                    (2975, 'MMCMLXXV'),
                    (3051, 'MMMLI'),
                    (3185, 'MMMCLXXXV'),
                    (3250, 'MMMCCL'),
                    (3313, 'MMMCCCXIII'),
                    (3408, 'MMMCDVIII'),
                    (3501, 'MMMDI'),
                    (3610, 'MMMDCX'),
                    (3743, 'MMMDCCXLIII'),
                    (3844, 'MMMDCCCXLIV'),
                    (3888, 'MMMDCCCLXXXVIII'),
                    (3940, 'MMMCMXL'),
                    (3999, 'MMMCMXCIX'),
                    (4000, 'MMMM'),                                       ***1***
                    (4500, 'MMMMD'),
                    (4888, 'MMMMDCCCLXXXVIII'),
                    (4999, 'MMMMCMXCIX'))

    def testToRomanKnownValues(self):
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.toRoman(integer)
            self.assertEqual(numeral, result)

    def testFromRomanKnownValues(self):
        """fromRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)

class ToRomanBadInput(unittest.TestCase):
    def testTooLarge(self):
        """toRoman should fail with large input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 5000) ***2***

    def testZero(self):
        """toRoman should fail with 0 input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 0)

    def testNegative(self):
        """toRoman should fail with negative input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, -1)

    def testNonInteger(self):
        """toRoman should fail with non-integer input"""
        self.assertRaises(roman71.NotIntegerError, roman71.toRoman, 0.5)

class FromRomanBadInput(unittest.TestCase):
    def testTooManyRepeatedNumerals(self):
        """fromRoman should fail with too many repeated numerals"""
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     ***3***
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)

    def testRepeatedPairs(self):
        """fromRoman should fail with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)

    def testMalformedAntecedent(self):
        """fromRoman should fail with malformed antecedents"""
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)

    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, "")

class SanityCheck(unittest.TestCase):
    def testSanity(self):
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 5000):                                    ***4***
            numeral = roman71.toRoman(integer)
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)

class CaseCheck(unittest.TestCase):
    def testToRomanCase(self):
        """toRoman should always return uppercase"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            self.assertEqual(numeral, numeral.upper())

    def testFromRomanCase(self):
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            roman71.fromRoman(numeral.upper())
            self.assertRaises(roman71.InvalidRomanNumeralError,
                              roman71.fromRoman, numeral.lower())

if __name__ == "__main__":
    unittest.main()

***1*** Les valeurs connues existantes ne changent pas (elles sont toujours des valeurs qu'il est raisonnable de tester), mais nous devons en ajouter quelques unes au-dessus de 4000. Nous incluons donc 4000 (le plus court), 4500 (le second en longueur), 4888 (le plus long) et 4999 (la plus grande valeur).

***2*** La définition de «grande valeur d'entrée» a changé. Ce test appelait toRoman avec 4000 et attendait une erreur, maintenant que 4000-4999 sont des valeurs correctes, nous devons remplacer l'argument par 5000.

***3*** La définition de «trop de nombres romains répétés» a aussi changé. Ce test appelait fromRoman avec 'MMMM' et attendait une erreur, maintenant que MMMM est considéré comme un nombre romain valide, nous devons le remplacer par 'MMMMM'.
r ***4*** Le test de cohérence et les tests de casse bouclent sur tous les nombres de 1 à 3999. Maintenant que l'étendue est agrandie, ces boucles for doivent être modifiées pour aller jusqu'à 4999.

Maintenant, nos cas de test sont à jours par rapport à nos nouvelles spécifications, mais notre code ne l'est pas, on peut donc s'attendre à ce que plusieurs tests échouent.

Exemple 15.7. Sortie de romantest71.py avec roman71.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ERROR        ***1***
toRoman should always return uppercase ... ERROR
fromRoman should fail with blank string ... ok
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ERROR ***2***
toRoman should give known result with known input ... ERROR   ***3***
fromRoman(toRoman(n))==n for all n ... ERROR                  ***4***
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

***1*** Les vérifications de casse échouent puisqu'elles bouclent de 1 à 4999 et que toRoman n'accepte que des nombres de 1 à 3999, la fonction échoue dès que le test lui passe 4000 comme argument.

***2*** Le test de valeurs connues pour fromRoman échoue dès qu'il arrive à 'MMMM' puisque fromRoman considère toujours que c'est un nombre romain non valide.

***3**** Le test de valeurs connues pour toRoman échoue dès qu'il arrive à 4000 puisque toRoman considère toujours que c'est hors de l'étendu valide.

***4*** Le test de cohérence échoue également à 4000 puisque toRoman refuse cette valeur.

 
Sélectionnez
======================================================================
ERROR: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 161, in testFromRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: toRoman should always return uppercase
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 155, in testToRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 102, in testFromRomanKnownValues
    result = roman71.fromRoman(numeral)
  File "roman71.py", line 47, in fromRoman
    raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
InvalidRomanNumeralError: Invalid Roman numeral: MMMM
======================================================================
ERROR: toRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 96, in testToRomanKnownValues
    result = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 147, in testSanity
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
----------------------------------------------------------------------
Ran 13 tests in 2.213s

FAILED (errors=5)

Maintenant que nous avons des cas de test qui échouent à cause des nouvelles spécifications, nous pouvons nous tourner vers la correction du code pour le mettre en concordance avec les tests. (Une des choses qui demande un peu de temps pour s'y habituer lorsque vous commencez à utiliser les tests unitaires est que le code que l'on teste n'est jamais «en avance» sur les cas de test. Tant qu'il est derrière, vous avez du travail à faire et dès qu'il rattrape les cas de test, vous vous arrêtez d'écrire du code.)

Exemple 15.8. Ecrire le code des nouvelles spécifications (roman72.py)

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

 
Sélectionnez
"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 5000):                                                         ***1***
        raise OutOfRangeError, "number out of range (must be 1..4999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' ***2***

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not re.search(romanNumeralPattern, s):
        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*** toRoman n'a besoin que d'une petite modification, la vérification d'étendue. Là où nous vérifions que 0 < n < 4000, nous vérifions maintenant que 0 < n < 5000. Nous changeons aussi le message d'erreur de l'instruction raise pour qu'il corresponde à la nouvelle étendue (1..4999 au lieu de 1..3999). Nous n'avons pas besoin de modifier le reste de la fonction, elle prend déjà en compte les nouveaux cas. (Elle ajoute 'M' pour chaque mille qu'elle trouve, pour 4000 elle donnera 'MMMM'. La seule raison pour laquelle elle ne le faisait pas auparavant est que nous la stoppions explicitement par la vérification d'étendue.)

***2*** Nous n'avons aucune modification à faire à fromRoman. La seule modification est pour romanNumeralPattern, si vous regardez attentivement, vous verrez que nous avons ajouté un autre M optionnel dans la première section de l'expression régulière. Cela permet jusqu'à 4 M au lieu de 3, ce qui veut dire que nous permettons l'équivalent en nombres romains de 4999 au lieu de 3999. La fonction fromRoman proprement dite est totalement générale, elle ne fait que rechercher des caractères représentant des nombres romains et les additionne, sans s'occuper de savoir combien de fois ils sont répétés. La seule raison pour laquelle elle ne prenait pas 'MMMM' en charge auparavant est que nous la stoppions explicitement avec le motif de l'expression régulière.

Vous pouvez douter que ces deux petites modifications soient tout ce qui est nécessaire. Vous n'avez pas à me croire sur parole, voyez par vous-même :

Exemple 15.9. Sortie de romantest72.py avec roman72.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok
fromRoman should fail with malformed antecedents ... ok
fromRoman should fail with repeated pairs of numerals ... ok
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 13 tests in 3.685s

OK ***1***

***1*** Tous les cas de test passent. Arrêtez d'écrire du code.

Des test unitaires exhaustifs permettent de ne jamais dépendre d'un programmeur qui dit «Faites-moi confiance.»

XV-C. 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

 
Sélectionnez
>>> 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.)

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.

 
Sélectionnez
# 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

 
Sélectionnez
.............          ***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.

 
Sélectionnez
# 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

 
Sélectionnez
.............
----------------------------------------------------------------------
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.

 
Sélectionnez
# 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

 
Sélectionnez
.............
----------------------------------------------------------------------
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.

XV-D. Postscriptum

Un lecteur astucieux a lu la section précédente et l'a amené au niveau supérieur. Le point le plus compliqué (et pesant le plus sur les performances) du programme tel qu'il est écrit actuellement est l'expression régulière, qui est nécessaire puisque nous n'avons pas d'autre moyen de subdiviser un nombre romain. Mais il n'y a que 5000 nombres romains, pourquoi ne pas construire une table de référence une fois, puis simplement la lire ? Cette idée est encore meilleure quand on réalise qu'il n'y a pas besoin d'utiliser les expressions régulière du tout. Au fur et à mesure que l'on construit la table de référence pour convertir les entiers en nombres romains, on peut construire la table de référence inverse pour convertir les nombres romains en entiers.

Et le meilleur de tout, c'est que nous avons déjà un jeu complet de tests unitaires. Le lecteur a modifié la moitié du code du module, mais les tests unitaires sont restés les mêmes, ce qui lui a permis de prouver que son code fonctionnait tout aussi bien que l'original.

Exemple 15.17. roman9.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage9/ 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.

 
Sélectionnez
#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Roman numerals must be less than 5000
MAX_ROMAN_NUMERAL = 4999

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

#Create tables for fast conversion of roman numerals.
#See fillLookupTables() below.
toRomanTable = [ None ]  # Skip an index since Roman numerals have no zero
fromRomanTable = {}

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n <= MAX_ROMAN_NUMERAL):
        raise OutOfRangeError, "number out of range (must be 1..%s)" % MAX_ROMAN_NUMERAL
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"
    return toRomanTable[n]

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, "Input can not be blank"
    if not fromRomanTable.has_key(s):
        raise InvalidRomanNumeralError, "Invalid Roman numeral: %s" % s
    return fromRomanTable[s]

def toRomanDynamic(n):
    """convert integer to Roman numeral using dynamic programming"""
    result = ""
    for numeral, integer in romanNumeralMap:
        if n >= integer:
            result = numeral
            n -= integer
            break
    if n > 0:
        result += toRomanTable[n]
    return result

def fillLookupTables():
    """compute all the possible roman numerals"""
    #Save the values in two global tables to convert to and from integers.
    for integer in range(1, MAX_ROMAN_NUMERAL + 1):
        romanNumber = toRomanDynamic(integer)
        toRomanTable.append(romanNumber)
        fromRomanTable[romanNumber] = integer

fillLookupTables()

Alors, est-ce que c'est rapide ?

Exemple 15.18. Sortie de romantest9.py avec roman9.py

 
Sélectionnez
.............
----------------------------------------------------------------------
Ran 13 tests in 0.791s

OK

Rappelez-vous que la meilleure performance que nous avons obtenu dans la version originale était 13 tests en 3,315 secondes. Bien sûr, ce n'est pas une comparaison entièrement juste, puisque cette version prendra plus de temps à importer (lorsqu'elle remplit les tables de référence). Mais comme l'importation n'est faite qu'une seule fois, c'est négligeable au bout du compte.

La morale de l'histoire ?

  • La simplicité est une vertu.
  • Particulièrement avec les expressions régulières.
  • Les tests unitaires vous donnent la confiance de conduire des refactorisations à grande échelle... même si vous n'avez pas écrit le code originel.

XV-E. Résumé

Les tests unitaires forment un concept puissant qui, s'il est implémenté correctement, peut à la fois réduire les coûts de maintenance et augmenter la flexibilité d'un projet à long terme. Il faut aussi comprendre que les tests unitaires ne sont pas une panacée, une baguette magique ou une balle d'argent. Ecrire de bons cas de test est difficile et les tenir à jour demande de la discipline (surtout quand les clients réclament à hauts cris la correction de bogues critiques). Les tests unitaires ne sont pas destinés à remplacer d'autres formes de tests comme les tests fonctionnels, les tests d'intégration et les tests utilisateurs. Mais ils sont réalisables et ils marchent et une fois que vous les aurez vu marcher, vous vous demanderez comment vous avez pu vous en passer.

Ce chapitre a couvert un large sujet et une bonne partie n'était pas spécifique à Python. Il y a des frameworks de tests unitaires pour de nombreux langages qui tous exigent que vous compreniez les mêmes concepts de base :

  • Concevoir des cas de tests spécifiques, automatisés et indépendants
  • Ecrire les cas de tests avant le code qu'ils testent
  • Ecrire des tests qui testent des entrées correctes et vérifient l'obtention de résultats corrects
  • Ecrire des tests qui testent des entrées incorrectes et vérifient qu'un échec se produit
  • Ecrire et mettre à jour des cas de test pour illustrer des bogues ou refléter des nouvelles spécifications
  • Refactoriser en profondeur pour améliorer la performance, la montée en charge, la lisibilité, la facilité de maintenance ou tout autre facteur dont vous manquez

En plus, vous devez vous sentir à l'aise pour des choses plus spécifiques à Python :

  • Dériver unittest.TestCase et écrire des méthodes pour des cas de test individuels
  • Utiliser assertEqual pour vérifier qu'une fonction retourne une valeur identifiée
  • Utiliser assertRaises pour vérifier qu'une fonction déclenche une exception identifiée
  • Appeler unittest.main() dans votre clause if __name__ pour exécuter tous vos cas de tests en une fois
  • Exécuter les tests unitaires en mode détaillé ou normal

Pour en savoir plus


précédentsommairesuivant