Zanurkuj w Pythonie/plural.py, etap 6
Teraz jesteście już gotowi, aby porozmawiać o generatorach.
Przykład 17.17. plural6.py
import re
def rules(language):
for line in file('rules.%s' % language):
pattern, search, replace = line.split()
yield lambda word: re.search(pattern, word) and re.sub(search, replace, word)
def plural(noun, language='en'):
for applyRule in rules(language):
result = applyRule(noun)
if result: return result
Powyższy kod używa generatora. Nie zacznę nawet tłumaczyć, na czym polega ta technika, dopóki nie przyjrzycie się najpierw prostszemu przykładowi.
Przykład 17.18. Wprowadzenie do generatorów
>>> def make_counter(x): ... print 'entering make_counter' ... while 1: ... yield x #(1) ... print 'incrementing x' ... x = x + 1 ... >>> counter = make_counter(2) #(2) >>> counter #(3) <generator object at 0x001C9C10> >>> counter.next() #(4) entering make_counter 2 >>> counter.next() #(5) incrementing x 3 >>> counter.next() #(6) incrementing x 4
- Obecność słowa kluczowego yield w definicji make_counter oznacza, że nie jest to zwykła funkcja. To specjalny rodzaj funkcji, która generuje wartości przy każdym wywołaniu. Możecie myśleć o niej jak o funkcji kontynuującej swoje działanie: wywołanie jej zwraca obiekt generatora, który może zostać użyty do generowania kolejnych wartości x.
- Aby uzyskać instancję generatora make_counter, wystarczy wywołać funkcję make_counter. Zauważcie, że nie spowoduje to jeszcze wykonania kodu tej funkcji. Można to stwierdzić na podstawie faktu, że pierwszą instrukcją w tej funkcji jest instrukcja print, a nic jeszcze nie zostało wypisane.
- Funkcja make_counter zwraca obiekt będący generatorem.
- Kiedy po raz pierwszy wywołamy next() na obiekcie generatora, zostanie wykonany kod funkcji make_counter aż do pierwszej instrukcji yield, która spowoduje zwrócenie pewnej wartości. W tym przypadku będzie to wartość 2, ponieważ utworzyliśmy generator wywołując make_counter(2).
- Kolejne wywołania next() na obiekcie generatora spowodują kontynuowanie wykonywania kodu funkcji od miejsca, w którym wykonywanie funkcji zostało przerwane aż do kolejnego napotkania instrukcji yield. W tym przypadku następną linią kodu oczekującą na wykonanie jest instrukcja print, która wypisuje tekst "incrementing x", a kolejną - instrukcja przypisania x = x + 1, która zwiększa wartość x. Następnie wchodzimy w kolejny cykl pętli while, w którym wykonywana jest instrukcja yield, zwracająca bieżącą wartość x (obecnie jest to 3).
- Kolejne wywołanie counter.next spowoduje wykonanie tych samych instrukcji, przy czym tym razem x osiągnie wartość 4. I tak dalej. Ponieważ make_counter zawiera nieskończoną pętlę, więc teoretycznie moglibyśmy robić to w nieskończoność: generator zwiększałby wartość x i wypluwał jego bieżącą wartość. Spójrzmy jednak na bardziej produktywne przypadki użycia generatorów.
Przykład 17.19. Użycie generatorów w miejsce rekurencji
def fibonacci(max):
a, b = 0, 1 #(1)
while a < max:
yield a #(2)
a, b = b, a+b #(3)
- Ciąg Fibonacciego składa się z liczb, z których każda jest sumą dwóch poprzednich (za wyjątkiem dwóch pierwszych liczb tego ciągu). Ciąg rozpoczynają liczby 0 i 1, kolejne wartości rosną powoli, następnie różnice między nimi coraz szybciej się zwiększają. Aby rozpocząć generowanie tego ciągu, potrzebujemy dwóch zmiennych: a ma wartość 0, b ma wartość 1.
- a to bieżąca wartość w ciągu, więc zwracamy ją
- b jest kolejną wartością, więc przypisujemy ją do a, jednocześnie obliczając kolejną wartość (a+b) i przypisując ją do b do późniejszego użycia. Zauważcie, że dzieje się to równolegle; jeśli a ma wartość 3 a b ma wartość 5, wówczas w wyniku wartościowania wyrażenia a, b = b, a+b do zmiennej a zostanie przypisana wartość 5 (wcześniejsza wartość b), a do b wartość 8 (suma poprzednich wartości a i b).
Mamy więc funkcję, która wyrzuca z siebie kolejne wartości ciągu Fibonacciego. Oczywiście moglibyśmy to samo osiągnąć przy pomocy rekurencji, jednak ten sposób jest znacznie prostszy w zapisie. No i świetnie się sprawdza w przypadku pętli:
Przykład 17.20. Generatory w pętlach
>>> for n in fibonacci(1000): #(1) ... print n, #(2) 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987
- Generatory (takie jak fibonacci) mogą być używane bezpośrednio w pętlach. Pętla for utworzy obiekt generatora i będzie wywoływać na nim metodę next(), przypisując ją do zmiennej sterującej pętli (n).
- W każdym przebiegu pętli for n będzie miało nową wartość, zwróconą przez generator w instrukcji yield funkcji fibonacci, i ta wartość zostanie wypisana. Jeśli generatorowi fibonacci skończą się generowane wartości (zmienna a przekroczy max, które w tym przypadku jest równe 1000), pętla for zakończy działanie.
OK, wróćmy teraz do funkcji plural i sprawdźmy, jak tam został użyty generator.
Przykład 17.21. Generator tworzący dynamicznie funkcje
def rules(language):
for line in file('rules.%s' % language): #(1)
pattern, search, replace = line.split() #(2)
yield lambda word: re.search(pattern, word) and re.sub(search, replace, word) #(3)
def plural(noun, language='en'):
for applyRule in rules(language): #(4)
result = applyRule(noun)
if result: return result
- for line in file(...) to często spotykany w języku Python idiom służący wczytaniu po jednej wszystkich linii z pliku. Działa on w ten sposób, że file zwraca generator, którego metoda next() zwraca kolejną linię z pliku. To szalenie fajna sprawa, robię się cały mokry, kiedy tylko o tym pomyślę[1].
- Tu nie ma żadnej magii. Pamiętajcie, że każda linia w pliku z regułami zawiera trzy wartości oddzielone białym znakiem, a więc line.split zwróci trzyelementową krotkę[2], a jej trzy wartości są tu przypisywane do trzech zmiennych lokalnych.
- A teraz następuje instrukcja yield. Co zwraca yield? Zwraca ona funkcję zbudowaną dynamicznie przy użyciu notacji lambda, będącą w rzeczywistości domknięciem (używa bowiem zmiennych lokalnych pattern, search oraz replace jako stałych). Innymi słowy, rules jest generatorem który generuje funkcje reprezentujące reguły.
- Jeśli rules jest generatorem, to znaczy, że możemy użyć go bezpośrednio w pętli for. W pierwszym przebiegu pętli wywołanie funkcji rules spowoduje otworzenie pliku z regułami, przeczytanie pierwszej linijki oraz dynamiczne zbudowanie funkcji, która dopasowuje i modyfikuje na podstawie pierwszej odczytanej reguły z pliku, po czym nastąpi zwrócenie zbudowanej w ten sposób funkcji w instrukcji yield. W drugim przebiegu pętli for wykonywanie funkcji rules rozpocznie się tam, gdzie się ostatnio zakończyło (a więc w środku pętli for line in file(...)), zostanie więc odczytana druga linia z pliku z regułami, zostanie dynamicznie zbudowana inna funkcja dopasowująca i modyfikująca na podstawie zapisanej w pliku reguły, po czym ta funkcja zostanie zwrócona w instrukcji yield. I tak dalej.
Co takiego osiągnęliśmy w porównaniu z etapem 5? W etapie 5 wczytywaliśmy cały plik z regułami i budowaliśmy listę wszystkich możliwych reguł zanim nawet wypróbowaliśmy pierwszą z nich. Teraz, przy użyciu generatora, podchodzimy do sprawy w sposób leniwy: otwieramy plik i wczytujemy pierwszą linię w celu zbudowania funkcji, jednak jeśli ta funkcja zadziała (reguła zostanie dopasowana, a rzeczownik zmodyfikowany), nie będziemy niepotrzebnie czytać dalej linii z pliku ani tworzyć jakichkolwiek innych funkcji.