Рубріки: Теория

Глубокое понимание аннотации типов в Python, часть 2

Игорь Грегорченко

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

Начало этой статьи ищите вот здесь, а далее наше продолжение.

Добавление подсказок типов к спискам

Списки Python аннотируются на основе типов элементов, которые они имеют или ожидают иметь. Начиная с Python ≥3.9, для аннотирования списка используется тип списка, за которым следует []. [] содержит тип элемента.

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

names: list[str] = ["john", "stanley", "zoe"].

Если вы используете Python ≤3.8, вам необходимо импортировать List из модуля typing.

from typing import List

names: List[str] = ["john", "stanley", "zoe"]

В определениях функций документация Python рекомендует использовать тип list для аннотации возвращаемых типов:

def print_names(names: str) -> list[int]:
...

Однако для параметров функций документация рекомендует использовать эти абстрактные типы коллекций:

  • Iterable.
  • Sequence.
  • Когда использовать тип Iterable.

Тип Iterable следует использовать, когда функция принимает итерабельную переменную и выполняет над ней итерацию.

Итерабельность — это свойство объекта, который может возвращать по одному элементу за раз. Примерами могут служить списки, кортежи и строки, а также все, что реализует метод __iter__.

Вы можете аннотировать Iterable следующим образом в Python ≥3.9:

from collections.abc import Iterable

def double_elements(items: Iterable[int]) -> list[int]:
return [item * 2 for item in items].

print(double_elements([2, 4, 6])) # список
print(double_elements((2, 4)))     # кортеж

В функции мы определяем параметр items и присваиваем ему подсказку типа Iterable, за которой следует [int], что указывает на то, что Iterable содержит элементы int.

Подсказка типа Iterable принимает все, у чего реализован метод __iter__. У списков и кортежей этот метод реализован, поэтому вы можете вызвать функцию double_elements со списком или кортежем, и функция выполнит итерацию по ним.

Чтобы использовать Iterable в Python ≤3.8, вы должны импортировать его из модуля typing.

from typing import Iterable

...

Использование Iterable в параметрах является более гибким, чем если бы у нас была подсказка типа list:

def double_elements(items: list[int]) -> list[int]:

...

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

Когда следует использовать тип Sequence

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

Подсказка типа Sequence может принимать список, строку или кортеж. Это связано с тем, что они имеют специальные методы: __getitem__ и __len__. Когда вы обращаетесь к элементу последовательности, скажем, items[index], используется метод __getitem__. При получении длины последовательности len(items) используется метод __len__.

В следующем примере мы используем тип Sequence[int] для получения последовательности, состоящей из целочисленных элементов:

from collections.abc import Sequence

def get_last_element(data: Sequence[int]) -> int:
    return data[-1]

