Zanurkuj w Pythonie/roman.py, etap 5

Funkcja fromRoman działa poprawnie dla poprawnych danych wejściowych, nadszedł więc czas na dołożenie ostatniego klocka w naszej układance: napisanie kodu, dzięki któremu funkcja ta będzie działała poprawnie również dla niepoprawnych danych wejściowych. Oznacza to, że musimy znaleźć sposób na ustalenie, czy dany napis stanowi poprawną rzymską reprezentację pewnej wartości. To zadanie jest znacznie trudniejsze, niż sprawdzenie poprawności wartości liczbowej w funkcji toRoman, jednak możemy do tego celu użyć silnego narzędzia: wyrażeń regularnych.

Jeśli nie znacie wyrażeń regularnych i nie przeczytaliście jeszcze podrozdziału 7 Wyrażenia regularne, nadszedł właśnie doskonały moment, aby to zrobić.

Jak widzieliśmy w podrozdziale 7.3 Analiza przypadku: Liczby rzymskie, istnieje kilka prostych reguł, dzięki którym można skonstruować napis reprezentujący wartość liczbową w zapisie rzymskim, używając liter M, D, C, L, X, V oraz I. Prześledźmy je po kolei:

  1. Znaki można dodawać. I to 1, II to 2, III to 3. VI to 6 (dosłownie: "5 i 1"), VII to 7, a VIII to 8.
  2. Liczby składające się z jedynki i (być może) zer (I, X, C oraz M) - liczby "dziesiątkowe" - mogą być powtarzane do trzech razy. Przy czwartym należy odjąć tę wartość od znaku reprezentującego liczbę składającą się z piątki i (być może) zer - liczbę "piątkową". Nie można przedstawić liczby 4 jako IIII, należy przedstawić ją jako IV ("1 odjęte od 5"). Liczbę 40 zapisujemy jako XL ("10 odjęte od 50"), 41 jako XLI, 42 jako XLII, 43 jako XLIII, a 44 jako XLIV ("10 odjęte od 50 oraz 1 odjęte od 5").
  3. Podobnie tworzymy liczby "dziewiątkowe": należy odejmować od najbliższej liczby dziesiątkowej: 8 to VIII, jednak 9 to IX ("1 odjęte od 10"), a nie VIIII (ponieważ I nie może być powtórzone więcej niż trzy razy), zaś 90 to XC, a 900 to CM.
  4. Liczby "piątkowe" nie mogą być powtarzane. 10 zawsze reprezentowane jest jako X, a nie VV, 100 jako C, nigdy zaś jako LL.
  5. Liczby w reprezentacji rzymskiej są zawsze zapisywane od największych do najmniejszych i odczytywane od lewej do prawej, a więc porządek znaków ma ogromne znaczenie. DC to 600; CD to zupełnie inna liczba (400, "100 odjęte od 500"). CI to 101, IC zaś nie jest poprawną wartością w zapisie rzymskim, ponieważ nie można bezpośrednio odjąć 1 od 100: należałoby zapisać XCIX ("10 odjęte od 100 oraz 1 odjęte od 10").

Przykład 14.12. roman5.py

Plik jest dostępny w katalogu in py/roman/stage5/ wewnątrz katalogu examples.

Jeśli jeszcze tego nie zrobiliście, możecie pobrać ten oraz inne przykłady używane w tej książce stąd.

"""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, "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?(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. To kontynuacja wyrażenia, o którym dyskutowaliśmy w podrozdziale 7.3 Analiza przypadku: Liczby rzymskie. Miejsce "dziesiątki" jest w napisie XC (90), XL (40) oraz w napisie złożonym z opcjonalnego L oraz następującym po niej opcjonalnym znaku X powtórzonym od 0 do 3 razy. Miejsce "jedynki" jest w napisie IX (9), IV (4) oraz przy opcjonalnym V z następującym po niej, opcjonalnym znakiem I powtórzonym od 0 do 3 razy.
  2. Po wpisaniu tej logiki w wyrażenie regularne otrzymamy trywialny kod sprawdzający poprawność napisów potencjalnie reprezentujących liczby rzymskie. Jeśli re.search zwróci obiekt, wyrażenie regularne zostało dopasowane, a więc dane wejściowe są poprawne; w przeciwnym wypadku, dane wejściowe są niepoprawne.

W tym momencie macie prawo być nieufni wobec tego wielkiego, brzydkiego wyrażenia regularnego, które ma się rzekomo dopasować do wszystkich poprawnych napisów reprezentujących liczby rzymskie. Oczywiście, nie musicie mi wierzyć, spójrzcie zatem na wyniki testów:

Example 14.13. Output of romantest5.py against 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. Jedna rzecz o jakiej nie wspomniałem w kontekście wyrażeń regularnych to fakt, że są one zależne od wielkości znaków. Ze względu na to, że wyrażenie regularne romanNumeralPattern zostało zapisane przy użyciu wielkich liter, sprawdzenie re.search odrzuci wszystkie napisy, które zawierają przynajmniej jedną małą literę. Dlatego też test wielkich liter przechodzi.
  2. Co więcej, przechodzą również testy nieprawidłowych danych wejściowych. Przykładowo test niepoprawnych poprzedników sprawdza przypadki takie, jak MCMC. Jak widzimy, wyrażenie regularne nie pasuje do tego napisu, a więc fromRoman rzuca wyjątek InvalidRomanNumeralError, i jest to dokładnie taki wyjątek, jakiego spodziewa się test niepoprawnych poprzedników, a więc test ten przechodzi.
  3. Rzeczywiście przechodzą wszystkie testy sprawdzające niepoprawne dane wejściowe. Wyrażenie regularne wyłapuje wszystkie przypadki, o jakich myśleliśmy podczas przygotowywania naszych przypadków testowych.
  4. Nagrodę największego rozczarowania roku otrzymuje słówko "OK", które zostało wypisane przez moduł unittest w chwili gdy okazało się, że wszystkie testy zakończyły się powodzeniem.