Protokoły Pythona: wykorzystanie podtypów strukturalnych


W Pythonie protokół określa metody i atrybuty, które klasa musi zaimplementować, aby została uwzględniona w danym typie. Protokoły są ważne w systemie wskazań typów Pythona, który umożliwia statyczne sprawdzanie typów za pomocą zewnętrznych narzędzi, takich jak mypy, Pyright i Pyre.

Zanim istniały protokoły, narzędzia te mogły jedynie sprawdzać nominalne podtypy na podstawie dziedziczenia. Nie było możliwości sprawdzenia podtypów strukturalnych, które opierają się na wewnętrznej strukturze klas. To ograniczenie miało wpływ na system kaczego pisania w Pythonie, który umożliwia używanie obiektów bez uwzględniania ich typów nominalnych. Protokoły pokonują to ograniczenie, umożliwiając statyczne pisanie metodą kaczki.

W tym samouczku:

  • Uzyskaj przejrzystość użycia terminu protokół w Pythonie
  • Dowiedz się, jak wskazówki dotyczące typów ułatwiają statyczne sprawdzanie typu
  • Dowiedz się, jak protokoły umożliwiają statyczne pisanie z kaczką
  • Twórz niestandardowe protokoły za pomocą klasy Protocol
  • Zrozum różnice między protokołami a abstrakcyjnymi klasami bazowymi

Aby w pełni wykorzystać ten samouczek, musisz znać podstawy programowania obiektowego w Pythonie, w tym pojęcia takie jak klasy i dziedziczenie. Powinieneś także wiedzieć o sprawdzaniu typu i pisaniu typu kaczego w Pythonie.

Znaczenie „protokołu” w Pythonie

Podczas ewolucji Pythona termin protokół został przeciążony dwoma subtelnie różnymi znaczeniami. Pierwsze znaczenie odnosi się do protokołów wewnętrznych, takich jak protokoły iteratora, menedżera kontekstu i protokołów deskryptorów.

Protokoły te są szeroko rozumiane w społeczeństwie i składają się ze specjalnych metod, które składają się na dany protokół. Na przykład metody .__iter__() i .__next__() definiują protokół iteratora.

Python 3.8 wprowadził drugi, nieco inny typ protokołu. Protokoły te określają metody i atrybuty, które klasa musi zaimplementować, aby została uwzględniona w danym typie. Zatem te protokoły mają również związek z wewnętrzną strukturą klasy.

Za pomocą tego rodzaju protokołu można zdefiniować wymienne klasy, o ile mają one wspólną strukturę wewnętrzną. Ta funkcja umożliwia wymuszenie relacji między typami lub klasami bez ciężaru dziedziczenia. Ta relacja jest nazywana podtypem strukturalnym lub statycznym typowaniem kaczym.

W tym samouczku skupisz się na drugim znaczeniu terminu protokół. Najpierw przyjrzyjmy się, jak Python zarządza typami.

Typowanie dynamiczne i statyczne w Pythonie

Python jest językiem dynamicznie typowanym, co oznacza, że interpreter Pythona sprawdza typ obiektu podczas działania kodu. Oznacza to również, że chociaż zmienna może odwoływać się tylko do jednego obiektu na raz, typ tego obiektu może zmieniać się w trakcie istnienia zmiennej.

Na przykład możesz mieć zmienną, która zaczyna się jako ciąg znaków i zmienia się w liczbę całkowitą:

>>> value = "One hundred"
>>> value
'One hundred'

>>> value = 100
>>> value
100

W tym przykładzie masz zmienną rozpoczynającą się jako ciąg znaków. W dalszej części kodu zmieniasz wartość zmiennej na liczbę całkowitą.

Ze względu na swoją dynamiczną naturę w Pythonie zastosowano elastyczny system pisania, zwany pisaniem kaczym.

Pisanie na kaczce

Duck typing to system typów, w którym obiekt jest uważany za zgodny z danym typem, jeśli posiada wszystkie metody i atrybuty wymagane przez typ. Ten system pisania obsługuje możliwość używania obiektów niezależnych i oddzielonych klas w określonym kontekście, o ile są one zgodne z pewnym wspólnym interfejsem.

Jako przykład pisania kaczego można rozważyć wbudowane typy danych kontenerów, takie jak listy, krotki, ciągi znaków, słowniki i zestawy. Wszystkie te typy danych obsługują iterację:

>>> numbers = [1, 2, 3]
>>> person = ("Jane", 25, "Python Dev")
>>> letters = "abc"
>>> ordinals = {"one": "first", "two": "second", "three": "third"}
>>> even_digits = {2, 4, 6, 8}

>>> containers = [numbers, person, letters, ordinals, even_digits]

>>> for container in containers:
...     for element in container:
...         print(element, end=" ")
...     print()
...
1 2 3 
Jane 25 Python Dev 
a b c 
one two three 
8 2 4 6 

W tym fragmencie kodu definiujesz kilka zmiennych przy użyciu różnych typów wbudowanych. Następnie uruchamiasz pętlę for po kolekcjach i wykonujesz iterację po każdej z nich, aby wydrukować ich elementy na ekranie. Mimo że typy wbudowane znacznie różnią się od siebie, wszystkie obsługują iterację.

System typu kaczego umożliwia tworzenie kodu, który może współpracować z różnymi obiektami, pod warunkiem, że mają one wspólny interfejs. System ten umożliwia ustawienie relacji między klasami, które nie opierają się na dziedziczeniu, co pozwala uzyskać elastyczny i oddzielony kod.

Wskazówki dotyczące typów i sprawdzanie typów

Mimo że Python jest językiem z typowaniem dynamicznym, który w dużym stopniu opiera się na typowaniu kaczym, nieznajomość typu argumentów, zwracanych wartości i atrybutów może być źródłem błędów. Jest to szczególnie prawdziwe w przypadku dużych baz kodu, w których funkcje i klasy są rozproszone w kilku modułach i pakietach.

