Protokoły w Pythonie pozwalają opisać zachowanie obiektów bez przywiązywania się do jednej klasy bazowej. To właśnie sedno tematu, który kryje się za angielskim hasłem python protocol: statyczny kontrakt, z którego korzystają type checkery, IDE i sam projekt kodu. W praktyce taki kontrakt pomaga pisać bardziej elastyczne funkcje, łatwiej podmieniać implementacje i szybciej łapać błędy na etapie analizy, a nie dopiero podczas uruchamiania aplikacji.
Najważniejsze rzeczy o protokołach w Pythonie
- Protokół opisuje zachowanie, czyli zestaw metod i atrybutów, a nie konkretną klasę.
- Działa głównie w typowaniu statycznym, więc pomaga type checkerom i IDE, ale nie zastępuje walidacji w runtime.
- Nie trzeba po nim dziedziczyć, żeby obiekt był zgodny z kontraktem.
- `@runtime_checkable` umożliwia `isinstance()` i `issubclass()`, ale sprawdza tylko obecność członków, nie pełne sygnatury.
- Najlepiej sprawdza się przy granicach systemu: adapterach, repozytoriach, streamach, cache, klientach API i test doubles.
- Nie zawsze jest lepszy od ABC ani od prostego `Callable` lub `TypedDict`; wybór zależy od tego, co naprawdę chcesz opisać.
Czym jest protokół w Pythonie
Ja traktuję protokół jak opis interfejsu zachowania: obiekt ma umieć to i to, a niekoniecznie pochodzić z tej samej klasy co inne obiekty. W typowaniu nominalnym liczy się dziedziczenie, a w typowaniu strukturalnym liczy się kształt obiektu, czyli to, jakie ma metody i atrybuty. To dokładnie dlatego protokół jest tak naturalny w Pythonie, gdzie duck typing od dawna jest częścią codziennego stylu pisania kodu.
- Opisuje kontrakt bez narzucania jednej hierarchii klas.
- Pomaga przy refaktoryzacji, bo kod konsumenta nie zależy od konkretnej implementacji.
- Jest czytelny dla człowieka i narzędzi, bo jasno mówi, czego funkcja oczekuje od argumentu.
Najważniejsza różnica względem klasy bazowej jest prosta: protokół nie służy do budowania wspólnej rodziny obiektów, tylko do opisywania zgodności zachowania. Z tak rozumianego kontraktu łatwo przejść do mechaniki strukturalnego typowania, bo to ona wyjaśnia, dlaczego protokół działa inaczej niż klasyczne dziedziczenie.

