Przedmowa
Napisanie na prośbę Davida przedmowy do jego nowej książki było dla mnie wielką przyjemnością, jako że zawsze spodziewam się od niego przydatnych i wnikliwych treści.
Choć od początku miałem wysokie oczekiwania, z radością mogę stwierdzić, że zostały one nie tylko spełnione, ale wręcz przewyższone: ta książka jest wciągającą lekturą, oferuje mnóstwo cennych informacji dla średniozaawansowanych i zaawansowanych programistów pozwalając im doskonalić swoje umiejętności w Pythonie, obszernie dzieli się cennym doświadczeniem związanym z wykorzystywaniem i nauczaniem języka, a przy tym jest zwięzła, łatwa w czytaniu i pisana stylem konwersacyjnym. Pomimo tego wszystkiego Davidowi udało się napisać książkę na tyle krótką i zwięzłą, aby zawarte w niej treści można było szybko i w pełni przyswoić.
Większość treści tej książki odzwierciedla i skutecznie uczy tego, co stanowi konsensus wśród ekspertów Pythona odnośnie najlepszych praktyk czy błędów, których należy unikać. W kilku przypadkach, w których dobrze wyjaśnione opinie autora na temat pewnych kwestii dotyczących stylu różnią się od opinii innych ekspertów, David starannie i jasno wskazuje te przypadki, aby czytelnicy mogli rozważyć wszelkie wady i zalety i na tej podstawie podjąć własne decyzje.
Większość rozdziałów tej książki omawia problemy związane z Pythonem na średnim poziomie doświadczenia i umiejętności. Do tych problemów należą również liczne przypadki, w których programiści zaznajomieni z innymi językami mogą przyjąć w Pythonie pewien gorszy styl, będący bezpośrednim "tłumaczeniem" stylu odpowiedniego dla języków, które dobrze znają.
Doskonałym przykładem tego ostatniego problemu jest pisanie API udostępniających metody pobierające (getter) i ustawiające (setter): w Pythonie ich miejsce powinno zająć bezpośrednie pobieranie i ustawianie atrybutu (często realizowane za pomocą dekoratora property). Napotkanie hipotetycznego kodu, takiego jak widgets.set_count(widgets.get_count() + 1) - zamiast którego doświadczeni pythoniści użyliby bezpośredniego, czytelnego sformułowania widgets.count += 1 - wyraźnie pokazałoby, że taki hipotetyczny programista ignoruje lub nie jest świadomy "najlepszych praktyk" w Pythonie. Książka Davida stara się wyjaśnić zarówno to, jak i wiele innych typowych nieporozumień.
Pomimo swojego średniozaawansowanego poziomu, ta książka nie waha się poruszyć kilku zaawansowanych tematów, takich jak zagrożenia związane z katastrofalnym cofaniem się w wyrażeniach regularnych, pewne dziwactwa w zmiennoprzecinkowych reprezentacjach liczb, problemy z konwersją w obie strony w podejściach do serializacji, takich jak JSON itd. Dzięki temu, że omawia ona te zagadnienia, będzie ona pomocna nie tylko dla programistów Pythona o średniozaawansowanych umiejętnościach, ale także dla osób zaawansowanych.
-Alex Martelli
Wstęp
Python jest bardzo dobrze zaprojektowanym językiem programowania. W zaskakująco dużej liczbie przypadków językowi temu udaje się wyrazić jeden z aforyzmów z listy Zen of Python (Zen Pythona) Tima Petersa: "Powinien istnieć jeden, a najlepiej tylko jeden, oczywisty sposób na zrobienie danej rzeczy". Kiedy można coś zrobić tylko w jeden sposób, trudno jest popełnić błąd.
Oczywiście ten aforyzm jest pewną aspiracją, do której nie wszyscy jednakowo dążą. W Pythonie często istnieje wiele sposobów na wykonanie jakiegoś zadania. Wiele z nich jest po prostu błędnych, wiele jest nieeleganckich, wiele opiera się w dużym stopniu na idiomach innych języków programowania zamiast być pythonicznymi, a niektóre z nich nie są do końca błędne, ale nadal są rażąco nieefektywne. Wszystkie problemy opisane w tej książce widziałem w rzeczywistym kodzie, czasem gdzieś na wolności, czasem wyłapane podczas recenzowania kodu, ale też nazbyt często w kodzie, który sam napisałem, zanim zastanowiłem się nad jego wadami.
O książce
Każdy z podrozdziałów tej książki przedstawia jakiś błąd, dziwny nawyk lub pułapkę, w którą deweloperzy mogą wpaść, a także zawiera opisy sposobów na ich uniknięcie. Niekiedy te rozwiązania obejmują po prostu niewielką zmianę w "pisowni", ale w większości przypadków wymagają od nas pewnych przemyśleń i przeprojektowania naszego kodu. Wiele dyskusji dotyczy także czegoś innego...
Mam nadzieję nie tylko pokazać Ci coś, o czym wcześniej nie wiedziałeś, ale w większości przypadków mam nadzieję pokazać Ci coś, o czym nie wiedziałeś, że można to o tym wiedzieć. Wierzę, że najbardziej efektywne pisanie i nauczanie przekazuje czytelnikom lub studentom nie tylko informacje, ale także dobre sposoby myślenia o problemach i rozumowania o ich konkretnych rozwiązaniach. Pola z informacjami, przypisy i zabawne dygresje zawarte w tej książce mają za zadanie umożliwić Ci głębsze zastanowienie nad konkretną dziedziną, konkretnym zadaniem lub konkretnym stylem programowania.
Tej książki nie trzeba czytać od deski do deski (ale uważam, że czytelnicy, którzy to zrobią, bardzo na tym skorzystają). Każdy rozdział dotyczy pewnego powiązanego zestawu pojęć, ale jest niezależny od pozostałych. Co więcej, każdy podrozdział w obrębie rozdziału również jest samodzielną jednostką. Każdy może być czytany niezależnie od pozostałych, a większość czytelników dowie się czegoś ciekawego w każdym z nich. Niektóre podrozdziały są bardziej zaawansowane niż inne, ale myślę, że nawet w tych, które wydają się być przeznaczone dla początkujących, będziesz w stanie znaleźć niuanse, o których wcześniej nie wiedziałeś. A nawet w przypadku podrozdziałów, które wydają się być zaawansowane, mam nadzieję, że zawarte w nich dyskusje okażą się być dla Ciebie przystępne i pouczające.
Pomimo tego, że każdy podrozdział tworzy pewnego rodzaju winietę, rozdziały są na ogół zorganizowane w kolejności rosnącej według stopnia zaawansowania, a poszczególne podrozdziały luźno na sobie bazują. Tam, gdzie wydaje się to pomocne, wiele dyskusji odwołuje się do innych podrozdziałów nakreślających pewne tło, bądź też zapowiada bardziej szczegółowe omówienie w dalszych podrozdziałach.
Ogólnie celuję w czytelnika, który jest deweloperem Pythona na średnim poziomie umiejętności, bądź też jest zaawansowanym początkującym. Zakładam, że znasz podstawy języka programowania Python. Te dyskusje nie uczą najbardziej podstawowej składni i semantyki, które można znaleźć na pierwszym kursie lub w pierwszej książce o Pythonie. Głównie zakładam po prostu, że masz dociekliwy umysł i chcesz pisać kod, który jest piękny, wydajny i poprawny.
Ta książka została napisana z myślą o Pythonie w wersji 3.12, który został wydany w październiku 2023 r. Zawarty w niej kod został przetestowany z użyciem wersji beta 3.12. Zdecydowana większość przykładowego kodu będzie działać w Pythonie 3.8, będącym najwcześniejszą wersją, która w połowie 2023 r. nie osiągnęła jeszcze końca cyklu życia. W niektórych przypadkach zwracam uwagę, że kod wymaga co najmniej Pythona 3.10, który został wydany 4 października 2021 r. (okazjonalnie co najmniej Pythona 3.11, wydanego 24 października 2022 r.). Zdecydowana większość błędów omawianych w tej książce to błędy występujące już w Pythonie 3.8, przy czym kilka z nich odzwierciedla ulepszenia w późniejszych wersjach Pythona.
Dokumenty zatytułowane "What's new in Python M.m.?" (Co nowego w Pythonie M.m.?) są utrzymywane co najmniej od czasu wydania Pythona w wersji 1.4 (w 1996 r.)1.
Przykładowy kod
Większość przykładowego kodu zawartego w tej książce wykorzystuje środowisko REPL (Read-Evaluate-Print-Loop) Pythona. Mówiąc dokładniej, przykłady te wykorzystują ulepszone środowisko REPL IPythona (https://ipython.readthedocs.io), ale z użyciem magicznej komendy %doctest_mode, aby monit i wyniki przypominały czyste środowisko REPL python. Jednym z takich magicznych poleceń IPythona, który jest dość powszechnie używany w przykładach w tej książce, jest %timeit. Opakowuje ono moduł timeit biblioteki standardowej, ale zapewnia łatwy w użyciu i adaptacyjny sposób niezawodnego pomiaru czasu wykonywania operacji. W tej książce omówiono kilka błędów, w ramach których wynik sam w sobie nie jest błędny, ale obliczenia zajmują znacznie więcej czasu niż powinny. To magiczne polecenie jest używane do zilustrowania tego.
Oczywiście, gdy piszemy własny kod, interakcja w obrębie REPL - w tym również w obrębie notatników Jupyter (https://jupyter.org) lub innych wysoce interaktywnych środowisk - stanowi tylko małą część tego, co piszemy. Ale błędy ukazane w tej książce starają się skupiać na przykładach kodu, które są tak krótkie, jak to tylko możliwe. Interaktywna powłoka jest często dobrym sposobem na zilustrowanie takich błędów. Zachęcam do zapożyczania wniosków z realizowanych przykładów i kopiowania ich do pełnych plików *.py. Zaczynając od krótkich fragmentów te dyskusje można w idealnym przypadku przekształcić w rozbudowane bazy kodu.
Czasem podczas prezentowania poleceń uruchamianych w powłoce systemu operacyjnego (tj. podczas uruchamiania jakiegoś skryptu Pythona w celu wyświetlenia jego wyników) pokazuję wiersz poleceń [BetterPython]$ w celu dostarczenia szybkiej wskazówki wizualnej. Nie jest to rzeczywisty monit na moim komputerze osobistym, ale raczej jest to coś, na co mógłbym zmienić monit, gdybym chciał to zrobić. W systemach uniksowych znak $ często (choć nie zawsze) stanowi część monitu powłoki.
Uwaga Krótkie wprowadzenie do REPL
Wielu programistów, którzy wywodzą się z innych języków programowania lub którzy dopiero zaczynają programować, może nie doceniać tego, jak niesamowicie wszechstronna i przydatna może być interaktywna powłoka. Często chcąc wymyślić jakiś sposób na wykonanie jakiegoś zadania programistycznego wskakuję do środowiska Python, IPython lub Jupyter, aby uzyskać lepsze zrozumienie na temat tego, jak sprawdzi się moje wyobrażone podejście do danego problemu.
Szybki przykład takiej sesji, w moim przypadku z poziomu terminala bash, może wyglądać następująco:
[BetterPython]$ ipython
Python 3.11.0 | packaged by conda-forge |
(main, Oct 25 2022, 06:24:40) [GCC 10.4.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.7.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]: %doctest_mode # ?
Exception reporting mode: Plain
Doctest mode is: ON
>>> from collections import ChainMap # ?
>>> ChainMap? # ?
Init signature: ChainMap(*maps)
Docstring:
A ChainMap groups multiple dicts (or other mappings) together
to create a single, updateable view.
[...]
File: ~/miniconda3/lib/python3.11/collections/__init__.py
Type: ABCMeta
>>> dict1 = dict(foo=1, bar=2, baz=3)
>>> dict2 = {"bar": 7, "blam": 55}
>>> chain = ChainMap(dict1, dict2)
>>> chain["blam"], chain["bar"] # ?
(55, 2)
>>> !ls src/d*.adoc # ?
src/datastruct2.adoc src/datastruct.adoc
? Użyj stylu wyświetlania podobnego do uruchamiania środowiska python bez żadnego skryptu
? Nacisnąłem <tab>, aby wybrać uzupełnioną linię po collections
? Chcę uzyskać informacje o tym, co robi ten obiekt (tutaj tylko skrót)
? Po wprowadzeniu wyrażeń od razu pokazywane są ich wartości
? Za pomocą ! mogę uruchomić polecenie w powłoce zewnętrznej i zobaczyć wyniki
REPL pozwala na wiele więcej, niż to tutaj pokazano, ale to powinno dać nam pewien pogląd co do możliwości tego środowiska.
Różne środowiska programistyczne będą różnie traktowały kopiowanie/wklejanie do nich fragmentów kodu. W samym IPythonie użycie magicznej komendy %paste zignoruje w odpowiedni sposób wiodące znaki >>> lub .... Inne powłoki, IDE i edytory kodu będą zachowywać się inaczej. Wiele fragmentów kodu, które prezentowane są poza REPL, a także wiele używanych plików z danymi dostępnych jest pod adresem https://gnosis.cx/better. Co więcej, ścieżki zostały w większości uproszczone na potrzeby prezentacji. Pliki często znajdują się w podkatalogach code/ lub data/ witryny internetowej tej książki, ale te ścieżki zwykle nie są pokazywane. Innymi słowy, przedstawiony w tej książce kod służy do wyjaśniania pojęć. Nie jest to kod wielokrotnego użytku, który powinno się bezpośrednio kopiować (oczywiście możesz go używać). W szczególności duża część pokazanego kodu to kod zawierający dziwne nawyki. W przypadku tego kodu zdecydowanie wolałbym, abyś nie używał go w produkcji.
Wszystkie bloki kodu zatytułowane "Kod źródłowy pliku <nazwa pliku>" dostępne są do pobrania na stronie https://gnosis.cx/better. W niektórych przypadkach kod pokazany w tej książce jest fragmentem dłuższego pliku o wskazanej nazwie. Wszystkie inne bloki kodu, opatrzone tytułem w celu łatwiejszego znalezienia lub pozbawione tytułu, są obecne wyłącznie w celu wyjaśnienia pojęć. Oczywiście możesz je swobodnie kopiować, przepisywać lub dostosowywać do swoich celów.
Pozyskiwanie narzędzi używanych w tej książce
Język programowania Python to wolne oprogramowanie, które można uzyskać na oficjalnej stronie Python Software Foundation (PSF). Wiele innych podmiotów stworzyło również niestandardowe dystrybucje języka Python z dodatkowymi lub innymi możliwościami zawartymi w pakiecie z tym samym podstawowym językiem programowania. Należą do nich także różni dostawcy systemów operacyjnych. Większość dystrybucji linuksowych jest dostarczanych z językiem Python. System macOS (wcześniej stylizowany na nieco inne sposoby, np. "Mac OS X" i "OS X") zawiera Pythona od 2001 r. Python dla systemu Windows jest dostępny w sklepie Microsoft Store.
Aby uzyskać dystrybucję PSF języka Python, należy wejść na stronę https://www.python.org/downloads/. Dostępne są wersje dla wielu różnych systemów operacyjnych i platform sprzętowych. Aby móc podążać za niektórymi przykładami z tej książki, wskazane jest użycie środowiska REPL opartego na IPythonie (https://ipython.org/install.html) lub notatników Jupyter (https://docs.jupyter.org/en/latest/install.html). Te ulepszone interaktywne środowiska obsługują "magiczne" komendy, takie jak %timeit. Są to specjalne polecenia, których nie ma w samym języku Python, ale które mogą usprawnić interaktywną eksplorację. Prezentowane w tej książce sesje interaktywne można łatwo rozpoznać po znakach wiodących >>> dla wierszy początkowych i znakach wiodących ... dla wierszy kontynuacji (jeśli występują). Jednak Jupyter - podobnie jak interaktywne powłoki w wielu zintegrowanych środowiskach programistycznych (IDE) lub wyrafinowanych edytorach kodu - oznacza wprowadzony kod i wygenerowane wyniki za pomocą innych wskaźników wizualnych. Wspomniane ulepszone środowiska REPL obsługują również dodawanie pojedynczego lub podwójnego znaku ? na końcu nazwy, w Pythonie, w celu wyświetlenia informacji o obiekcie, do którego ten znak się odnosi. Zostało to wykorzystane w niektórych przykładach.
Do instalowania Pythona, IPythona, Jupytera i wielu innych narzędzi i bibliotek osobiście używam Minicondy (https://docs.conda.io/en/latest/miniconda.html). Sama Miniconda zawiera już w sobie Pythona, ale pozwala także na tworzenie środowisk z różnymi wersjami Pythona, a nawet środowisk zawierających zamiast niego inne przydatne narzędzia. W niektórych przykładach widoczne są wskazówki dotyczące mojego wyboru instalacji, ale nic w tej książce nie wymaga podążania za moim wyborem.
Inne przydatne narzędzia
Większość dyskusji w tej książce dotyczy koncepcji, a nie tylko stylistyki. Jednak lintery często wykrywają błędy, które przynajmniej zahaczają o koncepcje, wliczając w to czasem błędy opisane w tej książce. Szczególnie dobrym linterem dla Pythona jest Flake8 (https://flake8.pycqa.org/), który w rzeczywistości wykorzystuje kilka linterów niższego poziomu jako (opcjonalne) zależności. Dobry linter może nie wykryć istotnych błędów, ale przynajmniej zrozumiemy, dlaczego narzeka na nasz kod.
Strona główna narzędzia do formatowania kodu Black (https://black.readthedocs.io/) bardzo dobrze opisuje to narzędzie:
Black to bezkompromisowe narzędzie do formatowania kodu Pythona. Korzystając z niego wyrażasz zgodę na scedowanie kontroli nad drobiazgami ręcznego formatowania. W zamian Black zapewnia Ci szybkość, determinizm oraz wolność od narzekań na formatowanie przez pycodestyle. Zaoszczędzisz czas i energię mentalną na znacznie ważniejsze sprawy.
- Strona główna Black
Opinie wśród pythonistów na temat używania Blacka są różne. Odkryłem, że nawet jeśli Black formatuje czasem kod w sposób, którego do końca bym nie wybrał, wymuszanie spójności podczas pracy z innymi deweloperami zwiększa czytelność współdzielonego kodu, zwłaszcza w przypadku dużych projektów.
Dosyć imponującym projektem dotyczącym lintingu i formatowania kodu jest Ruff (https://beta.ruff.rs/docs/). Ruff obejmuje większość tych samych zasad lintingu, co Flake8 i inne narzędzia, ale jest napisany w języku Rust i działa o kilka rzędów wielkości szybciej niż inne lintery. Podobnie jak Black, Ruff zapewnia automatyczne formatowanie, ale oczyszcza też wiele rzeczy, którymi Black się nie zajmuje (jednocześnie Black oczyszcza rzeczy, którymi nie zajmuje się Ruff. Te narzędzia uzupełniają się wzajemnie).
We współczesnym programowaniu w Pythonie dość powszechnie używane są adnotacje typów i narzędzia do sprawdzania typów. Najpopularniejsze spośród nich to prawdopodobnie Mypy (http://mypy-lang.org/), Pytype (https://google.github.io/pytype/), Pyright (https://github.com/Microsoft/pyright) i Pyre (https://pyre-check.org/). Wszystkie te narzędzia mają swoje zalety, szczególnie w przypadku projektów o dużej skali, ale w tej książce generalnie unikamy dyskusji na temat ekosystemu sprawdzania typów w Pythonie. Rodzaje błędów, które sprawdzanie typów może wykryć, w większości przypadków nie mają powiązania z kwestiami semantycznymi i stylistycznymi, które tutaj omawiamy.
Podziękowania
Kilku uczestników forum dyskusyjnego Python-Help Discourse (https://discuss.python.org/c/users/7) zgłosiło ciekawe pomysły na te błędy. W przypadku wielu takich sugestii uwzględniłem już daną ideę lub pewien jej wariant, ale w innych przypadkach ich przemyślenia przyczyniły się do uzupełnienia lub modyfikacji błędów omawianych w tej książce. Bardzo dziękuję Chrisowi Angelico, Charlesowi Machalowi, Johnowi Melendowskiemu, Stevenowi D'Aprano, Ryanowi Duve, Alexandrowi Bessmanowi, Cooperowi Leesowi, Peterowi Bartlettowi, Glennowi A. Richardowi, Rubenowi Vordermanowi, Mattowi Welke, Stevenowi Rumbalskiemu i Marco Sulli za ich sugestie.
Inni przyjaciele, którzy zgłosili swoje sugestie to Brad Huntting, Adam Peacock i Mary Ann Sushinsky.
Ta książka jest o wiele lepsza dzięki sugestiom, które od nich otrzymałem. Wszystkie popełnione błędy są moimi własnymi.
O autorze
Dr David Mertz jest członkiem społeczności Pythona od ponad 25 lat - wystarczająco długo, aby pamiętać, co nowego znalazło się w Pythonie 1.5 w porównaniu do wersji 1.4. Uważnie śledził rozwój języka, wygłaszał przemówienia na temat zmian w poszczególnych wersjach, a jego publikacje miały pewien skromny wpływ na kierunek obrany przez Pythona i jego popularne biblioteki. David uczył Pythona naukowców, deweloperów z doświadczeniem w innych językach i początkujących programistów.
Obszerne informacje na temat jego publikacji można znaleźć w dokumencie https://gnosis.cx/publish/resumes/david-mertz-publications.pdf. Więcej o tym, gdzie pracował, można dowiedzieć się z dokumentu https://gnosis.cx/publish/resumes/david-mertz-resume.pdf.
Wprowadzenie
Python jest potężnym, ale też dosyć upartym językiem programowania. Choć zwykle dane zadanie w Pythonie możemy wykonać na wiele różnych sposobów, bardzo często istnieje dokładnie jeden sposób, w jaki należy wykonać takie zadanie. Niektóre programy są uważane za "pythoniczne", a inne nie.
Uwaga Bycie pythonicznym
Nieco żartobliwe określenie pythoniczny jest szeroko stosowane w społeczności Pythona. Ogólnie oznacza ono "odzwierciedlający dobre praktyki programistyczne dla języka Python". Ale jest również coś niewyrażalnego w tym określeniu, w sposób podobny do tego, jak inni programiści używają słów kruchy, elegancki czy solidny do opisu konkretnego kodu. W tej książce pojęcia pythoniczny i niepythoniczny używane są dosyć często.
W ramach takiego pythonicznego humoru - w końcu ten język został nazwany na cześć trupy komediowej Monty Pythona - deweloperów, którzy mistrzowsko opanowali programowanie pythoniczne, często nazywamy pythonistami.
Bycie pythonicznym to w dużym stopniu cel polegający na poprawie czytelności naszych programów, tak aby inni użytkownicy i współpracownicy mogli łatwo zrozumieć nasze intencje i zachowanie kodu, a także byli w stanie zidentyfikować jego błędy. Często zdarza się również, że bycie niepythonicznym prowadzi do nieoczekiwanego zachowania, a tym samym szkodzi funkcjonalności w przypadkach brzegowych, których mogliśmy nie wziąć pod uwagę lub których nie sprawdziliśmy podczas wstępnego użycia.
W tej książce chętnie wyrażam własne opinie na temat dobrych praktyk w języku Python. W przedstawianych dyskusjach staram się wyjaśniać motywacje stojące za tymi opiniami i przywołuję moje długie doświadczenie w korzystaniu, nauczaniu i pisaniu o Pythonie. To naprawdę zachwycający język programowania, co do którego przejawiam szczery entuzjazm.
Wiele z tego, na co liczymy, wyjaśnia Zen Pythona2:
>>> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one---and preferably only one---obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea---let's do more of those!
W świecie programowania w Pythonie istnieje wiele tematów, które nie są omawiane w tej krótkiej książce, a które są bardzo ważne. Dodatek Tematy na inne książki odsyła do wybranych materiałów i zawiera krótkie podsumowania najważniejszych idei, z którymi moim zdaniem warto się zapoznać podczas nauki.
1
Przechodzenie w pętli po niewłaściwych rzeczach
Podobnie jak większość proceduralnych języków programowania, Python ma dwa rodzaje pętli: for i while. Semantyka tych pętli jest bardzo zbliżona do semantyki pętli w większości analogicznych języków. Jednak Python kładzie szczególny nacisk na przechodzenie w pętli po obiektach iterowalnych (iterables) - wliczając w to leniwe obiekty iterowalne wraz z konkretnymi kolekcjami - czego wiele języków nie robi. Wiele błędów opisanych w tym rozdziale to "najlepsze praktyki" w innych językach programowania, ale po zbyt bezpośrednim przetłumaczeniu ich na język Python są one albo wadliwe stylistycznie, albo niepotrzebnie kruche.
Pod względem technicznym Python umożliwia również rekurencję, która jest innym sposobem na "zapętlanie", przy czym głębokość rekurencji jest w Pythonie ograniczona i nie ma tutaj optymalizacji wywołań ogonowych (patrz https://en.wikipedia.org/wiki/Tail_call). Rekurencja może być niezwykle użyteczna w przypadku problemów, które są w naturalny sposób dzielone na mniejsze części w celu uzyskania dla nich rozwiązania, ale w Pythonie rzadko jest ona dobrym podejściem do konstruowania zwykłej sekwencji podobnych działań.
Żaden z przedstawionych w tym rozdziale błędów nie dotyczy konkretnie rekurencji, ale programiści pracujący z językami z rodziny Lisp, językami z rodziny ML, bądź też językami Haskell, Rust, Lua, Scala lub Erlang, powinni pamiętać, że chociaż rekurencja może być dobrym nawykiem w używanym przez nich języku programowania, w przypadku Pythona może to być zły nawyk.
1.1 (Rzadko) generuj listę na potrzeby iteracji
Częstym wzorcem w wielu programach w Pythonie jest wygenerowanie sekwencji elementów do przetworzenia, dołączenie ich do listy, a następnie przechodzenie po tej liście w pętli i przetwarzanie każdego z tych elementów po dotarciu do niego. Istotnie, konstruowanie programów w ten sposób jest często bardzo rozsądne i całkowicie intuicyjne. Z wyjątkiem sytuacji, w których nie jest.
Gdy taka sekwencja elementów może okazać się bardzo długa (lub potencjalnie nieskończona) lub gdy każdy element może być obiektem wymagającym dużej ilości pamięci, tworzenie i wypełnianie listy elementami często zużywa więcej pamięci niż to konieczne. Ponadto, gdy operacja przetwarzania może być współbieżna z operacją generowania, możemy spędzić dużo czasu na generowaniu takiej listy, a następnie podobną ilość czasu na jej przetwarzaniu. Jeżeli możliwa jest rzeczywista równoległość, możemy być w stanie ukończyć cały program w czasie krótszym o połowę. Kiedy jednak równoległość jest możliwa, nie zawsze jest ona łatwa (więcej informacji na temat współbieżności można znaleźć w dodatku na końcu tej książki).
Załóżmy, że mamy pewną funkcję o nazwie get_word(), która przy każdym wywołaniu zwraca jakieś słowo - zwykle w ramach różnych wywołań są to różne słowa. Przykładowo ta funkcja może w pewien sposób odpowiadać na dane przesyłane przez sieć lub obliczane dynamicznie w oparciu o coś innego, co dotyczy stanu programu. W przypadku tej przykładowej funkcji, jeśli get_word() zwróci None, oznacza to, że jej źródło danych zostało wyczerpane. Ponadto "słowo" jest na potrzeby tego przykładu tekstem złożonym wyłącznie z małych liter ASCII.
Kod zaprezentowany poniżej jest prosty do napisania i często spotykany.
Tworzenie listy z wygenerowanych elementów
# source = <jakiś identyfikator dla sposobu generowania danych>
words = []
while True:
word = get_word(src=source)
if word is None:
break
words.append(word)
print(f"{len(words):,}") # -> 267 752
Czytelnicy innych dyskusji w tej książce mogą rozpoznać liczbę wygenerowanych słów i na tej podstawie odgadnąć implementację funkcji get_word(). Załóżmy jednak, że te słowa mogą się zmieniać przy każdym uruchomieniu programu, a ich liczba może się różnić o wiele rzędów wielkości.
W ramach dosyć prymitywnej numerologii do każdego słowa przypisujemy pewną magiczną liczbę, wyceniając po prostu literę 'a' jako 1, 'b' jako 2 i tak dalej, aż do litery 'z' jako 26, i sumując ze sobą wszystkie te wartości. To konkretne przekształcenie nie jest istotne, ale idea polegająca na "obliczeniu pewnej wartości z każdego elementu danych" jest bardzo powszechna. Do wykonania tych obliczeń używamy następującej funkcji.
Magiczna liczba będąca wartością numerologiczną słowa
def word_number(word):
magic = 0
for letter in word:
magic += 1 + ord(letter) - ord('a')
return magic
Możemy zwizualizować rozkład tych wartości numerologicznych, jak to pokazano na rysunku 1.1.
# words = <lista wygenerowana ponownie z innego źródła>
import matplotlib.pyplot as plt # ?
plt.plot([word_number(word) for word in words])
plt.title(f"Magic values of {len(words):,} generated words")
plt.show()
? pip install matplotlib lub conda install matplotlib
Rysunek 1.1. Magiczne wartości wygenerowanych słów.
Zakładając, że interesuje nas tylko ten końcowy wygenerowany wykres, nie ma potrzeby tworzenia pełnej kolekcji słów, a jedynie ich magiczne liczby. Ten przykład jest zbyt prosty, aby można było pokazać pełną zaletę tej refaktoryzacji, ale rozsądnym podejściem jest leniwe skonstruowanie generatora tylko tych danych, na których nam naprawdę zależy i wykorzystywanie danych pośrednich wtedy, gdy są one potrzebne. Przykładowo poniższy kod tworzy wykres pokazany na rysunku 1.2.
Leniwe obliczanie w generatorze tylko tego, co jest potrzebne
def word_numbers(src):
while (word := get_word(src=src)) is not None:
yield word_number(word)
# source2 = <jakiś inny identyfikator dla źródła danych>
magic_nums = list(word_numbers(source2))
plt.plot(magic_nums)
plt.title(f"Magic values of {len(magic_nums):,} generated words")
plt.show()
Przykład pokazany na rysunku 1.2 nadal wymagał utworzenia listy liczb, ale nie listy samych słów. Gdyby te "słowa" były jakimś znacznie większym i pochłaniającym pamięć obiektem, ta zmiana byłaby wówczas bardziej znacząca. W przypadku wielu scenariuszy takie wyłącznie przyrostowe przetwarzanie każdej wartości z generatora indywidualnie, bez żadnej kolekcji pośredniej, jest wystarczające i pozwala zaoszczędzić znaczną ilość pamięci.
Rysunek 1.2. Więcej magicznych wartości wygenerowanych słów.
1.2 Używaj enumerate() zamiast przechodzić w pętli po indeksie
Deweloperzy z doświadczeniem w językach pochodnych od języka C czasem automatycznie sięgają po pętle przechodzące po indeksach elementów listy lub innej struktury danych. Jest to zwykle niepythoniczny sposób pisania pętli. Kod napisany w ten sposób nie jest znacząco wolniejszy niż w przypadku użycia funkcji enumerate(), ale jest mniej czytelny, jest bardziej szczegółowy i zwykle sprawia wrażenie "zapachu kodu".
Przykładowo w języku C++ tego rodzaju idiom jest powszechny:
// 'items' może być tablicą, wektorem lub innym rodzajem kolekcji
for (int i = 0; i < items.size(); i++) {
process(i, items[i]);
}
Bardzo zbliżona opcja jest dostępna w Pythonie, a w nieco odległej przeszłości Pythona takie podejście było standardowo dostępnym mechanizmem:
for i in range(len(items)):
process(i, items[i])
Rzeczywiście, jeśli nie musimy wykorzystywać pozycji indeksu wewnątrz pętli, jakiekolwiek jego wykorzystywanie jest zwykle w Pythonie zapachem kodu. O wiele bardziej idiomatyczną opcją jest po prostu:
for item in items:
process(None, item)
W tych dosyć częstych sytuacjach, gdy potrzebny nam jest zarówno indeks, jak i znajdujący się pod nim element, użycie funkcji enumerate() będzie znacznie bardziej ekspresyjne i idiomatyczne:
for i, item in enumerate(items):
process(i, item)
W stosunkowo rzadkich sytuacjach, kiedy potrzebuję indeksu, ale nie samego elementu, często i tak używam funkcji enumerate(), wykorzystując przy tym konwencję Pythona _ (pojedyncze podkreślenie), która reprezentuje "pewną wartość, na której mi nie zależy":
for i, _ in enumerate(items):
process(i, None)
Podejście, którego używam od czasu do czasu, kiedy chcę utrzymywać kilka wartości przyrostowych, polega na zainicjalizowaniu tych kilku liczników przed pętlą, nawet jeśli jeden z nich mógł pochodzić z enumerate():
total, n_foo, n_bar = 0, 0, 0
for item in items:
if is_foo(item):
process_foo(item)
n_foo += 1
elif is_bar(item):
process_bar(item)
n_bar += 1
else:
pass
total += 1
W tym przykładzie licznik total mógłby równie dobrze być resetowany podczas wyliczania samej pętli, ale możemy chcieć podkreślić podobieństwo do liczników n_foo i n_bar, a to prawdopodobnie zostało tutaj wyrażone w lepszy sposób.
1.3 Nie iteruj po dict.keys(), jeśli chcesz dict.items()
W pewnym sensie listy w Pythonie możemy niemal postrzegać jako odwzorowania z pozycji indeksu na wartości. W przypadku słownika liczby całkowite doskonale sprawdzają się jako klucze. Dlatego obj[7] równie dobrze może być indeksem dla słownika dict (lub innego odwzorowania) lub indeksem dla listy list (lub innej sekwencji elementów).
Podobnie jak widzimy czasem niepythoniczny kod, który przechodzi w pętli po pozycjach indeksu listy, ale potem sprawdza ten indeks w ciele pętli, czasem widzimy także niepythoniczny kod, który przechodzi w pętli po dict.keys(). Właściwie to cofnijmy się trochę: istnieją dwa błędy stylistyczne, które właśnie wymieniliśmy. Wyobraźmy sobie taki kod:
for key in my_dict.keys():
process(key, my_dict[key])
Jest to nieidiomatyczne, ponieważ przechodzenie w pętli po my_dict.keys() jest równoznaczne z przechodzeniem w pętli po samym słowniku my_dict. Za kulisami tworzone są nieco inne typy obiektów: dict_keys dla my_dict.keys() i dict_keyiterator z samego słownika. Od tej różnicy zależy jednak niewielka ilość rzeczywistego kodu, ponieważ w większości przypadków ich zachowanie jest takie samo:
>>> my_dict = {c:ord(c) for c in "Bread and butter"}
>>> type(my_dict.keys())
<class 'dict_keys'>
>>> type(iter(my_dict))
<class 'dict_keyiterator'>
W szczególności dla każdego słownika zawsze będzie obowiązywać następująca tożsamość (chyba że przy dużej przekorności przerwiemy tę tożsamość w jakiejś podklasie dict lub w jakimś niestandardowym odwzorowaniu):
>>> all(a is b for a, b in zip(iter(my_dict), iter(my_dict.keys())))
True
Innymi słowy, jeśli chcemy przejść w pętli po kluczach, powinniśmy po prostu napisać:
for key in my_dict:
process(key, my_dict[key])
Jednak stosunkowo rzadko się zdarza, że chcemy przechodzić w pętli tylko po kluczach jakiegoś słownika. Nawet jeśli jego wartości używamy rzadko, tylko w jednej gałęzi kodu, uwzględnienie tych wartości w postaci zmiennej pętli prawie nic nas nie będzie kosztować. Pamiętajmy, że dostęp do obiektów w Pythonie odbywa się przez referencję. Po prostu przypisujemy referencję do istniejącego obiektu poprzez powiązanie pewnej zmiennej pętli. Nie kopiujemy ani nie tworzymy żadnego obiektu.
Innymi słowy, nie zawracajmy sobie głowy czymś takim:
for key in my_dict:
if rare_condition(key):
val = my_dict[key]
process(key, val)
Po prostu napiszmy czysty, pythoniczny kod:
for key, val = my_dict.items():
if rare_condition(key):
process(key, val)
Jest to problem, przed którym prawdopodobnie lintery nas ostrzegą - podobnie jak z używaniem funkcji enumerate() - ale rozumienie mechanizmów pętli wykracza poza samo przeczytanie ostrzeżenia.
1.4 Zmienianie obiektu w czasie iteracji
Nie powinniśmy zmieniać obiektów, po których iterujemy. Czasami może nam to "ujść na sucho" bez żadnych skutków ubocznych, ale sam nawyk nadal będzie zły.
Pierwszą rzeczą, na którą powinniśmy zwrócić uwagę, jest to, że niektóre obiekty w Pythonie są niezmienne. W przypadku iterowania po obiekcie str, bytes, tuple lub frozenset, zmiana takiej kolekcji po prostu nie wchodzi w grę.
Nadal jednak wiele obiektów w Pythonie to obiekty zarówno iterowalne, jak i obiekty zmienne - w szczególności list, dict, set i bytearray, choć oczywiście takimi obiektami mogą być również obiekty niestandardowe lub obiekty zewnętrzne. Próba zmiany obiektu podczas iterowania po nim może zakończyć się niepowodzeniem na kilka różnych sposobów.
Podstawowa iteracja po niezmiennym obiekcie
>>> s = "Mary had a little lamb!"
>>> for c in s:
... if c <= "s":
... print(c, end="")
... print()
Mar had a lile lamb!
Ten przykładowy kod sam w sobie jest w większości bezcelowy, ale wykonujemy tutaj selektywną operację tylko na tych elementach obiektu iterowalnego, które spełniają pewien predykat. Jedną z rzeczy, jakie z pewnością moglibyśmy zrobić zamiast wypisywania poszczególnych znaków, jest ponowne zagregowanie znaków przechodzących przez filtr do postaci jakiejś nowej kolekcji. Takie podejście stanowi zwykle doskonałe rozwiązanie dla wszystkich problemów związanych ze zmienianiem obiektów, więc trzymajmy je pod ręką jako możliwą opcję.
Załóżmy, że chcemy spróbować czegoś podobnego używając zmiennych kolekcji zamiast niezmiennego tekstu.
Szybki błąd podczas zmieniania obiektów iterowalnych
>>> my_set
{'r', 'M', 'm', 'a', 'e', 'h', 'l', 't', 'd', 'b', '!', ' ', 'i'}
>>> my_set = set("Mary had a little lamb!")
>>> for c in my_set:
... if c > "s":
... my_set.discard(c)
...
Traceback (most recent call last):
[...]
RuntimeError: Set changed size during iteration
>>> my_dict = {c:ord(c) for c in "Mary had a little lamb!"}
>>> for c in my_dict:
... if c > "s":
... del my_dict[c]
...
Traceback (most recent call last):
[...]
RuntimeError: dictionary changed size during iteration
Pokusa zmiany tych obiektów iterowalnych zostaje zwalczona szybkim błędem RuntimeError. W przypadku uporządkowanych kolekcji mamy jednak mniej szczęścia. Coś jest nie tak, ale błąd może być znacznie bardziej subtelny i trudniejszy do zauważenia.
Ukryty błąd podczas zmieniania obiektów iterowalnych
>>> my_list = list("Mary had a little lamb!")
>>> for i, c in enumerate(my_list):
... if c > "s":
... del my_list[i]
...
>>> my_list
['M', 'a', 'r', ' ', 'h', 'a', 'd', ' ', 'a', ' ', 'l', 'i',
't', 'l', 'e', ' ', 'l', 'a', 'm', 'b', '!']
>>> "".join(my_list)
'Mar had a litle lamb!'
>>> my_ba = bytearray("Mary had a little lamb!", "utf8")
>>> for i, c in enumerate(my_ba):
... if c > ord("s"):
... del my_ba[i]
...
>>> my_ba
bytearray(b'Mar had a litle lamb!')
W tym kodzie, patrząc powierzchownie, wszystko wydaje się działać poprawnie. Nie są zgłaszane żadne wyjątki i rzeczywiście otrzymujemy kolekcję list lub bytearray z usuniętymi niektórymi znakami. Jednak przyglądając się nieco bliżej widzimy, że jeden ze znaków t, który powinien zostać odfiltrowany, nadal pozostaje w zmienionym obiekcie. Stało się tak, ponieważ po usunięciu elementu pozycja indeksu nie była już zgodna z rzeczywistą sekwencją. Podobny problem powstałby w przypadku wstawiania nowych elementów.
Prawidłowym podejściem dla tego wymagania jest po prostu utworzenie zupełnie nowego obiektu w oparciu o zastosowany predykat i selektywne dołączanie do niego. Dołączanie jest tanią operacją na kolekcji list lub bytearray Pythona (jednak wstawianie do środka nowej sekwencji może łatwo osiągnąć złożoność kwadratową - jest to zagrożenie, przed którym ostrzegamy w innych fragmentach tej książki).
Tworzenie nowego obiektu jako filtru dla pewnej sekwencji
>>> my_list = list("Mary had a little lamb!")
>>> new_list = []
>>> for c in my_list:
... if c <= "s":
... new_list.append(c)
...
>>> new_list
['M', 'a', 'r', ' ', 'h', 'a', 'd', ' ', 'a', ' ', 'l', 'i',
'l', 'e', ' ', 'l', 'a', 'm', 'b', '!']
>>> "".join(new_list)
'Mar had a lile lamb!'
Jeszcze bardziej zwięźle:
>>> new_list = [c for c in my_list if c <= "s"]
>>> "".join(new_list)
'Mar had a lile lamb!'
Pamiętajmy, że możemy także wykonać (płytką) kopię jakiejś sekwencji poprzez wycięcie z niej (slicing) pustego zakresu. W nieco innych scenariuszach my_list[:] lub my_ba[:] mogą być często przydatne jako prosta składnia do tworzenia nowej sekwencji zawierającej te same elementy.
1.5 Pętle for są bardziej idiomatyczne niż pętle while
Gdy jest możliwa, pętla pythoniczna wygląda następująco: for item in iterable. Jest to podstawowy idiom, więc gdy zauważymy, że robimy coś innego, zastanówmy się, czy ta inna rzecz jest faktycznie lepsza.
Chcąc napisać pętlę w programie często stajemy przed wyborem pomiędzy for i while. Mówiąc bardziej precyzyjnie, zawsze możemy użyć dowolnej z tych konstrukcji dla dowolnej pętli. Być może nie jest to oczywiste, ale można sobie świetnie radzić w języku, który ma tylko pętlę for, jeśli tylko ma nieskończone iteratory.
Odpowiednik while predicate(a, b) z użyciem wyłącznie for
>>> from itertools import repeat
>>> a, b = 17, 23 # Przykładowe wartości początkowe
# nie mają specjalnego znaczenia
>>> for _ in repeat(None): # ?
... print("Current values:", a, b)
... if predicate(a, b): # ?
... break
... a = get_data(a) # ?
... b = get_data(b) # ?
...
Current values: 857 338
Current values: 613 500
Current values: 611 47
Current values: 387 871
Current values: 689 812
Current values: 406 892
Current values: 817 522
? Nieskończony iterator zawsze zwracający None
? Celowo niejasne określenie tego, co sprawdza predicate()
? Celowo niejasne określenie tego, co robi funkcja get_data()
Powyższy kod jest standardowym przykładem pętli while True, ale napisanym bez użycia while. To znaczy dane, na których chcemy pracować, uzyskujemy w sposób stanowy. Następnie poddajemy te dane ocenie oczekując, że mogą osiągnąć stan, w przypadku którego chcemy wyjść z pętli (ale może to być również wiecznie działający serwer)3.
Wyrażanie for w kategoriach while jest jeszcze prostsze jako tłumaczenie.
Odpowiednik for item in iterable z użyciem wyłącznie while
>>> # iterable = <kolekcja, generator, coś innego>
>>> iterator = iter(iterable)
>>> try:
... while True:
... item = next(iterator)
... print("Current item:", item)
... except StopIteration:
... pass
...
Current item: 2
Current item: 3
Current item: 5
Current item: 7
Current item: 11
Oczywiście w powyższej konstrukcji while możemy stosować rozgałęzianie warunkowe, wykonywać instrukcje break i continue oraz wszystkie inne akcje, które możemy umieszczać w pętli for.
Niezależnie od ich formalnej równoważności (potencjalnie z niewielką liczbą dodatkowych linii kodu w celu jej wymuszenia), pętla for znacznie częściej sprawia wrażenie pythonicznej niż pętla while. Ta ogólna rada ma wiele wyjątków, ale prawie zawsze, gdy w Pythonie wykonujemy jakąś pętlę, przechodzimy w niej albo po kolekcji, albo po obiektach iterowalnych (takich jak funkcja generatora, wyrażenie generatora czy niestandardowa klasa iterowalna). W pozostałych przypadkach jest to wezwanie do refaktoryzacji tej części kodu, która dostarcza nam dane do działania i przekształcenie jej na postać obiektu iterowalnego.
Korzystanie z pętli while nie jest błędem, ale za każdym razem, gdy po nią sięgamy, powinniśmy sobie zadać pytanie: "Czy mogę to zrobić w formie pętli for?". Zadajmy sobie to samo pytanie w przypadku kodu, który zamierzamy poddać refaktoryzacji. Może się okazać, że pętla while będzie najbardziej ekspresyjną i przejrzystą wersją, ale i tak powinniśmy rozważyć to pytanie. Myślenie w kategoriach (potencjalnie nieskończonych) sekwencji zwykle sprzyja przejrzystemu i eleganckiemu projektowi w Pythonie.
1.6 Operator mors dla bloków "pętli i pół"
Pewien wzorzec, z którego często korzystali programiści Pythona - i programiści w wielu innych językach programowania - zawsze był trochę brzydki. Jest to wzorzec "pętli i pół" (loop-and-a-half). W rzeczywistości kilka języków zaprojektowano lub później wyposażono w pewne konstrukcje, takie jak do ... while lub repeat ... until, specjalnie na potrzeby uniknięcia tej drobnej kurzajki. Przykładowo poniższy kod wykorzystuje te same tajemnicze funkcje get_data() i predicate() z poprzednich podrozdziałów.
Pętla i pół w starym stylu w Pythonie
>>> val = get_data()
>>> while not predicate(val):
... print("Current value acceptable:", val)
... val = get_data()
...
Current value acceptable: 869
Current value acceptable: 805
Current value acceptable: 632
Current value acceptable: 430
Powtórne przypisywanie wartości do val zarówno przed pętlą, jak i w ciele pętli wydaje się trochę niewłaściwe z perspektywy stylistycznej i przejrzystości kodu (chociaż nie jest to faktyczny błąd).
Prawdopodobnie jeszcze mniej estetycznym wariantem tego wzorca jest zastosowanie instrukcji break w ciele pętli w celu uniknięcia tych powtórzeń.
Pętla i pół w stylu z wewnętrznym przerwaniem w Pythonie
>>> while True:
... val = get_data()
... if predicate(val):
... break
... print("Current value acceptable:", val)
...
Current value acceptable: 105
Current value acceptable: 166
Current value acceptable: 747
Od wersji Python 3.8 mamy możliwość użycia tak zwanego "operatora morsa" (walrus operator), co pozwala nam uprościć tę strukturę. Ten operator został tak fantazyjnie nazwany ze względu na jego podobieństwo do emotikony przedstawiającej morsa z oczami i kłami. Operator mors (:=) pozwala nam przypisać pewną wartość w obrębie wyrażenia, a nie tylko w postaci instrukcji przypisania.
Pętla i pół w nowym stylu w Pythonie
>>> while not predicate(val := get_data()):
... print("Current value acceptable:", val)
...
Current value acceptable: 859
Current value acceptable: 296
Current value acceptable: 235
Current value acceptable: 805
Current value acceptable: 383
W obu przypadkach, gdy predykat znajduje się wewnątrz instrukcji while, liczba wejść do pętli może być nawet zerowa. W przypadku while True pętla zawsze wykonuje się co najmniej jeden raz, ale może zakończyć się wcześnie (stąd "i pół"), jeżeli zostanie spełniony pewien warunek.
Sposób wykorzystywania operatora morsa w instrukcji if jest bardzo podobny, zarówno pod względem dostarczania wartości, jak i potencjalnego niewykonywania danego zestawu instrukcji w zależności od tej wartości.
Zestaw instrukcji if bez inicjalizacji oraz z inicjalizacją w miejscu
>>> val = get_data()
>>> if val:
... print("Current value acceptable:", val)
...
Current value acceptable: 247
>>> if val := get_data():
... print("Current value acceptable:", val)
...
Current value acceptable: 848