Aby przezwyciężyć potencjalne problemy, w Pythonie 3.5 wprowadzono wskazówki dotyczące typów lub opcjonalne pisanie statyczne. Wskazówki dotyczące typów pozwalają opcjonalnie określić typy argumentów, zwracanych wartości i atrybutów w funkcjach i klasach. Następnie możesz sprawdzić te typy za pomocą statycznego narzędzia do sprawdzania typów, takiego jak mypy, i uzyskać przydatne informacje, które mogą pomóc w debugowaniu i ulepszaniu kodu, czyniąc go bardziej niezawodnym.

Oto krótki przegląd wspaniałych korzyści płynących ze stosowania wskazówek dotyczących typów w kodzie:

  • Poprawiona czytelność kodu: możesz jawnie określić typ argumentów, zwracanych wartości i atrybutów, co zwiększa czytelność kodu dla programistów, ułatwiając zrozumienie interfejsu funkcji i klas.
  • Obsługa statycznego sprawdzania typu: możesz umieścić wskazówki dotyczące typów w swoim kodzie, a następnie użyć narzędzi takich jak mypy, Pyright i Pyre, aby przeprowadzić statyczne sprawdzanie typu. Narzędzia te pozwolą Ci znaleźć niespójności i niedopasowania typów na wczesnym etapie procesu programowania, dzięki czemu będziesz mógł je naprawić i zwiększyć niezawodność kodu.
  • Lepsze środowiska programistyczne: możesz korzystać ze wskazówek dotyczących typów i korzystać z nowoczesnego edytora kodu lub ulepszonego uzupełniania kodu w IDE, informacji o sygnaturach funkcji i wbudowanej dokumentacji, co poprawia jakość życia programisty Pythona. Funkcje te mogą również przyspieszyć rozwój i zmniejszyć błędy spowodowane nieprawidłowym użyciem typów.
  • Pomoc w refaktoryzacji: możesz tworzyć refaktoryzacje, które obejmują zmianę typu argumentów, zwracanych wartości i atrybutów danej funkcji lub klasy. W tej sytuacji moduły sprawdzania typu pomogą Ci znaleźć wszystkie części kodu, które należy zaktualizować, aby dostosować je do nowych definicji.
  • Ulepszona dokumentacja: możesz skorzystać ze wskazówek dotyczących typów podczas pisania dokumentacji kodu za pomocą narzędzi takich jak MkDocs. W ten sposób informacje o typie w dokumentacji będą zsynchronizowane z kodem.

Python nie robi nic ze wskazówkami dotyczącymi typów w czasie wykonywania. Są one przechowywane w atrybucie .__annotations__ i w przeciwnym razie są ignorowane. Zatem oprócz lepszej czytelności kodu potrzebujesz zewnętrznych narzędzi, takich jak statyczny moduł sprawdzania typu, nowoczesny edytor kodu lub IDE, aby cieszyć się tymi korzyściami.

Jako przykład użycia wskazówek dotyczących typów rozważmy następującą funkcję, która dodaje dwie liczby:

from typing import Union

def add(a: Union[float, int], b: Union[float, int]) -> float:
    return float(a + b)

print(add(2, 4))

print(add("2", "4"))

Moduł typing odgrywa kluczową rolę w definiowaniu wskazówek dotyczących typów. Definiuje klasy takie jak Union, których można użyć do określenia wielu możliwych typów zmiennych, argumentów funkcji i wartości zwracanych. W tym przykładzie dodasz wskazówki dotyczące typu argumentów funkcji, a i b, a także wartości zwracanej.

Argumenty funkcji add() mogą być liczbami zmiennoprzecinkowymi lub całkowitymi. Aby skonfigurować wskazówki dotyczące typów, użyj klasy Union z typami float i int. Alternatywnie możesz użyć operatora potoku, aby wyrazić wskazówkę tego samego typu. Możesz więc na przykład napisać coś w stylu a: float | int.

Następnie używasz składni strzałki (->float), aby stwierdzić, że wartość zwracana przez funkcję będzie liczbą zmiennoprzecinkową. Na koniec masz kilka wywołań funkcji add(). Oto, co otrzymasz po uruchomieniu tego skryptu:

$ python calculations.py
6.0
24.0

Pierwszy wynik jest poprawny, ponieważ jako argumentów użyłeś dwóch liczb. Niestety drugi wynik jest nieprawidłowy, ale Twój kod nie został złamany. Działało bez widocznych problemów. Jak uniknąć tego typu problemów? Możesz uruchomić w swoim kodzie statyczne sprawdzanie typu.

Aby przeprowadzić statyczne sprawdzanie typu powyższego kodu, potrzebujesz zewnętrznego narzędzia. Na przykład możesz użyć mypy, ale najpierw musisz go zainstalować za pomocą następującego polecenia:

$ python -m pip install mypy

To polecenie zainstaluje statyczny moduł sprawdzania typu mypy z PyPI. Teraz możesz uruchomić następujące polecenie w pliku calculations.py:

$ mypy calculations.py
calculations.py:8: error: Argument 1 to "add" has incompatible type "str";
    expected "float | int"  [arg-type]
calculations.py:8: error: Argument 2 to "add" has incompatible type "str";
    expected "float | int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Za pomocą tego polecenia uruchamiasz analizę typu statycznego w kodzie. W rezultacie dowiadujesz się, że w wierszu 8 Twój kod zawiera dwa błędy. Błędy te ostrzegają Cię o niespójnościach typu w kodzie. Mając te informacje, możesz przejść do swojego kodu i naprawić niespójności, aby kod działał poprawnie.

Wpisywanie kaczek a wskazówki dotyczące wpisywania

Jak się przekonałeś, pisanie metodą kaczki pozwala na tworzenie elastycznego i dynamicznego kodu. Dzięki typowaniu kaczem możesz używać różnych i niepowiązanych obiektów w danym kontekście, jeśli te obiekty mają oczekiwane metody i atrybuty. Nie musisz upewniać się, że obiekty mają ten sam typ nadrzędny poprzez dziedziczenie.

Kiedy dodasz wskazówki dotyczące typu do fragmentu kodu, który opiera się na pisaniu kaczym, możesz napotkać pewne wyzwania. Oto zabawkowy przykład ilustrujący, w jaki sposób wskazówki dotyczące typu mogą kolidować z pisaniem kaczym:

