Протоколы Python и структурная типизация

Python Protocols

Posted by     "Георгий Кузора" on Saturday, July 15, 2023

Система типов Python поддерживает два способа определения совместимости двух объектов как типов: номинальная типизация и структурная типизация.

Номинальная типизация

Номинальная типизация основана на иерархии классов. Если класс Dog наследует класс Animal, он является подтипом Animal. Экземпляры Dog могут использоваться, когда ожидаются экземпляры Animal. Система типов Python преимущественно использует эту форму типизации. Ее легко понять и она соответствует тому, как работает проверка isinstance - на основе иерархии классов.

Структурная типизация

Структурная типизация основана на операциях, которые могут быть выполнены с объектом. Класс Dog является структурным подтипом класса Animal, если первый имеет все атрибуты и методы последнего, и других совместимых типов.

Структурное типирование можно рассматривать как статический эквивалент Duck typing, которая хорошо известна программистам Python.

Определение протоколов

Вы можете определить свой собственный класс протокола, унаследовав специальный класс протокола:

from typing import Iterable
from typing_extensions import Protocol


class SupportsClose(Protocol):
    # Пустое тело метода обозначается '...'
    def close(self) -> None: ...


class Resource# Не нужно наследовать от класса SupportsClose!
    def close(self) -> None:
       self.resource.release()

    # ... прочие методы ...


def close_all(items: Iterable[SupportsClose]) -> None:
    for item in items:
        item.close()

close_all([Resource(), open('some/file')])  # Все работает

Resource — это подтип протокола SupportsClose, поскольку он определяет совместимый метод close().  Обычные файловые объекты, возвращаемые функцией open(), также совместимы с этим протоколом, поскольку они поддерживают функцию close().

Определение подпротоколов и протоколов подклассов

Вы также можете определить подпротоколы.

Существующие протоколы можно расширять и объединять с помощью множественного наследования.  Пример:

# ... Продолжение предыдущего примера

class SupportsRead(Protocol):
    def read(self, amount: int) -> bytes: ...


class TaggedReadableResource(SupportsClose, SupportsRead, Protocol):
    label: str


class AdvancedResource(Resource):
    def __init__(self, label: str) -> None:
        self.label = label

    def read(self, amount: int) -> bytes:

        # Имплементация метода

        ...

resource: TaggedReadableResource
resource = AdvancedResource('handle with care'# Все в порядке

Обратите внимание, что наследование от существующего протокола не превращает автоматически подкласс в протокол — он просто создает обычный (непротокольный) класс или ABC, реализующий данный протокол (или протоколы).  Базовый класс Protocol должен всегда присутствовать явно, если вы определяете протокол:

class NotAProtocol(SupportsClose):  # Это не протокол
    new_attr: int


class Concrete:
   new_attr: int = 0

   def close(self) -> None:
       ...


# Ошибка: по умолчанию будет использовано номинальное типирование

x: NotAProtocol = Concrete()  # Ошибка!

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

Явное включение протокола в качестве базового класса также является способом документирования того, что ваш класс реализует конкретный протокол, и заставляет статические анализаторы проверять, действительно ли реализация вашего класса совместима с протоколом.  В частности, пропуск значения атрибута или тела метода сделает его неявно абстрактным:

class SomeProto(Protocol):
    attr: int  # Обратите внимание, нет правой части присваивания
    def method(self) -> str: ...  # Можно не указывать реализацию


class ExplicitSubclass(SomeProto):
    pass

ExplicitSubclass()  # error: Cannot instantiate abstract class 'ExplicitSubclass'

                    # with abstract attributes 'attr' and 'method'

Точно так же явное присвоение экземпляру протокола может быть способом попросить средство проверки типов убедиться, что ваш класс реализует протокол:

SomeProto = cast(ExplicitSubclass, None)

Неизменность атрибутов протокола

Общая проблема с протоколами заключается в том, что атрибуты протокола неизменны.  Например:

class Box(Protocol):
      content: object


class IntBox:
      content: int


def takes_box(box: Box) -> None: ...

takes_box(IntBox())  # error: Argument 1 to "takes_box" has incompatible type "IntBox"; expected "Box"

                     # note:  Following member(s) of "IntBox" have conflicts:

                     # note:      content: expected "object", got "int"

Это связано с тем, что Box определяет содержимое как изменяемый атрибут.  Вот почему это проблематично:

def takes_box_evil(box: Box) -> None:

    box.content = "asdf"  # Это нехорошо, так как box.content будет иметь тип object


my_int_box = IntBox()
takes_box_evil(my_int_box)
my_int_box.content + 1  # Ошибка типа!

Это можно исправить, объявив содержимое доступным только для чтения в протоколе Box с помощью @property:

class Box(Protocol):
    @property
    def content(self) -> object: ...


class IntBox:
    content: int

def takes_box(box: Box) -> None: ...


takes_box(IntBox(42))  # Все в порядке

Рекурсивные протоколы

Протоколы могут быть рекурсивными (самореферентными) и взаимно рекурсивными.  Это полезно для объявления абстрактных рекурсивных коллекций, таких как деревья и связанные списки:

from typing import TypeVar, Optional
from typing_extensions import Protocol


class TreeLike(Protocol):
    value: int

    @property
    def left(self) -> Optional['TreeLike']: ...

    @property
    def right(self) -> Optional['TreeLike']: ...


class SimpleTree:
    def __init__(self, value: int) -> None:
        self.value = value
        self.left: Optional['SimpleTree'] = None
        self.right: Optional['SimpleTree'] = None


root: TreeLike = SimpleTree(0# Все хорошо

Использование isinstance() с протоколами

Вы можете использовать класс протокола с isinstance(), если украсите его декоратором класса @runtime_checkable.  Декоратор добавляет элементарную поддержку структурных проверок во время выполнения:

from typing_extensions import Protocol, runtime_checkable

@runtime_checkable
class Portable(Protocol):
    handles: int


class Mug:
    def __init__(self) -> None:
        self.handles = 1


def use(handles: int) -> None: ...


mug = Mug()
if isinstance(mug, Portable):  # Работает в рантайме!
   use(mug.handles)

isinstance() также работает с предопределенными протоколами ввода, такими как Iterable.

Предупреждение isinstance() с протоколами не является полностью безопасным во время выполнения.  Например, сигнатуры методов не проверяются.  Реализация среды выполнения проверяет только существование всех членов протокола, а не их правильный тип.  issubclass() с протоколами будет проверять только наличие методов.

Примечание isinstance() с протоколами также может быть на удивление медленным.  Во многих случаях лучше использовать hasattr() для проверки наличия атрибутов.