Летающая свинья, или протоколы в Python

Допустим, вы написали утилиту, которая отправляет что угодно в полет:

def launch(thing):
    thing.fly()

Ну, то есть не прям все что угодно. Любую штуку с методом fly(). Очень удобно — одной функцией запускаем и голубя Френка, и самолет, и даже Супермена:

class Frank:
    def fly(self):
        print("💩")

class Plane:
    def fly(self):
        print("Рейс задержан")

class Superman:
    def fly(self):
        print("ε===(っ≧ω≦)っ")
f = Frank()
launch(f)
# 💩

p = Plane()
launch(p)
# Рейс задержан

s = Superman()
launch(s)
# ε===(っ≧ω≦)っ

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

Работает, и ладно. Но иногда (особенно если программа разрастается) разработчику хочется добавить немного строгости. Дать понять, что параметр thing в launch() — это не любой объект, а обязательно летающая хреновина с методом fly(). Как лучше это сделать?

Описательно

Если вы привыкли избегать типов, то обойдетесь именем переменной или комментарием к функции:

def launch(flyer):
    """Launces a flyer (an object with a `fly()` method)"""
    flyer.fly()

Почему бы и нет. Беда в том, что чем сложнее код, тем чаще сбоит «описательный» подход.

Через базовый класс

Если расчехлить навыки программирования на джаве из 1990-х годов, получится небольшая иерархия:

class Flyer:
    def fly():
        ...

class Frank(Flyer):
    # ...

class Plane(Flyer):
    # ...

class Superman(Flyer):
    # ...
def launch(thing: Flyer):
    thing.fly()

Подход рабочий:

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

Но, как говорят авторы Python, ужасно «непитоничный»:

The problem is that a class has to be explicitly marked, which is unpythonic and unlike what one would normally do in idiomatic dynamically typed Python code.

Действительно. Мало того, что нам пришлось модифицировать три класса вместо одной функции. Мало того, что у нас в коде завелась иерархия наследования. Так еще и Френк, самолет и Супермен теперь объединены общим знанием о том, что они Летающие Объекты. Им прекрасно жилось и без этого, знаете ли.

Через протокол

Цитата выше взята из PEP 544 (Python Enhancement Proposal, предложение о доработке), который был реализован в Python 3.8. Начиная с этой версии в питоне появились протоколы.

Протокол описывает поведение. Вот наш Летающий Объект:

from typing import Protocol

class Flyer(Protocol):
    def fly(self):
        ...

Используем протокол, чтобы указать, что объект должен обладать конкретным поведением. Функция launch() умеет запускать только Летающие Объекты:

def launch(thing: Flyer):
    thing.fly()

Причем самим объектам не надо знать о протоколе. Достаточно обладать нужным поведением:

class Frank:
    def fly(self):
        # ...

class Plane:
    def fly(self):
        # ...

class Superman:
    def fly(self):
        # ...

Протокол — это статическая утиная типизация:

  • интерфейс явно описан в протоколе: летающий объект обладает методом fly();
  • но реализуется он неявно, по «утиному» принципу: у Супермена есть метод fly() — значит, он летающий объект.

Проверим:

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

Идеально!

Итого

Если ваш код должен единообразно работать с разными объектами — выделите у них общее поведение и вынесите в протокол. Используйте тип-протокол для статической проверки кода с помощью mypy.

По возможности избегайте голубей, самолетов и супергероев. От них одни проблемы.

Подписывайтесь на канал, чтобы не пропустить новые заметки 🚀