class Duck:
    def quack(self):
        return "The duck is quacking!"

def make_it_quack(duck: Duck) -> str:
    return duck.quack()

class Person:
    def quack(self):
        return "The person is imitating a duck quacking!"

print(make_it_quack(Duck()))

print(make_it_quack(Person()))

W tym kodzie definiujesz klasę Duck za pomocą metody .quack(). Następnie definiujesz funkcję make_it_quack(), która przyjmuje argument typu Duck. Następnie tworzysz klasę Person do użycia w kontekście pisania kaczego. W tym przykładzie będą działać wywołania funkcji print(). Jednak w drugim wywołaniu występuje niespójność typu, ponieważ instancja Person nie jest typu Duck.

Oto, co otrzymasz po uruchomieniu mypy na tym pliku:

$ mypy birds_v1.py
birds_v1.py:14: error: Argument 1 to "make_it_quack" has incompatible type
    "Person"; expected "Duck"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Moduł sprawdzania typu wyświetla błąd informujący, że wywołanie funkcji print() w linii 14 używa obiektu o niezgodnym typie.

Jednym ze sposobów rozwiązania tego problemu jest użycie dziedziczenia:

class QuackingThing:
    def quack(self):
        raise NotImplementedError(
            "Subclasses must implement this method"
        )

class Duck(QuackingThing):
    def quack(self):
        return "The duck is quacking!"

class Person(QuackingThing):
    def quack(self):
        return "The person is imitating a duck quacking!"

def make_it_quack(duck: QuackingThing) -> str:
    return duck.quack()

print(make_it_quack(Duck()))

print(make_it_quack(Person()))

W tej nowej implementacji tworzysz nową klasę o nazwie QuackingThing i używasz jej jako klasy nadrzędnej dla Duck i Person. Teraz sprawdzanie typu przechodzi:

$ mypy birds_v2.py
Success: no issues found in 1 source file

Dane wyjściowe modułu sprawdzania typu są czyste. Jednak zamieniłeś pisanie na kaczkę na dziedzictwo. Teraz Twoje klasy są ściśle powiązane, nawet jeśli nie mają jasnej relacji dziedziczenia. Jak naprawić tę kolizję między pisaniem kaczym a podpowiedziami? Tutaj na scenę wchodzą protokoły.

Podtypy strukturalne i protokoły

W systemie typów Pythona znajdziesz dwa sposoby na określenie, czy dwa obiekty są kompatybilne jako typy:

  1. Podtyp nominalny opiera się wyłącznie na dziedziczeniu. Klasa dziedzicząca z klasy nadrzędnej jest podtypem swojej klasy nadrzędnej.
  2. Podtypy strukturalne opierają się na wewnętrznej strukturze klas. Dwie klasy posiadające te same metody i atrybuty są względem siebie podtypami strukturalnymi.

Aby zrozumieć pierwszą strategię podtypów, załóżmy, że masz hierarchię klas reprezentującą zwierzęta:

class Animal:
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} is eating.")

    def drink(self):
        print(f"{self.name} is drinking.")

class Dog(Animal):
    def bark(self):
        print(f"{self.name} is barking.")

class Cat(Animal):
    def meow(self):
        print(f"{self.name} is meowing.")

W tym kodzie Pies i Kot są nominalnymi podtypami Animal. Oznacza to, że możesz używać instancji Dog lub Cat, gdy oczekiwane są instancje Animal. Ta forma podtypu jest łatwa do zrozumienia i pasuje do działania wbudowanej funkcji isinstance():

>>> from animals_v1 import Animal, Cat, Dog

>>> pluto = Dog("Pluto")
>>> isinstance(pluto, Animal)
True

>>> tom = Cat("Tom")
>>> isinstance(tom, Animal)
True

Wbudowana funkcja isinstance() pozwala sprawdzić, czy dany obiekt jest instancją klasy. Ta funkcja uwzględnia podtypy podczas sprawdzania. Dlatego w obu powyższych przykładach otrzymasz wartość True.

jawne sprawdzanie typów nie jest popularną praktyką w Pythonie. Zamiast tego język faworyzuje typowanie typu duck, w którym używasz obiektów bez uwzględnienia ich typów, a jedynie oczekiwanych operacji. Tutaj przydaje się podtyp strukturalny.

Na przykład możesz ponownie zaimplementować klasy zwierząt bez ustalania formalnej relacji między nimi:

class Dog:
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} is eating.")

    def drink(self):
        print(f"{self.name} is drinking.")

    def make_sound(self):
        print(f"{self.name} is barking.")

class Cat:
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} is eating.")

    def drink(self):
        print(f"{self.name} is drinking.")

    def make_sound(self):
        print(f"{self.name} is meowing.")

Teraz Pies i Kot nie mają ścisłego związku dziedziczenia. Są to całkowicie oddzielone i niezależne klasy.

Implementują jednak ten sam interfejs publiczny. Innymi słowy, mają tę samą strukturę wewnętrzną, w tym metody i atrybuty. Ze względu na tę cechę są to podtypy strukturalne i można ich używać w kontekście pisania kaczego:

>>> from animals_v2 import Cat, Dog

>>> for animal in [Cat("Tom"), Dog("Pluto")]:
...     animal.eat()
...     animal.drink()
...     animal.make_sound()
...
Tom is eating.
Tom is drinking.
Tom is meowing.
Pluto is eating.
Pluto is drinking.
Pluto is barking.

Klasy te działają dobrze w kontekście pisania kaczego. Jednak do tego momentu nie masz formalnego sposobu wskazania, że Kot i Pies są podtypami. Wiesz o tym tylko dlatego, że kod mówi, że mają tę samą wewnętrzną strukturę. Aby sformalizować tę relację podtypu, można użyć protokołu.

Jak już wiesz, protokoły pozwalają określić oczekiwane metody i atrybuty, które klasa powinna posiadać, aby obsługiwać daną funkcję, bez konieczności jawnego dziedziczenia. Zatem protokoły są jawnymi zbiorami metod i atrybutów.

