7.10. roman.py, fase 5

Ora che fromRoman funziona correttamente con input validi, è tempo di far combaciare l'ultimo pezzo del puzzle: farla funzionare con input non validi. Senza troppi giri di parole cerchiamo di osservare una stringa e di determinare se è un numero romano valido. Questo è ancor più difficoltoso se paragonato agli input validi in toRoman, ma noi abbiamo a disposizione un potente strumento a disposizione: le espressioni regolari

Se non avete familiarità con le espressioni regolari e non avete letto il capitolo sulle Espressioni regolari, questo è il momento opportuno per farlo.

Come abbiamo visto all'inizio di questo capitolo, ci sono diverse semplici regole per comporre un numero romano. La prima di tali regole è che le migliaia, ammesso che ve ne siano, sono rappresentate da una serie di caratteri M.

Esempio 7.18. Controllare la presenza delle migliaia

>>> import re
>>> pattern = '^M?M?M?$'       1
>>> re.search(pattern, 'M')    2
<SRE_Match object at 0106FB58>
>>> re.search(pattern, 'MM')   3
<SRE_Match object at 0106C290>
>>> re.search(pattern, 'MMM')  4
<SRE_Match object at 0106AA38>
>>> re.search(pattern, 'MMMM') 5
>>> re.search(pattern, '')     6
<SRE_Match object at 0106F4A8>
1 Questo pattern ha tre parti:
  1. ^ - che fa in modo che quello che segue corrisponda solo se è all'inizio della stringa. Se dopo non venisse specificato nient'altro, qualunque stringa corrisponderebbe con questo pattern, non importa dove si trovino i caratteri M, che non è quello che vogliamo. Noi vogliamo essere sicuri che i caratteri M, se ci sono, siano all'inizio della stringa.
  2. M? - che corrisponde ad un singolo carattere M, opzionale. Dato che M? viene ripetuto tre volte, abbiamo un pattern che corrisponde a qualunque stringa contenente da 0 a 3 caratteri M di seguito.
  3. $ - che corrisponde solamente con ciò che precede la fine di una stringa. Quando combinato con il carattere ^ all'inizio, fa in modo che il pattern specificato debba corrispondere con l'intera stringa, senza altri caratteri prima o dopo i caratteri M.
2 L'essenza del modulo re è la funzione di ricerca search che prende una espresione regolare (pattern) e una stringa ('M') per cercare di confrontarle l'una con l'altra. Se si trova una corrispondenza, la funzione search restituisce un oggetto che ha vari metodi che danno i dettagli sulla corrispondenza; se non c'è una corrispondenza, la funzione search restituisce None, il valore nullo in Python. Non entreremo nei dettagli sull'oggetto restituito dalla funzione (sebbene sia un argomento interessante), perché tutto ciò che ci importa al momento è sapere se c'è corrispondenza, cosa che possiamo stabilire semplicemente dal valore restituito dalla funzione search. La stringa 'M' corrisponde alla nostra espressione regolare, perché il primo carattere opzionale M corrisponde ed il secondo ed il terzo carattere opzionale M vengono ignorati.
3 La stringa 'MM' corrisponde perché il primo ed il secondo carattere M opzionali corrispondono ed il terzo è ignorato.
4 'MMM' corrisponde perché tutti e tre i caratteri M corrispondono.
5 'MMMM' non corrisponde. Tutti e tre i caratteri M corrispondono, ma poi l'espressione regolare richiede che la stringa finisca (a causa del caratterer $) mentre la stringa non è ancora finita (a causa della quarta M) . Quindi la funzione search restituisce None.
6 È interessante notare che la stringa vuota ha corrispondenza con la nostra espressione regolare perché tutti i caratteri M richiesti sono opzionali. Tenetelo a mente, risulterà importante nella prossima sezione.

Trattare le centinaia è più difficile che trattare le migliaia, perché ci sono diversi modi mutualmente esclusivi in cui possono essere espresse, a seconda del valore da considerare.

Dunque ci sono quattro possibili schemi:

  1. CM
  2. CD
  3. da 0 a 3 caratteri C (0 se non ci sono centinaia)
  4. D, seguito da 0 a 3 caratteri C

Gli ultimi due schemi possone essere riassunti in uno:

Esempio 7.19. Controllare la presenza delle centinaia

>>> import re
>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)$' 1
>>> re.search(pattern, 'MCM')            2
<SRE_Match object at 01070390>
>>> re.search(pattern, 'MD')             3
<SRE_Match object at 01073A50>
>>> re.search(pattern, 'MMMCCC')         4
<SRE_Match object at 010748A8>
>>> re.search(pattern, 'MCMC')           5
>>> re.search(pattern, '')               6
<SRE_Match object at 01071D98>
1 Questo pattern comincia come il precedente, controllando che dall'inizio della stringa (^) ci siano fino a tre caratteri di migliaia (M?M?M?). Quindi c'è la parte nuova, tra parentesi, che definisce un insieme di tre pattern mutualmente esclusivi, separati da barre verticali: CM, CD, e D?C?C?C? (quest'ultimo è una D opzionale seguita da 0 a 3 caratteri C opzionali). Il parser delle espressioni regolari cerca una corrispondenza per ciascuno di questi tre pattern, nell'ordine (da sinistra a destra), prendendo il primo pattern che corrisponde ed ignorando il resto.
2 'MCM' corrisponde, perché il primo pattern M corrisponde, il secondo ed il terzo M sono ignorati, il pattern CM corrisponde (cosicché i patterns alternativi CD e D?C?C?C? non sono mai nemmeno considerati). MCM è la rappresentazione in numeri romani di 1900.
3 'MD' corrisponde perché il primo pattern M corrisponde, il secondo ed il terzo M sono ignorati ed il pattern D?C?C?C? corrisponde con la stringa D (ciascuno dei 3 caratteri C nel pattern è opzionale ed in questo caso sono ignorati). MD è la rappresentazione in numeri romani di 1500.
4 'MMMCCC' corrisponde perché tutti e 3 i pattern M corrispondono, ed il pattern D?C?C?C? corrisponde con CCC (la D è opzionale ed è ignorata). MMMCCC è la rappresentazione in numeri romani di 3300.
5 'MCMC' non corrisponde. Il primo pattern M corrisponde, il secondo ed il terzo M sono ignorati, il pattern CM corrisponde, ma poi $ non corrisponde perché non siamo ancora alla fine della stringa (abbiamo ancora un carattere C che non corrisponde a niente). Il carattere C non non corrisponde come parte del pattern D?C?C?C? perché è già stata trovata una corrispondenza con il pattern CM, che è mutualmente esclusivo rispetto al pattern D?C?C?C?
6 È interessante notare che la stringa vuota continua a corrispondere a questo pattern, perché tutti i caratteri M sono opzionali ed ignorati, e la stringa vuota corrisponde al pattern D?C?C?C? in cui tutti i caratteri sono opzionali e possono essere ignorati.

Wow! Vedete quanto rapidamente un'espressione regolare può diventare maligna? Ed abbiamo solo coperto le migliaia e le centinaia. (Più tardi, in questa sezione, vedremo una sintassi leggermente diversa per scrivere le espressioni regolari; questa sintassi, pur essendo altrettanto complicata, perlomeno ci permette di documentare sul posto le differenti componenti delle espressioni.) Fortunatamente, se avete seguito fino ad ora, le decine e le unità sono facili, perché seguono esattamente lo stesso pattern.

Esempio 7.20. roman5.py

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

"""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 < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "decimals 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?(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 re.search(romanNumeralPattern, 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 è solo la continuazione del pattern che abbiamo costruito per gestire le migliaia e le centinaia. Le decine sono rappresentate o da XC (90), XL (40), oppure da un opzionale L seguito da zero a tre opzionali X. Le unità sono rappresentate o da IX (9), IV (4) o da un opzionale V seguito da zero a tre opzionali I.
2 Avendo codificato tutta questa logica nella nostra espressione regolare, il codice per controllare numeri romani non validi diventa banale. Se re.search restituisce un oggetto, vuol dire che è stata trovata una corrispondenza con la nostra espressione regolare ed il nostro input è valido; altrimenti, il nostro input non è valido.

A questo punto, siete autorizzati ad essere scettici sul fatto che quella orribile espressione regolare possa filtrare tutti i tipi di numeri romani non validi. Ma non dovete prendere per buona la mia parola; osservate i risultati:

Esempio 7.21. Output di romantest5.py a confronto con roman5.py


fromRoman should only accept uppercase input ... ok          1
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      2
fromRoman should fail with repeated pairs of numerals ... ok 3
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 12 tests in 2.864s

OK                                                           4
1 Una cosa che non ho detto a proposito delle espressioni regolari è che, di norma, trattano diversamente maiuscole e minuscole. Dato che la nostra espressione regolare romanNumeralPattern era espressa in caratteri maiuscoli, il controllo fatto con re.search rigetta ogni input che non sia completamente in lettere maiuscole. Quindi il nostro test sugli input in lettere maiuscole passa con succcesso.
2 Cosa ancora più importante, tutti i nostri test con valori non validi passano con successo. Per esempio, il test sull'ordine erroneo delle cifre controlla casi come MCMC. Come abbiamo visto, questa stringa non corrisponde alla nostra espressione regolare, per cui la funzione fromRoman solleva una eccezione InvalidRomanNumeralError, che è quello che il nostro test si aspetta, per cui il test ha successo.
3 In effetti, tutti i nostri test con input non validi hanno successo. Questa nostra espressione regolare individua tutti i casi a cui avevamo pensato quando abbiamo preparato i nostri test.
4 Il premio dell'anno per il termine più eccitante va alla parola “OK”, che è stampata dal modulo unittest quando tutti i test hanno successo.
Nota
Quando tutti i test passano con successo, smettete di scrivere codice.