Введение в аннотации типов Python

    Введение



    Автор иллюстрации — Magdalena Tomczyk


    Вторая часть


    Python — язык с динамической типизацией и позволяет нам довольно вольно оперировать переменными разных типов. Однако при написании кода мы так или иначе предполагаем переменные каких типов будут использоваться (это может быть вызвано ограничением алгоритма или бизнес логики). И для корректной работы программы нам важно как можно раньше найти ошибки, связанные с передачей данных неверного типа.


    Сохраняя идею динамической утиной типизации в современных версиях Python (3.6+) поддерживает аннотации типов переменных, полей класса, аргументов и возвращаемых значений функций:



    Аннотации типов просто считываются интерпретатором Python и никак более не обрабатываются, но доступны для использования из стороннего кода и в первую очередь рассчитаны для использования статическими анализаторами.


    Меня зовут Тихонов Андрей и я занимаюсь backend-разработкой в Lamoda.


    В этой статье я хочу объяснить основы использования аннотаций типов и рассмотреть типичные примеры, реализуемые аннотациями из пакета typing.


    Инструменты, поддерживающие аннотации


    Аннотации типов поддерживаются многими IDE для Python, которые выделяют некорректный код или выдают подсказки в процессе набора текста.


    Например, так это выглядит в Pycharm:


    Подсветка ошибок



    Подсказки:



    Так же аннотации типов обрабатываются и консольными линтерами.


    Вот вывод pylint:


    $ pylint example.py
    ************* Module example
    example.py:7:6: E1101: Instance of 'int' has no 'startswith' member (no-member)

    А вот для того же файла что нашел mypy:


    $ mypy example.py
    example.py:7: error: "int" has no attribute "startswith"
    example.py:10: error: Unsupported operand types for // ("str" and "int")

    Поведение разных анализаторов может отличаться. Например, mypy и pycharm по разному обрабатывают смену типа переменной. Далее в примерах я буду ориентироваться на вывод mypy.


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


    Основы


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


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


    Аннотации для переменных пишут через двоеточие после идентификатора. После этого может идти инициализация значения. Например,


    price: int = 5
    title: str

    Параметры функции аннотируются так же как переменные, а возвращаемое значение указывается после стрелки -> и до завершающего двоеточия. Например,


    def indent_right(s: str, width: int) -> str:
        return " " * (max(0, width - len(s))) + s

    Для полей класса аннотации должны быть указаны явно при определении класса. Однако анализаторы могут выводить автоматически их на основе __init__ метода, но в этом случае они не будут доступны во время выполнения программы. Подробнее про работу с аннотациями в рантайме во второй части статьи


    class Book:
        title: str
        author: str
    
        def __init__(self, title: str, author: str) -> None:
            self.title = title
            self.author = author
    
    b: Book = Book(title='Fahrenheit 451', author='Bradbury')

    Кстати, при использовании dataclass типы полей необходимо указывать именно в классе. Подробнее про dataclass


    Встроенные типы


    Хоть вы и можете использовать стандартные типы в качестве аннотаций, много полезного сокрыто в модуле typing.


    Optional


    Если вы пометите переменную типом int и попытаетесь присвоить ей None, будет ошибка:


    Incompatible types in assignment (expression has type "None", variable has type "int")


    Для таких случаев предусмотрена в модуле typing аннотация Optional с указанием конкретного типа. Обратите внимание, тип опциональной переменной указывается в квадратных скобках


    from typing import Optional
    
    amount: int
    amount = None  # Incompatible types in assignment (expression has type "None", variable has type "int")
    
    price: Optional[int]
    price = None

    Any


    Иногда вы не хотите ограничивать возможные типы переменной. Например, если это действительно не важно, или если вы планируете сделать обработку разных типов самостоятельно. В этом случае, можно использовать аннотацию Any. На следующий код mypy не будет ругаться:


    unknown_item: Any = 1
    print(unknown_item)
    print(unknown_item.startswith("hello"))
    print(unknown_item // 0)

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


    unknown_object: object
    print(unknown_object)
    print(unknown_object.startswith("hello"))  # error: "object" has no attribute "startswith"
    print(unknown_object // 0)  # error: Unsupported operand types for // ("object" and "int")

    Union


    Для случаев, когда необходимо допустить использование не любых типов, а только некоторых, можно использовать аннотацию typing.Union с указанием списка типов в квадратных скобках.


    def hundreds(x: Union[int, float]) -> int:
        return (int(x) // 100) % 10
    
    hundreds(100.0)
    hundreds(100)
    hundreds("100")  # Argument 1 to "hundreds" has incompatible type "str"; expected "Union[int, float]"

    Кстати, аннотация Optional[T] эквивалентна Union[T, None], хотя такая запись и не рекомендуется.


    Коллекции


    Механизм аннотаций типов поддерживает механизм дженериков (Generics, подробнее во второй части статьи), которые позволяют специфицировать для контейнеров типы элементов, хранящихся в них.


    Списки


    Для того, чтобы указать, что переменная содержит список можно использовать тип list в качестве аннотации. Однако если хочется конкретизировать, какие элементы содержит список, он такая аннотация уже не подойдёт. Для этого есть typing.List. Аналогично тому, как мы указывали тип опциональной переменной, мы указываем тип элементов списка в квадратных скобках.


    titles: List[str] = ["hello", "world"]
    titles.append(100500)  # Argument 1 to "append" of "list" has incompatible type "int"; expected "str"
    titles = ["hello", 1]  # List item 1 has incompatible type "int"; expected "str"
    
    items: List = ["hello", 1]

    Предполагается, что список содержит неопределенное количество однотипных элементов. Но при этом нет ограничений на аннотацию элемента: можно использовать Any, Optional, List и другие. Если тип элемента не указан, предполагается, что это Any.


    Кроме списка аналогичные аннотации есть для множеств: typing.Set и typing.FrozenSet.


    Кортежи


    Кортежи в отличие от списков часто используются для разнотипных элементов. Синтаксис похож с одним отличием: в квадратных скобках указывается тип каждого элемента кортежа по отдельности.


    Если же планируется использовать кортеж аналогично списку: хранить неизвестное количество однотипных элементов, можно воспользоваться многоточием (...).


    Аннотация Tuple без указания типов элементов работает аналогично Tuple[Any, ...]


    price_container: Tuple[int] = (1,)
    price_container = ("hello")  # Incompatible types in assignment (expression has type "str", variable has type "Tuple[int]")
    price_container = (1, 2)  # Incompatible types in assignment (expression has type "Tuple[int, int]", variable has type "Tuple[int]")
    
    price_with_title: Tuple[int, str] = (1, "hello")
    
    prices: Tuple[int, ...] = (1, 2)
    prices = (1, )
    prices = (1, "str")  # Incompatible types in assignment (expression has type "Tuple[int, str]", variable has type "Tuple[int, ...]")
    
    something: Tuple = (1, 2, "hello")

    Словари


    Для словарей используется typing.Dict. Отдельно аннотируется тип ключа и тип значений:


    book_authors: Dict[str, str] = {"Fahrenheit 451": "Bradbury"}
    book_authors["1984"] = 0  # Incompatible types in assignment (expression has type "int", target has type "str")
    book_authors[1984] = "Orwell"  # Invalid index type "int" for "Dict[str, str]"; expected type "str"
    

    Аналогично используются typing.DefaultDict и typing.OrderedDict


    Результат выполнения функции


    Для указания типа результата функции можно использовать любую аннотацию. Но есть несколько особенных случаев.


    Если функция ничего не возвращает (например, как print), её результат всегда равен None. Для аннотации так же используем None.


    Корректными вариантами завершения такой функции будут: явный возврат None, возврат без указания значения и завершение без вызова return.


    def nothing(a: int) -> None:
        if a == 1:
            return
        elif a == 2:
            return None
        elif a == 3:
            return ""  # No return value expected
        else:
            pass

    Если же функция никогда не возвращает управление (например, как sys.exit), следует использовать аннотацию NoReturn:


    def forever() -> NoReturn:
        while True:
            pass

    Если это генераторная функция, то есть её тело содержит оператор yield, для возвращаемого можно воспользоватьтся аннотацией Iterable[T], либо Generator[YT, ST, RT]:


    def generate_two() -> Iterable[int]:
        yield 1
        yield "2"  # Incompatible types in "yield" (actual type "str", expected type "int")
    

    Вместо заключения


    Для многих ситуаций в модуле typing есть подходящие типы, однако я не буду рассматривать все, так как поведение аналогично рассмотренным.
    Например, есть Iterator как generic-версия для collections.abc.Iterator, typing.SupportsInt для того, чтобы указать что объект поддерживает метод __int__, или Callable для функций и объектов, поддерживающих метод __call__


    Так же стандарт определяет формат аннотаций в виде комментариев и stub-файлы, которые содержат информацию только для статических анализаторов.


    В следующей статье я бы хотел остановиться на механизме работы дженериков и обработке аннотаций в рантайме.

    Lamoda
    198.71
    Russian Fashion Tech
    Support the author
    Share post

    Comments 38

      +4
      Еще бы использование аннотаций давало бы какой-то бонус к производительности…
        0
        О. А как по-вашему, статичческая типизация даёт или не даёт бонус?
        Вы про производительность программиста или скорость работы кода?
          0
          Про работу кода. Как в Cython дописал типы переменных — получи более быстрое выполнение.
            0
            Где-то мне попадались планы разработчиков Cython по использованию новых аннотация типов для оптимизации. Будем надеяться, что они это реализуют.
            0
            Позволяет компилятору применять оптимизации на базе информации о типах и не делать проверки типов в рантайме.

            Ну и ИМХО у программиста тоже производительность вырастает, начиная с определенного (и не такого уж большого) объема проекта.
            0
            Говорят, PEP по типизации располагает к созданию инструментов, которые бы использовали эти самые аннотации в целях улучшения производительности. Вот вдруг JIT нам прикрутят? :)
              +1
              В больших проектах оно дает неплохой прирост производительности в командной работе и чистоте кода. Когда ты видишь, какие аргументы приходят тебе в функцию и какие надо передавать в другие, тебе меньше надо заниматься Goto Definition и чтением чужого кода. Это, во-первых, а, во-вторых, Type Hinting не располагает прокидывать через лапшу методов какой-нибудь dict, в который каждый из методов что-то подкладывает, а следующий чего-то ожидает там найти.
                0
                Есть mypyc. Компилирует код с аннотациями. Вообще, производительность программиста улучшает тоже, позволяет ловить ошибки, которые иначе всплыли бы только в рантайме и их пришлось бы искать.
                –3
                Вообще конечно аннотация изрядно засирает код. Кстати не кто не знает может есть плагин к Pycharm который скрывает аннотаи?
                  0

                  Чтобы не писать аннотации в код, можно использовать stub-файлы — файлы с расширением *.pyi

                  0
                  С аннотациями сложных структур на базе словаря все не так просто, пока есть официально поддерживаемое mypy решение TypedDict (https://mypy.readthedocs.io/en/latest/more_types.html#typeddict), однако это еще не в стандарте, кроме того, там с Optional не все просто — нельзя сказать что конкретного элемента может не быть, только всей структуре указать, что допускается отсутствие ключей
                  total=False

                  Ну и сам он не то, чтобы очень удобен в эксплуатация — каждый вложенный словарь тоже должен объявляться отдельно как TypedDict
                    +1

                    А зачем именно словари, когда есть namedtuple и dataclass?

                      0
                      Иногда нужно работать именно с dict, напр. десереализованный ответ в виде json и наоборот
                        0
                        И, кстати говоря, использование вышеупомянутых способов по-моему не решает описанную проблему :)
                          +1

                          По моему опыту обычно отсутствие поля в json считают эквивалентным null в качестве значения.

                            0
                            Бывает так, что не ставят в null, а просто отсутствует ключ, и вот здесь кроется проблема. Вроде бы хочется точно сказать, вот этот key вот тут optional, а нельзя, только всю структуру тоталить (если оно на первом уровне)
                              +1

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

                      0
                      Хочу немного дополнить: очень часто возникает ситуация (например, обработка JSON), когда надо типизировать Dict вида
                      from .types import ImageJson
                      
                      image: ImageJson = {
                          "src": "/image.jpg",
                          "srcset": {
                              "@2x": "/image@2x.jpg"
                          }
                      }
                      

                      Для таких ситуаций для python до 3.7 подходит TypedDict из mypy_extensions
                      from typing import Dict
                      
                      from mypy_extensions import TypedDict
                      
                      ImageJson = TypedDict('ImageJson', {
                          'src': str,
                          'srcset': Dict[str, str]
                      })
                      

                      А в python 3.7 появились dataclass и лучше использовать их.
                        +1

                        Опять же, почему не dataclass?


                        @dataclass
                        class Image:
                            src: str
                            srcset: Dict[str, str]

                        Делал, кстати, себе небольшую либу для разбора словарей в инстансы датаклассов, которая как раз юзает аннотации типов для правильной инициализации.

                          0
                          dataclass появились в python 3.7, это достаточно недавно, typehints появились раньше.
                          Поправил свой комментарий выше.
                            +1

                            Для 3.6 датаклассы есть в виде библиотеки. typehints по сути тоже с 3.6

                            • UFO just landed and posted this here
                                0

                                pip все берет из PyPi, это же не apt/yum.


                                Насколько я понимаю, бэкпорт для 3.6 сделал автор PEP про датаклассы.

                                • UFO just landed and posted this here
                              +1

                              И, кстати, в 3.6 есть NamedTuple, хотя его я никогда не юзал.


                              class Employee(NamedTuple):
                                  src: str
                                  srcset: Dict[str, str]
                                0
                                NamedTuple это все же неизменяемый, т.е. в ситуации, где надо принять JSON, что-то в нем поменять и отдать обратно, не подходит.
                                Mypy работает даже в 2.7 (они так говорят, я не проверял), т.е. уже в 2.7 можно использовать typehints.
                                В дополнение к этому, для внедрения typehints в проекты, где изначально не было ни typehints ни dataclass, проще создать тип для словаря и его указать, чем переделывать со словарей на dataclass.
                                Я полностью согласен с тем, что новые проекты желательно делать на 3.7 и с dataclass и прочими прелестями, но есть применение и для TypedDict.
                                  0

                                  А что значит неизменяемый?


                                  >>> class Employee(NamedTuple):
                                  ...    src: str
                                  ...    srcset: Dict[str, str]
                                  ... 
                                  >>> e = Employee(src="1", srcset={"2": "4"})
                                  >>> e.srcset["2"] = "7"
                                  >>> e.srcset["4"] = "17"
                                  >>> e
                                  Employee(src='1', srcset={'2': '7', '4': '17'})
                                  >>> 
                                    0
                                    А теперь попробуйте поменять e.src.
                                    Или записать в e.srcset прямо новый словарь, а не менять значения в старом.

                                    То, что неизменяемый контейнер может содержать изменяемые элементы, никак не меняет того, что контейнер — неизменяемый.
                                +1
                                Можно использовать attrs: www.attrs.org/en/stable
                                Работает в том числе и на python 2.7
                            0

                            Хм. Похоже, чистая динамическая типизация вымирает. PHP, Python, Ruby — все языки обрастают типами. Да и JavaScript уже заменяется на TypeScript'ы всякие. Любопытная картина.

                              0
                              Не вымирает, а просто больше стало свободы выбора как кодить. По-умолчанию будет всё та же динамическая утиная типизация, а когда надо или хочется — аннотация типов.
                              Первый вариант использую когда нужно быстро накидать скрипты (1-2 файла = 1 задача), где и так понятно что должно происходить, какие данные будут, и т.д., второй вариант — когда делается проект.
                                0

                                Но для того, что это работало нормально, осталось всего ничего — проаннотировать все библиотеки для питона.

                              +1
                              Можно тогда немного странный вопрос задам? Я как бы ненастоящий сварщик, но довольно часто пишу на Python под какие-то свои задачи. Как мне корректно узнать предполагаемый тип переменной, если он чуть менее очевидный, чем int или str?

                              Например:
                              def get_certificate_san(x509cert):
                                  san = ''
                                  ext_count = x509cert.get_extension_count()
                                  for i in range(0, ext_count):
                                      ext = x509cert.get_extension(i)
                                      if 'subjectAltName' in str(ext.get_short_name()):
                                          san = ext.__str__()
                                  return san
                              

                              Как проще всего узнать тип ext_count? Нет, я могу, конечно весь код усеять print(type(variable)) и потом выписать вручную. Но как-то выглядит некрасиво. А типы иногда бывают неочевидными, если просто смотреть на код.

                              UPD
                              Нашел в PyCharm раздел, который это описывает. Интересно) www.jetbrains.com/help/pycharm/type-hinting-in-product.html
                                0

                                Наверно, я немножко опоздал с ответом, но в подобной ситуации тип x509cert по идее должен быть заранее известен тому, кто пишет эту функцию. Беглый гуглинг подсказывает, что скорее всего подразумевается что-то вроде этого:


                                from OpenSSL.crypto import X509
                                
                                def get_certificate_san(x509cert: X509) -> str:
                                    ext_count = x509cert.get_extension_count()
                                    # ...

                                Дальше, если в библиотеке прописаны аннотации путей или если они очевидно выводятся, PyCharm и другие статические анализаторы автоматически догадаются, что метод get_extension_count() у класса X509 возвращает int и что ext_count будет иметь соответствующий тип, без дополнительных подсказок.


                                К сожалению, конкретно pyOpenSSL, похоже, немного поленился прописать аннотации типов у себя (и в typeshed прописано не всё), так что работать это наверно будет плоховато (и я не в курсе, умеет ли кто-нибудь вытаскивать типы из docstring'ов)

                                  0

                                  Блин. Трудоёмко для всяких специфичных типов. Хотя нужно, согласен.

                                0
                                Что дает указание типов кроме, собственно, самого указания? Прирост скорости? Динамическая типизация тем и хороша, что можно не думать о типах. А так получается та же джава только в профиль.
                                  0

                                  Проверки во время разработки.

                                  • UFO just landed and posted this here

                                  Only users with full accounts can post comments. Log in, please.