W praktyce klasa może obsługiwać wiele protokołów. Na przykład możesz nieformalnie powiedzieć, że klasy Dog i Cat mają żywy protokół składający się z .eat() i .drink(), które są operacjami niezbędnymi do podtrzymania życia. Można też powiedzieć, że klasy mają protokół dźwiękowy składający się z metody .make_sound().

Mając to na uwadze, możesz utworzyć inne klasy obsługujące którykolwiek z tych protokołów:

class Person:
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} is eating.")

    def drink(self):
        print(f"{self.name} is drinking.")

    def talk(self):
        print(f"{self.name} is talking.")

Ta klasa Person obsługuje protokół życia, ponieważ implementuje wymagane zachowania, .eat() i .drink(). Jednak klasa nie implementuje metody .make_sound(), więc nie obsługuje protokołu soundingowego.

Protokoły są szczególnie przydatne, gdy modyfikowanie struktury dziedziczenia klas jest niepraktyczne. Dzięki protokołom możesz skupić się na definiowaniu pożądanego zachowania i cech bez konieczności projektowania złożonych relacji dziedziczenia.

Statyczne pisanie metodą kaczki przy użyciu protokołów

Jak już się dowiedziałeś, pisanie z kaczką i wskazówki dotyczące typu kolidują ze sobą, nakładając ograniczenia na programistów Pythona, którzy chcą używać obu technik. Python 3.8 wprowadził sposób tworzenia formalnych protokołów w systemie pisania.

Od tej wersji moduł typing definiuje klasę bazową o nazwie Protocol, której można używać do tworzenia niestandardowych protokołów. Ta nowa klasa zapewnia formalny sposób obsługi podtypów strukturalnych w systemie podpowiedzi dotyczących typów.

Od wersji Python 3.8 statyczne moduły sprawdzające typy zaczęły obsługiwać podtypy strukturalne, co oznacza, że sprawdzają, czy obiekty są określonego typu nominalnego, a także czy spełniają wymagania, aby należeć do danego typu strukturalnego.

Rozważmy następujący przykład zabawki:

from typing import Protocol

class Adder(Protocol):
    def add(self, x, y): ...

class IntAdder:
    def add(self, x, y):
        return x + y

class FloatAdder:
    def add(self, x, y):
        return x + y

def add(adder: Adder) -> None:
    print(adder.add(2, 3))

add(IntAdder())
add(FloatAdder())

W tym kodzie definiujesz klasę o nazwie Adder, dziedzicząc z typing.Protocol. Adder implementuje metodę .add(), która definiuje sam protokół Adder. Należy pamiętać, że metody protokołu nie mają treści, którą zwykle wskazuje się za pomocą składni wielokropka (...).

Następnie definiujesz dwie klasy, IntAdder i FloatAdder. Klasy te implementują protokół Adder, ponieważ posiadają metodę .add(). Dlatego możesz używać obiektów dowolnej klasy jako argumentów funkcji add(), która jako argument przyjmuje obiekt Adder.

Statyczny moduł sprawdzania typu będzie zadowolony ze wskazówek dotyczących typu w funkcji add():

$ mypy adder_v1.py
Success: no issues found in 1 source file

Statyczna kontrola typu przebiega pomyślnie, ponieważ zarówno IntAdder, jak i FloatAdder obsługują protokół Adder. W tym przypadku mypy bierze pod uwagę wewnętrzną strukturę obiektu, a nie jego typ nominalny.

Pamiętaj, że nie zmusiłeś tych klas do dziedziczenia z Adder. Są to całkowicie niezależne i oddzielone klasy, których można używać w kontekście kaczego pisania, jak na przykład funkcja add().

Protokoły niestandardowe w Pythonie

Jest dużo więcej informacji na temat tworzenia niestandardowych protokołów w Pythonie. Można na przykład tworzyć protokoły poprzez dziedziczenie pojedyncze lub wielokrotne. Możesz mieć protokoły z różnymi typami członków. Można nawet tworzyć protokoły ogólne.

W kolejnych sekcjach dowiesz się o tych tematach i o tym, jak mogą pomóc w ulepszeniu niestandardowych protokołów i dostosowaniu ich do Twoich wymagań dotyczących pisania.

Tworzenie protokołów niestandardowych

Wiesz już, że można definiować niestandardowe protokoły za pomocą klasy Protocol z modułu typing. Dodanie wskazówek dotyczących typu do równania sprawi, że proces sprawdzania typu będzie bardziej rygorystyczny.

Rozważ następujący przykład:

from typing import Protocol

class Adder(Protocol):
    def add(self, x: float, y: float) -> float:
        ...

class IntAdder:
    def add(self, x, y):
        return x + y

def add(adder: Adder) -> None:
    print(adder.add(2, 3))

add(IntAdder()) # mypy: Success: no issues found in 1 source file

Ponieważ klasa IntAdder nie używa jawnych wskazówek dotyczących typów, mypy uważa, że argumenty metody i zwracana wartość są typu Any, co sprawia, że sprawdzenie typu zakończy się pomyślnie niezależnie od typu używany.

Teraz przejdź dalej i dodaj wskazówki dotyczące typu argumentów do .add() i jego wartości zwracanej w klasie IntAdder, aby umożliwić mypy sprawdzanie typu:

# ...

class IntAdder:
    def add(self, x: int, y: int) -> int:
        return x + y

def add(adder: Adder) -> None:
    print(adder.add(2, 3))

add(IntAdder())

W tej aktualizacji stwierdziłeś, że metoda .add() będzie przyjmować argumenty w postaci liczb całkowitych i zwracać wartość całkowitą. Śmiało, uruchom mypy na nowej wersji adder_v2.py:

$ mypy adder_v2.py
adder_v2.py:17: error: Argument 1 to "add" has incompatible type "IntAdder";
    expected "Adder"  [arg-type]
adder_v2.py:17: note: Following member(s) of "IntAdder" have conflicts:
adder_v2.py:17: note:     Expected:
adder_v2.py:17: note:         def add(self, x: float, y: float) -> float
adder_v2.py:17: note:     Got:
adder_v2.py:17: note:         def add(self, x: int, y: int) -> int
Found 1 error in 1 file (checked 1 source file)

