В Python 3.8. появилась новая примечательная возможность — протоколы (protocols). Протоколы — это альтернатива абстрактным базовым классам (abstract base classes, ABC). Они позволяют пользоваться структурной подтипизацией (structural subtyping), то есть — осуществлять проверку совместимости классов исключительно на основе анализа их атрибутов и методов. В этом материале мы поговорим о протоколах в Python и разберём практические примеры работы с ними.
Типизация в Python
Начнём с рассмотрения системы типизации в Python. Это — динамически типизированный язык, то есть — типы выводятся во время выполнения программы, что ведёт, например, к тому, что следующий код нормально запустится и отработает:
def add(x, y):
return x + y
print(add(2, 3))
print(add("str1", "str2"))
Первый вызов функции add()
приводит к сложению целых чисел и к возврату числа 5. Второй вызов производит конкатенацию строк с возвратом строки str1str2
. То, что такое возможно, отличает Python от статически типизированных языков, вроде C++, где типы необходимо объявлять:
int add(int x, int y) {
return x + y;
}
std::string add(std::string x, std::string y) {
return x + y;
}
int main()
{
std::cout<<add(2, 3);
std::cout << add("str1", "str2");
return 0;
}
Сильная сторона статической типизации — возможность выявления ошибок при компиляции кода. А в динамически типизированных языках подобные ошибки проявляются лишь во время выполнения кода. Но, с другой стороны, динамическая типизация может способствовать ускорению создания прототипов программ, может помочь в проведении различных экспериментов. Это — одна из причин того, что Python обрёл огромную популярность.
Динамическую типизацию ещё называют «утиной типизацией«. Такое название этот термин получил от определения „утиного теста“: „Если нечто выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, и есть утка“. В нашем случае это означает следующее: если объекты предлагают пользователю одни и те же атрибуты и методы, то с ними можно работать похожим образом. То есть, например, если есть пара схожих типов, объекты одного из них можно передавать функциям, рассчитанным на объекты другого типа.
Но такая гибкость даёт больше минусов, чем плюсов. Особенно — в больших программных проектах, которые ближе к профессиональным продуктам, чем к прототипам. В результате в мире программирования наблюдается тренд на статическую проверку типов. В Python это, например, включение в код подсказок типов, рассчитанных на применение статического анализатора типов mypy.
Подтипизация
Тут есть один интересный вопрос, намёк на который был дан выше, в разговоре об утиной типизации. Речь идёт о подтипизации. Предположим, имеется функция с такой сигнатурой: foo(x: X)
. Какие классы, помимо X
, mypy позволит передать этой функции?
Обратите внимание на то, что мы сейчас говорим лишь о типизации и о подсказках типов. Ведь Python, как уже было сказано, это язык с динамической типизацией, а это значит, что функции foo
можно передать любой объект. Если этот объект имеет атрибуты и методы, которые ожидает увидеть функция, то всё будет работать, а если нет — программа даст сбой.
Итак, при обсуждении подобных вещей различают структурную и номинальную подтипизацию. Структурная подтипизация основана на иерархии классов, на отношениях наследования. Если класс B
является наследником класса A
— он является подтипом класса A
, а значит — может быть использован везде, где ожидается класс A
.
А номинальная подтипизация основана на анализе операций, доступных для данного класса. Если класс B
предлагает все атрибуты и методы, предоставляемые классом A
— его можно использовать везде, где ожидается наличие класса A
.
Тут, сравнивая структурную и номинальную подтипизацию, можно сказать, что первая не такая «питонистичная», как вторая, так как вторая лучше соответствует идее утиной типизации. Но, тем не менее, протоколы в Python — это механизмы, основанные на структурной подтипизации.
Подтипизация на практике
До выхода Python 3.8 для подтипизации можно было использовать только наследование и, например, применять абстрактные базовые классы. Ниже мы определяем именно такой класс — «чертёж» для дочерних классов, а после этого определяем несколько классов‑потомков, являющихся наследниками абстрактного базового класса:
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def feed(self) -> None:
pass
class Duck(Animal):
def feed(self) -> None:
print("Duck eats")
def feed(animal: Animal) -> None:
animal.feed()
duck = Duck()
feed(duck)
Здесь мы сначала определяем абстрактный базовый класс Animal
, символизирующий живое существо, в котором описан абстрактный метод feed
, который позволяет это существо покормить. Затем мы создаём класс Duck
, представляющий утку, являющийся наследником класса Animal
и реализующий абстрактный метод. Далее — мы определяем универсальную функцию feed
, которая, в качестве параметра, принимает объект класса Animal
и вызывает его метод feed
, то есть — позволяет покормить то существо, которое ей передали.
Выглядит всё это вполне здраво. Так в чём же тогда проблема? На самом деле, тут можно найти даже несколько проблем, которые способны оттолкнуть программистов от использования подобного подхода к работе с типами
Во‑первых — базовые классы часто плохо подготовлены для их использования сторонним кодом, их тяжело включать в свои проекты из, например, сторонних библиотек. То есть — если нужно создать класс, являющийся наследником некого базового класса, существующего во внешнем модуле, может — в общедоступной библиотеке, этот класс сначала надо самостоятельно найти.
Во‑вторых — возможна ситуация, когда нельзя менять существующий код базовых классов, содержащийся в общедоступных или в любых других внешних по отношению к проекту библиотеках. Это становится проблемой в том случае, если нужно, чтобы типы, импортированные из этих вот внешних источников, выглядели бы в проекте как подтипы других типов, возможно — в комбинации с другими типами, созданными разработчиком проекта.
И наконец — этот подход противоречит самому духу Python и идее утиной типизации.
Протоколы
Итак, в Python 3.8 появились протоколы. Это позволило смягчить вышеописанные проблемы. Протоколы, как можно догадаться из названия, воздействуют на код неявным образом. Они определяют «интерфейсы», описывающие ожидаемые атрибуты и методы, и, при необходимости, организуют проверку наличия всего этого в соответствующих классах:
from typing import Protocol
class Animal(Protocol):
def feed(self) -> None:
pass
class Duck:
def feed(self) -> None:
print("Duck eats")
def feed(animal: Animal) -> None:
animal.feed()
duck = Duck()
feed(duck)
Как видно, Animal
— это теперь протокол (Protocol
). Класс Duck
не является наследником какого-либо базового класса. Но mypy всё это полностью устраивает.
Подтипизация протоколов
Протоколы, как и следовало ожидать, поддерживают создание подклассов, то есть — определение дочерних протоколов, являющихся наследниками родительских протоколов и расширяющих их возможности. При создании подклассов протоколов главное помнить о том, что наследственные отношения надо устанавливать и с родительским протоколом, и с typing.Protocol
:
from typing import Protocol
class Animal(Protocol):
def feed(self) -> None:
pass
class Bird(Animal, Protocol):
def fly(self) -> None:
pass
class Duck:
def feed(self) -> None:
print("Duck eats")
def fly(self) -> None:
print("Duck flies")
def feed(animal: Animal) -> None:
animal.feed()
def feed_bird(bird: Bird) -> None:
bird.feed()
bird.fly()
duck = Duck()
feed_bird(duck)
В этом коде мы создаём класс Bird
(птица) в виде наследника Animal
, а затем определяем функцию, реализующую следующий план действий: сначала птицу кормят, а после этого она улетает.
Краткая история протоколов
Весь код, который мы рассмотрели выше — это правильные Python‑программы, даже с точки зрения Python версий ниже 3.8 (не забывайте о том, что Python — это динамически типизированный язык). Тут лишь, чтобы не волновать mypy, нужно импортировать ABC
. Кроме того, mypy жаловался бы на последние примеры, где ABC
не используется. При этом надо сказать, что протоколы существовали и до Python 3.8. Просто раньше они были не такими заметными, они не были так чётко описаны, как сейчас. Дело в том, что большинство Python‑разработчиков использовало понятие «протокол», имея в виду соглашение соответствию определённому интерфейсу. Теперь в это понятие вкладывается тот же смысл. Один из известных примеров — протокол итератора (iterator protocol) — интерфейс, описывающий то, какие методы нужно реализовать пользовательскому итератору (custom iterator). Для того чтобы всё это, при отсутствии явным образом описанных протоколов, работало бы с mypy, существовало несколько «трюков», таких, как применение пользовательских типов:
from typing import Iterable
class SquareIterator:
def __init__(self, n: int) -> None:
self.i = 0
self.n = n
def __iter__(self) -> "SquareIterator":
return self
def __next__(self) -> int:
if self.i < self.n:
i = self.i
self.i += 1
return i**2
else:
raise StopIteration()
def iterate(items: Iterable[int]) -> None:
for x in items:
print(x)
iterator = SquareIterator(5)
iterate(iterator)
Сравнение абстрактных базовых классов и протоколов
Мы уже обсудили возможные проблемы, связанные с абстрактными базовыми классами (сложности с внешними модулями и интерфейсами, «непитонистический» код). Но протоколы не должны рассматриваться как замена абстрактных базовых классов. Пользоваться стоит и тем и другим. Например, абстрактные базовые классы — это хороший механизм многократного использования кода: весь общий функционал можно реализовать в виде базового класса, а в классах‑наследниках можно реализовывать лишь небольшие уникальные возможности программы. Протоколы же подобного не позволяют.
Итоги
В этом материале мы обсудили статическую и динамическую типизацию (утиную типизацию), поговорили о том, как mypy относится к подтипизации. До Python 3.8 применение подтипизации означало необходимость использования абстрактных классов. А с появлением протоколов в Python появился изящный механизм определения «интерфейсов». И у mypy появилась возможность проверки классов на соответствие этим «интерфейсам». Протоколы, отличаясь более «питонистическим» стилем, чем абстрактные базовые классы, позволяют указывать то, какие атрибуты и методы должны реализовывать классы. Такие классы можно использовать в качестве подтипов протокола, которому они соответствуют.
О, а приходите к нам работать? ???? ????
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Комментарии (3)
rsashka
31.07.2023 11:03Динамическую типизацию ещё называют «утиной типизацией«.
Динамическую типизацию никогда не называли "утиной типизацией", о чем и написано по приведенной вами ссылки в вики. Это совершенно разные вещи, и несмотря на то, что и там и там есть слово "типизация", но относятся они к разным понятиям.
truthseeker
31.07.2023 11:03Спасибо, даже не знал про протоколы, знал только про абстрактные классы. Теперь буду помнить, что иногда достаточно использовать протокол????
brotchen
Названия показались мне контринтуитивными, и я решил поискать определения.
https://en.wikipedia.org/wiki/Nominal_type_system
https://wiki.c2.com/?StructuralSubtyping
Вроде как всё наоборот: структурная (под)типизация - это типизация "по факту", по утиному принципу, а номинальная - по декларации.