Сегодня ночью вышел 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__ не сделали
