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

в 13:01, , рубрики: api, json, python, ruvds_перевод, Программирование, словари

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


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

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

Содержание

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

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

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

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

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

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

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

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

Как правило, словари в коде создаются путём десереализации 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. Модуль 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, отвечающую за проверку сторонних данных. Если сравнивать её с классами данных, то она намного функциональней. Мне особенно нравятся её сериализаторы и десериализаторы, автоматическое преобразование типов и кастомные валидаторы.

Сериализаторы упрощают сохранение записей во внешнее хранилище, например, для кэширования. Преобразование типов особенно помогает в случае превращения сложных иерархических документов 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 экономит моё время, создавая модели Pydantic из данных, получаемых от сторонних API. Я копирую в этот сервис примеры ответов из документации, и он возвращает модели Pydantic.

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

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

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

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

В Python 3.8 появились так называемые TypedDicts. В среде выполнения они действуют как обычные словари, но предоставляют дополнительную информацию о своей структуре для разработчиков, валидаторов типов и 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js