Protokoły w Pythonie

Posted by Niedźwiedź on March 06, 2025 · 14 mins read

“Daję słowo, zatem ja już przeszło czterdzieści lat mówię prozą, nie mając o tem żywnego pojęcia! Jestem panu najszczerzej obowiązany, żeś mnie pouczył.”
Molier “Mieszczanin szlachcicem

Jakiś czas temu zająłem się drobnym bugiem w kodzie. Nasze modele opierają się o Pydantica, wykorzystując jako bazę, naszą wersję BaseModel, wzbogaconą o kilka metod.

Jedną z nich jest update modelu za pomocą słownika. To dość prosta funkcjonalność, której niestety w Pydanticu brakuje. Chodzi o to, aby podać słownik, zawierający tylko część pól, a nie cały model.

Działanie było bardzo proste, poza jednym aspektem- nasze modele są dość mocno zagnieżdżone, zatem jeśli pole modelu X było modelem Y, to zamiast nadpisywać wartością ze słownika, należało wywołać na instancji modelu Y metodę updatującą ze słownika.

Wydawało się to bardzo proste, ale Pydantic jest tak skonstruowany, że użycie metody isSubclass, zwłaszcza w przypadku gdy dane pole zawierało listę instancji naszego modelu, dawało mocno niejednoznaczne wyniki.

Biedziłem się nad tym cały dzień, aż w końcu skonstruowałem działające rozwiązanie, oparte na kilku różnych sprawdzeniach typów. Dumny jak paw wystawiłem pull requesta, i czekałem na pochwały.

Zamiast nich, dostałem krótki komentarz od jednego z dużo bardziej doświadczonych developerów - a dlaczego, zamiast sprawdzać typy, nie sprawdzisz, czy dany obiekt wystawia metodę update_from_dict.

Tak właśnie poznałem w działaniu praktykę zwaną “kaczkotypowaniem” (ang. duck-typing).

Nazwa nie wzięła się od gumowej kaczuszki, szeroko używanej w procesie tworzenia oprogramowania1, a od powiedzenia “jeśli to chodzi jak kaczka, ma skrzydła jak kaczka i kwacze jak kaczka, to najprawdopodobniej jest to kaczka.

Przykładowe zastosowanie kaczkotypowania

Jest to praktyka dość powszechna w językach dynamicznie typowanych, oznaczająca, że typ zmiennych rozpoznajemy nie poprzez przypisany im typ- w końcu ta sama zmienna może wskazywać w kilku momentach na wartości różnego typu - ale przez to jaki interfejs wystawiają.

Jeśli brzmi to dla was cokolwiek pokrętnie, zadajcie sobie pytanie, czy kiedykolwiek spróbowaliście stworzyć obiekt, po którym można by iterować.

Jak wiadomo, wymaga to dodania do klasy metod __iter__() oraz __next__(). Jeśli spróbujemy wykorzystać nasz obiekt w pętli, albo składaniu listy, Python będzie próbował wywołać te metody.

I zauważcie, że nie musimy w tym celu dziedziczyć po żadnej klasie, ani wskazywać interpreterowi, że oto nasza klasa umożliwia takie zachowanie. Po prostu, jeśli chodzi jak kaczka i kwacze jak kaczka, możemy traktować ją jak kaczkę.

Dlatego właśnie, wielu programistów, niczym pan Jourdain, nawet nie zauważa, że całe życie korzysta z kaczkotypowania.

I tak, jak w poprzednim wpisie wspominając o typach, pisałem raczej o tzw. Typowaniu nominalnym. Oznacza to, że obiekt x jest typu Y, jeśli jest instancją klasy Y, albo którejś z jej podklas2.

Kaczkotypowanie zaś jest przykładem typowania strukturalnego. Zatem obiekt x jest typu Y, jeśli zachowuje się tak, jakbyśmy tego oczekiwali od obiektu typu Y. Co to dokładnie znaczy?

Wyobraźcie sobie, że w naszym kodzie postanawiamy zamodelować samochody. Tworzymy więc klasę bazową Car, która będzie zawierać wszystkie niezbędne metody i atrybuty, by nasz pojazd można było zatankować, uruchomić i ruszyć nim w drogę.

Część naszych użytkowników chce używać taksówek, zatem modelujemy również taki pojazd, do klasy Car dodając metody potrzebne, by móc naliczyć opłatę za przejazd.

class Car:
    def drive(self) -> None:
        print("Wroom wroom")
    
    def refuel(self) -> None:
        pass

    def average_fuel_consumption(self) -> float: 
        pass