To wyjście informuje, że Twoja klasa IntAdder jest niezgodna z protokołem Adder, ponieważ .add() przyjmuje i zwraca niezgodne typy. Jak sprawić, by czek przeszedł pomyślnie? Możesz zaktualizować protokół Adder, aby akceptował liczby całkowite lub zmiennoprzecinkowe:

from typing import Protocol

class Adder(Protocol):
    def add(self, x: int | float, y: int | float) -> int | float:
        ...

# ...

W tym przypadku metoda .add() może przyjmować argumenty całkowite lub zmiennoprzecinkowe. Może także zwracać argumenty dowolnego typu.

Śmiało, uruchom ponownie mypy:

$ mypy adder_v3.py
adder_v2.py:17: error: Argument 1 to "add" has incompatible type "IntAdder";
    expected "Adder"  [arg-type]
adder_v2.py:17: note: Following member(s) of "IntAdder" have conflicts:
adder_v2.py:17: note:     Expected:
adder_v2.py:17: note:         def add(self, x: int | float, y: int | float)
    -> int | float
adder_v2.py:17: note:     Got:
adder_v2.py:17: note:         def add(self, x: int, y: int) -> int
Found 1 error in 1 file (checked 1 source file)

Ta aktualizacja nie rozwiązuje problemu. Musisz zastosować inne podejście i wtedy przydadzą się protokoły generyczne.

Budowanie protokołów ogólnych

Klasy protokołów mogą być ogólne. Aby utworzyć protokół ogólny, możesz użyć klasy typing.TypeVar. Składnia protokołu ogólnego jest następująca:

from typing import Protocol, TypeVar

T = TypeVar("T")

class GenericProtocol(Protocol[T]):
    def method(self, arg: T) -> T:
        ...

W tym fragmencie kodu najpierw definiujesz typ ogólny T, używając klasy TypeVar. Następnie tworzysz ogólny protokół, używając Protocol[T] jako klasy nadrzędnej. Typów ogólnych można także używać w argumentach i zwracanych wartościach metod protokołu.

Aby rozwiązać problem znaleziony w poprzedniej sekcji, możesz użyć protokołu ogólnego:

from typing import Protocol, TypeVar

T = TypeVar("T", bound=int | float)

class Adder(Protocol[T]):
    def add(self, x: T, y: T) -> T:
        ...

class IntAdder:
    def add(self, x: int, y: int) -> int:
        return x + y

class FloatAdder:
    def add(self, x: float, y: float) -> float:
        return x + y

def add(adder: Adder) -> None:
    print(adder.add(2, 3))

add(IntAdder())
add(FloatAdder())

W tym przykładzie najpierw definiujesz typ ogólny protokołu. Argumentu bound używasz do stwierdzenia, że typem ogólnym może być obiekt int lub float. Następnie masz konkretne dodatki. W tym przypadku masz IntAdder i FloatAdder do sumowania liczb.

Jeśli używasz Pythona 3.12, możesz użyć uproszczonej składni:

from typing import Protocol

class Adder(Protocol):
    def add[T: int | float](self, x: T, y: T) -> T:
        ...

# ...

Dzięki tej składni nie musisz importować TypeVar i wcześniej tworzyć typu ogólnego. Wystarczy użyć nawiasów kwadratowych po nazwie metody, użyć ogólnego typu T i opcjonalnie powiązać niektóre istniejące typy.

Badanie możliwych członków protokołu

Do tego momentu pisałeś protokoły ze zwykłymi metodami instancji. Jednakże protokoły mogą mieć różne typy członków, w tym następujące:

  • Atrybuty klas
  • Atrybuty instancji
  • Metody instancji
  • Metody zajęć
  • Metody statyczne
  • Właściwości
  • Metody abstrakcyjne

Aby rozróżnić atrybuty klasy od atrybutów instancji, powinieneś użyć klasy ClassVar. Oto klasa protokołu demonstracyjnego, która definiuje wszystkie powyższe elementy:

from abc import abstractmethod
from typing import ClassVar, Protocol

class ProtocolMembersDemo(Protocol):
    class_attribute: ClassVar[int]
    instance_attribute: str = ""

    def instance_method(self, arg: int) -> str:
        ...

    @classmethod
    def class_method(cls) -> str:
        ...

    @staticmethod
    def static_method(arg: int) -> str:
        ...

    @property
    def property_name(self) -> str:
        ...

    @property_name.setter
    def property_name(self, value: str) -> None:
        ...

    @abstractmethod
    def abstract_method(self) -> str:
        ...

Do zdefiniowania atrybutu klasy używasz ClassVar. Następnie masz atrybut instancji z wartością domyślną, która jest opcjonalna.

Należy zauważyć, że atrybuty instancji muszą być zadeklarowane na poziomie klasy, aby moduł sprawdzający typ uznał je za część protokołu. W przeciwnym razie, jeśli zdefiniujesz atrybuty instancji wewnątrz metod instancji, co jest powszechną praktyką w Pythonie, podczas sprawdzania typu pojawi się błąd.

Następnie masz różne typy metod. W tym przykładzie żadna z metod nie ma zdefiniowanej implementacji. Można jednak mieć metody protokołu z domyślnymi implementacjami. Na koniec zauważ, że wszyscy członkowie klasy protokołu mogą mieć wskazówki dotyczące typów.

Tworzenie podprotokołów

Możesz wykorzystać istniejące protokoły do tworzenia nowych za pomocą dziedziczenia. Załóżmy na przykład, że tworzysz platformę do publikowania postów na blogu i postów wideo. Chcesz używać odpowiednich wskazówek dotyczących typów we wszystkich swoich klasach i funkcjach, więc piszesz następujące protokoły:

from typing import Protocol

class ContentCreator(Protocol):
    def create_content(self) -> str:
        ...

class Blogger(ContentCreator, Protocol):
    posts: list[str]

    def add_post(self, title: str, content: str) -> None:
        ...

class Vlogger(ContentCreator, Protocol):
    videos: list[str]

    def add_video(self, title: str, path: str) -> None:
        ...

W tym fragmencie kodu znajduje się protokół podstawowy o nazwie ContentCreator, który definiuje metodę .create_content(). Następnie tworzysz dwa pochodne protokoły, Blogger i Vlogger. Klasy te dziedziczą z protokołu podstawowego ContentCreator, co czyni je podprotokołami.

