Когда следует использовать `Protocol` для определения интерфейсов в Python?

Ответ

Протоколы (typing.Protocol) в Python используются для определения интерфейсов на основе структурной типизации (также известной как 'утиная типизация'). Они позволяют статическим анализаторам типов (например, mypy, pyright) проверять, соответствует ли объект определенному набору методов и атрибутов, без необходимости явного наследования.

Основные сценарии использования Protocol:

  1. Абстракция без наследования: Когда нужно определить интерфейс, но нет необходимости или возможности использовать абстрактные базовые классы (abc.ABC) или явное наследование. Это идеально для случаев, когда классы уже существуют и реализуют нужные методы, но не имеют общего предка.

    from typing import Protocol
    
    class Drawable(Protocol):
        def draw(self) -> None: ... # Определение сигнатуры метода
    
    class Circle:
        def draw(self) -> None:
            print("Drawing a circle")
    
    class Square:
        def draw(self) -> None:
            print("Drawing a square")
    
    def render(obj: Drawable) -> None:
        obj.draw()
    
    render(Circle()) # Работает, так как Circle соответствует протоколу Drawable
    render(Square())
  2. Обратная совместимость и интеграция: Для типизации объектов из сторонних библиотек или унаследованного кода, где вы не можете изменить иерархию наследования, но хотите обеспечить проверку типов.

  3. Аннотация callback-функций: Для четкого описания ожидаемой сигнатуры функций, передаваемых в качестве аргументов.

    from typing import Protocol
    
    class StringProcessor(Protocol):
        def __call__(self, data: str) -> int: ...
    
    def process_data(processor: StringProcessor, text: str) -> int:
        return processor(text)
    
    def my_processor(s: str) -> int:
        return len(s)
    
    print(process_data(my_processor, "hello")) # 5
  4. Гибкость в дизайне: Позволяют создавать более гибкие и слабосвязанные системы, где компоненты взаимодействуют через их поведение, а не через их иерархию классов.

Протоколы особенно полезны в сочетании с декоратором @runtime_checkable (из typing), который позволяет проверять соответствие протоколу во время выполнения с помощью isinstance() и issubclass(), что расширяет их применение за пределы статической проверки типов.

Ответ 18+ 🔞

Ну ты посмотри, что эти умники в Python придумали! Протоколы, блядь! typing.Protocol! Звучит-то как, ёпта, будто мы не код пишем, а в ООН заявку подаём.

А на деле-то что? Обычная утиная типизация, только в пиджаке и с галстуком. Если оно крякает как утка и плавает как утка — значит, это утка, и похуй, что в паспорте у неё написано «гусь» или «индюк». Вот и протоколы так же работают.

Зачем эта штука, на самом деле, нужна?

  1. Чтобы не ебаться с наследованием. Ну правда, зачем городить огород из абстрактных классов, если можно просто сказать: «Эй, чувак, у тебя есть метод draw()? Есть? Ну и отлично, ты нам подходишь, иди сюда». Это ж как в жизни — тебе же похуй, диплом у сантехника есть или нет, главное, чтобы жопу не залил.

    from typing import Protocol
    
    class Drawable(Protocol):
        def draw(self) -> None: ...  # Вот такая вот заявка: «Хочу, чтобы у объекта был этот метод»
    
    class Circle:
        def draw(self) -> None:  # А у этого круга он есть! И он даже не знает, что он в каком-то протоколе состоит!
            print("Рисую кружочек")
    
    class Square:
        def draw(self) -> None:  # И квадрат тоже рисуется! Красота!
            print("Рисую квадратик")
    
    def render(obj: Drawable) -> None:  # А эта функция говорит: «Дайте мне что угодно, что умеет рисоваться»
        obj.draw()
    
    render(Circle()) # И работает! Потому что круг соответствует протоколу, хоть он об этом и не догадывался.
    render(Square())
  2. Чтобы не переписывать старый код, который писал какой-то мудак десять лет назад. У тебя есть библиотека, написанная на коленке, все классы там — сироты, без папы-мамы. А ты хочешь её типизировать. Ну так объяви протокол, скажи: «Вот такие методы я от тебя жду». И всё, статический анализатор успокоится, а код менять не надо. Волшебство, блядь.

  3. Чтобы не путаться в колбэках. Раньше писали Callable[[str], int] и думали: «Ну вроде понятно». А теперь можно дать этому колбэку имя и описать, что он делает, прямо в типе. Красота!

    from typing import Protocol
    
    class StringProcessor(Protocol):
        def __call__(self, data: str) -> int: ...  # «Дайте мне функцию, которая жрёт строку и выплёвывает число»
    
    def process_data(processor: StringProcessor, text: str) -> int:
        return processor(text)
    
    def my_processor(s: str) -> int:  # Эта функция жрёт строку и выплёвывает число. Она — процессор!
        return len(s)
    
    print(process_data(my_processor, "hello")) # 5
  4. Просто для гибкости. Чтобы твой код зависел не от конкретного класса-родителя (который, возможно, ещё и в другой библиотеке), а от поведения. «Мне нужен не сын Ивана Ивановича, а любой, кто умеет забивать гвозди». Здорово, правда?

А ещё есть волшебный декоратор @runtime_checkable. Он позволяет во время работы программы ткнуть в объект пальцем и спросить: «Ты соответствуешь протоколу Drawable?» Через isinstance(). Это уже не просто для статических анализаторов, а реальная, живая проверка. Ёперный театр!

Короче, протоколы — это охуенно, когда надоело строить жёсткие иерархии классов и хочется просто договориться о том, кто что умеет. Как на стройке: «Ты умеешь класть кирпич? Умеешь? Ну так давай, работай».