Zanurkuj w Pythonie/Refaktoryzacja

Najcenniejszą rzeczą, jaką daje testowanie jednostkowe, nie jest uczucie, jakiego doświadczamy, kiedy wszystkie testy przechodzą, ani nawet uczucie w chwili, gdy ktoś obwinia nas o popsucie swojego kodu, a my jesteśmy w stanie udowodnić, że to nie nasza wina. Najcenniejszą rzeczą w testowaniu jednostkowym jest to, że daje nam ono nieskrępowaną wolność podczas refaktoryzacji.

Refaktoryzacja to proces, który polega na tym, że bierze się działający kod i zmienia go tak, aby działał jeszcze lepiej. Zwykle "lepiej" znaczy "szybciej", choć może to również znaczyć "przy mniejszym zużyciu pamięci", "przy mniejszym zużyciu przestrzeni dyskowej" czy nawet "bardziej elegancko". Czymkolwiek refaktoryzacja jest dla was, dla waszego projektu czy waszego środowiska pracy, służy ona utrzymaniu programu w dobrym zdrowiu przez długi czas.


W naszym przypadku "lepiej" znaczy "szybciej". Dokładnie rzecz ujmując, funkcja fromRoman jest wolniejsza niż musiałaby być ze względu na duże, brzydkie wyrażenie regularne, którego używamy do zweryfikowania, czy napis stanowi poprawną reprezentację liczby w notacji rzymskiej. Prawdopodobnie nie opłaca się całkowicie eliminować tego wyrażenia (byłoby to trudne i mogłoby doprowadzić do powstania jeszcze wolniejszego kodu), jednak można uzyskać pewne przyspieszenie dzięki temu, że wyrażenie regularne zostanie wstępnie skompilowane.

'''Przykład 15.10. Kompilacja wyrażenia regularnego'''
  >>> 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. To składnia, którą już wcześniej widzieliście: re.search pobiera wyrażenie regularne jako napis (pattern) oraz napis, do którego wyrażenie będzie dopasowywane ('M'). Jeśli wyrażenie zostanie dopasowane, funkcja zwróci obiekt match, który można następnie odpytać, aby dowiedzieć się, co zostało dopasowane i w jaki sposób.
  2. To jest już nowa składnia: re.compile pobiera wyrażenie regularne jako napis i zwraca obiekt pattern. Zauważmy, że nie przekazujemy napisu, do którego będzie dopasowywane wyrażenie. Kompilacja wyrażenia regularnego nie ma nic wspólnego z dopasowywaniem wyrażenia do konkretnego napisu (jak np. 'M'); dotyczy ona wyłącznie samego wyrażenia.
  3. Obiekt pattern zwrócony przez funkcję re.compile posiada wiele pożytecznie wyglądających funkcji, między innymi kilka takich, które są dostępne bezpośrednio w module re (np. search czy sub).
  4. Wywołując funkcji search na obiekcie pattern z napisem 'M' jako parametrem osiągamy ten sam efekt, co wywołując re.search z wyrażeniem regularnym i napisem 'M' jako parametrami. Z tą różnicą, że osiągamy go o wiele, wiele szybciej. (W rzeczywistości funkcja re.search kompiluje wyrażenie regularne i na obiekcie będącym wynikiem tej kompilacji wywołuje metodę search.)


Przykład 15.11.Skompilowane wyrażenie regularne w roman81.py

Plik jest dostępny w katalogu in py/roman/stage8/ 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.

# 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. Wygląda podobnie, choć w rzeczywistości bardzo dużo się zmieniło. romanNumeralPattern nie jest już napisem; to obiekt pattern, który został zwrócony przez re.compile.
  2. Ta linia oznacza, że na obiekcie romanNumeralPattern można bezpośrednio wywoływać metody. Będą one wykonane o wiele szybciej, niż np. podczas każdorazowego wywołania re.search. Tutaj wyrażenie regularne zostało skompilowane dokładnie jeden raz i zapamiętane pod nazwą romanNumeralPattern w momencie pierwszego importu modułu; od tego momentu. ilekroć będzie wywołana metoda fromRoman, gotowe wyrażenie będzie dopasowywane do napisu wejściowego, bez żadnych kroków pośrednich odbywających się niejawnie.


Wobec tego o ile szybciej działa kod po skompilowaniu wyrażenia regularnego? Sprawdźcie sami:


Przykład 15.12. Wyjście programu romantest81.py testującego roman81.py

 .............                                                                      #(1)
 ----------------------------------------------------------------------             
 Ran 13 tests in 3.385s                                                             #(2)
 
 OK                                                                                 #(3)
  1. Tutaj tylko mała uwaga: tym razem uruchomiłem testy bez podawania opcji -v, dlatego też zamiast pełnego napisu komentującego dla każdego testu, który zakończył się powodzeniem, została wypisana kropka. (Gdyby test zakończył się niepowodzeniem, zostałaby wypisana litera F, a w przypadku błędu - litera E. Potencjalne problemy można wciąż łatwo zidentyfikować, ponieważ w razie niepowodzeń lub błędów wypisywana jest zawartość stosu.)
  2. Uruchomienie 13 testów zajęło 3.385 sekund w porównaniu z 3.685 sekund, jakie zajęły testy bez wcześniejszej kompilacji wyrażenia regularnego. To poprawa w wysokości 8%, a warto pamiętać, że przez większość czasu w testach jednostkowych wykonywane są także inne rzeczy. (Gdy przetestowałem same wyrażenia regularne, niezależnie od innych testów, okazało się, że kompilacja wyrażenia regularnego polepszyła czas operacji wyszukiwania średnio o 54%.) Nieźle, jak na taką niewielką poprawkę.
  3. Och, gdybyście się jeszcze zastanawiali, prekompilacja wyrażenia regularnego niczego nie zepsuła, co właśnie udowodniliśmy.


