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


В первой части статьи я описал основы использования аннотаций типов. Однако несколько важных моментов остались не рассмотрены. Во-первых, дженерики — важный механизм, во-вторых иногда может оказаться полезным узнать информацию об ожидаемых типах в рантайме. Но начать хотелось с более простых вещей


Предварительное объявление


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


class LinkedList:
    data: Any
    next: LinkedList  # NameError: name 'LinkedList' is not defined

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


class LinkedList:
    data: Any
    next: 'LinkedList'

Так же вы можете обращаться к классам из других модулей (конечно, если модуль импортирован): some_variable: 'somemodule.SomeClass'


Замечание

Вообще говоря, в качестве аннотации можно использовать любое вычислимое выражение. Однако рекомендуется их делать максимально простыми, чтобы утилиты статического анализа могли их использовать. В частности, скорее всего ими не будут поняты динамически вычислимые типы. Подробнее про ограничения тут: PEP 484 — Type Hints # Acceptable type hints


Например, следующий код будет работать и даже аннотации будут доступны в рантайме, однако mypy на него выдаст ошибку


def get_next_type(arg=None):
    if arg:
        return LinkedList
    else:
        return Any

class LinkedList:
    data: Any
    next: 'get_next_type()'  # error: invalid type comment or annotation

UPD: В Python 4.0 планируется включить отложенное вычисление аннотаций типов (PEP 563), которое позволит избавиться от этого приема со строковыми литералами. с Python 3.7 можно включить новое поведение с помощью конструкции from __future__ import annotations


Функции и вызываемые объекты


Для ситуаций, когда необходимо передать функцию или другой вызываем объект (например, в качестве callback) нужно использовать аннотацию Callable[[ArgType1, ArgType2,...], ReturnType]
Например,


def help() -> None:
    print("This is help string")

