- PVSM.RU - https://www.pvsm.ru -

Не позволяйте словарям портить ваш код

Не позволяйте словарям портить ваш код - 1


Как часто ваши простенькие прототипы или предметные скрипты превращаются в полномасштабные приложения?

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

Содержание

  1. Что не так со словарями? [1]
    1. Словари непрозрачны. [2]
    2. Словари изменяемы. [3]
  2. Рассматривайте словари как формат передачи данных. [4]
  3. Упрощайте создание моделей. [5]
    1. Создавайте модели с помощью [6] dataclasses.
    2. Либо создавайте модели с помощью Pydantic. [7]
  4. В легаси-коде аннотируйте словари как [8] TypedDict.
  5. В хранилищах пар ключ-значение аннотируйте словари как мэппинги. [9]
  6. Возьмите словари под контроль. [10]

Что не так со словарями?

▍ Словари непрозрачны

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

▍ Словари изменяемы

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

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

Рассматривайте словари как формат сетевой передачи данных

Как правило, словари в коде создаются путём десереализации JSON, полученного, к примеру, из ответа стороннего API.

Словарь, возвращённый из API:

>>> requests.get("https://api.github.com/repos/imankulov/empty").json()
{'id': 297081773,
 'node_id': 'MDEwOlJlcG9zaXRvcnkyOTcwODE3NzM=',
 'name': 'empty',
 'full_name': 'imankulov/empty',
 'private': False,
...
}

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

Не позволяйте словарям портить ваш код - 2

Используйте сериализацию/десериализацию для преобразования данных между форматом сетевой передачи и внутренним представлением

Реализовать это легко.

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

В предметно-ориентированном дизайне (Domain-Driven Design, DDD) этот паттерн известен как предохранительный уровень (anti-corruption layer). Помимо семантической ясности, предметная модель обеспечивает естественный уровень, отделяющий внешнюю архитектуру от бизнес-логики приложения.

Ниже я приведу две реализации функции, извлекающей с GitHub информацию о репозитории:

Возвращение словаря:

import requests

def get_repo(repo_name: str):
    """Return repository info by its name."""
    return requests.get(f"https://api.github.com/repos/{repo_name}").json()

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

>>> get_repo("imankulov/empty")
{'id': 297081773,
 'node_id': 'MDEwOlJlcG9zaXRvcnkyOTcwODE3NzM=',
 'name': 'empty',
 'full_name': 'imankulov/empty',
 'private': False,
 # Множество строк ненужных атрибутов, URL и прочего.
 # ...
}

Модель предметной области:

class GitHubRepo:
    """GitHub repository."""
    def __init__(self, owner: str, name: str, description: str):
        self.owner = owner
        self.name = name
        self.description = description

    def full_name(self) -> str:
        """Get the repository full name."""
        return f"{self.owner}/{self.name}"

def get_repo(repo_name: str) -> GitHubRepo:
    """Return repository info by its name."""
    data = requests.get(f"https://api.github.com/repos/{repo_name}").json()
    return GitHubRepo(data["owner"]["login"], data["name"], data["description"])
>>> get_repo("imankulov/empty")
<GitHubRepo at 0x103023520>

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

В чём же объективные отличия.

  • Структура данных отчётливо определена, и мы можем задокументировать все необходимые детали.
  • В классе также есть метод full_name(), реализующий его бизнес-логику. В отличие от словарей модели данных позволяют размещать код и данные рядом.
  • Зависимость от GitHub API изолируется в функции get_repo(). Объекту GitHubRepo не нужно ничего знать о внешнем API и создании объектов. Благодаря этому, вы можете изменять десериализатор независимо от модели или добавлять новые способы создания объектов: из фикстур pytest, GraphQL API, локального кэша и так далее.

☝️ Игнорируйте поля, полученные от API, если они вам не нужны, оставляя только те, которые используете.

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

Упрощайте создание модели

Для обёртывания словарей нужно множество классов. В этом плане вы можете упростить себе работу с помощью библиотеки, которая будет создавать для вас «более качественные классы».

▍ Создавайте модели с помощью dataclasses

Начиная с v 3.7, в Python появились Data Classes [11]. Модуль dataclasses стандартной библиотеки предоставляет декоратор и функции для автоматического добавления в классы специально сгенерированных методов вроде __init__() и __repr__(). В итоге шаблонного кода писать приходится меньше.

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

from dataclasses import dataclass

@dataclass(frozen=True)
class GitHubRepo:
    """GitHub repository."""
    owner: str
    name: str
    description: str

    def full_name(self) -> str:
        """Get the repository full name."""
        return f"{self.owner}/{self.name}"

