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 страниц (хотя иногда и так бывает), а хотя бы честный ответ на вопросы: какие поля обязаны быть, какие могут отсутствовать, а какие могут отсутствовать, и тогда мы подставляем значение по умолчанию. Если вы этот ответ не дали, то программа даст его сама — обычно через падение в самый неподходящий момент.

Давайте договоримся на примере мини-приложения, которое хранит список задач (Task List) в 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 << "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 отдельно

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

  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 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 вместо отсутствия, и ваш код внезапно перестанет работать. Решение — заранее выбрать правило для каждого поля и реализовать его одинаково при чтении и записи.

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