class Taxi(Car):

    def charge_for_ride(self, destination: Address) -> None:
        print(f"Charging fare for a ride to {destination}")

Kod działa znakomicie, dzięki dziedziczeniu udało nam się ograniczyć powtarzalność kodu i w każdej klasie implementujemy tylko te metody, które świadczą o jej wyjątkowości.

Następnie chcemy móc skorzystać z dobrodziejstw rynku przewozów, tworzymy więc funkcję, która pozwoli użytkownikowi na przejazd taksówką

    def take_a_taxi(taxi: Taxi, destination: Address):
        pass

I wszystko działa wspaniale, dopóki nasi klienci nie zażyczą sobie dodania opcji przejazdu rikszą, przy użyciu już istniejących metod.

W końcu, argumentują, i zwykła taksówka i riksza, korzystają z podobnego algorytmu- klient wsiada, podaje adres, a kiedy tam dotrze płaci w zależności od odległości i czasu przejazdu.

Brzmi to prosto, ale jeśli spróbujemy dziedziczyć po klasie Taxi, wystawimy do użytkowników rower (rikszę), który można zatankować- wszak Taxi dziedziczy po Car, która to klasa wystawia metodę refuel.

Owszem, możemy ją nadpisać i rzucać NotImplementedError, z wiadomością, że tankowanie roweru jest cokolwiek egzotycznym pomysłem. Ale to rozwiązanie raz, że brzydkie, dwa- niezgodne z zasadą podstawienia Liskov , oraz zasady otwarte/zamknięte.

Możemy też napisać klasę Bike, dziedziczącą po niej Rikshaw3. Wtedy jednak musimy zmienić sygnaturę funkcji take_a_taxi, której pierwszy argument będzie miał typ Taxi | Rikshaw.

I wydaje się to niezłym rozwiązaniem, dopóki nie zdamy sobie sprawy, że w przyszłym tygodniu nasz klient może zażyczyć sobie przejazdów motorówką, później awionetką, drezyną i kilkoma innymi pojazdami, z których każdy będzie wymagał stworzenia zupełnie innych klas bazowych.

A w międzyczasie nasz kod może wzbogacić się o kilka innych funkcji przyjmujących jako argumenty taksówki, riksze, lektyki, dorożki i inne pojazdy, które możemy wynająć by nas przewieziono z miejsca na miejsce.

Czy zatem, za każdym razem będziemy musieli dopisywać nowy pojazd do kilkunastu funkcji? Czy zachowanie bezpieczeństwa typów będzie wymagało stworzenia funkcji o takiej sygnaturze?