first_item = get_last_element((3, 4, 5))    # 5
second_item = get_last_element([3, 8]    # 8

В этой функции мы принимаем последовательность и получаем доступ к последнему элементу из нее с помощью data[-1]. Для доступа к последнему элементу используется метод __getitem__ последовательности.

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

Для Python ≤3.8 необходимо импортировать Sequence из модуля typing:

from typing import Sequence

...

Теперь мы знаем, как добавлять подсказки типов к спискам. В следующем разделе мы добавим подсказки типов в словари.

Добавление подсказок типов в словари

Чтобы добавить подсказки типов к словарям, вы используете тип dict, за которым следует [тип_ключа, тип_значения]:

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

person = { "first_name": "John", "last_name": "Doe"}

Вы можете аннотировать его следующим образом:

person: dict[str, str] = { "first_name": "John", "last_name": "Doe"}

Тип dict указывает, что ключи словаря person имеют тип str, а значения — тип str. Если вы используете Python ≤3.8, вам необходимо импортировать Dict из модуля typing.

from typing import Dict

person: Dict[str, str] = { "first_name": "John", "last_name": "Doe"}

В определениях функций документация рекомендует использовать dict в качестве возвращаемого типа:

def make_student(name: str) -> dict[str, int]:

...

Для параметров функций рекомендуется использовать эти абстрактные базовые классы:

  • Mapping
  • MutableMapping
  • Когда использовать класс Mapping

В параметрах функций, когда вы используете подсказки типа dict, вы ограничиваете аргументы, которые может принимать функция, только dict, defaultDictor OrderedDict. Однако существует множество подтипов словарей, таких как UserDict и ChainMap, которые можно использовать аналогичным образом. Вы можете получить доступ к элементу и выполнить итерацию или вычислить его длину, как и в случае со словарем.

Это происходит потому, что они реализуют:

  • __getitem__: для доступа к элементу.
  • __iter__: для итерации.
  • __len__: вычисление длины.

Поэтому вместо ограничения структур, которые принимает параметр, вы можете использовать более общий тип Mapping, поскольку он принимает:

  • dict
  • UserDict
  • defaultdict
  • OrderedDict
  • ChainMap

Еще одним преимуществом типа Mapping является то, что он указывает, что вы только читаете словарь, а не изменяете его.

Следующий пример представляет собой функцию, которая получает доступ к значениям элементов из словаря:

from collections.abc import Mapping

def get_full_name(student: Mapping[str, str]) -> str:
    return f'{student.get("first_name")} {student.get("last_name")}'

john = {
  "first_name": "John",
  "last_name": "Doe",
}

get_full_name(john)

В функции мы добавляем подсказку типа Mapping[str, str], которая указывает, что структура данных student имеет ключи типа str и значения типа str. В качестве аргумента мы передаем dict, но UserDict, defaultdict, OrderedDict или ChainMap будут приняты без проблем.

Если вы используете Python ≤3.8, импортируйте Mapping из модуля typing:

from typing import Mapping

Теперь мы знаем, что когда мы хотим прочитать словарь, нам нужно использовать подсказку типа Mapping. Но когда вы хотите изменить словарь, больше подходит MutableMapping.

Использование класса MutableMapping

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

Класс MutableMapping принимает любой экземпляр, который реализует следующие специальные методы:

  • __getitem__
  • __setitem__
  • __delitem__
  • __iter__
  • __len__

Методы __delitem__ и __setitem__ используются для мутации, и это методы, которые отделяют тип Mapping от типа MutableMapping.

В следующем примере функция принимает словарь и мутирует его:

from collections.abc import MutableMapping

def update_first_name(student: MutableMapping[str, str], first_name: str) -> None:
    student["first_name"] = first_name

john = {
    "first_name": "John",
    "last_name": "Doe",
}

update_first_name(john, "james")

В параметре student функции мы добавляем подсказку типа MutableMapping[str, str], которая указывает, что параметр student имеет ключи типа str и значения типа str, и он будет мутирован.

В теле функции мы устанавливаем значение свойства first_name в значение второго аргумента first_name. Для изменения значения ключа словаря используется метод __setitem__.

Если вы работаете на Pyth ≤3.8, импортируйте MutableMapping из модуля typing.

from typing import MutableMapping

...

Использование класса TypedDict

До сих пор мы рассматривали, как аннотировать словари с помощью dict, Mapping и MutableMapping, но большинство словарей имеют только один тип: str.

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

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

student = {
  "first_name": "John",
  "last_name": "Doe",
  "age": 18,
  "hobbies": ["singing", "dancing"],
}

Как вы можете видеть, значения словаря варьируются от str, int и list. Если бы в нем были только значения str, мы бы аннотировали его как dict[str, str].

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

from typing import TypedDict

class StudentDict(TypedDict):
    first_name: str
    last_name: str
    age: int
    hobbies: list[str]

Мы определяем класс StudentDict, который наследуется от TypedDict. Внутри класса мы определяем каждое поле и его ожидаемый тип.

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

from typing import TypedDict

class StudentDict(TypedDict):
    ...

student1: StudentDict = {
    "first_name": "John",
    "last_name": "Doe",
    "age": 18,
    "hobbies": ["singing", "dancing"],
}

Вы также можете использовать его для аннотации параметра функции, которая ожидает словарь, как показано ниже:

def get_full_name(student: StudentDict) -> str:
    return f'{student.get("first_name")} {student.get("last_name")}'

Здесь мы определили функцию get_full_name, которая принимает в качестве параметра словарь типа StudentDict. Если аргумент словаря не соответствует StudentDict, mypy выдаст предупреждение.

Добавление подсказок типов к кортежам

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

Ниже приведен пример аннотации кортежа с двумя элементами:

student: tuple[str, int] = ("John Doe", 18)

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

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

def student_info(student: tuple[str, int]) -> None:
    ...

Если ожидается, что ваш кортеж будет содержать неизвестное количество элементов одинакового типа, вы можете использовать tuple[type, ...] для их аннотации:

letters: tuple[str, ...] = ('a', 'h', 'j', 'n', 'm', 'n', 'z')

Чтобы аннотировать именованный кортеж, необходимо определить класс, который наследуется от NamedTuple. Поля класса определяют элементы и их типы:

from typing import NamedTuple

class StudentTuple(NamedTuple):
    name: str
    age: int

john = StudentTuple("John Doe", 33)

Здесь мы создаем StudentTuple, который является именованным кортежем. Затем мы создаем его экземпляр, передавая ему ожидаемые элементы.

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

def student_info(student: StudentTuple) -> None:
    name, age = student
    print(f"Name: {name}\nAge: {age}")

student_info(john)

Создание и использование протоколов

Бывают случаи, когда вам не важен аргумент, который принимает функция. Вас волнует только то, есть ли у нее нужный вам метод.

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

Любой объект, реализующий методы класса протокола, будет принят. Протокол можно представить как интерфейс, используемый в таких языках программирования, как Java или TypeScript.

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

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

def calc_age(current_year: int, data) -> int:
    return current_year - data.get_birthyear()

Функция принимает два параметра: current_year, целое число и data. В теле функции мы находим разницу между current_year и значением, возвращенным из метода get_birthyear().

Приведем пример класса, реализующего метод get_birthyear:

class Person:
    def __init__(self, name, birthyear):
        self.name = name
        self.birthyear = birthyear

    def get_birthyear(self) -> int:
        return self.birthyear

# create an instance
john = Person("john doe", 1996)

Это один из примеров такого класса, но могут быть и другие классы, такие как Dog или Cat, реализующие метод get_birthyear. Аннотирование всех возможных типов было бы громоздким (поскольку нас интересует только метод get_birthyear()).

Чтобы реализовать это поведение, давайте создадим наш протокол:

from typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

Класс HasBirthYear наследуется от Protocol, который является частью модуля typing. Чтобы протокол знал о методе get_birthyear, мы переопределим метод точно так же, как это сделано в примере класса Person, который мы рассматривали ранее. Единственным исключением будет тело функции, где мы должны заменить тело на многоточие (...).

Определив протокол, мы можем использовать его в функции calc_age, чтобы добавить подсказку типа к параметру данных (data):

rom typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

def calc_age(current_year: int, data: HasBirthYear) -> int:
    return current_year - data.get_birthyear()

Теперь параметр data был аннотирован протоколом HasBirthYear. Теперь функция может принимать любой объект, если у него есть метод get_birthyear.

Вот полная реализация кода с использованием протокола:

from typing import Protocol

class HasBirthYear(Protocol):
    def get_birthyear(self) -> int: ...

class Person:
    def __init__(self, name, birthyear):
        self.name = name
        self.birthyear = birthyear

    def get_birthyear(self) -> int:
        return self.birthyear

def calc_age(current_year: int, data: HasBirthYear) -> int:
    return current_year - data.get_birthyear()

john = Person("john doe", 1996)
print(calc_age(2021, john))

Запуск кода в mypy не вызовет никаких проблем.

Аннотирование функций с помощью перегрузки

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

def add_number(value, num):
    if isinstance(value, int):
        return value + num
    elif isinstance(value, list):
        return [i + num for i in value]

print(add_number(3, 4))              # 7
print(add_number([1, 2, 5], 4))    # [5, 6, 9]

Когда вы вызываете функцию с целым числом в качестве первого аргумента, она возвращает целое число. При вызове функции со списком в качестве первого аргумента она возвращает список, каждый элемент которого дополнен значением второго аргумента.

Итак, как мы можем аннотировать эту функцию? Исходя из того, что мы знаем до сих пор, наша первая идея — использовать синтаксис объединения:

def add_number(value: int | list, num: int) -> int | list:
 ...

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

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

Аналогично, передача списка в качестве первого аргумента функции никак не может привести к получению int. Она всегда будет возвращать список.

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

Для этого мы будем использовать декоратор перегрузки из модуля типизации. Определим две перегрузки перед реализацией функции add_number:

from typing import overload

@overload
def add_number(value: int, num: int) -> int: ...

@overload
def add_number(value: list, num: int) -> list: ...

def add_number(value, num):
    if isinstance(value, int):
        return value + num
    elif isinstance(value, list):
        return [i + num for i in value]

print(add_number(3, 4))
print(add_number([1, 2, 5], 4)

Перед основной функцией add_number мы определяем две перегрузки. Параметры перегрузок аннотированы соответствующими типами и типами возвращаемых значений. Их тела функций содержат многоточие (...).

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

@overload
def add_number(value: int, num: int) -> int: ...

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

@overload
def add_number(value: list, num: int) -> list: ...

Наконец, основная реализация add_number не имеет никаких подсказок типа.

Как вы теперь видите, перегрузки аннотируют поведение функции гораздо лучше, чем использование союзов.

Аннотирование констант с помощью Final

На момент написания статьи в Python не было встроенного способа определения констант. Начиная с Python 3.10, вы можете использовать тип Final из модуля типизации. Это означает, что mypy будет выдавать предупреждения при попытках изменить значение переменной.

from typing import Final
MIN: Final = 10
MIN = MIN + 3

Запуск кода с помощью mypy выдаст предупреждение:

final.py:5: error: Cannot assign to final name "MIN"
Found 1 error in 1 file (checked 1 source file)

Это происходит потому, что мы пытаемся изменить значение переменной MIN на MIN = MIN + 3.

Обратите внимание, что без mypy или любой статической проверки файлов, Python не будет этого делать, и код будет выполняться без проблем:

>>> from typing import Final
>>> MIN: Final = 10
>>> MIN = MIN + 3
>>> MIN
>>> 13

Как видите, во время выполнения вы можете в любой момент изменить значение переменной MIN. Чтобы внедрить постоянную переменную в вашу кодовую базу, вы должны зависеть от mypy.

Работа с проверкой типов в сторонних пакетах

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

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

import third_party # type ignore

У вас также есть возможность добавлять подсказки типов с помощью заглушек. Чтобы узнать, как использовать заглушки, смотрите раздел Файлы-заглушки в документации по mypy.

Заключение

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

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023