Klasy również dziedziczą z Protocol. Pamiętaj, że nie jest to obowiązkowe. Jednak najlepszą praktyką jest upewnienie się, że semantyka i intencje są jasne.

Następnie definiujesz dwie konkretne klasy:

# ...

class Blog:
    def __init__(self):
        self.blog_posts = []

    def create_content(self) -> str:
        return "Creating a post."

    def add_post(self, title: str, content: str) -> None:
        self.blog_posts.append(f"{title}: {content}")
        print(f"Post added: {title}")

class Vlog:
    def __init__(self):
        self.videos = []

    def create_content(self) -> str:
        return "Recording a video."

    def add_video(self, title: str, path: str) -> None:
        self.videos.append(f"{title}: {path}")
        print(f"Video added: {title}")

Klasa Blog jest zgodna z protokołem Blogger, natomiast klasa Vlog jest zgodna z protokołem Vlogger. Teraz możesz mieć kod klienta do korzystania z tych klas:

# ...

def produce_content(creator: ContentCreator):
    print(creator.create_content())

def add_post(blogger: Blogger, title: str, content: str):
    blogger.add_post(title, content)

def add_video(vlogger: Vlogger, title: str, path: str):
    vlogger.add_video(title, path)

Pierwsza funkcja pozwala na utworzenie nowego fragmentu treści. Może przyjąć dowolny obiekt zgodny z protokołem ContentCreator. Pozostałe dwie funkcje umożliwiają dodanie nowego wpisu na blogu i filmu. Mogą przyjmować obiekty spełniające odpowiednio protokoły Blogger i Vlogger.

Teraz możesz użyć mypy, aby sprawdzić, czy wskazówki dotyczące typu działają:

$ mypy contents.py
Success: no issues found in 1 source file

Świetnie! Dane wyjściowe są czyste. Twoje klasy podprotokołu działają zgodnie z oczekiwaniami!

Protokoły rekurencyjne

Możesz także zdefiniować protokoły rekurencyjne, czyli protokoły, które w swojej definicji odwołują się do siebie. Aby odwołać się do protokołu, należy podać jego nazwę w postaci ciągów znaków.

Protokoły rekurencyjne są przydatne do reprezentowania samoodwołujących się struktur danych, takich jak listy połączone. Rozważ następujący przykład:

from typing import Optional, Protocol

class LinkedListNode(Protocol):
    value: int
    next_node: Optional["LinkedListNode"]

    def __str__(self) -> str:
        return f"{self.value} -> {self.next_node}"

W tym fragmencie kodu definiujesz protokół o nazwie LinkedListNode, który reprezentuje węzeł na połączonej liście. Klasa ma dwa atrybuty instancji: .value do przechowywania wartości bieżącego węzła i .next_node do przechowywania następnego węzła na liście.

from __future__ import annotations
from typing import Optional, Protocol

class LinkedListNode(Protocol):
    value: int
    next_node: Optional[LinkedListNode]

    def __str__(self) -> str:
        return f"{self.value} -> {self.next_node}"

Importując adnotacje z __future__, masz pewność, że adnotacje lub wskazówki dotyczące typów będą traktowane jak ciągi znaków, dzięki czemu będziesz mógł odwoływać się do LinkedListNode nawet zanim zostanie on w pełni zdefiniowany .

Atrybut .next_node powinien być także obiektem węzła, dlatego należy użyć rekurencyjnego odniesienia do LinkedListNode jako ciągu znaków. W tym przykładzie używasz klasy Optional, aby wyrazić, że .next_node może mieć również wartość None.

Teraz śmiało utwórz konkretny węzeł i kod klienta:

# ...

class Node:
    def __init__(
        self,
        value: int,
        next_node: Optional["LinkedListNode"] = None,
    ):
        self.value = value
        self.next_node = next_node

    def __str__(self) -> str:
        return f"{self.value} -> {self.next_node}"

def print_linked_list(start_node: LinkedListNode):
    print(start_node)

node3 = Node(3)
node2 = Node(2, node3)
node1 = Node(1, node2)

print_linked_list(node1)

Klasa Node jest zgodna z protokołem LinkedListNode. Następnie masz funkcję print_linked_list(), która przyjmuje obiekt LinkedListNode jako argument i wyświetla połączoną listę na ekranie. Na koniec masz kod umożliwiający utworzenie kilku węzłów i wywołanie funkcji print_linked_list().

Możesz teraz uruchomić mypy na tym pliku:

$ mypy linked_list.py
Success: no issues found in 1 source file

Dane wyjściowe mypy są czyste, więc Twój protokół rekurencyjny działa zgodnie z oczekiwaniami. Napisałeś odpowiednie wskazówki dotyczące typów dla swoich zajęć, co jest świetne!

Predefiniowane protokoły w Pythonie

Python ma kilka predefiniowanych protokołów. Dobrymi przykładami są Iterable i Iterator. Kilka z nich znajduje się w module typing w sekcji Protokoły. Jednak większość obecnie dostępnych protokołów znajduje się w module collections.abc, ponieważ są one zaimplementowane jako abstrakcyjne klasy bazowe.

Niektóre z najpopularniejszych predefiniowanych protokołów obejmują:

Container

.__zawiera__()

Hashable

.__hash__()

Iterable

.__iter__()

Iterator

.__next__() i .__iter__()

Reversible

.__odwrócony__()

Generator

.send(), .throw(), .close(), .__iter__() i < kod>.__next__()

Sized

.__len__()

Callable

.__call__()

Collection

.__contains__(), .__iter__() i .__len__()

Sequence

.__getitem__(), .__len__(), .__contains__(), .__iter__(), .__reversed__(), .index() i .count()

MutableSequence

.__getitem__(), .__setitem__(), .__delitem__(), .__len__(), .insert(), .append(), .clear(), .reverse(), . Extend(), .pop(), .remove() i .__iadd__()

ByteString

.__getitem__() i .__len__()

Set

