Аннотация базовыми типами

Курс по Python: https://stepik.org/course/100707

На нескольких последующих занятиях мы поговорим об аннотации типов. Что это такое и зачем это нужно? Я напомню, что в Python тип переменной (ссылки) определяется типом объекта, на который она ссылается. То есть, динамически. Отсюда и пошло название динамическая типизация. Например:

cnt = 0             # тип int
msg = "hello"       # тип str
lst = [1, 2, 3]     # тип list

Мало того, мы можем запросто присваивать другие типы данных этим же переменным:

cnt = -5.3          # тип float
msg = 0             # тип int

и никаких ошибок не будет. Все благодаря тому, что переменные – это ссылки на другие объекты, сами по себе они не хранят присваиваемые данные. Но что если в программе мы хотим явно указать, что, например, переменная cnt работает исключительно с целыми значениями? С типом int. Как это сделать? Очень просто. Если мы после переменной cnt поставим двоеточие, а затем укажем ожидаемый тип данных, на который она должна ссылаться по программе, например, int:

cnt: int
cnt = 0

то в строчке программы, где происходит присвоение не целого значения:

cnt = -5.3          # тип float

интегрированная среда подсветит число -5.3. Это и есть пример аннотации типов и работы статического анализатора, который проверяет на соответствие указанным типам. При наведении курсора на подсветку увидим сообщение:

Expected type ‘int’, got ‘float’ instead

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

Так все же, для чего была придумана аннотация типов и с какой версии языка Python ее можно использовать? Сначала отвечу на вторую часть вопроса. Активно аннотации стали внедряться, начиная с версии Python 3.5, и продолжают совершенствоваться до сих пор (на момент записи видео – это версия Python 3.10). А теперь каким целям она служит?

  • Для удобства восприятия стороннего кода.
  • Для удобства редактирования кода, когда IDE «подсказывает» атрибуты указанного типа переменных.
  • Для отслеживания некоторых явных ошибок на уровне несоответствия типов.

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

https://docs.python.org/3/library/typing.html

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

cnt: int = 0

Если присвоить другой тип данных:

cnt: int = 0.5

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

Аннотация типов в функциях

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

Предположим, мы определяем функцию для умножения некоторого числа на два, следующим образом:

def mul2(x):
    return x * 2

И, затем, разумеется, можем вызвать эту функцию:

res = mul2(5)
print(res)

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

res = mul2([5])

или строку:

res = mul2("5")

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

def mul2(x: int):
    return x * 2

Смотрите, теперь в вызове:

res = mul2("5")

интегрированная среда нам подсвечивает аргумент "5". Конечно, программа по-прежнему выполнится без ошибок, но теперь программист видит, что с аргументом функции что то не так. Если же записать целое число:

res = mul2(5)

то подсветка пропадет, т.к. типы совпадают.

Еще один плюс такого аннотирования – это подсказки интегрированной среды в виде набора атрибутов, присущих тому или иному типу. Например, если оставить только объявление функции без аннотирования:

def mul2(x):
    return x * 2

То, записывая внутри функции «x.», мы увидим лишь общие атрибуты. Если же добавить аннотацию:

def mul2(x: float):
    return x * 2

то появятся атрибуты для типа float. Это дополнительное удобство, который дает анализатор IDE.

Кстати, если нужно посмотреть, какие параметры функции как аннотированы, то можно обратиться к магической коллекции __annotations__, которая теперь есть у каждой функции:

print(mul2.__annotations__)

Увидим в консоли следующий словарь:

{'x': <class 'float'>}

Если в функцию mul2 добавить еще один параметр, например, так:

def mul2(x: float, y):
    return x * y

то словарь __annotations__ не изменится, так как второй параметр не аннотирован. Давайте добавим аннотацию и для него:

def mul2(x: float, y: int):
    return x * y

Теперь видим два ключа и два значения:

{'x': <class 'float'>, 'y': <class 'int'>}

Причем аннотации не мешают нам определять параметры со значениями по умолчанию. Мы по-прежнему можем использовать запись вида:

def mul2(x: float, y: int = 2):
    return x * y

Тогда при передаче только одного первого аргумента, значение y будет принимать число 2, прописанное по умолчанию.

Последнее, что осталось – это определить аннотацию для возвращаемого значения. Для этого после круглой скобки ставится стрелочка -> и указывается возвращаемый тип:

def mul2(x: float, y: int) -> float:
    return x * y

В результате в коллекции __annotations__ появился ключ ‘return’ со значением float:

{'x': <class 'float'>, 'y': <class 'int'>, 'return': <class 'float'>}

Если же функция ничего не возвращает (на самом деле она будет возвращать None), то указывается значение None:

def show_x(x: float) -> None:
    print(f"x = {x}")

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

Модуль typing и типы Union, Optional, Any, Final

Если внимательно посмотреть на нашу функцию mul2(), то по идее вполне допустимо, чтобы параметры x и y принимали как тип int, так и тип float. Как сделать такую составную, более сложную аннотацию типов? Для этого можно воспользоваться специальными типами из модуля typing:

from typing import Union, Optional, Any, Final

Давайте рассмотрим их по порядку. Первый тип Union позволяет комбинировать несколько разных типов в один, образуя составной тип. Например, это можно сделать так:

def mul2(x: Union[int, float], y: Union[int, float]) -> Union[int, float]:
    return x * y

То есть мы в квадратных скобках прописываем через запятую те типы, которые ожидаем увидеть у параметров x, y, а также у возвращаемого значения функции.

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

def mul2(x: int | float, y: int | float) -> Union[int, float]:
    return x * y

Но вернемся к прежней записи и здесь:

Union[int, float]

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

Digit = Union[int, float]

и, затем использовать при типизации:

def mul2(x: Digit, y: Digit) -> Digit:
    return x * y

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

Следующий тип Optional позволяет указать один какой-либо тип данных и еще автоматически добавляется тип None. Например:

Str = Optional[str]

эквивалентно

StrType = Union[str, None]

Этот случай выделили отдельно в тип Optional, т.к. такая комбинация с None довольно часто используется на практике. Например, объявим функцию show_x, у которой второй параметр descr по умолчанию будет принимать значение None:

def show_x(x: float, descr: Optional[str] = None) -> None:
    if descr:
        print(f"{descr} {x}")
    else:
        print(f"x = {x}")

Если теперь при вызове функции show_x() указать один аргумент:

show_x(55.6768)

то отработает блок else, а если указать два аргумента:

show_x(55.6768, 'x:')

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

show_x(55.6768, 10)

то интегрированная среда подсветит его из-за несоответствия ожидаемого типа данных. То есть, Optional введен для удобства, чтобы в Union не писать два типа, один из которых None.

Следующий тип Any означает буквально любой тип данных. Он используется, если параметр или переменная или возвращаемое значение функции может принимать любой тип. Или же, мы попросту не можем указать какой-либо конкретный тип данных, т.к. он нам неизвестен. Записывается такая нотация следующим образом:

def show_x(x: Any, descr: Optional[str] = None) -> None:
    if descr:
        print(f"{descr} {x}")
    else:
        print(f"x = {x}")

И, по сути, она эквивалентна поведению по умолчанию, когда мы не прописываем совсем никаких типов.

Наконец, последний тип Final появился в версии Python 3.10 и служит для отметки констант в программе. Например, если прописать:

MAX_VALUE: Final = 1000

то при попытке позже поменять это значение:

MAX_VALUE = 2000

интегрированная среда нам подсветит эту переменную с указанием, что идет попытка изменить константную переменную. При этом программа отработает без ошибок.

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

Курс по Python: https://stepik.org/course/100707

Видео по теме