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

Примеры моего использования 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 и защитить от ошибок.


▍ В хранилищах пар ключ-значение аннотируйте словари как мэппинги
Оправданным случаем применения словарей является хранилище пар ключ-значение, где все значения имеют один тип, а ключи используются для их поиска.
Словарь, используемый как мэппинг:
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)
Держите словари под контролем
Следите за словарями. Не позволяйте им захватить ваше приложение. Как и в случае любого элемента технического долга, чем дольше вы откладываете внедрение подходящих структур данных, тем сложнее на них в итоге перейти.
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻

