1. Зачем вообще валидировать JSON
Когда JSON успешно распарсился, мозг новичка радостно думает: «Ну всё, данные корректные!» Это примерно как радоваться, что вам в руки попал конверт — ещё не значит, что внутри именно те документы, которые вы ожидали, и что они не написаны карандашом на салфетке. Парсинг гарантирует только одно: текст был синтаксически JSON. Но он не гарантирует, что внутри есть нужные поля, что они нужного типа, что числа в допустимом диапазоне, и что строка "42" внезапно не приехала вместо числа 42.
Поэтому мы разделяем две большие группы проблем. Первая — «это вообще не JSON» (ошибка парсинга). Вторая — «это JSON, но не наш формат» (ошибка структуры/типов/контракта). Сегодня мы занимаемся второй группой: учимся проверять, что JSON соответствует ожиданиям нашей программы, и аккуратно применять дефолты там, где это действительно часть контракта.
Для ориентира полезно держать в голове простой конвейер:
flowchart LR
A[Текст/файл] --> B[parse: получаем json-дерево]
B --> C[валидация структуры и типов]
C --> D["чтение в struct (from_json / ручное)"]
D --> E[работа программы]
2. Контракт полей: обязательные, опциональные и с дефолтом
Переход от «прочитал JSON и верю ему» к «у меня есть контракт формата» — это момент взросления программы. Контракт — это не обязательно отдельный документ на 30 страниц (хотя иногда и так бывает), а хотя бы честный ответ на вопросы: какие поля обязаны быть, какие могут отсутствовать, а какие могут отсутствовать, и тогда мы подставляем значение по умолчанию. Если вы этот ответ не дали, то программа даст его сама — обычно через падение в самый неподходящий момент.
Давайте договоримся на примере мини-приложения, которое хранит список задач (Task List) в JSON. Модель у нас будет простой:
— обязательное целое число.id
— обязательная строка.title
— опциональное поле, но если его нет, считаемdone
.false
— опциональное поле с дефолтомpriority
.0
— опциональная строка без дефолта (может отсутствовать).note
Таблично это выглядит так:
| Поле | Категория | Тип в JSON | Тип в C++ | Что делаем, если нет поля |
|---|---|---|---|---|
|
обязательное | number (integer) | |
ошибка |
|
обязательное | string | |
ошибка |
|
опциональное с дефолтом | boolean | |
|
|
опциональное с дефолтом | number (integer) | |
|
|
опциональное без дефолта | string (или null, по решению) | |
|
Обратите внимание на тонкий момент: «опциональное» и «с дефолтом» — это разные идеи. Опциональное без дефолта означает, что отсутствие поля — это тоже нормальное состояние, просто у нас нет значения. А опциональное с дефолтом означает, что отсутствие поля интерпретируется как конкретное значение, которое мы выбрали в контракте.
3. Инструменты nlohmann::json для проверки
Если вы раньше писали код вида j.at("id").get<int>(), то вы уже знакомы с «строгим стилем»: нет поля — будет ошибка, не тот тип — будет ошибка. Это нормально, но как только у нас появляется хотя бы одно опциональное поле, хочется не падать, а вести себя предсказуемо. В этом месте важно не начать использовать operator[] как универсальную отмычку — он удобный, но коварный: при работе с не-const JSON он может создавать поле, которого не было, и вы потом часами ищете «кто дописал в конфиг странный мусор».
Давайте закрепим роли основных инструментов:
contains("key") отвечает на вопрос «поле вообще есть?».
at("key") даёт доступ к полю, но строго: если поля нет — будет исключение. Это хорошо для обязательных полей после валидации или когда вы действительно хотите «пусть упадёт, если нет».
is_string(), is_boolean(), is_number_integer(), is_array(), is_object(), is_null() позволяют проверить тип до get<T>(). Это важно, потому что get<T>() при несовпадении типов обычно бросает исключение — и ваша программа превращается в лотерею «где упадём».
value("key", default) позволяет удобно читать опциональные поля с дефолтом… но есть нюанс: дефолт срабатывает, когда поля нет. Если поле есть, но тип неправильный, библиотека чаще всего всё равно бросит исключение. То есть value() не заменяет валидацию, а только делает код короче, когда вы уже договорились о контракте.
Мини-пример, который показывает, что value() — не «пылесос, который проглотит любой мусор»:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
nlohmann::json j = {{"done", "yes"}}; // строка вместо bool
try {
bool done = j.value("done", false);
std::cout << done << '\n';
} catch (const std::exception& e) {
std::cout << "Oops: " << e.what() << '\n'; // Oops: ... type must be boolean ...
}
}
Смысл прост: дефолты — это часть контракта, а не способ «замести проблемы под ковёр».
4. Отсутствует поле vs null: как зафиксировать смысл
Когда вы начинаете валидировать JSON, вы довольно быстро встречаете философский вопрос: а чем отличается «поля нет» от «поле есть и оно null»? И почему программисты спорят об этом с таким азартом, будто обсуждают лучший редактор (подсказка: лучший — тот, в котором вы успеваете сдать задачу).
С точки зрения JSON это разные состояния, и очень часто они должны по-разному интерпретироваться. Например, note: null может означать «пользователь намеренно очистил заметку», а отсутствие note может означать «в старой версии формата такого поля ещё не было». Или наоборот: вы решаете, что null вообще запрещён, и тогда note: null — это ошибка.
Сегодня наша задача не «угадать правильный смысл», а зафиксировать его в контракте и написать код, который следует этому контракту. Для опциональных строк часто выбирают один из двух подходов:
- разрешить только отсутствие поля (если note нет, значит nullopt), а null считать ошибкой;
- разрешить и отсутствие, и null, трактуя оба случая как nullopt.
Покажем второй вариант (более терпимый к данным), потому что он часто удобен при загрузке конфигов и старых файлов:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> read_optional_string(const nlohmann::json& j, const char* key) {
if (!j.contains(key) || j.at(key).is_null()) {
return std::nullopt;
}
if (!j.at(key).is_string()) {
return std::nullopt; // или лучше считать ошибкой валидации
}
return j.at(key).get<std::string>();
}
Обратите внимание: это пример «мягкого» чтения. В реальной валидации мы чаще хотим не молча возвращать nullopt, а сообщать «поле note должно быть строкой или null».
5. Шаблон: валидируем отдельно, читаем в struct отдельно
Когда вы только начинаете, очень хочется сделать всё в одной функции: и проверить, и прочитать, и сразу добавить в вектор, и ещё распечатать. На маленьком примере это кажется удобным. На реальном коде получается «комбайн», который сложно тестировать и страшно трогать. Поэтому хороший учебный шаблон выглядит так:
- функция валидации говорит: «это похоже на наш формат или нет» и выдаёт понятную ошибку;
- функция чтения (from_json или ручной парсер) предполагает, что данные уже валидны, и занимается только преобразованием в C++ типы.
Давайте начнём с модели Task:
#include <optional>
#include <string>
struct Task {
int id{};
std::string title;
bool done{false}; // дефолт по контракту
int priority{0}; // дефолт по контракту
std::optional<std::string> note; // опционально
};
Валидация одной задачи: обязательные поля и типы
Сделаем функцию, которая возвращает std::optional<std::string>: nullopt, если всё хорошо, и строку с ошибкой, если плохо. Это простой, понятный контракт (без будущих наворотов вроде expected).
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_task_json(const nlohmann::json& j) {
if (!j.is_object()) return "Task must be an object";
if (!j.contains("id") || !j.at("id").is_number_integer())
return "Task.id is required and must be integer";
if (!j.contains("title") || !j.at("title").is_string())
return "Task.title is required and must be string";
if (j.contains("done") && !j.at("done").is_boolean())
return "Task.done must be boolean if present";
return std::nullopt;
}
Здесь видно ключевую идею: обязательные поля проверяем как «contains + нужный тип», опциональные — как «если есть, то тип должен быть такой-то».
Дефолты не отменяют ограничения на значения
С полем priority у нас есть дефолт, но это не значит, что мы обязаны принимать любые значения. Даже с дефолтом можно иметь ограничения: например, priority должен быть от 0 до 5. Это уже не «типовая» валидация, а валидация инварианта.
Расширим проверку:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_priority(const nlohmann::json& j) {
if (!j.contains("priority")) return std::nullopt;
if (!j.at("priority").is_number_integer())
return "Task.priority must be integer if present";
int p = j.at("priority").get<int>();
if (p < 0 || p > 5)
return "Task.priority must be in range [0..5]";
return std::nullopt;
}
Смысл: дефолт покрывает отсутствие поля, но не оправдывает «priority = -9000». Хотя мем смешной, но данные — нет.
Чтение в Task после валидации
Теперь сделаем функцию, которая строит Task из JSON. Она предполагает, что валидация уже прошла, поэтому код короче.
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
Task parse_task(const nlohmann::json& j) {
Task t;
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.value("done", false); // дефолт по контракту
t.priority = j.value("priority", 0); // дефолт по контракту
if (j.contains("note") && !j.at("note").is_null()) {
t.note = j.at("note").get<std::string>();
}
return t;
}
value() здесь используется по назначению — для дефолтов. Мы не надеемся, что он спасёт нас от неправильных типов, потому что это работа валидации.
6. Документ целиком: корень, массив задач и загрузка
Проверка одной задачи — это хорошо, но файл обычно содержит не одну задачу, а целый документ. Корневой JSON может быть объектом вида { "tasks": [ ... ] }. И тут появляется ещё одна важная вещь: диагностика должна говорить не просто «плохо», а «плохо вот здесь». Иначе пользователь (или вы же через неделю) будет смотреть на сообщение «Task.title is required» и думать: «У какой из 200 задач?!»
Начнём с простого формата файла:
{
"tasks": [
{ "id": 1, "title": "Buy milk", "done": false },
{ "id": 2, "title": "Learn C++", "priority": 5 }
]
}
Валидация корня документа
Сделаем валидацию корня:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_root(const nlohmann::json& root) {
if (!root.is_object()) return "Root must be an object";
if (!root.contains("tasks") || !root.at("tasks").is_array())
return "Root.tasks is required and must be array";
return std::nullopt;
}
Валидация массива с контекстом ошибки
Теперь добавим «индекс элемента», чтобы ошибка была конкретной:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_tasks_array(const nlohmann::json& arr) {
for (std::size_t i = 0; i < arr.size(); ++i) {
auto err = validate_task_json(arr.at(i));
if (err) return "tasks[" + std::to_string(i) + "]: " + *err;
}
return std::nullopt;
}
Это уже заметно удобнее. Да, строка с "tasks[17]" выглядит не как поэзия, но зато экономит вам часы.
Практический пример: загрузка списка задач из файла
Теперь соберём маленький «каркас» функции загрузки. Мы читаем JSON из файла, валидируем корень, валидируем массив, и только потом строим std::vector<Task>. В идеальном мире данные всегда корректны. В реальном мире идеальный мир обычно в отпуске, поэтому мы пишем защитный код.
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
std::optional<std::string> load_tasks(const std::string& path, std::vector<Task>& out) {
std::ifstream in(path);
if (!in) return "Cannot open file: " + path;
nlohmann::json root;
in >> root;
if (auto err = validate_root(root)) return *err;
const auto& arr = root.at("tasks");
if (auto err = validate_tasks_array(arr)) return *err;
out.clear();
for (const auto& item : arr) {
out.push_back(parse_task(item));
}
return std::nullopt;
}
Если вы посмотрите на эту функцию как на историю, она читается очень логично: «открыли → распарсили → проверили структуру → проверили каждую задачу → построили объекты». Именно так и должен выглядеть код, который не стыдно поддерживать.
Мини-main, чтобы увидеть поведение:
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks;
if (auto err = load_tasks("tasks.json", tasks)) {
std::cout << "Load error: " << *err << '\n';
return 1;
}
std::cout << "Loaded tasks: " << tasks.size() << '\n'; // Loaded tasks: 2
}
Когда дефолты помогают, а когда маскируют проблему
Дефолты — штука коварная. Они делают UX приятнее, когда отсутствие поля действительно нормально. Но они же способны замаскировать ошибку данных, если вы ставите дефолт «лишь бы не упало». Пример: если поле id отсутствует, подставить 0 — плохая идея, потому что вы не отличите «задача с id=0» (если вдруг такая существует) от «сломанный ввод». В таких местах лучше честно сказать: «данные некорректны».
Хороший критерий такой: дефолт допустим, если он не ломает смысл и не делает данные двусмысленными. Для done=false это обычно нормально: если поле отсутствовало, скорее всего задача ещё не выполнена. Для priority=0 тоже зачастую нормально: отсутствие приоритета означает «обычный». А вот для title="" дефолт уже спорный: пустой заголовок часто означает, что данные повреждены или пользователь ввёл что-то странное.
Если хочется чуть более формально, можно думать так: обязательные поля — это то, без чего объект нельзя считать корректным; опциональные поля — это то, что расширяет объект, но не определяет его существование; дефолтные поля — это опциональные поля, для которых вы заранее выбрали интерпретацию «нет поля = вот это значение».
7. Типичные ошибки при валидации и работе с дефолтами
В этом разделе легко скатиться в «список грехов», но лучше запомнить это как несколько жизненных историй, которые регулярно происходят с новичками (и иногда с не-новичками, которые слишком уверенно пишут “я и так всё знаю”).
Ошибка №1: считать успешный parse признаком корректных данных.
Успешный парсинг означает только то, что JSON синтаксически правильный. Структура может быть любой: вместо объекта — массив, вместо числа — строка, нужные поля могут отсутствовать. Поэтому после парсинга почти всегда нужен отдельный шаг проверки структуры и типов.
Ошибка №2: использовать value() как «универсальный спасатель» от неправильных типов.
value("x", default) удобен, когда поля нет. Но если поле есть и оно неправильного типа, вы часто получите исключение. Это нормальное поведение: библиотека не умеет угадывать, что строка "yes" означает true. Поэтому value() — это про дефолты, а не про “проглотить любой мусор”.
Ошибка №3: читать через operator[] и случайно менять JSON.
operator[] у nlohmann::json при работе с не-const объектом может создавать отсутствующее поле. Если вы затем делаете dump() или сохраняете JSON обратно, вы неожиданно «исправили» входные данные, хотя хотели их только прочитать. Для чтения лучше сочетать contains() и at().
Ошибка №4: делать дефолты «на всё подряд» и терять диагностику.
Если вы поставили дефолты даже на обязательные поля, программа перестаёт отличать «всё хорошо» от «данные сломаны». В итоге ошибки не исчезают — они просто переезжают в другое место, где вам будет сложнее понять причину. Обязательные поля должны оставаться обязательными, иначе контракт расползается.
Ошибка №5: не различать “поля нет” и “поле = null”, а потом удивляться несовместимости данных.
Отсутствие поля и null — два разных состояния. Если вы не зафиксировали контракт, завтра кто-то (или вы сами) начнёт писать null вместо отсутствия, и ваш код внезапно перестанет работать. Решение — заранее выбрать правило для каждого поля и реализовать его одинаково при чтении и записи.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