Protokoły, czyli kaczka to nie kaczka

Jak wygląda kaczka, każdy chyba wie od dziecka 😉 Ale czy kaczka zawsze jest kaczką? W Pythonie – nie koniecznie ale już tłumaczę. W językach typowanych dynamicznie istnieje taki termin jak duck typing, czy możliwość rozpoznania typu obiektu bez sprawdzania zawartych w nim metod. Zobaczmy na przykładzie jak wygląda klasa korzystająca z klasy ABC a jak wygląda z użyciem protokołów.

Jak to działa

Poniżej przybliżę Ci sposób działania protokołów na przykładzie. Załóżmy, że chcemy zadeklarować szkielet klasy która będzie obsługiwała płatności. W przypadku protokołów będzie to wyglądać w następujący sposób:

from decimal import Decimal


class MyCustomPaymentHandler:

    def add_payment(self, value: Decimal, title: str) -> dict:
        return {
            "value": value,
            "title": title,
        }

Już na pierwszy rzut oka widać, że klasa po niczym nie dziedziczy. Aby zadeklarować protokół, możemy zrobić to w następujący sposób:

from typing import Protocol

class PaymentHandler(Protocol):

    def add_payment(self, value: Decimal, title: str) -> dict: ...

Widać, że wygląda to zupełnie podobnie do klasy abstrakcyjnej, jednak nie mamy tutaj żadnego „ścisłego” powiązania z klasą. Całość możemy użyć tak:

class MyPaymentHandler:

    def add_payment(self, value: Decimal, title: str) -> dict:
        return {
            "value": value,
            "title": title,
        }

def use_payment_handler(handler: PaymentHandler) -> None:
    handler.add_payment(Decimal("50"), "Burgerek")

my_handler = MyPaymentHandler()
use_payment_handler(handler=my_handler)

Funkcja use_payment_handler przyjmuje parametr handler typu PaymentHandler, co określa jakiego „typu” obiektu będzie oczekiwać. Tracimy tutaj sporo na czytelności, ponieważ widząc klasę MyCustomPaymentHandler na pierwszy rzut oka, nie mamy pojęcia, że jest dla niej specjalnie przygotowany szkielet. Tak samo nie możemy sprawdzić typu obiektu. Mamy możliwość użycia dekoratora @runtime_checkable na protokole, ale całość zacznie zbliżać się do klas abstrakcyjnych.

Jak żyć?

Z pomocą przychodzą nam stare i dobre klasy abstrakcyjne, dzięki którym kod jest czytelniejszy i jesteśmy w stanie od razu określić czy dana klasa jest konkretnego typu. Oczywiście, protokoły mają swoje plusy takiej jak brak nadmiarowego kodu, czy szybkość i prostota tworzenia, ale tracimy na czytelności – szczególnie w dużych projektach gdzie ma to znaczenie. Przecież całość wygląda znacznie czytelniej jeśli mamy coś takiego:

from abc import ABC, abstractmethod
from decimal import Decimal


class PaymentHandler(ABC):

    @abstractmethod
    def add_payment(self, value: Decimal, title: str) -> dict: ...


class MyCustomPaymentHandler(PaymentHandler):

    def add_payment(self, value: Decimal, title: str) -> dict:
        return {
            "value": value,
            "title": title,
        }


def use_payment_handler(handler: PaymentHandler) -> None:
    handler.add_payment(Decimal("50"), "Burgerek")


my_handler = MyCustomPaymentHandler()
use_payment_handler(handler=my_handler)

Photo by Nikolay Tchaouchev on Unsplash