JavaRush /Курси /C++ SELF /Валідація та значення за замовчуванням

Валідація та значення за замовчуванням

C++ SELF
Рівень 65 , Лекція 3
Відкрита

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++ Що робимо, якщо поля немає
id
обовʼязкове number (integer)
int
помилка
title
обовʼязкове string
std::string
помилка
done
опціональне зі значенням за замовчуванням boolean
bool
false
priority
опціональне зі значенням за замовчуванням number (integer)
int
0
note
опціональне без значення за замовчуванням string (або null, залежно від рішення)
std::optional<std::string>
std::nullopt

Зверніть увагу на важливий нюанс: «опціональне» та «зі значенням за замовчуванням» — це різні ідеї. Опціональне поле без значення за замовчуванням означає, що його відсутність — теж нормальний стан: просто в нас немає значення. А опціональне поле зі значенням за замовчуванням означає, що відсутність поля інтерпретується як конкретне значення, яке ми заздалегідь визначили в контракті.

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 окремо

Коли ви лише починаєте, дуже хочеться зробити все в одній функції: і перевірити, і прочитати, і одразу додати у вектор, і ще щось надрукувати. На маленькому прикладі це здається зручним. На реальному коді виходить «комбайн», який складно тестувати й страшно чіпати. Тому хороший навчальний шаблон виглядає так:

  1. функція валідації каже: «це схоже на наш формат чи ні» і повертає зрозумілу помилку;
  2. функція читання (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 замість відсутності, і ваш код раптом перестане працювати. Рішення просте: заздалегідь вибрати правило для кожного поля й однаково реалізувати його під час читання та запису.

1
Задача
C++ SELF, 65 рівень, 3 лекція
Недоступна
Контракт задачі
Контракт задачі
1
Задача
C++ SELF, 65 рівень, 3 лекція
Недоступна
Нормалізація задачі
Нормалізація задачі
1
Задача
C++ SELF, 65 рівень, 3 лекція
Недоступна
Список завдань
Список завдань
1
Задача
C++ SELF, 65 рівень, 3 лекція
Недоступна
Конфіг сервісу
Конфіг сервісу
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