Когда я создаю классы данных, они почти всегда определяются как фиксированные (frozen). Вместо изменения объекта я создаю новый экземпляр при помощи dataclasses.replace(). Используя атрибуты только для чтения, вы облегчаете жизнь разработчику, который будет читать или обслуживать ваш код.

▍ Альтернатива — создание моделей с помощью Pydantic

Недавно я начал использовать для определения моделей библиотеку Pydantic [12], отвечающую за проверку сторонних данных. Если сравнивать её с классами данных, то она намного функциональней. Мне особенно нравятся её сериализаторы и десериализаторы, автоматическое преобразование типов и кастомные валидаторы.

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

В случае Pydantic та же модель может выглядеть так:

from pydantic import BaseModel


class GitHubRepo(BaseModel):
    """GitHub repository."""
    owner: str
    name: str
    description: str

    class Config:
        frozen = True

    def full_name(self) -> str:
        """Get the repository full name."""
        return f"{self.owner}/{self.name}"

Онлайн-сервис jsontopydantic.com [13] экономит моё время, создавая модели Pydantic из данных, получаемых от сторонних API. Я копирую в этот сервис примеры ответов из документации, и он возвращает модели Pydantic.

Не позволяйте словарям портить ваш код - 3

jsontopydantic.com преобразует ответ Todoist API в модель Pydantic

Примеры моего использования Pydantic можно найти в статье «Time Series Caching with Python and Redis [14]».

▍ В легаси-коде аннотируйте словари как TypedDict

В Python 3.8 появились так называемые TypedDicts [15]. В среде выполнения они действуют как обычные словари, но предоставляют дополнительную информацию о своей структуре для разработчиков, валидаторов типов и IDE.

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

from typing import TypedDict


class GitHubRepo(TypedDict):
    """GitHub repository."""
    owner: str
    name: str
    description: str

repo: GitHubRepo = {
    "owner": "imankulov",
    "name": "empty",
    "description": "An empty repository",
}

Ниже я привёл два скриншота из PyCharm, чтобы показать, каким образом добавление информации типа может упростить процесс разработки в IDE и защитить от ошибок.

Не позволяйте словарям портить ваш код - 4

PyCharm знает тип значения и предоставляет варианты автозавершения

Не позволяйте словарям портить ваш код - 5

PyCharm знает о недостающем ключе и выдаёт предупреждение

▍ В хранилищах пар ключ-значение аннотируйте словари как мэппинги

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

Словарь, используемый как мэппинг:

colors = {
    "red": "#FF0000",
    "pink": "#FFC0CB",
    "purple": "#800080",
}

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

Вот пример, где я определил мэппинг цветов и аннотировал функцию. Заметьте, что функция использует операцию, разрешённую для словарей, но недопустимую для экземпляров Mapping:

# файл: colors.py
from typing import Mapping

colors: Mapping[str, str] = {
    "red": "#FF0000",
    "pink": "#FFC0CB",
    "purple": "#800080",
}

def add_yellow(colors: Mapping[str, str]):
    colors["yellow"] = "#FFFF00"

if __name__ == "__main__":
    add_yellow(colors)
    print(colors)

Несмотря на неверные типы, в среде выполнения проблем не обнаруживается.

$ python colors.py
{'red': '#FF0000', 'pink': '#FFC0CB', 'purple': '#800080', 'yellow': '#FFFF00'}

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

$ mypy colors.py
colors.py:11: error: Unsupported target for indexed assignment ("Mapping[str, str]")
Found 1 error in 1 file (checked 1 source file)

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

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

Автор: Bright_Translate

Источник [16]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/python/413888

Ссылки в тексте:

[1] Что не так со словарями?: #1

[2] Словари непрозрачны.: #2

[3] Словари изменяемы.: #3

[4] Рассматривайте словари как формат передачи данных.: #4

[5] Упрощайте создание моделей.: #5

[6] Создавайте модели с помощью: #6

[7] Либо создавайте модели с помощью Pydantic.: #7

[8] В легаси-коде аннотируйте словари как : #8

[9] В хранилищах пар ключ-значение аннотируйте словари как мэппинги.: #9

[10] Возьмите словари под контроль.: #10

[11] Data Classes: https://docs.python.org/3/library/dataclasses.html

[12] Pydantic: https://pydantic-docs.helpmanual.io/

[13] jsontopydantic.com: https://jsontopydantic.com/

[14] Time Series Caching with Python and Redis: https://roman.pt/posts/time-series-caching/

[15] TypedDicts: https://www.python.org/dev/peps/pep-0589/

[16] Источник: https://habr.com/ru/companies/ruvds/articles/890402/?utm_source=habrahabr&utm_medium=rss&utm_campaign=890402