Очень несекретные дела

Программируем и говорим об ужасах высшего образования

Протоколы в Python

Сегодня у нас немного относительно сложного материальчика, который в 2024 году становится очень популярным, т.к. классическое ООП во всю сдает свои позиции.

Все, о чем будет разговор в этой заметке будет посвещено типизации в Python, обрабатываемой с помощью статических анализаторов, а в частности, mypy.

И начнем мы с того, что типы в Python бывают как минимум двух видов: номинальные и структурные.

Номинальный (nominal) тип -- набор значений (читай, объектов), чей атрибут __class__ является транзитивным типом или любым его подклассом.

Если не знаете или не помните из школы, нагуглите, что такое транзитивность.

Структурный (structural) тип -- набор значений, определяемых не магическим атрибутом __class__, а по их свойствам: атрибутам, методам, ключам и т.п.

И разговор сегодня пойдет про то, как же контролировать структурные типы. А занимается этим его величество класс Protocol из библиотеки typing, который является инстансом ABCMeta. Если интересно, гляньте на реализацию, станет понятно, как cpython определяет является ли класс протоколом и как такие классы взаимодействуют с isinstance(). А можно просто прочитать PEP544.

Определяется протокол с помощью наследования от класса Protocol.

class ConvertableProtocol(Protocol):
    def convert(self) -> None:
        ...

Таким нехитрым способом был объявлен протокол (кто знает ООП, то читай, интерфейс), говорящий, что в любом объекте, который будет следовать этому протоколу должен быть метод convert() с указанной сигнатурой.

Сопру определние с сайта.

Сигнатура метода - это его уникальная идентификационная схема, которая включает в себя имя метода, типы его параметров (их порядок и количество) и тип возвращаемого значения.

Пусть у нас теперь есть два таких класса и некая функция, которая в параметре принимает объект, следующий объявленному ранее протоколу.

class JPGFile:
    def convert(self) -> None:
        print('jpg converted')


class BMPFile:
    def convert_file(self):
        print('bmp converted')


def convert_obj(obj: ConvertableProtocol):
    obj.convert()

В одном классе есть метод, названный так же, как в протоколе, в другом -- названный по-другому.

Дальше понадобится mypy (хотя PyCharm справляется и сам). Добавляем вызовы функции с объектами разных классов.

convert_obj(JPGFile())
convert_obj(BMPFile())

И скармливаем все это дело mypy:

Argument 1 to "convert_obj" has incompatible type "BMPFile"; expected "ConvertableProtocol"

Класс, который не наследует напрямую протокол, соответствует типу этого протокола. Магия, не иначе.

Протоколы можно использовать в нормальных типах. Пусть, есть функция, которая должна принимать какой-то контейнер итерируемый, все объекты внутри которого должны следовать протоколу выше. И сразу вызовем функцию с передачей списка из "правильного" и "неправильного" объектов.

from typing import Iterable
...

def convert_files(files: Iterable[ConvertableProtocol]):
    for file in files:
        convert_obj(file)
...

convert_files(
    [JPGFile(), BMPFile()]
)

Проверка mypy снова показывает магию!

List item 1 has incompatible type "BMPFile"; expected "ConvertableProtocol"

Ну а дальше тут все очень похоже на привычный ООП, только часто можно обойстись без огромной цепочки наследования. Например, можно внутри протокола использовать абстрактные, классовые, статические методы.

class ConvertableProtocol(Protocol):
    def convert(self) -> None:
        ...

    @abstractmethod
    def method(self):
        raise NotImplementedError

Попробуйте запустить код, посмотрите на сообщения.

Внутри протокола можно использовать переменные класса, но их придется аннотировать с помощью ClassVar.

Протоколы можно наследовать от других протоколов, таким образом собирая нужный интерфейс из кусков. Пусть объекты нашего протокола, допускающего конвертацию объектов, должны еще поддерживать функцию len(). Просто пишем в наследниках еще и тип Sized.

class ConvertableProtocol(Sized, Protocol):
    ...

Теперь во всех классах, которые должны следовать этому протоколу придется описать метод __len__().

За кадром остались обобщенные и рекурсивные протоколы. Но я не смог придумать легкий пример, демонстрирующий их приколы. Поэтому отправлю читать доку. Изучите сначала, что такое ковариантность, контравариантность и инвариантность в программировании.

Резюме такое. Этот подход точно будет дальше активно лезть в продакшн, этот подход сложнее, python может стать практически на с++. Использовать или нет -- выбор каждого. Я точно буду, мне зашло.