Новинки аннотаций типов в Python 3.8 (Protocol, Final, TypedDict, Literal)

?v=1

Сегодня ночью вышел Python 3.8 и аннотации типов получили новые возможности:


  • Протоколы
  • Типизированные словари
  • Final-спецификатор
  • Соответствие фиксированному значению

Если вы ещё не знакомы с аннотациями типов, рекомендую обратить внимание на мои предыдущие статьи (начало, продолжение)
И пока все переживают о моржах, я хочу кратко рассказать о новинках в модуле typing


Протоколы

В Python используется утиная типизация и от классов не требуется наследование от некоего интерфейса, как в некоторых других языках.
К сожалению, до версии 3.8 мы не могли выразить необходимые требования к объекту с помощью аннотаций типов.
PEP 544 призван решить эту проблему.

Такие термины как «протокол итератора» или «протокол дескрипторов» уже привычны и используются давно.
Теперь можно описывать протоколы в виде кода и проверять их соответствие на этапе статического анализа.

Стоит отметить, что начиная с Python 3.6 в модуль typing уже входят несколько стандартных протоколов.
Например, SupportsInt (требующего наличие метода __int__), SupportsBytes (требует __bytes__) и некоторых других.


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

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

from abc import abstractmethod
from typing import Protocol, Iterable

class SupportsRoar(Protocol):
    @abstractmethod
    def roar(self) -> None:
        raise NotImplementedError

class Lion(SupportsRoar):
    def roar(self) -> None:
        print("roar")

class Tiger:
    def roar(self) -> None:
        print("roar")

class Cat:
    def meow(self) -> None:
        print("meow")

def roar_all(bigcats: Iterable[SupportsRoar]) -> None:
    for t in bigcats:
        t.roar()

roar_all([Lion(), Tiger()])  # ok
roar_all([Cat()])  # error: List item 0 has incompatible type "Cat"; expected "SupportsRoar"

Мы можете комбинировать протоколы с помощью наследования, создавая новые.
Однако в этом случае вы так же должны явно указать Protocol как родительский класс

class BigCatProtocol(SupportsRoar, Protocol):
    def purr(self) -> None:
        print("purr")


Дженерики, self-typed, callable

Протоколы как и обычные классы могут быть Дженериками. Вместо указания в качестве родителей Protocol и Generic[T, S,...] можно просто указать Protocol[T, S,...]

Ещё один важный тип протоколов — self-typed (см. PEP 484). Например,

C = TypeVar('C', bound='Copyable')
class Copyable(Protocol):
    def copy(self: C) -> C:

class One:
    def copy(self) -> 'One':
        ...

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


Проверки в рантайме

Хотя протоколы и рассчитаны в первую очередь на использование статическими анализаторами, иногда бывает нужно проверить принадлежность класса нужному протоколу.
Чтобы это было возможно, примените к протоколу декоратор @runtime_checkable и isinstance/issubclass проверки начнут проверять соответствие протоколу

Однако такая возможность имеет ряд ограничений на использование. В частности, не поддерживаются дженерики


Типизированные словари

Для представления структурированных данных обычно используются классы (в частности, дата-классы) или именованные кортежи.
но иногда, например, в случае описания json-структуры бывает полезно иметь словарь с определенным ключами.
PEP 589 вводит понятие TypedDict, который ранее уже был доступен в расширениях от mypy

Аналогично датаклассам или типизированным кортежам есть два способа объявить типизированный словарь. Путем наследования или с помощью фабрики:

class Book(TypedDict):
    title: str
    author: str

AlsoBook = TypedDict("AlsoBook", {"title": str, "author": str})  # same as Book

book: Book = {"title": "Fareneheit 481", "author": "Bradbury"}  # ok
other_book: Book = {"title": "Highway to Hell", "artist": "AC/DC"}  # error: Extra key 'artist' for TypedDict "Book"
another_book: Book = {"title": "Fareneheit 481"}  # error: Key 'author' missing for TypedDict "Book"

Типизированные словари поддерживают наследование:

class BookWithDesc(Book):
    desc: str

По умолчанию все ключи словаря обязательны, но можно это отключить передав total=False при создании класса.
Это распространяется только на ключи, описанные в текущем кассе и не затрагивает наследованные

class SimpleBook(TypedDict, total=False):
    title: str
    author: str

simple_book: SimpleBook = {"title": "Fareneheit 481"}  # ok

Использование TypedDict имеет ряд ограничений. В частности:


  • не поддерживаются проверки в рантайме через isinstance
  • ключи должны быть литералами или final значениями

Кроме того, с таким словарем запрещены такие «небезопасные» операции как .clear или del.
Работа по ключу, который не является литералом, так же может быть запрещена, так как в этом случае невозможно определить ожидаемый тип значения


Модификатор Final

PEP 591 вводит модификатор final (в виде декоратора и аннотации) для нескольких целей


  • Обозначение класса, от которого нельзя наследоваться:
from typing import final

@final
class Childfree:
    ...

class Baby(Childfree):  # error: Cannot inherit from final class "Childfree"
    ...


  • Обозначение метода, который запрещено переопределять:
from typing import final

class Base:
    @final
    def foo(self) -> None:
        ...

class Derived(Base):
    def foo(self) -> None:  # error: Cannot override final attribute "foo" (previously declared in base class "Base")
        ...


  • Обозначение переменной (параметра функции. поля класса), которую запрещено переприсваивать.
ID: Final[float] = 1
ID = 2  # error: Cannot assign to final name "ID"

SOME_STR: Final = "Hello"
SOME_STR = "oops"  # error: Cannot assign to final name "SOME_STR"

letters: Final = ['a', 'b']
letters.append('c')  # ok

class ImmutablePoint:
    x: Final[int]
    y: Final[int]  # error: Final name must be initialized with a value

    def __init__(self) -> None:
        self.x = 1  # ok

ImmutablePoint().x = 2 # error: Cannot assign to final attribute "x"

При этом допустим код вида self.id: Final = 123, но только в __init__ методе


Literal

Literal-тип, определенный в PEP 586 используется когда нужно проверить на конкретным значениям буквально (literally)

Например, Literal[42] означает, что ожидается в качестве значения ожидается только 42.
Важно, что проверяется не только равенство значения, но и его тип (например, нельзя будет использовать False, если ожидается 0).

def give_me_five(x: Literal[5]) -> None:
    pass

give_me_five(5)  # ok
give_me_five(5.0) # error: Argument 1 to "give_me_five" has incompatible type "float"; expected "Literal[5]"
give_me_five(42)  # error: Argument 1 to "give_me_five" has incompatible type "Literal[42]"; expected "Literal[5]"

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

В качестве значения нельзя использоваться выражения (например, Literal[1+2]) или значения мутабельных типов.

В качестве одного из полезных примеров использование Literal — функция open(), которая ожидает конкретные значения mode.


Обработка типов в рантайме

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

Так, для типа вида X[Y, Z,...] в качестве origin будет возвращён тип X, а в качестве аргументов — (Y, Z, ...)
Стоит отметить, что если X является алиасом для встроенного типа или типа из модуля collections, то он будет заменен на оригинал.

assert get_origin(Dict[str, int]) is dict
assert get_args(Dict[int, str]) == (int, str)

assert get_origin(Union[int, str]) is Union
assert get_args(Union[int, str]) == (int, str)

К сожалению, функцию для __parameters__ не сделали


Ссылки


© Habrahabr.ru