W dniu dzisiejszym ukazała się kolejna (szósta już) wersja alpha Pythona 3.10, wprowadzając tym samym dość przełomową dla tego języka funkcjonalność – dopasowywanie wzorca (ang. pattern matching). Specyfikacja tej funkcjonalności zawarta jest w PEP 634, zaś dokumenty PEP 635 oraz PEP 636 zawierają odpowiednio uzasadnienie dokonanych zmian oraz tutorial dla programistów.

W tym wpisie przyjrzymy się szczegółom składniowym pythonowego pattern matchingu. Postaram się też wyjaśnić dlaczego funkcjonalność ta budzi pewne kontrowersje oraz zaprezentuję streszczenie motywacji, stojących za jej implementacją.

Gdzie spróbować

Jeśli chcesz już teraz, nie czekając na wydanie stabilnej wersji Pythona 3.10, sprawdzić w praktyce działanie pattern matchingu, to z oficjalnej strony języka możesz pobrać wersję 3.10a6 i zainstalować ją u siebie.

Innym sposobem na zabawę nową funkcjonalnością, jest skorzystanie ze strony mybinder.org, która pozwala na interaktywne korzystanie z pythonowych notebooków. Oczywiście całkowicie online. Pod tym linkiem można uruchomić notebooka przygotowanego przez Guido, który działa na eksperymentalnej wersji Pythona 3.10 z zaimplementowanym pattern matchingiem.

Kontrowersyjna zmiana?

Przeglądając opinie użytkowników, komentujących zaakceptowanie PEP 634, można zaobserwować, że dzielą się oni na dwie grupy: tych, którzy uważają nową funkcjonalność w zaproponowanej formie za świetny pomysł oraz tych, wyrażających wątpliwości, bądź też otwarcie krytykujących nowe zmiany.

Co istotne, raczej mniejsza część osób sprzeciwia się samej idei pattern matchingu w Pythonie. Kontrowersje budzą natomiast szczegóły zaakceptowanej implementacji. Przyjęta wersja nie była bowiem jedyną – komitet odrzucił PEP 642, a także PEP 640, które proponowały pewne zmiany w stosunku do pomysłów wyrażonych w PEP 634 (np. używanie ? zamiast _ jako symbolu wieloznacznego).

Jedną z dyskusji związanych z nową funkcjonalnością można prześledzić na stronie discuss.python.org, gdzie wypowiadają się programiści rozwijający CPythonowy core (w tym również sam Guido van Rossum).

Python 3.10 przyniesie więc parę „dziwactw”, które nie wszystkim przypadną do gustu. To, co można z tym zrobić, to po prostu poznać je, aby nie dać się w przyszłości zaskoczyć nowej składni i jej pułapkom.

Idea

Przede wszystkim do języka została wprowadzona nowa instrukcja  – match. Jako argument przyjmuje ona wyrażenie i porównuje je z określonymi przez programistę wzorcami (każdy wzorzec następuje po słowie kluczowym case).

Składnia ta przypomina spotykaną w wielu językach programowania instrukcję switch ... case:

Sprawdzanie wartości następuje kolejno z góry na dół, co oznacza, że jeśli zmienna day_of_week będzie przechowywać liczbę 1, to block match zostanie opuszczony natychmiast po wydrukowaniu zdefiniowanej przez nas wiadomości.

Z powyższego kodu widać też, że domyslny przypadek (w niektórych językach określa go słowo kluczowe default), do którego wpadają wartości nie pasujące nigdzie indziej,  jest określany przez symbol _.

Ale dopasowywanie wzorców przy użyciu match to coś więcej niż niż odpowiednik instrukcji switch, o czym zapewne dobrze wiedzą programiści języków funkcyjnych. Oprócz prostego porównywania wartości, możemy też dokonywać destrukturyzacji obiektów w celu bardziej szczegółowego dopasowania wzorca (np. biorąc pod uwagę tylko jeden element z pary wartości, albo rozkładając na czynniki pierwsze dowolnego typu obiekt):

