1. Зачем нужен маппинг struct ↔ JSON
Когда вы только начинаете писать программу, кажется логичным работать “напрямую”: вот JSON-объект, вот ключ "name", вот j["name"]. И первое время это даже приятно: быстро, без лишних слоёв, компилируется, и вы чувствуете себя повелителем данных.
Проблема начинается на второй неделе жизни проекта (то есть примерно через 15 минут после первого успешного запуска). Ключи расползаются по коду, появляются “магические строки”, кто-то пишет "userName", кто-то "username", а кто-то "user_name" — и всё это одно и то же поле, но в разных вселенных. Маппинг — это способ собрать правила преобразования в одном месте и перестать играть в “найди опечатку”.
Цель лекции: научиться выбирать между двумя подходами и понимать критерии выбора, а не действовать по принципу “потому что так принято”.
Два подхода: вручную и через to_json/from_json
Важно сразу снять страх: оба подхода нормальные. Ручная сериализация — не “позор новичка”, а инструмент, который иногда реально лучше. А to_json/from_json — не “магия”, а просто дисциплина: вы договорились, где живут ключи и правила.
Разница примерно как между “готовлю по рецепту” и “готовлю на глаз”. На глаз иногда получается быстрее и вкуснее — но если вы захотите повторить блюдо через месяц, вы внезапно обнаружите, что “щепотка соли” у всех разная, а “поджарить до готовности” компилятор не проверяет.
Сначала посмотрим на ручной подход, потом на to_json/from_json, а затем соберём понятные критерии выбора.
2. Ручная сериализация
Ручная сериализация — это когда вы явно создаёте nlohmann::json и явно из него читаете. Обычно это выглядит как две функции: serializeXxx(...) и parseXxx(...). Плюс этого подхода в том, что он прямолинейный и легко отлаживается: вы буквально видите, что куда кладёте.
Минус проявляется позже: логика чтения и записи начинает дублироваться, ключи расползаются, а изменения формата требуют редактировать кучу мест. Но на маленьких структурах и в разовых форматах ручной подход часто выигрывает по простоте.
Мини-пример ручной сериализации Task
Представим, что у нас есть учебное приложение “TaskBox” — консольный менеджер задач, который мы развивали с тех пор, как научились делать struct, vector, файлы и проверки ввода. Пока без версий формата и без миграций (это будет дальше по дню), просто сохраняем список задач в JSON.
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
nlohmann::json serialize_task(const Task& t) {
return nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
Код короткий, понятный. Здесь легко поставить брейкпоинт и посмотреть JSON-дерево глазами.
Ручная десериализация
Теперь читаем обратно. И вот здесь появляется первая развилка: вы делаете строго (обязательные поля через at()), или более мягко (через value() с дефолтами). По плану дня про валидацию будет отдельная лекция, поэтому сейчас покажем “строго и просто”, без хитрых дефолтов.
#include <nlohmann/json.hpp>
#include <string>
Task parse_task_strict(const nlohmann::json& j) {
Task t;
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
return t;
}
Это работает, но есть нюанс: если структура не та или тип поля другой — будет исключение. На этапе “сделать, чтобы работало” это нормально. На этапе “сделать, чтобы не падало на мусоре” — нужна валидация (следующая лекция дня).
Где ручной подход начинает болеть
Боль начинается не из-за JSON как такового, а из-за человеческого фактора.
Вы добавили поле priority в Task. Потом забыли обновить serialize_task. Или обновили сериализацию, но забыли обновить парсинг. Или обновили всё, но в одном месте написали ключ "prio", потому что “я так быстрее”. И всё: данные начали сохраняться “как-то странно”.
Ручной подход становится хрупким, когда:
- структура используется во многих местах,
- формат живёт долго,
- есть несколько сущностей,
- есть вложенные модели (Project содержит vector<Task>),
- есть опциональные поля, и нужно единое правило.
4. to_json/from_json: правила рядом с типом
Подход to_json/from_json у nlohmann::json — это способ сказать: “Для типа Task вот правила, как его превращать в JSON и обратно”. Тогда внешний код становится чище: вы можете писать json j = task; и Task t = j.get<Task>();.
Это не “автоматическая сериализация из воздуха”. Вы всё равно пишете правила руками, просто делаете это в стандартизированном месте и получаете единый интерфейс.
Ключевая идея: не размазывать ключи по проекту, а держать их рядом с моделью (или в модуле сериализации модели).
Пример to_json/from_json для Task
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
}
Обратите внимание: логика почти та же, что и в ручном подходе. Разница в том, что теперь библиотека умеет вызывать эти функции автоматически.
Как это выглядит “снаружи”
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
Task t{1, "Write JSON lecture", false};
nlohmann::json j = t;
std::cout << j.dump() << '\n'; // {"done":false,"id":1,"title":"Write JSON lecture"}
Task back = j.get<Task>();
std::cout << back.id << ' ' << back.title << '\n'; // 1 Write JSON lecture
}
Это и есть главный плюс: внешний код перестаёт знать про ключи "id", "title", "done". Он работает в терминах Task.
5. Критерии выбора
В реальном проекте вы не выбираете “раз и навсегда”. Иногда вы делаете to_json/from_json для основных моделей, а ручную сериализацию — для каких-то технических “пакетов” или временных форматов.
Чтобы не гадать, полезно держать критерии в виде таблицы. Это не закон, а шпаргалка для мозга.
| Критерий | Ручная сериализация | to_json/from_json |
|---|---|---|
| Размер проекта | Хорошо для маленьких, “одноразовых” мест | Хорошо для средних и больших |
| Кол-во моделей | 1–2 модели ещё терпимо | Чем больше моделей — тем выгоднее |
| Риск “магических строк” | Высокий, ключи легко расползаются | Низкий, ключи локализованы |
| Внешний код | Часто вынужден знать ключи | Работает с типами (Task, Project) |
| Тестирование | Придётся тестировать функции вручную | Тоже тестируете, но интерфейс единый (get<T>) |
| Гибкость | Очень гибко | Тоже гибко, но иногда хочется отдельные функции |
| Обработка ошибок | Полностью на вас | Тоже на вас, но точки входа стандартизированы |
Теперь переведём таблицу на человеческий язык.
Ручной подход чаще выигрывает, когда вы пишете маленький скрипт/утилиту, у вас один JSON “на входе” и один “на выходе”, и вы уверены, что формат не будет жить годами. to_json/from_json выигрывает, когда формат становится частью приложения, когда данные сохраняются в файл и должны читаться завтра, послезавтра и после вашей летней сессии.
6. Полезные нюансы маппинга
Локализация ключей: одна правда на весь проект
Даже если вы используете to_json/from_json, остаётся проблема: ключи всё равно строки. Хорошая практика — сделать так, чтобы ключи жили в одном месте и использовались одинаково.
Это особенно важно, когда у вас несколько разработчиков или когда вы сами через неделю забудете, как именно называли поле (а вы забудете, не сомневайтесь — это не баг, это “особенность оперативной памяти”).
Приём: namespace keys
#include <string_view>
namespace task_keys {
inline constexpr std::string_view id = "id";
inline constexpr std::string_view title = "title";
inline constexpr std::string_view done = "done";
}
Теперь в from_json и to_json вы используете task_keys::id, а не "id".
Использование ключей в from_json
#include <nlohmann/json.hpp>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at(task_keys::id).get<int>();
t.title = j.at(task_keys::title).get<std::string>();
t.done = j.at(task_keys::done).get<bool>();
}
Да, это на пару символов длиннее. Зато при переименовании ключа вы исправляете его в одном месте, а не в десяти.
optional и контракт: “поля нет” vs “поле = null”
С std::optional вы рано или поздно столкнётесь, потому что реальные данные не всегда полные. И тут важно не просто “как написать код”, а какой контракт вы выбираете.
В рамках маппинга у вас есть две разумные политики.
Первая политика: “если optional пуст — поля в JSON вообще нет”. Это обычно самый удобный и компактный вариант.
Вторая политика: “поле есть всегда, но иногда оно равно null”. Это иногда нужно, если вы хотите отличать “поля нет” от “явно сбросили поле”.
Сейчас (до лекции про валидацию и дефолты) мы зафиксируем только первую политику, потому что она проще для начала и хорошо работает для большинства прикладных форматов.
Пример: optional<std::string> note
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
std::optional<std::string> note;
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
if (t.note) {
j["note"] = *t.note;
}
}
Чтение обратно
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
if (j.contains("note")) {
t.note = j.at("note").get<std::string>();
} else {
t.note.reset();
}
}
Смысловой момент: contains("note") проверяет отсутствие поля. is_null() — другое состояние, и его мы отдельно будем обсуждать в теме “валидация и дефолты”.
Вложенные структуры и контейнеры: эффект домино
Когда вы настроили маппинг для одного типа, стандартная библиотека и nlohmann::json позволяют “масштабировать” это на контейнеры. Это очень приятный эффект: вы описали правила для Task, и внезапно vector<Task> тоже начинает сериализоваться почти сам.
Это похоже на домино, но без драматичного падения продакшена (хотя я бы не зарекался).
Модель “проект” с задачами
#include <string>
#include <vector>
struct Project {
std::string name;
std::vector<Task> tasks;
};
to_json/from_json для Project
#include <nlohmann/json.hpp>
void to_json(nlohmann::json& j, const Project& p) {
j = nlohmann::json{{"name", p.name}, {"tasks", p.tasks}};
}
void from_json(const nlohmann::json& j, Project& p) {
p.name = j.at("name").get<std::string>();
p.tasks = j.at("tasks").get<std::vector<Task>>();
}
Обратите внимание на строку {"tasks", p.tasks} и на get<std::vector<Task>>(). Это работает, потому что для Task уже есть to_json/from_json.
7. Практический сценарий: сохранение и загрузка TaskBox
Соберём кусочки в цельную картинку. Мы не делаем “идеальный продакшн”, мы делаем учебную, читаемую версию: сохранить проект в JSON-файл и загрузить обратно. Файлы и потоки у нас уже были, JSON-парсинг тоже был, а сегодня мы делаем так, чтобы код не превратился в кашу из ключей.
Ниже будет несколько коротких функций — именно так в реальности и пишут: маленькими кусками, чтобы мозг не перегревался.
Сохранение Project в файл
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_project(const Project& p, const std::string& path) {
std::ofstream out(path);
if (!out) return false;
nlohmann::json j = p;
out << j.dump(2);
return true;
}
Здесь красота в одной строке: nlohmann::json j = p;. Вся “грязь” ключей осталась внутри to_json.
Загрузка Project из файла
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool load_project(Project& p, const std::string& path) {
std::ifstream in(path);
if (!in) return false;
nlohmann::json j;
in >> j;
p = j.get<Project>();
return true;
}
Эта версия “оптимистичная”: предполагает, что формат корректен. Если формат некорректен, get<Project>() может бросить исключение — и вот это мы будем приводить в порядок в следующей лекции (валидация + дефолты).
Как бы выглядело без маппинга
Чтобы вы прочувствовали критерий выбора на практике, представьте, что load_project вместо j.get<Project>() делает так:
- j.at("name")...
- j.at("tasks")...
- цикл по массиву
- внутри цикла task.at("id")... и так далее
И всё это прямо внутри функции загрузки файла. Это работает, но функции становятся длинными, трудночитаемыми, и самое неприятное — “формат” оказывается размазан по коду. То есть формат — это уже не “контракт приложения”, а “нечаянный побочный эффект того, как вы сегодня написали цикл”.
Где размещать to_json/from_json в проекте
Очень хочется дать универсальный рецепт “как правильно по папкам”, но у нас курс идёт по шагам, и архитектура большими мазками будет отдельно. Сейчас нам важно не это, а базовая дисциплина: чтобы правила были в одном месте.
Для учебного проекта можно держать struct Task и его to_json/from_json рядом в одном .cpp (или даже в одном main.cpp, пока проект маленький). Позже, когда вы перейдёте к разбиению на .hpp/.cpp, логика станет такой: модель объявляется в заголовке, а сериализация либо тоже рядом (если это “часть публичного контракта”), либо в отдельном модуле сериализации (если вы хотите отделить доменную модель от формата хранения).
Важно только не допускать “половинчатого состояния”, когда to_json лежит в одном месте, from_json в другом, и никто не уверен, что они симметричны. Это как написать “вход” в лабиринт, но забыть выход.
8. Типичные ошибки
Ошибка №1: смешать сериализацию с бизнес-логикой.
Иногда возникает соблазн: “Раз уж я читаю JSON, давайте тут же проверю, что id > 0, и если нет — исправлю”. В итоге сериализация начинает принимать решения за предметную область: подменять значения, “лечить” данные, тихо вставлять дефолты. Это удобно ровно до момента, пока вы не пытаетесь понять, откуда в программе взялась задача с id = 0. Сериализация должна преобразовывать формат, а не менять смысл данных.
Ошибка №2: расползание ключей по коду («магические строки»).
Даже при наличии to_json/from_json разработчик может случайно начать читать JSON напрямую в других местах: “ну мне тут только "name" посмотреть”. Потом таких мест становится десять, потом вы переименовали ключ, обновили to_json, а половина кода продолжает читать старый ключ. Если уж выбрали маппинг — старайтесь, чтобы внешний код работал с struct, а не с ключами.
Ошибка №3: асимметрия “записали одно — читаем другое”.
Классика: в to_json вы записали "done", а в from_json читаете "is_done". Компилятор не ругается: обе строки валидны. Ошибка проявится только во время выполнения. Поэтому полезно держать ключи централизованно (хотя бы через namespace keys) и по возможности писать сериализацию и десериализацию рядом, чтобы глазами увидеть симметрию.
Ошибка №4: считать from_json валидацией.
from_json — это “переложить значения из JSON в поля”. Он может упасть, если типы не совпали, но он не обязан проверять, что строка не пустая, что id в диапазоне, что массив не слишком большой. Валидация — отдельная тема и отдельный слой ответственности. Если вы попытаетесь впихнуть всю валидацию в from_json, он раздуется и станет тяжёлым для чтения.
Ошибка №5: путаница “нет поля” и “поле = null” при optional.
Если сегодня вы пишете optional как “нет поля”, а завтра начинаете иногда писать null, то через неделю у вас появятся три состояния вместо двух, и никто не будет помнить, что они означают. Выберите контракт (например, “optional → поля нет”) и соблюдайте его в обе стороны. А если нужен null — это тоже допустимо, но тогда договоритесь об этом явно и везде одинаково.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