1. Контракт: JSON как дерево значений
Когда вы впервые видите #include <nlohmann/json.hpp>, может возникнуть ощущение, что это «какая-то магия». На самом деле это просто библиотека, которая умеет превращать JSON-текст в удобный объект json и обратно. В рамках курса мы считаем, что она уже доступна в проекте, поэтому не обсуждаем установку.
Наша задача — научиться пользоваться базовыми операциями parse() и dump(), и отличать «сломанный JSON» от «JSON нормальный, но не той формы».
Начнём с минимального «скелета», чтобы договориться о стиле:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = json::object();
std::cout << j.dump() << '\n'; // {}
}
Обратите внимание на две вещи.
Во‑первых, json — это тип «любой JSON-узел»: он может быть объектом, массивом, строкой, числом, true/false или null.
Во‑вторых, dump() — это способ получить строку, которую можно вывести или записать в файл.
2. json::parse(): превращаем строку в JSON-дерево
Обычно JSON к нам приходит как текст: из файла, из сети, из настроек, из теста, от пользователя (и это всегда чуть подозрительно). Поэтому первый шаг почти всегда одинаковый: берём std::string, вызываем json::parse(), получаем дерево значений.
Важно понимать: parse() не «угадывает смысл», он строго читает синтаксис. Лишняя запятая или неправильные кавычки — и будет ошибка парсинга.
Самый комфортный способ хранить JSON-текст внутри кода — raw-строка R"( ... )", чтобы не экранировать кавычки.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string text = R"({"id": 7, "name": "Alice", "active": true})";
json j = json::parse(text);
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
}
Да, формат вывода «сплюснутый» — это нормально: dump() по умолчанию делает компактную строку. Красивый вывод мы разберём чуть позже в этой лекции.
Ещё один важный момент: JSON-число — это просто «number». А у нас в C++ есть int, long long, double… Поэтому 42 и "42" — это принципиально разные данные.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json a = json::parse(R"({"age": 42})");
json b = json::parse(R"({"age": "42"})");
std::cout << a.at("age").dump() << '\n'; // 42
std::cout << b.at("age").dump() << '\n'; // "42"
}
Здесь мы пока используем at("age") просто чтобы показать разницу. Про «как правильно читать поля» мы ещё поговорим, но без фанатизма: сегодня фокус именно на parse() и dump().
3. dump(): превращаем JSON в строку и печатаем красиво
После того как мы распарсили JSON в json j, нам часто нужно сделать обратную операцию: сохранить, вывести, залогировать, отправить куда-то ещё. Для этого есть dump().
И у неё есть суперспособность, которая экономит нервы: dump(2) (или dump(4)) печатает JSON с отступами. Это не меняет смысл, только делает текст читабельным для людей.
Сравним компактный и «красивый» вывод:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = {
{"id", 7},
{"name", "Alice"},
{"active", true}
};
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
std::cout << j.dump(2) << '\n'; // красивый JSON с отступами
}
Если вы хотите реально увидеть, как выглядит dump(2), это будет примерно так:
// {
// "active": true,
// "id": 7,
// "name": "Alice"
// }
И вот тут возникает важная практическая мысль: когда вы сохраняете данные в файл «для людей» (конфиги, настройки, учебные данные), dump(2) почти всегда выигрывает у компактного вывода. Для «машинного» хранения может быть достаточно и компактного, но читаемость обычно окупается.
4. Мини-практика: объект и массив руками
Перед тем как уходить в файлы, полезно «пощупать» JSON как структуру данных в памяти. Когда вы создаёте json программно, вы фактически строите дерево: объект с ключами, внутри которого массивы и другие объекты.
Это похоже на std::map<std::string, ...> и std::vector<...>, только тип «значение» один — json.
Соберём документ вида «список задач», но пока без сериализации struct (это будет следующая лекция). Пусть это будет просто JSON, который мы умеем печатать.
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json doc;
doc["version"] = 1;
doc["tasks"] = json::array();
doc["tasks"].push_back({{"id", 1}, {"title", "Buy milk"}, {"done", false}});
std::cout << doc.dump(2) << '\n';
}
Здесь мы впервые использовали json::array() и push_back. Это выглядит почти как работа с std::vector, и это специально: библиотека старается быть дружелюбной.
Обратите внимание на «опасно удобное» место: doc["version"] и doc["tasks"]. Оператор operator[] для объекта при записи действительно удобен. Но при чтении он может быть коварным (иногда он создаёт поле, если его не было).
Поэтому в этой лекции мы придерживаемся простого правила: запись — можно через [], чтение — лучше через contains() + at().
5. Ошибки: парсинг и неправильная структура
Когда у вас что-то ломается при работе с JSON, крайне важно понять, на каком этапе.
Ошибка парсинга означает «текст не является JSON-документом». Ошибка доступа/типа означает «JSON распарсился, но структура не соответствует ожиданиям программы». Эти два случая лечатся по-разному: в первом вы чините входной текст, во втором — контракт формата или валидацию.
Ловим ошибку парсинга: json::parse_error
Сломаем JSON специально: сделаем лишнюю запятую. Это классика жанра — «запятая-мститель».
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string bad = R"({"x": 1, })"; // лишняя запятая
try {
json j = json::parse(bad);
std::cout << j.dump() << '\n';
} catch (const json::parse_error& e) {
std::cerr << "Parse error: " << e.what() << '\n';
}
}
Здесь важно, что мы ловим именно json::parse_error. Это помогает в больших программах разделять «проблема формата текста» и «проблема структуры данных».
Если вы ловите просто std::exception, тоже будет работать, но диагностика получится менее «педагогичная».
Ошибки структуры: ключа нет или тип не совпал
Теперь представим, что JSON распарсился, но в нём нет поля, или оно другого типа. Например, ожидаем число, а там строка. Это уже не parse_error.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json j = json::parse(R"({"age": "42"})"); // age — строка
try {
int age = j.at("age").get<int>(); // ожидаем int
std::cout << age << '\n';
} catch (const json::type_error& e) {
std::cerr << "Type error: " << e.what() << '\n';
}
}
А если ключа нет, то at("...") бросит исключение про отсутствие ключа (часто это out_of_range внутри nlohmann::json). Это тоже важная диагностика: «поля нет» — не то же самое, что «поле есть, но тип не тот».
6. Чтение JSON из файла: ifstream + in >> j
До этого момента мы читали JSON из строки, потому что так проще показать идею. Но реальная жизнь любит хранить данные в файлах: config.json, tasks.json, save.json — называйте как хотите.
Тут сразу появляется два разных класса проблем: файл может не открыться, а JSON внутри может быть битым. И эти случаи нужно различать, иначе вы будете чинить «парсер», когда виноват путь к файлу.
Минимальный шаблон чтения JSON из файла выглядит так:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
std::ifstream in("tasks.json");
if (!in) {
std::cerr << "Cannot open tasks.json\n";
return 1;
}
nlohmann::json j;
in >> j; // парсим прямо из потока
std::cout << j.dump(2) << '\n';
}
Это очень удобная форма: operator>> у nlohmann::json делает парсинг «из потока». Но помните: если внутри файла невалидный JSON, то здесь может вылететь исключение парсинга. Поэтому в более аккуратной версии вы обычно добавите try/catch.
Вот «чуть более взрослая» версия, но всё ещё короткая:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
using nlohmann::json;
std::ifstream in("tasks.json");
if (!in) return 1;
try {
json j;
in >> j;
std::cout << "Loaded OK\n"; // Loaded OK
} catch (const json::parse_error& e) {
std::cerr << "Bad JSON: " << e.what() << '\n';
return 2;
}
}
И вот теперь вы точно знаете: return 1 — не открылся файл, return 2 — открылся, но внутри мусор.
7. Запись JSON в файл: ofstream + dump(2)
Запись — это вторая половина цикла «данные ↔ файл». У нас уже есть std::ofstream, и мы уже умеем печатать JSON как строку через dump().
Поэтому сохранение выглядит удивительно приземлённо: открыли файл, проверили, записали строку.
Сохраним документ в tasks.json с отступами:
#include <nlohmann/json.hpp>
#include <fstream>
int main() {
using nlohmann::json;
json doc = {{"version", 1}, {"app", "TaskTracker"}};
std::ofstream out("tasks.json");
if (!out) return 1;
out << doc.dump(2) << '\n';
}
Технически можно писать и без '\n', но обычно новая строка в конце файла делает жизнь чуть приятнее при просмотре в редакторе и при diff’ах в git.
8. Мини-кусочек приложения: загрузка и сохранение документа
Сейчас мы сделаем важный шаг, который выглядит маленьким, но архитектурно очень полезен: вынесем чтение/запись JSON в отдельные функции. Это помогает держать main() «тонким» и не размазывать try/catch по всему коду.
Мы пока не делаем полноценную сериализацию struct Task (это будет дальше), но уже научимся хранить весь документ как json.
Функция загрузки документа
Сделаем функцию load_json_document, которая либо возвращает документ, либо — в случае проблем — пустой объект (упрощение для учебной версии). Да, строго говоря, лучше возвращать optional или expected, но это мы уже умеем и будем активно применять в следующих шагах. Сегодня оставим дизайн максимально простым.
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path) {
using nlohmann::json;
std::ifstream in(path);
if (!in) return json::object();
try {
json doc;
in >> doc;
return doc;
} catch (const json::parse_error&) {
return json::object();
}
}
Здесь специально нет «умной диагностики» — мы пока учимся механике parse()/dump(). Но уже видно, как удобно: в main() можно просто вызвать load_json_document("tasks.json") и работать дальше.
Функция сохранения документа
Теперь симметричная запись. Сохраняем красиво, чтобы можно было открыть файл глазами и не пожалеть об этом.
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_json_document(const std::string& path, const nlohmann::json& doc) {
std::ofstream out(path);
if (!out) return false;
out << doc.dump(2) << '\n';
return true;
}
И вот теперь у нас появилось приятное ощущение «интерфейса»: загрузить документ и сохранить документ — это две понятные операции, и они не захламляют остальной код.
Мини-использование в main()
Соберём маленький сценарий: загрузили документ, если пустой — создали дефолтный, записали обратно. Это не «идеальная» логика, но как демонстрация цикла file → json → file — отлично.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path);
bool save_json_document(const std::string& path, const nlohmann::json& doc);
int main() {
nlohmann::json doc = load_json_document("tasks.json");
if (doc.empty()) {
doc["version"] = 1;
doc["tasks"] = nlohmann::json::array();
}
std::cout << doc.dump(2) << '\n';
save_json_document("tasks.json", doc);
}
Обратите внимание: мы не обсуждаем здесь «правильность» полей version/tasks. Сегодня мы просто научились технически читать и писать JSON.
9. Типичные ошибки при работе с parse() и dump()
Ошибка №1: пытаться «читать JSON» без обработки ошибок парсинга.
Новички часто пишут json j = json::parse(text); и уверены, что так будет всегда. Но JSON может быть битым: лишняя запятая, неправильные кавычки, обрезанный файл. Если не ловить json::parse_error, программа превращается в «иногда работает». Минимальная страховка — try/catch вокруг парсинга.
Ошибка №2: не различать «файл не открылся» и «JSON внутри файла сломан».
Если вы делаете std::ifstream in(path); in >> j; без if (!in), то при проблеме с файлом вы начинаете подозревать всё подряд: права доступа, парсер, космические лучи. Правильный порядок простой: сначала проверяем поток, потом парсим, и отдельно ловим parse_error.
Ошибка №3: использовать operator[] для чтения и случайно «создавать» поля.
j["x"] выглядит удобно, но при определённых сценариях может мутировать объект и добавлять ключ, которого не было. Это особенно неприятно при логике «прочитал → сохранил», потому что вы вдруг начинаете записывать в файл поля-призраки. Для чтения безопаснее думать в стиле contains() + at().
Ошибка №4: считать, что dump() — это валидация.
Если вы распарсили JSON и потом сделали dump(), это не значит, что данные «корректны по смыслу». Это значит только, что у вас в памяти есть какое-то JSON-дерево. Поле могло быть строкой вместо числа, ключ мог отсутствовать, массив мог быть пустым. Семантическая корректность — это отдельная тема, и она появится дальше по плану дня.
Ошибка №5: сохранять JSON «как попало» и потом мучиться при чтении файла глазами.
Технически dump() без отступов корректен, но людям больно. Когда вы открываете tasks.json и видите одну строку на 5 тысяч символов, хочется закрыть файл и уйти в монастырь. dump(2) стоит почти ничего по сложности, но резко улучшает читаемость и поддержку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