Jak widać, może się tutaj dziać naprawdę wiele. W tajemniczej zmiennej możemy mieć zarówno dwuelementową tuplę, jak i łańcuch znakowy albo obiekt klasy Pair, a dopasowanie przechowywanej wartości do jednego z przypadków jest teraz o wiele prostsze i czytelniejsze niż gdybyśmy byli ograniczeni tylko do ifów.

Za chwilę po kolei omówimy sobie wszystkie te ciekawe przypadki, w których wzorcami są listy, słowniki albo własne typy danych. Póki co, zwróćmy jeszcze tylko uwagę na użycie zmiennej y (w linii 2. oraz 4.).

Jej zadaniem jest przechwycenie dopasowanej wartości. Jeśli w zmiennej mysterious kryje się krotka (20, 30), to nawet po wyjściu z bloku match, wartością zmiennej y będzie liczba 30.

Możliwości

Listy

Mając do czynienia z listą, o nieznanej długości, dzięki pattern matchingowi możemy w prosty i czytelny sposób obsługiwać poszczególne przypadki. Unikamy bezpośredniego wywołania funkcji len (pod spodem i tak jest ona używana) oraz ifów, zyskując eleganckie rozwiązanie z od razu rozpakowanymi elementami listy, które nas interesują.

Słowniki

W przypadku słowników składnia też jest dość intuicyjna. Możemy dopasowywać poszczególne klucze i wartości, ale należy pamiętać, że zapis case {"key": value} nie oznacza, że przechwycony zostanie wyłącznie słownik jednoelementowy z kluczem key. Wzorzec zostanie dopasowany, jeśli słownik zawiera minimum ten jeden element, ale może ich zawierać znacznie więcej.

Gdybyśmy chcieli dopasować słownik o konkretnej długości, należy użyć tzw. strażnika (ang. guard), w postaci następującej po treści wzorca instrukcji if. Rozwiązanie to można zobaczyć w przedostatniej linijce, która ma za zadania zmatchować pusty słownik.

Dopasowanie pustego słownika jest też możliwe np. za pomocą wzorca case {} as x if not x, ale już nie przy użyciu case {}. W ten sposób osiągniemy bowiem dopasowanie do dowolnego słownika.

Jeszcze więcej możliwości daje nam opcja wielokrotnego zagnieżdżania wzorców. Dzięki temu możemy dopasowywać dość zaawansowane wzorce, jak chociażby ten poniżej:

Własne typy danych

Dopasowywanie instancji własnych typów danych jest możliwe przy pomocy wspomnianej już wcześniej destrukturyzacji. Jej składnia celowo podobna jest do sposobu konstruowania nowych obiektów.

Najprostszy przypadek to tzw. dataclasses – funkcjonalność wprowadzona w Pythonie 3.7 i upraszczająca tworzenie klas, których głównym zadaniem jest przechowywanie danych. Po więcej informacji na ten temat odsyłam do dokumentacji.

Dopasowując tego typu obiekty, możemy explicite podawać nazwy poszczególnych parametrów, albo też podawać je zgodnie z kolejnością z jaką zostały zadeklarowane wewnątrz klasy:

Sytuacja nieco zmienia się, kiedy mamy do czynienia z własnym typem danych, zdefiniowanym bez użycia dekoratora @dataclass, np.:

Dla takiej klasy dozwolone będą jedynie dwa ostatnie przypadki:

  • case Pair(first=x, second=20) – ponieważ używamy argumentów kluczowych, a nie pozycyjnych
  • case Pair as p – ponieważ dopasowujemy jedynie typ obiektu, bez używania argumentów pozycyjnych

Próba użycia argumentów pozycyjnych (np. case Pair(x, y)) poskutkuje wyrzuceniem wyjątku:

Rozwiązaniem jest zdefiniowanie dla naszej klasy specjalnego atrybutu __match_args__:

