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 сторінок, хоча інколи буває й так. Достатньо хоча б чесно відповісти собі на запитання: які поля мають бути, які можуть бути відсутні, а які можуть бути відсутні, і тоді ми підставляємо значення за замовчуванням. Якщо ви не дасте собі цієї відповіді, програма дасть її за вас — зазвичай падінням у найнедоречніший момент.
Розгляньмо приклад міні-застосунку, який зберігає список завдань у 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 << "Помилка: " << e.what() << '\n'; // Помилка: ... 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 має бути обʼєктом";
if (!j.contains("id") || !j.at("id").is_number_integer())
return "Task.id — обовʼязкове поле й має бути цілим числом";
if (!j.contains("title") || !j.at("title").is_string())
return "Task.title — обовʼязкове поле й має бути рядком";
if (j.contains("done") && !j.at("done").is_boolean())
return "Task.done, якщо поле є, має бути булевим значенням";
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, якщо поле є, має бути цілим числом";
int p = j.at("priority").get<int>();
if (p < 0 || p > 5)
return "Task.priority має бути в діапазоні [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": "Купити молоко", "done": false },
{ "id": 2, "title": "Вивчати 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 "Корінь документа має бути обʼєктом";
if (!root.contains("tasks") || !root.at("tasks").is_array())
return "Root.tasks — обовʼязкове поле й має бути масивом";
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 "Не вдалося відкрити файл: " + 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 << "Помилка завантаження: " << *err << '\n';
return 1;
}
std::cout << "Завантажено завдань: " << tasks.size() << '\n'; // Завантажено завдань: 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 замість відсутності, і ваш код раптом перестане працювати. Рішення просте: заздалегідь вибрати правило для кожного поля й однаково реалізувати його під час читання та запису.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