Jest jeszcze jedna optymalizacja wydajności, którą chciałem wypróbować. Nie powinno być niespodzianką, że przy wysokiej złożoności składni wyrażeń regularnych istnieje więcej niż jeden sposób napisania tego samego wyrażenia. W trakcie dyskusji nad tym rozdziałem, jaka odbyła się na comp.lang.python ktoś zasugerował, żebym dla powtarzających się opcjonalnych znaków spróbował użyć składni {m, n}.


Przykład 15.13. roman82.py

Plik jest dostępny w katalogu in py/roman/stage8/ 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.

# 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. Zastąpiliśmy M?M?M?M? wyrażeniem M{0,4}. Obydwa zapisy oznaczają to samo: "dopasuj od 0 do 4 znaków M". Podobnie C?C?C? zostało zastąpione C{0,3} ("dopasuj od 0 do 3 znaków C") i tak dalej dla X oraz I.

Powyższy zapis wyrażenia regularnego jest odrobinę krótszy (choć nie bardziej czytelny). Pytanie brzmi: czy jest on szybszy?


Przykład 15.14. Wyjście programu romantest82.py testującego roman82.py

 .............
 ----------------------------------------------------------------------
 Ran 13 tests in 3.315s                                                             #(1)
 
 OK                                                                                 #(2)
  1. Przy tej formie wyrażenia regularnego testy jednostkowe działały w sumie 2% szybciej. Nie brzmi to może zbyt ekscytująco, dlatego przypomnę, że wywołania funkcji wyszukującej stanowią niewielką część wszystkich testów; przez większość czasu testy robią co innego. (Gdy niezależnie od innych testów przetestowałem wyłącznie wydajność wyrażenia regularnego, okazało się, że jest ona o 11% większa przy nowej składni). Dzięki prekompilacji wyrażenia regularnego i zmianie jego składni udało się poprawić wydajność samego wyrażenia o ponad 60%, a wszystkich testów łącznie o ponad 10%.
  2. Znacznie ważniejsze od samego wzrostu wydajności jest to, że moduł wciąż doskonale działa. To jest właśnie wolność, o której wspominałem już wcześniej: wolność poprawiania, zmieniania i przepisywania dowolnego fragmentu kodu i możliwość sprawdzenia, że zmiany te w międzyczasie wszystkiego nie popsuły. Nie chodzi tu o poprawki dla samych poprawek; mieliśmy bardzo konkretny cel ("przyspieszyć toRoman") i byliśmy w stanie go zrealizować bez zbytnich wahań i troski o to, czy nie wprowadziliśmy do kodu nowych błędów.


Chcę zrobić jeszcze jedną, ostatnią zmianę i obiecuję, że na niej skończę refaktoryzację i dam już temu modułowi spokój. Jak wielokrotnie widzieliście, wyrażenia regularne szybko stają się bardzo nieporządne i mocno tracą na swej czytelności. Naprawdę nie chciałbym dostać tego modułu do utrzymania za sześć miesięcy. Oczywiście, testy przechodzą, więc mam pewność, że kod działa, jednak jeśli nie jestem całkowicie pewien, w jaki sposób on działa, to będzie mi trudno dodawać do niego nowe wymagania, poprawiać błędy czy w inny sposób go utrzymywać. W podrozdziale Zanurkuj w Pythonie/Rozwlekłe_wyrażenia_regularne widzieliście, że Python umożliwia dokładne udokumentowanie logiki kodu.


Przykład 15.15. roman83.py

Plik jest dostępny w katalogu in py/roman/stage8/ 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.

# 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. Funkcja re.compile może przyjmować drugi (opcjonalny) argument, będący zbiorem znaczników kontrolujących wiele aspektów skompilowanego wyrażenia regularnego. Powyżej wyspecyfikowaliśmy znacznik re.VERBOSE, który podpowiada kompilatorowi języka Python, że wewnątrz wyrażenia regularnego znajdują się komentarze. Komentarze te wraz z białymi znakami nie stanowią części wyrażenia regularnego; funkcja re.compile nie bierze ich pod uwagę podczas kompilacji. Dzięki temu zapisowi, choć samo wyrażenie jest identyczne jak poprzednio, jest ono niewątpliwie znacznie bardziej czytelne.


Przykład 15.16. Wyjście z programu romantest83.py testującego roman83.py

 .............
 ----------------------------------------------------------------------
 Ran 13 tests in 3.315s                                                             #(1)
 
 OK                                                                                 #(2)
  1. Nowa, "rozwlekła" wersja wyrażenia regularnego działa dokładnie tak samo, jak wersja poprzednia. Rzeczywiście, skompilowane obiekty wyrażeń regularnych będą identyczne, ponieważ re.compile wyrzuca z wyrażenia dodatkowe znaki, które umieściliśmy tam w charakterze komentarza.
  2. Nowa, "rozwlekła" wersja wyrażenia regularnego przechodzi wszystkie testy, tak jak wersja poprzednia. Nie zmieniło się nic oprócz tego, że programista, który wróci do kodu po sześciu miesiącach będzie miał możliwość zrozumienia, w jaki sposób działa wyrażenie regularne.