Jak działa strukturalne typowanie w praktyce
W praktyce type checker pyta nie o to, z czego klasa dziedziczy, ale czy ma wymagane elementy. Jeśli obiekt ma odpowiednie metody i atrybuty o zgodnych typach, może być uznany za zgodny z protokołem nawet wtedy, gdy nie zna o nim ani jednej linii kodu. To jest bardzo bliskie filozofii duck typing, ale z dodatkową korzyścią: kontrakt staje się jawny i weryfikowalny narzędziowo.
from collections.abc import Iterable, Iterator
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
class Resource:
def close(self) -> None:
print("zamykam zasób")
def close_all(items: Iterable[SupportsClose]) -> None:
for item in items:
item.close()
class Bucket:
def __iter__(self) -> Iterator[int]:
yield from [1, 2, 3]
close_all([Resource()])
W tym przykładzie `Resource` nie dziedziczy po `SupportsClose`, a mimo to jest zgodny z kontraktem. I właśnie na tym polega siła protokołów: kod pozostaje luźno sprzężony, a jednocześnie type checker nadal chroni przed literówkami i brakującymi metodami. W dokumentacji Pythona podobny sens mają też gotowe interfejsy dla strumieni, gdzie zamiast ciężkiego `IO` można opisać tylko to, co jest faktycznie używane, na przykład `read()` albo `write()`.
Skoro widać już, że protokół opisuje kształt obiektu, naturalne pytanie brzmi: jak taki kontrakt zapisać własnoręcznie?
Jak napisać własny Protocol
Tworzenie własnego protokołu jest zaskakująco proste, ale warto trzymać się kilku zasad. Po pierwsze, wpisuj w nim tylko to, co jest naprawdę potrzebne konsumentowi. Po drugie, pamiętaj o adnotacjach typów, bo bez nich type checker zaczyna traktować brakujące informacje jak `Any`, a wtedy część korzyści znika. Po trzecie, jeśli opisujesz atrybuty, wpisz je w ciele klasy, a nie dopiero wewnątrz metody.
from typing import Protocol
class CacheLike(Protocol):
def get(self, key: str) -> bytes | None: ...
def set(self, key: str, value: bytes, ttl: int | None = None) -> None: ...
def warm_cache(cache: CacheLike) -> None:
value = cache.get("config")
if value is None:
cache.set("config", b"default", ttl=300)
Tak zapisany protokół pasuje do wielu implementacji: cache w pamięci, Redis, adapter testowy, a nawet obiekt „pusty”, który nic nie zapisuje. Jeśli utrzymujesz starszy projekt, analogiczny interfejs można też opisać przez `typing_extensions.Protocol`, więc nie musisz od razu przesiadać całej bazy na najnowszą wersję Pythona.
- Minimalizm działa najlepiej - jeśli protokół zaczyna mieć osiem metod, zwykle jest za duży.
- Atrybuty deklaruj jawnie - na przykład `name: str` w ciele klasy.
- Jeśli potrzebujesz tylko odczytu, dobrym narzędziem bywa też `@property`.
- W nowszym Pythonie wygodnie definiuje się także protokoły generyczne, kiedy ten sam kontrakt ma przenosić różne typy danych.
To prowadzi wprost do kwestii, czy sprawdzać protokół także w czasie działania.
Kiedy używać runtime_checkable, a kiedy nie
Domyślnie protokoły są narzędziem dla statycznej analizy typów, nie dla runtime. Jeśli chcesz użyć ich z `isinstance()` albo `issubclass()`, musisz oznaczyć klasę dekoratorem `@runtime_checkable`. Wtedy Python sprawdzi tylko obecność wymaganych metod lub atrybutów, ale już nie pełną zgodność sygnatur czy typów argumentów. To jest ważne rozróżnienie, bo wielu początkujących oczekuje od protokołu czegoś w rodzaju automatycznej walidacji obiektu, a to nie jest jego rola.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
def stop(obj: Closable) -> None:
obj.close()
file_obj = open("config.txt", "r")
if isinstance(file_obj, Closable):
stop(file_obj)
Tego mechanizmu używałbym tylko wtedy, gdy naprawdę potrzebuję decyzji w runtime, na przykład przy wykrywaniu pluginów, defensywnym sprawdzaniu obiektów na granicy systemu albo podczas pracy z dynamicznie dostarczanymi implementacjami. W zwykłym kodzie aplikacyjnym często wystarczy sam type checker, a jeśli zależy Ci tylko na obecności atrybutu, to `hasattr()` bywa prostsze i szybsze. Co więcej, `isinstance()` na protokole z `@runtime_checkable` może być zauważalnie wolniejsze niż zwykły test klasy, więc w gorących ścieżkach nie jest to narzędzie pierwszego wyboru.
Gdy już rozumiesz różnicę między statycznym a runtime-check, zostaje jeszcze wybór właściwego narzędzia do konkretnego problemu.
Protocol, ABC, Callable i TypedDict rozwiązują różne problemy
Najwięcej błędów widzę wtedy, gdy ktoś wrzuca do jednego worka protokół, abstrakcyjną klasę bazową, `Callable` i `TypedDict`. Te narzędzia wyglądają podobnie tylko z daleka. W praktyce każde z nich opisuje inny aspekt kodu: zachowanie obiektu, dziedziczenie i współdzieloną implementację, wywoływalność albo sam kształt słownika.
| Narzędzie | Kiedy pasuje | Co daje | Ograniczenie |
|---|---|---|---|
Protocol |
Gdy liczy się zachowanie obiektu i chcesz dopuścić wiele niezależnych implementacji | Elastyczny kontrakt bez sztywnego dziedziczenia | Głównie statyczne typowanie; runtime tylko po dekoracji |
| ABC | Gdy potrzebujesz wspólnej bazy, domyślnej implementacji albo świadomego dziedziczenia | Jasna hierarchia klas i możliwość dzielenia kodu | Wymaga bardziej jawnego włączenia w strukturę klas |
Callable |
Gdy opisujesz pojedynczą funkcję lub prosty obiekt wywoływalny | Krótka, czytelna adnotacja | Słabiej radzi sobie z bardziej złożonymi sygnaturami i nazwanymi parametrami |
TypedDict |
Gdy chcesz opisać strukturę słownika, na przykład payload JSON | Precyzja dla danych klucz-wartość | Nie modeluje zachowania, tylko kształt danych |
Jeśli kod konsumenta używa tylko `read()`, `write()` albo `close()`, protokół zwykle jest lepszy niż cięższy `IO`. Jeśli natomiast chcesz współdzielić implementację albo wymusić świadome dziedziczenie, `ABC` nadal ma sens. Z kolei `Callable` zostaje wygodnym wyborem dla prostych callbacków, a `TypedDict` sprawdza się wtedy, gdy chcesz opisać strukturę danych przychodzących z API lub z formularza.
Samo rozróżnienie teorii nie wystarczy, jeśli projektujesz kontrakt zbyt szeroko, więc warto znać typowe pułapki.
Najczęstsze błędy przy projektowaniu protokołów
Protokoły są użyteczne tylko wtedy, gdy pozostają małe, celne i łatwe do zrozumienia. Jeśli zaczynasz upychać do jednego interfejsu wszystko, co „może się przydać”, to szybko kończysz z czymś, co wygląda jak kolejna klasa bazowa, tylko bez jej zalet. Ja zwykle pilnuję, żeby protokół opisywał jeden wyraźny obowiązek obiektu, a nie całą jego osobowość.
- Zbyt szeroki kontrakt - jeśli potrzebujesz wielu metod, podziel odpowiedzialność na kilka mniejszych protokołów.
- Oczekiwanie walidacji w runtime - protokół nie zastępuje testów ani sprawdzania danych wejściowych.
- Brak adnotacji - bez typów type checker traci część informacji i zaczyna dopuszczać zbyt wiele.
- Wpychanie atrybutów tylko do wnętrza metod - to nie tworzy członków protokołu, a w praktyce prowadzi do błędów projektowych.
- Tworzenie protokołu dla jednej klasy - jeśli nikt poza jednym miejscem z niego nie korzysta, prostsze rozwiązanie często będzie lepsze.
- Pomijanie wersji środowiska - w starszych projektach czasem trzeba sięgnąć po backport z `typing_extensions`.
Po wyłapaniu tych błędów najłatwiej przełożyć całą koncepcję na codzienną pracę w aplikacjach webowych.
Gdzie protokoły dają największy zysk w kodzie webowym
W projektach webowych protokoły zwracają się najszybciej tam, gdzie masz granice systemu: wejście z requestu, wyjście do cache, zapis do storage, integrację z zewnętrznym API i testy jednostkowe. To są miejsca, w których implementacje często się zmieniają, a kod konsumenta powinien być od nich możliwie niezależny. Ja zaczynam zwykle od najmniejszego sensownego kontraktu, bo właśnie on daje najlepszy stosunek prostoty do elastyczności.
- Repozytoria i warstwa dostępu do danych - możesz podmienić SQL, ORM, mock albo adapter testowy bez przepisywania logiki biznesowej.
- Klienci zewnętrznych API - wystarczy kontrakt na `get()` lub `post()`, zamiast wiązać się z konkretną biblioteką.
- Strumienie i uploady - protokół z `read()` i `close()` dobrze opisuje plik, bufor i obiekt z biblioteki HTTP.
- Cache i broker wiadomości - jeśli kod używa tylko kilku metod, nie ma sensu wiązać go z pełnym SDK.
- Test doubles - łatwiej zbudować prosty obiekt testowy niż dziedziczyć po rozbudowanej bazie.
W praktyce najlepiej działa zasada: opisuj tylko to, czego kod konsumenta naprawdę potrzebuje. Jeśli masz do czynienia z prostą funkcją, `Callable` wystarczy. Jeśli przekazujesz tylko dane, lepszy będzie `TypedDict` albo model danych. Jeśli jednak chcesz zdefiniować elastyczny kontrakt dla zachowania obiektów, protokół jest zwykle najczystszym rozwiązaniem. I właśnie wtedy daje największy zwrot: ogranicza sprzężenie, ułatwia testy i nie zamienia projektu w labirynt klas bazowych.