HangOps.ru

Использование дженериков в Python

Если вы используете подсказки типов в Python и инструменты статического анализа, такие как mypy, то вы, вероятно, довольно часто использовали тип Any, чтобы обойти функции и методы ввода, где тип аргумента может меняться во время выполнения. Хотя Any сам по себе является очень удобным инструментом, который позволяет постепенно вводить текст в коде Python (возможность постепенно добавлять подсказки к вашему коду с течением времени), он не добавляет никакой реальной ценности с точки зрения представления знаний читателю. Вот тут-то и пригодятся дженерики. В этой статье я расскажу об основах дженериков и о том, как их можно использовать в Python, чтобы лучше документировать и передавать намерения вашего кода, а в некоторых случаях даже обеспечивать корректность.

Хотите освоить язык Python и начать свой путь программиста? Мы собрали для вас лучшие курсы Python для начинающих от ведущих платформ. Переходите по ссылке и выбирайте свой путь к успеху уже сегодня!

Зачем использовать подсказки типа?

Прежде чем мы начнем, мы должны объяснить, почему мы вообще будем использовать подсказки типов в Python.

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

В прошлом я работал над некоторыми довольно большими монолитами Python, где единственным способом действительно понять, что происходит, было либо запустить его на вашей машине (часто сложнее, чем кажется), либо написать тест, который запускает код, который вы хотите понять, добавить несколько отладок и начать пошаговый через это продираться. Подсказки типа повышают ценность, поскольку они дают вам больше информации о том, что происходит в вашем коде, чтобы вы могли лучше рассуждать об этом без дополнительных усилий. Эти подсказки типа также позволяют вам использовать такие инструменты, как mypy, которые могут проверять наличие ошибок, которые могли быть замечены только во время выполнения, поскольку подсказки типа добавляют контракт к вашему коду, который эти инструменты могут проверять.

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

Использование дженериков в Python

Все следующие примеры написаны с использованием Python 3.8 и, следовательно, используют модуль typing. Допустим, у нас есть функция, и эта функция принимает список и возвращает первый элемент этого списка.

def first(container):
    return container[0]

if __name__ == "__main__":
    list_one = ["a", "b", "c"]
    print(first(list_one))

    list_two = [1, 2, 3]
    print(first(list_two))

Функция first(), определенная здесь, будет работать со списком любого типа, даже со списком смешанных типов. Но что происходит, когда мы решаем лучше определить наши списки? Если мы решим ограничить типы элементов в этих списках, добавив подсказки типов в наш код и используя mypy для принудительного применения этих типов, вы можете получить что-то вроде этого:

from typing import Any, List

def first(container: List[Any]) -> Any:
    return container[0]

if __name__ == "__main__":
    list_one: List[str] = ["a", "b", "c"]
    print(first(list_one))

    list_two: List[int] = [1, 2, 3]
    print(first(list_two))

Это обычное явление при добавлении подсказок типа. Нам лучше определить контейнеры, но мы только что использовали Any в самой функции first(). Это не добавляет никакой ценности! В этом очень простом примере, где мы можем сразу увидеть, что происходит в коде, это не так уж плохо. Однако даже в этом примере, не говоря уже о более крупном примере реального мира, работающем с определяемыми пользователем типами, эти подсказки типа не отражают то, что мы знаем как истинное. Мы знаем, что наш метод будет использоваться со списками одного типа. Мы также знаем, что возвращаемый тип будет соответствовать типу элементов в списке, поэтому давайте зафиксируем эти вещи в коде.

from typing import List, TypeVar

T = TypeVar("T")

def first(container: List[T]) -> T:
    return container[0]

if __name__ == "__main__":
    list_one: List[str] = ["a", "b", "c"]
    print(first(list_one))

    list_two: List[int] = [1, 2, 3]
    print(first(list_two))

Здесь мы добавили универсальный тип с именем T. Мы сделали это с помощью фабрики TypeVar, указав имя типа, который мы хотим создать, а затем записав его в переменную с именем T. Затем он используется так же, как и любой другой тип, используемый в подсказках типа Python. T и U - это обычно используемые имена в дженериках (T означает тип, а U означает .... ничего. Это просто следующая буква в алфавите) аналогично тому, как i и x используются в качестве переменных итерации.

Используя этот универсальный тип T, функция first() теперь утверждает, что параметр контейнера представляет собой список "generic type" (универсального типа). Нас не волнует фактический тип аргумента, но мы заботимся о том, чтобы возвращаемое значение было того же типа, что и элементы в списке. Используя это, мы фиксируем связь между аргументом и возвращаемым значением в коде. У него есть дополнительный бонус, позволяющий вам определить, пытаемся ли мы вернуть что-то, чего нет в аргументе контейнера. Давайте посмотрим, что произойдет, если мы вернем значение того же типа, что и содержимое container, но на самом деле оно не из container...