W ten sposób zyskujemy możliwość używania argumentów pozycyjnych do destrukturyzacji obiektów dowolnych typów, zgodnie z określoną kolejnością.

Alternatywa

Dla pojedynczego przypadku case możemy przechwytywać kilka różnych wzorców, używając operatora |, oznaczającego alternatywę.

Do pierwszego wzorca zostanie dopasowana np. tupla (1, 2), zaś do drugiego – (1, 5).

Co istotne (choć w sumie logiczne – nie wiedzielibyśmy później która ze zmiennych została zdefiniowana), zabronione jest bindowanie różnych zmiennych dla alternatywnych przypadków, np.:

Pułapki

Chociaż w większości przypadków zachowanie pythonowego pattern matchingu jest raczej intuicyjne, jest parę kwestii, na które trzeba uważać.

Po pierwsze – użycie symbolu wieloznacznego do dopasowywania pozostałych elementów słownika. Wiemy już, że dozwoloną składnią jest:

Po dopasowaniu wzorca, zmienna personal_data będzie słownikiem, zawierającym wszystkie elementy dopasowanego słownika za wyjątkiem klucza „age”. Wiemy też, że pozostałe elementy listy, które nas nie interesują, można dopasować przez:

Błędem składniowym będzie natomiast coś takiego:

Dlaczego? Takie wyrażenie i tak byłoby redundantne, bowiem już samo case {"age": age} oznacza dowolnej długości słownik.

Kolejna i chyba najgroźniejsza pułapka to używanie we wzorcach zmiennych oraz stałych. Zgadnij – co będzie rezultatem wykonania poniższego kodu:

Wydrukowany zostanie tekst „Not found”, a nową wartością zmiennej HTTP_404 będzie liczba 500. Zaskoczony? Cóż, tak właśnie – stety lub niestety – działa tu bindowanie zmiennych.

Gdybyśmy chcieli użyć we wzorcu jakiejś „stałej”, możemy to zrobić jedynie posługując się tzw. dotted consts, czyli stałymi, do których odnosimy się przez znak kropki. Może być to zarówno enum, jak i pole dowolnej zdefiniowanej przez nas klasie, np.:

Teraz nareszcie rezultatem będzie wydrukowanie wiadomości „Server error”, a wartości wcześniej zdefiniowanych „stałych” nie ulegną zmianie.

Podsumowanie

Już od jakiegoś czasu, Python oferuje coraz większe możliwości tworzenia w nim kodu zgodnie z paradygmatem programowania funkcyjnego. Wprowadzenie pattern matchingu ewidentnie wpisuje się w tę tendencję.

Dopasowywanie wzorców to jeden z charakterystycznych elementów wielu języków funkcyjnych – począwszy od Haskella, przez OCamla, aż po Erlanga, ale coraz częściej wkracza on do świata języków wieloparadygmatowych. Znajdziemy go już chociażby w takich językach jak Rust, C# czy Ruby.

Cieszy więc fakt, że i Python dołącza do tego grona. Dzięki pattern matchingowi możemy wyeliminować znacznie mniej czytelne ifowe drabinki oraz uczynić kod bardziej zwięzłym i eleganckim.

Na koniec warto jeszcze wspomnieć, że omawiana zmiana zachowuje pełną kompatybilność wsteczną. Nowe słowa kluczowe match oraz case zostają wprowadzone jako tzw. miękkie słowa kluczowe (ang. soft keywords), co pozwala na używanie ich w innych kontekstach (np. jako nazwy zmiennych lub funkcji).

Do czasu ukończenia prac nad Pythonem 3.10, czyli do października (a przynajmniej takie są aktualne plany) prace nad samą implementacją będą jeszcze trwały. Gdyby pojawiły się w niej jakieś istotne zmiany, postaram się na bieżąco aktualizować niniejszy artykuł, aby w pełni oddawał on stan pattern matchingu w Pythonie 3.10.

Linki i źródła

.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *