7.13. Rifattorizzazione

La cosa migliore nel fare test esaustivi delle unità di codice non è la sensazione piacevole che si ha quando tutti i test hanno finalmente successo, e neanche la soddisfazione di quando qualcun altro ti rimprovera di aver scombinato il loro codice e tu puoi effettivamente provare che non è vero. La cosa migliore nell'effettuare i test delle unità di codice è la sensazione che ti lascia la libertà di rifattorizzare senza provare rimorsi.

La rifattorizzazione è il procedimento con il quale si prende del codice funzionante e lo si modifica in modo che funzioni meglio. Di solito, “meglio” significa “più velocemente”, sebbene possa anche significare “che usa meno memoria” oppure “che usa meno spazio su disco” o semplicemente “in modo più elegante”. Qualunque sia il significato per voi, per il vostro progetto e per il vostro ambiente, la rifattorizzazione è importante per la salute a lungo termine di ogni programma.

Nel nostro caso, “meglio” significa “più veloce”. In particolar modo, la funzione fromRoman è più lenta del necessario, a causa di quella lunga e ostica espressione regolare che viene usata per verificare i numeri romani. Probabilmente non vale la pena di cercare di fare a meno completamente delle espressioni regolari (sarebbe difficile, e potrebbe risultare niente affatto più veloce), ma possiamo velocizzare la funzione precompilando l'espressione regolare.

Esempio 7.31. Compilare le espressioni regolari