from typing import List, TypeVar

T = TypeVar("T")

def first(container: List[T]) -> T:
    print(container)
    return "a" # mypy raises: Incompatible return value type (got "str", expected "T")

if __name__ == "__main__":
    list_one: List[str] = ["a", "b", "c"]
    print(first(list_one))

В приведенном выше примере, несмотря на то, что единственный аргумент контейнера, переданный функции, содержит элементы типа str, и мы возвращаем str, mypy выдает ошибку - "Incompatible return value type" (Несовместимый тип возвращаемого значения), поскольку ожидалось возвращаемое значение универсального типа T. Мы определяем T только как тип содержимого для параметра контейнера в этой функции, поэтому возвращаемое значение должно быть получено из контейнера.

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

Давайте воспользуемся еще несколькими простыми примерами, чтобы продемонстрировать все, что мы только что узнали, и показать насколько это полезно. В этих примерах мы используем K и V в качестве наших общих типов, поскольку они представляют типы ключей и значений словаря.

from typing import Dict, TypeVar

K = TypeVar("K")
V = TypeVar("V")

def get_item(key: K, container: Dict[K, V]) -> V:
    return container[key]

if __name__ == "__main__":
    test: Dict[str, int] = {"k": 1}
    print(get_item("k", test))

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

Давайте посмотрим на один последний пример:

from typing import Dict, TypeVar

K = TypeVar("K")
V = TypeVar("V")

def get_first(container: Dict[K, V]) -> K:
    return list(container.keys())[0]

if __name__ == "__main__":
    test: Dict[str, int] = {"k": 1}
    print(get_first(test))

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

from typing import Dict, TypeVar

K = TypeVar("K")
V = TypeVar("V")

def get_first(container: Dict[K, V]) -> K:
    return list(container.values())[0] # mypy raises: Incompatible return value type (got "V", expected "K")

if __name__ == "__main__":
    test: Dict[str, int] = {"k": 1}
    print(get_first(test))

mypy выдает тот же "несовместимый тип возвращаемого значения", который мы видели ранее, объясняя, что мы сделали неправильно.

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

T = TypeVar("T", str, int) # T может представлять только типы int и str

Или установив тип "верхней границы"

T = TypeVar("T", bound=int) # T может быть только int или подтипом int

Затем это ограничивает то, что может представлять этот универсальный тип, типом верхней границы и подтипами данного типа, в данном случае int и его подтипом bool.

Generic Types

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

Давайте вернемся к предыдущему списку в качестве примера:

from typing import Any, List

def first(container: List[Any]) -> Any:
    return container[0]

if __name__ == "__main__":
    list_one: List[str] = ["a", "b", "c"]
    print(first(list_one))

    list_two: List[int] = [1, 2, 3]
    print(first(list_two))

Здесь мы обновили наш код, чтобы использовать списки фиксированных типов. Мы использовали конструкторы List[str] и List[int], чтобы определить, что список будет содержать только строковые и целочисленные значения соответственно. Это работает, потому что List - это универсальный тип. Когда мы создаем экземпляр списка, мы сообщаем ему, к какому типу будут относиться его значения. Если мы не определили тип при создании экземпляра, то он предполагает любой. Это означает, что my_list: List = ['a'] и my_list: List[Any] = ['a'] - это одно и то же.

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

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

Определяемые пользователем универсальные типы

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

from typing import Dict, Generic, TypeVar

T = TypeVar("T")

class Registry(Generic[T]):
    def __init__(self) -> None:
        self._store: Dict[str, T] = {}

    def set_item(self, k: str, v: T) -> None:
        self._store[k] = v

    def get_item(self, k: str) -> T:
        return self._store[k]

if __name__ == "__main__":
    family_name_reg = Registry[str]()
    family_age_reg = Registry[int]()

    family_name_reg.set_item("husband", "steve")
    family_name_reg.set_item("dad", "john")

    family_age_reg.set_item("steve", 30)

Здесь мы создали общий класс Registry. Это делается путем расширения универсального базового класса и определения универсальных типов, которые мы хотим использовать в этом классе. В этом случае мы определяем T, который затем используется в методах класса Registry.

Когда мы создаем экземпляр family_name_reg, мы заявляем, что он будет содержать только значения типа string (с помощью Registry[str]), а экземпляр family_age_reg будет содержать только значения типа integer (с помощью Registry[int]).

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

Используя наш приведенный выше пример, если мы попытаемся установить строковое значение в Registry age, мы увидим, что mypy выдаст это как ошибку

family_age_reg.set_item("steve", "yeah")
# mypy raises: Argument 2 to "set_item" of "Registry" has incompatible type "str"; expected "int"

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

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

Источник

Python