
Как часто ваши простенькие прототипы или предметные скрипты превращаются в полномасштабные приложения?
Простота естественного разрастания кода не лишена и обратной стороны — такой код становится трудно обслуживать. Количественное размножение словарей в качестве основных структур данных чётко сигнализирует о наличии технического долга. К счастью, сегодня Python предоставляет для простых словарей много адекватных альтернатив.
Содержание
- Что не так со словарями?
- Рассматривайте словари как формат передачи данных.
- Упрощайте создание моделей.
- В легаси-коде аннотируйте словари как
TypedDict
. - В хранилищах пар ключ-значение аннотируйте словари как мэппинги.
- Возьмите словари под контроль.
Что не так со словарями?
▍ Словари непрозрачны
Функции, которые получают словари, очень трудно расширять и изменять. Как правило, для изменения функции, получающей словарь, нужно вручную отследить её вызовы вплоть до источников, где создавался словарь. Зачастую существует не один путь вызова, и если программа разрастается без чёткого плана, в структурах словарей наверняка возникнут расхождения.
▍ Словари изменяемы
Изменение значений словарей для соответствия конкретному рабочему потоку может быть весьма заманчивым. И программисты нередко этим грешат. Изменения на конкретных местах могут происходить под разными именами: предварительная обработка, заполнение, расширение, обработка данных и так далее. Но результат будет один и тот же. Подобные действия нарушают структуру данных и делают их зависимыми от рабочего потока приложения.
Причём словари позволяют изменять не только свои данные, но и структуру объектов. Вы можете добавлять или удалять поля, а также изменять их типы. Вот только всё это является худшим, что можно сделать с данными.
Рассматривайте словари как формат сетевой передачи данных
Как правило, словари в коде создаются путём десереализации 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,
...
}
Выработайте привычку воспринимать словари как формат передачи информации и сразу преобразовывайте их в структуры данных, обеспечивающие семантическую ясность.

Используйте сериализацию/десериализацию для преобразования данных между форматом сетевой передачи и внутренним представлением
Реализовать это легко.
- Определите модели предметной области. В приложении они выражаются простым классом.
- Выполняйте получение и десериализацию в одном шаге.
В предметно-ориентированном дизайне (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.

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 и защитить от ошибок.

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

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