Def take_a_taxi( taxi: Taxi | Rikshaw | Helicopter | Motorboat | Draisine | Droshky | StageCoach |....

Sami przyznacie, że mocno zaburza to czytelność kodu.

Dlatego, w takiej sytuacji, na ratunek przychodzi nam opisane wyżej kaczkotypowanie. A dokładniej jego sformalizowana postać, jaką są protokoły.

Jeśli macie doświadczenie np. z Javą, i wykorzystywaliście w niej interfejsy, to pewnie już wiecie dokładnie o co chodzi.

Bowiem, o ile zwykłe klasy mają modelować (w dużym uproszczeniu) dane i zachowania z ich użyciem, to protokoły, podobnie jak interfejsy, modelują same zachowania4.

Zatem, w tym przypadku, będziemy chcieli stworzyć protokół, określający te metody, które klasa musi implementować, by można było jej użyć w funkcji take_a_taxi - czyli na przykład charge_for_ride. Przykładowa implementacja będzie wyglądała tak:

From typing import Protocol

class Takeable(Protocol):
    def charge_for_ride(self, destination: Address) -> None:
        

Jak widać, protokół, to rodzaj klasy abstrakcyjnej- zatem nie możemy utworzyć instancji tego typu. Nie mogą również dziedziczyć po jakiejkolwiek klasie konkretnej(nie-abstrakcyjnej).

Po protokole nie musimy dziedziczyć, wystarczy, że klasa implementuje wszystkie wskazane w nim metody. Dlatego teraz, każda klasa, niezależnie czy dziedzicząca po Bike, Car, czy Train, wystawiająca metodę charge_for_ride, o tej samej sygnaturze, będzie uznana za przedstawiciela typu Takeable.

Zatem nasza funkcja ma take_a_taxi ma znacznie prostszą sygnaturę. Jej pierwszy argument to taxi: Takeable. Jeśli będziemy implementować kolejne typy pojazdów, nie musimy niczego więcej zmieniać, wystarczy, że zaimplementujemy w nich metodę charge_for_ride.

Jeśli nadal nie jesteście przekonani do tego rozwiązania, wyobraźcie sobie, że nasza funkcja do przejazdów stanowi część biblioteki. Nasi użytkownicy to programiści, którzy będą chcieli zastosować ją w swoich projektach, używając swoich typów danych.

Przy podejściu nominalnym, musieli by dziedziczyć po którejś z przygotowanych przez nas klas- przez co musieli by się zastanawiać, co ma zwracać odziedziczona z klasy Car metoda average_fuel_consumption w odniesieniu do używanych przez nich sanek, albo teleportu międzygalaktycznego, albo czy któraś z metod nie przysłoni czegoś co napisali wcześniej.

Dzięki protokołom zaś, taka integracja jest niemal bezbolesna. Nie musimy nawet w żaden sposób oznaczać, że nasza klasa implementuje dany protokół. Nie musimy dziedziczyć po protokole, ani nawet go importować.

Jeśli nasz protokół jest w pliku takeable.py to implementując ani w pliku stagecoach.py, zawierającym klasę implementującą protokół, ani taxi_rides.py zawierającym funkcje przyjmujące argumenty typu Takeable nie musimy go importować. Interpreter ogarnie to za nas

No dobrze. Ale co, jeśli dojdzie do sytuacji, w której jednak potrzebujemy typowania nominalnego? W jakimś miejscu kodu, chcielibyśmy sprawdzić, czy wartość danej zmiennej ma typ str, int albo Takeable. Jak to zrobić?

Biblioteka typing ma na tę okazję dekorator @runtime_checkable. Jeśli dodamy go do definicji protokołu, to wszystkie obiekty wystawiające metody protokołu, zostaną uznane za jego instancje. W praktyce wygląda to tak:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Takeable(Protocol):
    def charge_for_ride(self, destination: Address) -> None:
        

class Carriage:
    def charge_for_ride(self, destination: Address) -> None:
        pass

carriage = Carriage()
is_instance(carriage,Takeable) # To zwraca True

Na koniec warto jeszcze wspomnieć o przyjętym przeze mnie nazewnictwie. Większość materiałów dotyczących protokołów nie zwraca uwagi na ich nazwy- są one podobne jak te, które nadajemy zwykłym klasom.

Jednak pracując w Scali, przyzwyczaiłem się, że traity (czyli odpowiednik m.in. interfejsów), często nazywa się przymiotnikami odczasownikowymi. Mamy zatem Startable, Stopable, Configurable.

Jak wspomniałem wcześniej, interfejsy okreslają zachowania- zatem taka właśnie konwencja ich nazewnictwa wydaje mi się najlepsza. Po pierwsze- odróżnia je od klas. Po drugie- jasno wskazuje na to, jakie zachowanie opisują.

Co więcej, konsekwentne trzymanie się takiego nazewnictwa, może pomóc nam w utrzymaniu zasady jednej odpowiedzialności.

W końcu, jeśli protokół nazywa się Startable a nas korci, by dodać do niego metodę stop, może łatwiej będzie nam zastanowić się, czy stanowi ona warunek konieczny, by uznać coś za “startowalne”? A może to czas na wydzielenie jej do kolejnego protokołu?

Oczywiście, nie jest to w żaden sposób wymagane, ani przez konwencję, ani tym bardziej interpreter.

Wierzę jednak, że dobry kod to również dobre nazewnictwo, które wspiera nas w pracy. Ale o tym napiszę innym razem.

  1. O tym jak i dlaczego napiszę w niedługiej przyszłości 

  2. Uważny czytelnik może się przyczepić, że nie dokońca- i słusznie, ale o pojęciach ko- i kontra-wariancji napiszę w bliżej nieokreślonej (acz raczej niedalekiej) przyszłości. 

  3. Dopiero po napisaniu tego zdania, sprawdziłem że w wielu krajach rikszami określa się również wózki ciągnięte przez idącego pieszo “kierowcę”. Co oczywiście pokazuje dalsze komplikacje w budowaniu naszego modelu rzeczywistości, ale dla klarowności wywodu pozostańmy przy rikszach opartych na budowie roweru. 

  4. Uważny czytelnik zada zapewne pytanie- czego użyć, jeśli chcę modelować same dane. I bardzo słusznie- służą do tego dataclassy, którym poświęcony będzie kolejny wpis. 


Zdjęcie w nagłówku:
     Pexels (Photo by Thomas Parker)