>>> 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 Questa è la sintassi che abbiamo visto in precedenza: re.search prende un'espressione regolare in forma di stringa (pattern) e una stringa con cui confrontarla ('M'). Se il pattern corrisponde, la funzione restituisce un oggetto corrispondenza, che può essere analizzato per capire essattamente cosa corrisponde a cosa ed in che modo.
2 Questa è la nuova sintassi: re.compile prende un'espressione regolare come stringa e restituisce un oggetto di tipo pattern. Si noti che non c'è nessuna stringa da confrontare in questo caso. Compilare un'espressione regolare non ha niente a che vedere con il fare il confronto tra l'espressione ed una stringa specifica (come 'M'); la compilazione coinvolge solamente l'espressione regolare.
3 L'oggetto pattern compilato, restituito da re.compile, ha diverse funzioni che sembrano utili, incluse alcune (come search e sub) che sono disponibili direttamente nel modulo re.
4 Chiamare la funzione search dell'oggetto pattern compilato, passandogli la stringa 'M' produce lo stesso risultato di una chiamata a re.search passandogli come argomenti sia l'espressione regolare che la stringa 'M'. Solo che è molto, molto più veloce. (In effetti, la funzione re.search semplicemente compila l'espressione regolare di input e chiama al posto nostro il metodo search del risultante oggetto pattern compilato.)
Nota
Ogni qualvolta si ha intenzione di usare più di una volta una espressione regolare, la si dovrebbe prima compilare per ottenerne il corrispondente oggetto pattern, e quindi chiamare direttamente i metodi di tale oggetto.

Esempio 7.32. Uso di un espressione regolare compilata in roman81.py

Se non lo avete ancora fatto, potete scaricare questo ed altri esempi usati in questo libro.

# 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 Questa sembra molto simile alla precedente, ma in effetti sono cambiate molte cose. romanNumeralPattern non è più una stringa; è l'oggetto patter restituito da re.compile.
2 Questo significa che possiamo chiamare direttamente i metodi di romanNumeralPattern. Questo risulterà molto, molto più veloce che chiamare ogni volta re.search. L'espressione regolare è compilata una volta e poi memorizzata in romanNumeralPattern quando il modulo è importato per la prima volta; poi, ogni volta che viene chiamata fromRoman, è immediatamente possibile confrontare la stringa di input con l'espressione regolare, senza che nessun passaggio intermedio sia eseguito in modo implicito.

E allora, quanto abbiamo guadagnato in velocità compilando l'espressione regolare? Osservate voi stessi:

Esempio 7.33. Output di romantest81.py a fronte di roman81.py

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

OK                     3
1 Solo una nota di passaggio: questa volta, ho eseguito i test senza l'opzione -v, cosicché invece della completa stringa di documentazione, per ogni test che ha successo è stampato solo un carattere punto. (Se un test fallisce, è stampata una F, se ha un errore, è stampata una E. Viene sempre stampato il traceback completo per ogni fallimento od errore, cosicché possiamo risalire a qual'era il problema.)
2 Abbiamo eseguito 13 test in 3.385 secondi, a fronte dei 3.685 secondi ottenuti senza precompilare l'espressione regolare. Questo è un miglioramento complessivo dell'8%, senza contare che che la maggior parte del tempo speso durante i test è stato utilizzato facendo altre cose. (Separatamente, ho misurato il tempo occorso all'elaborazione delle espressioni regolari a sé stanti, separate dal resto dei test, e ho trovato che compilare l'espressione regolare accellera l'esecuzione della ricerca di una media del 54%.) Non male, per una modifica così semplice.
3 Oh, e nel caso ve lo stesse chiedendo, precompilare la nostra espressione regolare non ha scombinato niente, lo abbiamo appena dimostrato.

C'è un'altra ottimizzazione di prestazioni che voglio provare. Data la complessità della sintassi delle espressioni regolari, non dovrebbe sorprendere il fatto che spesso ci sia più di un modo per scrivere la stessa espressione. Dopo qualche discussione circa questo modulo nel newsgroup comp.lang.python, qualcuno mi ha suggerito di provare ad usare la sintassi {m,n} per i caratteri opzionali ripetuti.

Esempio 7.34. roman82.py

Se non lo avete ancora fatto, potete scaricare questo ed altri esempi usati in questo libro.

# 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 Abbiamo rimpiazzato M?M?M?M? con M{0,4}. Entrambi significano la stessa cosa: “corrisponde con una stringa formata da 0 a 4 caratteri M”. Allo stesso modo, C?C?C? diventa C{0,3} (“corrisponde con una stringa formata da 0 a 3 caratteri C”) e lo stesso è fatto per le X e le I.

Questa forma dell'espressione regolare è un po' più corta (sebbene non certo più leggibile). Veniamo alla grande domanda: è più veloce?

Esempio 7.35. Output di romantest82.py a fronte di roman82.py

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

OK                     2
1 Nel complesso, i test girano il 2% più veloci con questa forma della nostra espressione regolare. Questo non suona particolarmente eccitante, ma tenete presente che la funzione search è una piccola parte di tutto il test; la maggior parte del tempo è spesa facendo altre cose. (Separatamente, ho misurato il tempo impiegato solo dalle espressioni regolari, ed ho trovato che la funzione di ricerca è l'11% più veloce con questa sintassi.) Precompilando l'espressione regolare e riscrivendo parte di essa per usare questa nuova sintassi, abbiamo migliorato le prestazioni dell'espressione regolare di più del 60% e le prestazioni generali di tutto il test di oltre il 10%.
2 Più importante di ogni aumento di prestazioni è il fatto che il modulo continui a funzionare perfettamente. Questa è la libertà di cui vi parlavo in precedenza: la liberta di ritoccare, cambiare o riscrivere qualunque parte del codice e poter poi verificare di non aver combinato guai nel farlo. Questa non è una licenza a ritoccare all'infinito il vostro codice giusto per il piacere di farlo; noi avevamo un obbiettivo molto specifico (“rendere fromRoman più veloce”), e siamo stati capaci di raggiungerlo senza l'angoscia latente di avere introdotto nuovi bachi con le modifiche fatte.

C'è un altro ritocco che vorrei fare, e quindi prometto che poi smetterò di rifattorizzare questo modulo e lo metterò a nanna. Come abbiamo visto più volte, le espressioni regolari possono rapidamente diventare piuttosto rognose ed illeggibili. Non mi piacerebbe tornare su questo modulo fra sei mesi e cercare di fargli delle modifiche. Certo, i test hanno successo, per cui so che il modulo funziona, ma se non sono in grado di capire come funziona non sarò capace di aggiungere nuove caratteristiche, eliminare nuovi bachi, oppure manutenerlo in altro modo. La documentazione è un fattore critico per la manutenibilità del codice, e Python fornisce un modo di documentare in modo esteso le espressioni regolari.

Esempio 7.36. roman83.py

Se non lo avete ancora fatto, potete scaricare questo ed altri esempi usati in questo libro.

# 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 funzione re.compile può prendere un secondo argomento opzionale, che è un insieme di uno o più flag che controllano varie opzioni sull'espressione regolare compilata. Qui stiamo specificando il flag re.VERBOSE, che dice a Python che all'interno dell'espressione regolare vi sono commenti. I commenti e i caratteri vuoti prima e dopo di essi non vengono considerati come parte dell'espressione regolare; la funzione re.compile si limita a rimuoverli prima di compilare l'espressione. Questa nuova, “prolissa” (ndt: “verbose”) versione dell'espressione regolare è identica a quella vecchia, ma è infinitamente più leggibile.

Esempio 7.37. Output di romantest83.py a fronte di roman83.py

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

OK                     2
1 Questa nuova versione “prolissa” gira esattamente alla stessa velocità della vecchia. In effetti, gli oggetti pattern compilati sono identici, visto che la funzione re.compile rimuove tutta la roba che abbiamo aggiunto.
2 Questa nuova versione “prolissa” supera gli stessi test della vecchia versione. Niente è cambiato, eccetto che il programmatore che ritorna su questo modulo dopo sei mesi ha più possibilità di capire come lavora la funzione.