.__contains__(), .__iter__(), .__len__(), .__le__(), .__lt__(), .__eq__(), .__ne__(), .__gt__(), . __ge__(), .__and__(), .__or__(), .__sub__(), .__xor__( ) i .isdisjoint()

MutableSet

.__contains__(), .__iter__(), .__len__(), .add(), .discard(), .clear(), .pop(), .remove(), . __ior__(), .__iand__(), .__ixor__() i .__isub__()

Mapping

.__getitem__(), .__iter__(), .__len__(), .__contains__(), .keys(), .items(), .values(), .get(), . __eq__() i .__ne__()

MutableMapping

.__getitem__(), .__setitem__(), .__delitem__(), .__iter__(), .__len__(), .pop(), .popitem(), .clear(), . update() i .setdefault()

AsyncIterable

.__aiter__()

AsyncIterator

.__anext__() i .__aiter__()

AsyncGenerator

.asend(), .athrow(), .aclose(), .__aiter__() i < kod>.__anext__()

Buffer

.__buffer__()

Mimo że te klasy są raczej abstrakcyjnymi klasami bazowymi (ABC), a nie formalnymi protokołami, można ich używać jako protokołów we wskazówkach dotyczących typów. Statyczne moduły sprawdzające typy powinny być w stanie przetworzyć je zgodnie z oczekiwaniami.

W niektórych sytuacjach prawdopodobnie nie będziesz musiał używać tych klas, ponieważ możesz po prostu użyć wbudowanego typu betonowego. Na przykład zamiast używać ABC Set do wpisania podpowiedzi jako argumentu lub wartości zwracanej, możesz użyć wbudowanej klasy set.

Są jednak sytuacje, w których użycie rodzaju betonu nie spełni Twoich potrzeb. Załóżmy na przykład, że chcesz zakodować funkcję, która przyjmuje wartości całkowite i filtruje liczby parzyste, zwracając listę. Oto funkcja bez wskazówek dotyczących typu:

def filter_even_numbers(numbers):
    return [number for number in numbers if number % 2 == 0]