def render_hundreds(num: int) -> str:
    return str(num // 100)

def app(helper: Callable[[], None], renderer: Callable[[int], str]):
    helper()
    num = 12345
    print(renderer(num))

app(help, render_hundreds)
app(help, help)  # error: Argument 2 to "app" has incompatible type "Callable[[], None]"; expected "Callable[[int], str]"

Допустимо указать только возвращаемый тип функции без указания её параметров. В этом случае используется многоточие: Callable[..., ReturnType]. Обратите внимание, что квадратные скобки вокруг многоточия отсутствуют.


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


Generic-типы


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


Такие типы как List или Callable, которые, мы видели раньше как раз используют механизм дженериков. Но кроме стандартных типов, вы можете создать свои дженерик-типы. Для этого надо, во-первых, завести TypeVar переменную, которая будет атрибутом дженерика, и, во-вторых, непосредственно объявить generic-тип:


T = TypeVar("T")

class LinkedList(Generic[T]):
    data: T
    next: "LinkedList[T]"

    def __init__(self, data: T):
        self.data = data

head_int: LinkedList[int] = LinkedList(1)
head_int.next = LinkedList(2)
head_int.next = 2  # error: Incompatible types in assignment (expression has type "int", variable has type "LinkedList[int]")
head_int.data += 1
head_int.data.replace("0", "1")  # error: "int" has no attribute "replace"

head_str: LinkedList[str] = LinkedList("1")
head_str.data.replace("0", "1")

head_str = LinkedList[str](1)  # error: Argument 1 to "LinkedList" has incompatible type "int"; expected "str"

Как вы можете заметить, для generic-типов работает автоматический вывод типа параметра.


Если требуется, дженерик может иметь любое количеством параметров: Generic[T1, T2, T3].


Также, при определении TypeVar вы можете ограничить допустимые типы:


T2 = TypeVar("T2", int, float)

class SomethingNumeric(Generic[T2]):
    pass

x = SomethingNumeric[str]()  # error: Value of type variable "T2" of "SomethingNumeric" cannot be "str"

Cast


Иногда анализатор статический анализатор не может корректно определить тип переменной, в этом случае можно использовать функцию cast. Её единственная задача — показать анализатору, что выражение имеет определённый тип. Например:


from typing import List, cast

def find_first_str(a: List[object]) -> str:
    index = next(i for i, x in enumerate(a) if isinstance(x, str))
    return cast(str, a[index])

Также это может быть полезно для декораторов:


MyCallable = TypeVar("MyCallable", bound=Callable)

def logged(func: MyCallable) -> MyCallable:
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(func.__name__, args, kwargs)
        return func(*args, **kwargs)

    return cast(MyCallable, wrapper)

@logged
def mysum(a: int, b: int) -> int:
    return a + b

mysum(a=1)  # error: Missing positional argument "b" in call to "mysum"

Работа с аннотациями во время выполнения


Хотя интерпретатор и не использует аннотации самостоятельно, они доступны для вашего кода во время работы программы. Для этого предусмотрен атрибут объектов __annotations__, содержащий словарь с указаннами аннотациями. Для функций это — аннотации параметров и возвращаемого типа, для объекта — аннотации полей, для глобального scope — переменные и их аннотации.


def render_int(num: int) -> str:
    return str(num)

print(render_int.annotations)  # {'num': <class 'int'>, 'return': <class 'str'>}

Так же доступна get_type_hints — она возвращает аннотации для переданного ей объекта, во многих ситуациях это совпадает с содержимым __annotations__, но есть отличия: он также добавляет аннотации родительских объектов (в порядке обратном __mro__), а так же разрешает предварительные объявления типов указанные как строки.


T = TypeVar("T")

class LinkedList(Generic[T]):
    data: T
    next: "LinkedList[T]"

print(LinkedList.__annotations__)
# {'data': ~T, 'next': 'LinkedList[T]'}
print(get_type_hints(LinkedList))
# {'data': ~T, 'next': __main__.LinkedList[~T]}

Для generic-типов доступна информация о самом типе и его параметрах через атрибуты __origin__ и __args__, но это не является частью стандарта и поведение уже менялось между версиями 3.6 и 3.7

Комментарии (33)


  1. LighteR
    27.02.2019 11:30

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

    В mypy_extensions есть для этого способ: mypy.readthedocs.io/en/latest/additional_features.html#extended-callable-types


    1. Tishka17 Автор
      27.02.2019 11:36

      Есть такое, да. Но в стандарте вот такой абзац и я в целом согласен с ним:


      Since using callbacks with keyword arguments is not perceived as a common use case, there is currently no support for specifying keyword arguments with Callable. Similarly, there is no support for specifying callback signatures with a variable number of argument of a specific type.


  1. Andy_U
    27.02.2019 13:16
    +1

    Извините, но у вас в первом же примере одна ошибка и одна неточность:

    1) Отсутствует импорт Any;
    2) В питоне, начиная с 3.7 'forward declaration' работают, если попросить.

    from __future__ import annotations
    from typing import Any
    
    
    class LinkedList:
        data: Any
        next: LinkedList
    


    1. Tishka17 Автор
      27.02.2019 13:44

      Да, совсем упустил. Если я правильно понимаю, речь о PEP-563?


      1. Andy_U
        27.02.2019 14:43

        Да, оно.


        1. Tishka17 Автор
          27.02.2019 14:56

          Спасибо, добавил в статью


  1. rgs350
    28.02.2019 08:33

    Ой! Прямо как недавний хайп и натирание эбонитовой палки на статическую типизацию в JS. А зачем она нужна?


    1. pesh1983
      28.02.2019 11:14

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


      1. rgs350
        28.02.2019 12:03

        Описание типов позволяет писать более понятный код и выловить ошибки при передаче неправильных параметров сразу на этапе написания кода, а не при прогоне тестов, или, что ещё хуже, на рабочем боксе.
        Предсказуемо. В JS-типизации такие же аргументы. Больше похоже на мантру, натягивание совы на глобус и высасывание преимуществ из пальца, которые говорят люди, никогда не писавшие на языках со статической типизацией ибо в них (в языках со статической типизацией) преимущества лежат совсем в другой плоскости.

        Примеры (я не питонист, поэтому на JS):
        1. У вас есть функция принимающая в качестве аргумента строку, вы пишете:
        function foo(arg: string)
        Вроде неплохо, но:
        2. У вас есть функция, принимающая в качестве аргумент объект Date или целое число (timestamp) или строку ('2019.01.01') какой тип выберете. Нормальную перегрузку методов ведь не завезли?
        Если первый пример — самодокументируемый, то на второй все равно придется писать доку. Я к тому, что вместе с преимуществами (не очень большими), вы до кучи получаете и пачку проблем.

        Теперь про плоскость в которой лежат преимущества статической типизации:
        Вы пишете на Java/C++. Вам нужно написать класс A, в конструктор этого класса передается объект класса B. Вспомнив про SOLID вы решили, что хардкодить B в конструкторе — не самая хорошая идея и нужно написать интерфейс, чтобы, в случае необходимости, у вас была возможность вместо объекта класса B передать любой другой объект с таким же интерфейсом, что заставляет вас заранее думать о снижении зависимости между различными частями вашей программы, что, в свою очередь, делает архитектуру вашей программы менее дерьмовой. Упс интерфейсов тоже не завезли (в TypeScrips завезли, но что-то в реальных проектах они не часто используются, в каком-нибудь PropTypes даже близко ничего нет, зато гонору хоть отбавляй).

        Ну и т.д. и т.п. в том же духе.


        1. Tishka17 Автор
          28.02.2019 12:57

          Во-первых, хочу напомнить, что в питоне сильная типизация. Если Вы ожидаете в методе строку, вы не можете передать туда дату и надеяться, что все будет работать
          Во-вторых, никто не мешает создать абстрактный класс с пустыми реализациями методов и использовать его как интерфейс,
          В-третьих, есть PEP-544, который к сожалению ещё не принят, но внутри модуля typing уже используется аналогичные конструкции.


          1. pesh1983
            28.02.2019 13:26

            > Во-первых, хочу напомнить, что в питоне сильная типизация. Если Вы ожидаете в методе строку, вы не можете передать туда дату и надеяться, что все будет работать

            Вы не правы) Вот этот код прекрасно работает

            import datetime
            from typing import Union
            
            
            def foo(start_date: Union[datetime.date, str]) -> datetime.date:
                if isinstance(start_date, str):
                    start_date = datetime.datetime.strptime(start_date, '%Y.%m.%d')
            
                return start_date
            
            
            print(foo('2018.12.12'))
            print(foo(datetime.datetime.now()))
            


            1. shaukote
              28.02.2019 13:33

              Как данный пример отрицает наличие в Python строгой типизации? Подозреваю, что передать число всё равно не выйдет. А передача даты соответствует описанной сигнатуре функции.


              1. pesh1983
                28.02.2019 14:03

                Вы что-то путаете. Строгая типизация — это когда нельзя поменять тип переменной после ее объявления, а также отсутствует неявное преобразование типов (хотя на самом деле термин еще более обширный ru.wikipedia.org/wiki/%D0%A1%D0%B8%D0%BB%D1%8C%D0%BD%D0%B0%D1%8F_%D0%B8_%D1%81%D0%BB%D0%B0%D0%B1%D0%B0%D1%8F_%D1%82%D0%B8%D0%BF%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F). В питоне можно поменять тип переменной после ее объявления, но также отсутствует неявное приведение типов (например, не получится сложить строку и число, как это можно сделать в JS). Хотя по ссылке выше написано, что Питон имеет сильную динамическую типизацию.
                Вот это работает прекрасно

                import datetime
                from typing import Union
                
                
                def foo(start_date: Union[datetime.date, str]) -> datetime.date:
                    if isinstance(start_date, str):
                        start_date = datetime.datetime.strptime(start_date, '%Y.%m.%d')
                
                    if isinstance(start_date, int):
                        raise RuntimeError('Invalid argument type')
                
                    return start_date
                
                
                print(foo('2018.12.12'))
                print(foo(datetime.datetime.now()))
                print(foo(1))
                

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


                1. shaukote
                  28.02.2019 14:54

                  А работает оно потому, что аннотации никак на выполнение не влияют. Вы можете в них что угодно прописать, там можно будет любой тип передать.
                  Вы правы, я слегка напутал.
                  Привык к TS с его фазой компиляции, который ругнётся на попытку вот так передать number, когда указан тип string | date.


            1. Tishka17 Автор
              28.02.2019 13:40

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


              Например, в python:


              >>> def x(arg: str):
              ...   return "**"+arg+"**"
              ...
              >>> x("hello")
              '**hello**'
              >>> x(1)
              Traceback (most recent call last):
                File "<stdin>", line 1, in <module>
                File "<stdin>", line 2, in x
              TypeError: can only concatenate str (not "int") to str

              И в js:


              -> function x(arg) {
                  return "**"+arg+"**"
              }
              <- undefined
              -> x("hello")
              <- "**hello**"
              -> x(1)
              <- "**1**"


              1. pesh1983
                28.02.2019 14:12

                Теперь я вас понял) Спасибо


        1. pesh1983
          28.02.2019 13:17

          > 2. У вас есть функция, принимающая в качестве аргумент объект Date или целое число (timestamp) или строку ('2019.01.01') какой тип выберете.

          Если у вас реально возникает такая необходимость, в питоне это решается объявлением списка типов, которые может иметь аргумент.

          def foo(arg: Union[date, str]):
              ...
          

          Подозреваю, что в JS/TS такой возможности нет, и это печально. Но это не проблема аннотации типов, а проблема реализации в конкретном языке.

          > Упс интерфейсов тоже не завезли

          Интерфейсы обычно используются, чтобы внести понимание, как можно с этим объектом работать и какие методы он имеет. В питоне для этого можно использовать абстрактные классы, либо те же тайпинги
          class T1:
              def foo1(self):
                  pass
          
              def foo2(self):
                  pass
          
          class T2:
              def foo2(self):
                  pass
          
              def foo3(self):
                  pass
          
          SomeInterface = NewType('SomeInterface', Union[T1, T2])
          
          def main(arg: SomeInterface):
              arg.foo2()
          
          main(T1())
          main(T2())
          

          Тут мы и объявили интерфейс SomeInterface и он уже выступает в качестве типа для тайпчекинга. Опять же, если в JS/TS или что вы используете в работе, такой возможности нет, это проблема конкретной реализации. В питоне это сделано грамотно и вызывает только положительные чувства и никем не навязывается.


          1. shaukote
            28.02.2019 13:25

            Подозреваю, что в JS/TS такой возможности нет, и это печально.
            Да нет, в TS можно аналогично объявить сигнатуру функции:
            function foo(arg: string | Date) { ... }


        1. shaukote
          28.02.2019 13:31

          Теперь про плоскость в которой лежат преимущества статической типизации
          Вы очень лихо притянули преимущества интерфейсов к статической типизации.

          А что, статически типизированные функциональные языки (не имеющие интерфейсов за отсутствием классов), бесполезны?


          1. rgs350
            28.02.2019 13:54

            Понятно что полезно. Я двумя руками за типизацию, но предполагаю, что в языках с изначально обязательной типизацией подходы к проектированию ПО несколько отличаются от языков где такая возможность является лишь дополнением и следовательно в JS/python etc. это обернется лишь указанием типов в переменных, аргументах функций и т.д. Это полезно, но вряд ли приведет к существенному улучшению качества написанного кода, ибо, по сути, лишь позволит узнать на этапе компиляции/трансляции о передаче в функцию аргумента с неправильным типов, но такая проблема возникает не часто, да и тесты её обычно решают.


            1. pesh1983
              28.02.2019 14:20

              > в языках с изначально обязательной типизацией

              Питон — внезапно, язык с изначальной обязательной типизацией) Просто она динамическая, а не статическая. Ну и никто не мешает вам объявить тип с помощью аннотации. Да, оно не будет работать в рантайме, но подразумевается, что если вы используете тайпчекер и аннотации, до рантайма такие ошибки не дойдут.
              Если же вам нужна именно статическая типизация, то тут просто нужно использовать другой язык, а не жаловаться, что в питон не завезли статику. Ну не будет ее там, поймите уже, это как раз одно из преимуществ языка. Он так спроектирован.


              1. rgs350
                28.02.2019 14:27

                Мы от разных вещах говорим. ТЧК


            1. shaukote
              28.02.2019 14:48

              Ничто не мешает рассматривать типизацию в TS не как «дополнение», а как неотъемлемую часть языка, включить --noImplicitAny и сразу проектировать опираясь на интерфейсы.
              И по моему опыту, это приводит к значительному улучшению улучшению кодовой базы.


              1. rgs350
                28.02.2019 15:41

                и сразу проектировать опираясь на интерфейсы.
                Готовы переучить всех программистов в команде, в особенности тех, кто такого никогда не делал? Уверены, что качество кода после этого вырастет? В требования к вакансии будете писать: «Умение работать с включенным --noImplicitAny». Такие штуки в теории хорошо работают, а на практике все равно большинство будут говнякать как привыкли. Ну не объясните вы сферическому фронтендеру в вакууме зачем это нужно.


                1. shaukote
                  28.02.2019 15:50

                  Готовы переучить всех программистов в команде, в особенности тех, кто такого никогда не делал?
                  Ну, на моих глазах люди переучивались вполне успешно, в т. ч. junior'ы. Требования к вакансиям особо не изменялись — интеграция в рабочий процесс дело неизбежное. Качество кода, поддерживаемость и расширяемость выросли очень сильно.

                  на практике все равно большинство будут говнякать как привыкли
                  Было бы желание (и отсутсвие командного контроля за качеством кода) — «говнякать» можно на любом языке.


        1. iroln
          28.02.2019 14:50

          Аннотации типов и тайпхинты как минимум помогают делать статический анализ кода, помогают делать правильный автокомплит "ну и т.д и т.п.". Не надо сравнивать тайпхинты и статически-типизированные языки. Идея в том, чтобы при минимуме оверхеда получить хоть какой-то контроль за кодом в полностью динамической среде, без потери этой самой динамичности.


          1. rgs350
            28.02.2019 15:32

            По возможностям подозрительно напоминает JS/JAVA/etc-doc и реинкарнацию венгерской нотации :) То бишь чисто информационная вещь намертво вплетенная в код.


  1. werewol
    28.02.2019 11:39

    Прям инструкция: «Как перестать ломать мозг команде девелоперов, если программа написана на C++, а вы техподдержка, и пишете на встроенном python»


  1. Andy_U
    28.02.2019 12:23

    Нормальную перегрузку методов ведь не завезли?

    Ну, если использования Union не хватает, то можно так (если параметров больше одного и не все комбинации допустимы)…
    from typing import overload
    
    
    @overload
    def f(arg: int) -> None:
        ...
    
    
    @overload
    def f(arg: str) -> None:
        ...
    
    
    def f(arg) -> None:
    
        if isinstance(arg, int):
            print('int')
        elif isinstance(arg, str):
            print('str')
        else:
            raise TypeError
    
    
    if __name__ == '__main__':
        f(1)
        f('abc')
        f(1.3)      # Pycharm warning here.
    


    1. Tishka17 Автор
      28.02.2019 14:05

      Тогда стоит упомянуть про functools.singledispatch:


      >>> from functools import singledispatch
      >>> @singledispatch
      ... def fun(arg, verbose=False):
      ...     if verbose:
      ...         print("Let me just say,", end=" ")
      ...     print(arg)
      
      >>> @fun.register
      ... def _(arg: int, verbose=False):
      ...     if verbose:
      ...         print("Strength in numbers, eh?", end=" ")
      ...     print(arg)
      ...
      
      >>> @fun.register
      ... def _(arg: list, verbose=False):
      ...     if verbose:
      ...         print("Enumerate this:")
      ...     for i, elem in enumerate(arg):
      ...         print(i, elem)


      1. Andy_U
        28.02.2019 14:19

        Если к вашему коду добавить:

        if __name__ == '__main__':
            fun(1.1)
        


        то ни pycharm ошибки не заметит, ни сам питон при запуске не отругается. Надо добавить type hints к «основной» функции. И я не очень понимаю, поможет ли такой подход, если допустимыми сигнатурами будут:

        f(int, str) 
        f(str, int)
        



        1. Tishka17 Автор
          28.02.2019 15:10

          В таком случае можно совместить typing.overload и functools.singledispatch, хотя я бы посоветовал избегать такого кода


          @overload
          def f(arg: int, arg2: str) -> None:
              ...
          
          @overload
          def f(arg: str, arg2: int) -> None:
              ...
          
          @singledispatch
          def f(arg, arg2) -> None:
              raise TypeError
          
          @f.register
          def f1(arg: int, arg2: str) -> None:
              print('int, str')
          
          @f.register
          def f2(arg: str, arg2: int) -> None:
              print('str, int')
          
          if __name__ == '__main__':
              f(1, "abc")
              f('abc', 1)
              f(1.3, 1.2)  # Pycharm warning here.


    1. iroln
      28.02.2019 22:36
      +1

      В языке с динамической типизацией есть понятие диспетчеризации, а не перегрузки методов. В питоне не завезли даже multipledispatch, хотя эта идея отлично ложится на тайпхинты, то есть с помощью тайпхинтов можно красиво сделать диспетчеризацию. К сожалению, в питоне есть только singledispatсh, который особо и негде использовать, и он в том числе не применим к методам класса. В некоторых языках, например, в Julia на диспетчеризации строится многое и этот механизм встроен в сам язык. Для питона есть несколько библиотек с реализацией multipledispatсh, самая продвинутая и архитектурно грамотная реализация вот. Туда бы ещё только добавить поддержку аннотаций в полной мере, чтобы можно было делать как-то так:


      @dispatch
      def func(*args, **kwargs):
          """Default function
          """
      
      @func.dispatch
      def func(foo: int, bar: str):
          pass
      
      @func.dispatch
      def func(s: str, b: int, d: tuple):
          pass