print(filter_even_numbers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
print(filter_even_numbers((1, 2, 3, 4, 5, 6, 7, 8, 9, 10)))
print(filter_even_numbers({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}))

Ta funkcja działa z dowolnym obiektem iterowalnym. Innymi słowy, możesz użyć listy, krotki, zestawu lub dowolnego innego obiektu iterowalnego jako argumentu funkcji filter_even_numbers().

Jak wpisać wskazówkę dotyczącą argumentu liczby w tej funkcji? Możesz pomyśleć o zrobieniu czegoś takiego:

def filter_even_numbers(numbers: list[int]) -> list[int]:
    return [number for number in numbers if number % 2 == 0]

print(filter_even_numbers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
print(filter_even_numbers((1, 2, 3, 4, 5, 6, 7, 8, 9, 10)))
print(filter_even_numbers({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}))

Wskazówka dotycząca typu list[int] dla liczb nie działa dobrze. Powoduje, że numbers akceptuje tylko obiekty listowe, co czyni tę funkcję mniej ogólną. Twój statyczny moduł sprawdzania typu nie powiedzie się w przypadku krotek, zestawów lub innych iterowalnych:

$ mypy even_v2.py
even_v2.py:5: error: Argument 1 to "filter_even_numbers" has incompatible
    type "tuple[int, int, int, int, int, int, int, int, int, int]";
    expected "list[int]"  [arg-type]
even_v2.py:6: error: Argument 1 to "filter_even_numbers" has incompatible
    type "set[int]"; expected "list[int]"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Te dane wyjściowe pokazują, że dwa ostatnie wywołania funkcji print() powodują problemy związane z typami danych wartości wejściowych argumentu numbers.

Aby rozwiązać problemy i podać ogólną wskazówkę dotyczącą typu, która pozwala Twojej funkcji na przyjmowanie różnych iteracji liczb, możesz użyć ABC Iterable, jak w poniższym kodzie:

from collections.abc import Iterable

def filter_even_numbers(numbers: Iterable[int]) -> list[int]:
    return [number for number in numbers if number % 2 == 0]

print(filter_even_numbers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
print(filter_even_numbers((1, 2, 3, 4, 5, 6, 7, 8, 9, 10)))
print(filter_even_numbers({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}))

W tej aktualizacji importujesz klasę Iterable z pliku collections.abc. Ta klasa implementuje metodę .__iter__(), spełniając wymagania protokołu iterowalnego. Ponieważ ta metoda jest iterowalna dla wartości całkowitych, jest dokładnie tym, czego potrzebuje Twoja funkcja filter_even_numbers(), aby działać poprawnie.

Teraz śmiało uruchom ponownie mypy:

$ mypy even_v3.py
Success: no issues found in 1 source file

Dane wyjściowe są teraz czyste. Oznacza to, że wskazówki dotyczące typów funkcji działają poprawnie. Funkcji tej można używać z dowolnymi danymi wejściowymi, które można iterować po wartościach całkowitych.

Protokoły a abstrakcyjne klasy bazowe

Abstrakcyjna klasa bazowa (ABC) została zaprojektowana tak, aby można ją było podklasować, ale nie można jej tworzyć. Ten typ klasy definiuje określony interfejs publiczny (API) i wymusza ten interfejs w swoich podklasach. Aby utworzyć abstrakcyjną klasę bazową, możesz użyć klasy ABC z modułu abc.

Aby zilustrować, jak zazwyczaj będziesz używać alfabetu, rozważ następujący przykład:

from abc import ABC, abstractmethod
from math import pi

class Shape(ABC):
    @abstractmethod
    def get_area(self) -> float:
        pass

    @abstractmethod
    def get_perimeter(self) -> float:
        pass

class Circle(Shape):
    def __init__(self, radius) -> None:
        self.radius = radius

    def get_area(self) -> float:
        return pi * self.radius**2

    def get_perimeter(self) -> float:
        return 2 * pi * self.radius

class Square(Shape):
    def __init__(self, side) -> None:
        self.side = side

    def get_area(self) -> float:
        return self.side**2

    def get_perimeter(self) -> float:
        return 4 * self.side

W tym przykładzie klasa Shape jest abstrakcyjną klasą bazową. W przypadku tej klasy stwierdzasz, że każda klasa utworzona jako podklasa Shape musi mieć funkcje .get_area() i .get_perimeter() metody w swoim publicznym interfejsie. To właśnie robią klasy Circle i Square. Jeśli nie zaimplementujesz jednej z wymaganych metod, pojawi się błąd.

Możesz użyć klasy Shape, aby zapewnić wskazówki dotyczące typów kodu klienta. Śmiało, dodaj następujący kod do swojego pliku shapes_v1.py:

# ...

def print_shape_info(shape: Shape):
    print(f"Area: {shape.get_area()}")
    print(f"Perimeter: {shape.get_perimeter()}")

circle = Circle(10)
square = Square(5)
print_shape_info(circle)
print_shape_info(square)

Wskazówka dotycząca typu argumentu shape będzie działać poprawnie, ponieważ Circle i Square są podklasami Shape.

W tym przykładzie użyłeś ABC i dziedziczenia, aby wymusić określony interfejs w grupie klas. Czasami jednak pożądane jest uzyskanie podobnego wyniku bez dziedziczenia. Wtedy możesz użyć protokołu:

from math import pi
from typing import Protocol

class Shape(Protocol):
    def get_area(self) -> float:
        ...

    def get_perimeter(self) -> float:
        ...

class Circle:
    def __init__(self, radius) -> None:
        self.radius = radius

    def get_area(self) -> float:
        return pi * self.radius**2

    def get_perimeter(self) -> float:
        return 2 * pi * self.radius

class Square:
    def __init__(self, side) -> None:
        self.side = side

    def get_area(self) -> float:
        return self.side**2

    def get_perimeter(self) -> float:
        return 4 * self.side

def print_shape_info(shape: Shape):
    print(f"Area: {shape.get_area()}")
    print(f"Perimeter: {shape.get_perimeter()}")

circle = Circle(10)
square = Square(5)
print_shape_info(circle)
print_shape_info(square)

W tej nowej implementacji zamiast ABC używasz protokołu. Teraz nie musisz polegać na dziedziczeniu, aby wskazówki dotyczące typów działały poprawnie. Twoje klasy są od siebie oddzielone. Ich jedynym punktem zbiegu okoliczności jest to, że mają wspólny kawałek interfejsu.

Krótko mówiąc, główna różnica między abstrakcyjną klasą bazową a protokołem polega na tym, że ta pierwsza działa poprzez formalną relację dziedziczenia, podczas gdy druga nie potrzebuje tej relacji. Należy pamiętać, że ta różnica nie czyni ABC lepszymi od protokołów i odwrotnie. Mają różne przypadki użycia i cele.

ABC są odpowiednie, gdy masz kontrolę nad hierarchią klas i chcesz zdefiniować spójny interfejs między podklasami. Tymczasem protokoły są przydatne w scenariuszach, w których modyfikowanie hierarchii klas jest niepraktyczne lub gdy nie ma jasnej relacji dziedziczenia między klasami.

Rozważanie potencjalnych wad protokołów

Warto wspomnieć o wadach protokołów, których ABC nie wykazują. Na przykład protokoły mogą sprawić, że moduł sprawdzania typu zaakceptuje zupełnie niepowiązany typ, co zdarza się całkowicie przez przypadek spełniać protokół, ale poza tym jest niewłaściwe.

Oto odpowiedni przykład:

from typing import Protocol

class Message(Protocol):
    def encode(self) -> bytes:
        ...

def send(message: Message) -> None:
    ...

send("Hello, World!")  # Passes the type checker

W tym przykładzie ciągi znaków mają metodę .encode(), która zwraca bajty i jest zgodna z protokołem. Możesz jednak chcieć, aby funkcja send() akceptowała tylko wystąpienia niestandardowych klas, które implementują protokół Message. To zachowanie tworzy lukę w systemie sprawdzania typów.

Inną potencjalną wadą protokołów jest to, że funkcja isinstance() zgłosi wyjątek, gdy zostanie z nimi użyta. Rozważ następujący kod, który wykorzystuje przykład kształtów z poprzedniej sekcji:

>>> from shapes_v2 import circle, Shape

>>> isinstance(circle, Shape)
Traceback (most recent call last):
    ...
TypeError: Instance and class checks can only be used with
    @runtime_checkable protocols

To sprawdzenie może być zawodne, ponieważ protokoły nie ustalają relacji dziedziczenia. Jeśli chcesz, aby Twoje klasy współpracowały z isinstance(), nawet jeśli nie są podklasami, możesz użyć dekoratora @runtime_checkable w definicji klasy:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Shape(Protocol):
    ...

Dekorator @runtime_checkable oznacza klasę protokołu jako protokół środowiska wykonawczego, dzięki czemu można go używać z isinstance() i issubclass().

Oto jak działa teraz poprzedni przykład:

>>> from shapes_v3 import circle, Shape

>>> isinstance(circle, Shape)
True

Po udekorowaniu klasy protokołu Shape za pomocą @runtime_checkable wbudowana funkcja isinstance() działa tak, jak chciałeś.

Wniosek

Teraz wiesz, jak używać protokołów w Pythonie. Protokoły pozwalają zdefiniować relację typów między obiektami bez ciężaru dziedziczenia. Zależność ta opiera się na wewnętrznej strukturze klas.

Dzięki protokołom możesz wykonywać podtypy strukturalne lub statyczne typy kaczek, korzystając z systemu podpowiedzi Pythona i zewnętrznych statycznych modułów sprawdzania typów, takich jak mypy, Pyright i Pyre.

W tym samouczku:

  • Uzyskano jasność co do różnych zastosowań terminu protokół w Pythonie
  • Dowiedziałeś się, jak wskazówki dotyczące typów ułatwiają statyczne sprawdzanie typu
  • Dowiedziałem się, jak protokoły zapewniają obsługę statycznego pisania kaczego
  • Utworzono niestandardowe protokoły z klasą Protocol
  • Zrozumiałem różnice między protokołami i ABC

Dzięki tej wiedzy możesz w pełni wykorzystać system podpowiedzi dotyczących typów Pythona i statyczne moduły sprawdzania typów.

Powiązane artykuły