JavaRush /Курсы /C++ SELF /Надёжная загрузка: стратегия fail fast vs best effort

Надёжная загрузка: стратегия fail fast vs best effort

C++ SELF
63 уровень , 4 лекция
Открыта

1. Три слоя надёжной загрузки

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

Логику удобно держать в голове как «цепочку ответственности» из трёх слоёв:

  • I/O: физически читаем байты/строки из файла и проверяем, что поток не умер.
  • Парсинг: превращаем строку в поля (например, id, done, title).
  • Валидация: проверяем, что поля имеют смысл для нашей модели и не ломают инварианты.

Удобно держать в голове такую схему (она же будет нашим «контрактом загрузки»):

flowchart TD
    A[Открыть файл] --> B{Поток открылся?}
    B -- нет --> X[Ошибка открытия]
    B -- да --> C[Читать построчно getline]
    C --> D{Строка прочитана?}
    D -- нет, EOF --> Z[Успех: конец файла]
    D -- нет, ошибка --> Y[Ошибка чтения]
    D -- да --> E[Парсинг строки в поля]
    E --> F{Парсинг успешен?}
    F -- нет --> G[Ошибка формата записи]
    F -- да --> H[Валидация инвариантов]
    H --> I{Валидно?}
    I -- нет --> J[Ошибка смысла данных]
    I -- да --> K[Добавить запись в vector]
    K --> C

2. Мини‑модель для примеров: список задач

Чтобы не объяснять «в вакууме», возьмём простую модель, похожую на то, что вы уже много раз делали в консольных задачах: список задач (todo). Она достаточно простая, чтобы не отвлекать, и достаточно «живая», чтобы показать и парсинг, и валидацию, и разные политики ошибок.

Пусть в памяти у нас такая модель:

#include <string>

struct Task {
    int id{};
    bool done{};
    std::string title;
};

Теперь договоримся о формате файла (текстовый, «одна строка = одна запись»):

id;done;title

Где done — это 0 или 1. Например:

1;0;Buy milk
2;1;Learn C++ streams
3;0;Stop writing while(!eof())

Почему я выбрал ;? Потому что пробелы в title нам важны, а «чтение токенами через >>» как раз пробелы режет. Поэтому логика будет «читаем строку целиком → разбираем внутри».

3. Парсинг и валидация одной строки

Аккуратный разбор строки

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

Сделаем маленькую функцию «распилить строку на три части». Для текстового формата с разделителем удобно использовать std::getline внутри std::stringstream:

#include <sstream>
#include <string>

bool split_task_line(const std::string& line,
                     std::string& id_s,
                     std::string& done_s,
                     std::string& title) {
    std::stringstream ss(line);
    return std::getline(ss, id_s, ';')
        && std::getline(ss, done_s, ';')
        && std::getline(ss, title);
}

Здесь важный момент: третье getline(ss, title) читает «до конца», то есть title может содержать пробелы — и это нормально. Если строка не содержит двух ;, функция вернёт false, и мы честно скажем: «формат битый».

Разбор чисел и флагов через from_chars

Теперь шаг, который делает вашу загрузку ощутимо более взрослой: аккуратный разбор чисел без исключений. Мы в курсе уже обсуждали, что stoi может привести к ошибке выполнения на плохом вводе. В загрузке файла «плохой ввод» — не исключение, а обычная ситуация: файл мог быть руками отредактирован, сломан другой версией программы или просто быть пустым.

Используем std::from_chars: он не кидает исключения, а возвращает код ошибки. Это идеально для «надёжной загрузки».

#include <charconv>
#include <optional>
#include <string_view>

std::optional<int> parse_int(std::string_view s) {
    int value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
    return value;
}

Заметьте маленькую проверку ptr != end: она отсекает строки вроде "12abc". Иначе вы бы «успешно» прочитали 12, а хвост молча проигнорировали — а это один из самых неприятных классов багов: «программа работает, но неправильно».

Для done сделаем аналогично, но с узким набором значений:

#include <optional>
#include <string_view>

std::optional<bool> parse_done(std::string_view s) {
    if (s == "0") return false;
    if (s == "1") return true;
    return std::nullopt;
}

Валидация инвариантов

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

Смысловые правила (инварианты) для нашей Task можно сформулировать так: id должен быть больше нуля, заголовок не должен быть пустым, и заголовок не должен быть слишком длинным.

Сделаем функцию валидации, которая возвращает текст ошибки (или пустую строку):

#include <string>

std::string validate_task(const Task& t) {
    if (t.id <= 0) return "id must be > 0";
    if (t.title.empty()) return "title must not be empty";
    if (t.title.size() > 200) return "title is too long";
    return "";
}

Почему строка, а не bool? Потому что bool без сообщения почти бесполезен в диагностике. А сообщение, особенно с номером строки, экономит вам часы жизни.

Функция parse_task_line: парсинг + валидация

Теперь мы можем собрать всё в функцию, которая получает строку и либо создаёт Task, либо возвращает проблему. В рамках «базового» стиля покажем вариант с std::optional<Task> и отдельной строкой ошибки — он понятный и компилируется почти везде.

#include <optional>
#include <string>

std::optional<Task> parse_task_line(const std::string& line, std::string& error) {
    std::string id_s, done_s, title;
    if (!split_task_line(line, id_s, done_s, title)) {
        error = "bad format, expected: id;done;title";
        return std::nullopt;
    }

    auto id = parse_int(id_s);
    auto done = parse_done(done_s);
    if (!id || !done) {
        error = "bad number in id/done";
        return std::nullopt;
    }

    Task t{.id = *id, .done = *done, .title = title};
    error = validate_task(t);
    if (!error.empty()) return std::nullopt;

    return t;
}

Да, это чуть больше 10 строк, но обратите внимание на идею: мы явно разделили «формат», «разбор чисел» и «инварианты». Если что‑то ломается, вы понимаете что именно, а не просто видите «что‑то пошло не так, удачи».

4. Стратегии обработки ошибок

Fail fast и best effort: что это и когда применять

В этом месте обычно хочется спросить: «Ну и какой вариант правильный?» Fail fast и best effort — это не «сильный и слабый» подход, а две политики, которые выбираются под задачу. Проблема новичков в том, что политика выбирается случайно: как «само получилось». Мы сделаем наоборот — выберем осознанно.

Сравним их в таблице, чтобы мозг видел разницу не как абстракцию:

Политика Что делаем при первой плохой записи Что возвращаем Когда удобно
Fail fast Останавливаем загрузку и считаем файл некорректным «успех со всеми данными» или «ошибка» Конфиги, критичные данные, миграции формата, когда частичная загрузка опасна
Best effort Пропускаем плохую запись и идём дальше Список валидных записей + отчёт о проблемах Логи, импорт пользовательских данных, «грязные» файлы, когда лучше показать хоть что‑то

Важная деталь: даже best effort обычно не должен игнорировать I/O‑ошибку чтения файла. Если поток сломался на середине из‑за проблем с диском, «пропуск строки» не спасёт. Best effort — про плохие записи, а не про поломанный ввод как таковой.

Fail fast: реализация загрузчика

Fail fast проще тестировать и проще объяснять пользователю: «файл битый, загрузка отменена». Это удобно, когда файл — часть вашего контракта: он должен быть корректен целиком.

Сделаем загрузчик, который читает построчно, парсит, и если встречает проблему — прекращает работу с понятным сообщением и номером строки:

#include <fstream>
#include <optional>
#include <string>
#include <vector>

std::optional<std::vector<Task>> load_tasks_fail_fast(const std::string& filename,
                                                      std::string& error) {
    std::ifstream in(filename);
    if (!in) { error = "cannot open file"; return std::nullopt; }

    std::vector<Task> tasks;
    std::string line;
    int line_no = 0;

    while (std::getline(in, line)) {
        ++line_no;
        if (line.empty()) continue;

        std::string local_error;
        auto t = parse_task_line(line, local_error);
        if (!t) { error = "line " + std::to_string(line_no) + ": " + local_error; return std::nullopt; }

        tasks.push_back(*t);
    }

    if (!in.eof()) { error = "read error"; return std::nullopt; }
    return tasks;
}

Обратите внимание на if (!in.eof()) после цикла. Мы уже обсуждали, что getline может остановиться не только на EOF. Эта проверка — ваш «датчик честности»: он не даёт случайно принять ошибку чтения за «нормальный конец файла».

Если ваш компилятор поддерживает C++23 std::expected, то в таком месте очень красиво ложится сигнатура «или значение, или ошибка». В стандарте C++ действительно фигурирует заголовок <expected>, но его доступность зависит от конкретной стандартной библиотеки и версии компилятора.

Best effort: реализация загрузчика и отчёт

Best effort обычно выбирают там, где данные «грязные по природе»: пользователь экспортировал CSV из Excel, руками поправил, потерял один разделитель, и вот уже половина строк битая. Политика best effort позволяет сказать: «я загрузил 93 задачи, 7 строк пропустил — вот почему».

Сделаем структуру отчёта:

#include <string>
#include <vector>

struct LoadReport {
    std::vector<Task> tasks;
    std::vector<std::string> problems;
};

И сам загрузчик:

#include <fstream>
#include <string>

LoadReport load_tasks_best_effort(const std::string& filename) {
    std::ifstream in(filename);

    LoadReport r;
    if (!in) { r.problems.push_back("cannot open file"); return r; }

    std::string line;
    int line_no = 0;

    while (std::getline(in, line)) {
        ++line_no;
        if (line.empty()) continue;

        std::string err;
        auto t = parse_task_line(line, err);
        if (!t) {
            r.problems.push_back("line " + std::to_string(line_no) + ": " + err);
            continue;
        }
        r.tasks.push_back(*t);
    }

    if (!in.eof()) r.problems.push_back("read error (not EOF)");
    return r;
}

Тут специально видно, что «ошибка открытия» превращается в проблему внутри отчёта, а не в исключение или аварийное завершение. Это нормально для best effort: отчёт сам по себе является «мягким результатом».

Ещё один практический момент: список problems может стать огромным. В реальном приложении часто добавляют лимит: например, хранить только первые 50 проблем, а дальше писать «и ещё N проблем…».

Интеграция в main

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

Вот пример для fail fast:

#include <iostream>

int main() {
    std::string error;
    auto tasks = load_tasks_fail_fast("tasks.txt", error);

    if (!tasks) {
        std::cerr << "Load failed: " << error << '\n';
        return 1;
    }

    std::cout << "Loaded tasks: " << tasks->size() << '\n'; // Loaded tasks: 3
}

А вот пример для best effort:

#include <iostream>

int main() {
    auto report = load_tasks_best_effort("tasks.txt");

    std::cout << "Loaded tasks: " << report.tasks.size() << '\n';
    std::cout << "Problems: " << report.problems.size() << '\n';

    if (!report.problems.empty()) {
        std::cerr << report.problems.front() << '\n'; // line 4: bad format...
    }
}

Заметьте: best effort не «делает вид, что всё идеально». Он честно сообщает, что были проблемы, иначе это превращается в «тихий баг», который потом находят пользователи.

5. Типичные ошибки

Ошибка №1: смешивать чтение файла, парсинг и валидацию в одном цикле без структуры.
Когда всё делается «внутри while(getline)», код быстро превращается в кашу: часть проверок на формат, часть на смысл, часть на состояние потока. Исправление обычно простое: выделить parse_* и validate_* в отдельные функции, чтобы в загрузчике читался «сценарий», а не «подробности кухни».

Ошибка №2: считать любую остановку getline нормальным завершением по EOF.
while (getline(...)) действительно останавливается и на EOF, и на ошибке. Если после цикла не проверить in.eof(), можно «принять» реальную проблему чтения за штатный конец файла и загрузить половину данных без единого предупреждения.

Ошибка №3: использовать stoi без защиты и удивляться аварийному завершению на битом файле.
stoi удобен в демонстрации, но в надёжной загрузке он часто превращается в мину: файл «чуть-чуть» испортился, а программа просто падает. В таких местах лучше использовать без‑исключительный парсинг (например, from_chars) или как минимум превращать ошибку в диагностируемый результат.

Ошибка №4: в best effort «проглатывать» ошибки молча.
Best effort — не про молчание, а про частичный успех. Если вы пропускаете плохие строки и не сохраняете информацию «что именно было пропущено», пользователь увидит потерянные данные и решит, что программа ненадёжна. Даже одна строка вида "line 17: bad number in id" резко повышает доверие, потому что это честно и проверяемо.

Ошибка №5: не фиксировать политику загрузки как часть контракта функции.
Очень неприятный сценарий: часть проекта думает, что загрузка fail fast (и после неё данные гарантированно корректны), а часть думает, что best effort (и допускает «дырки»). В итоге либо падаете там, где не ожидали, либо работаете с «полуданных». Лечится просто: выбираете политику и отражаете её в типе результата (optional / ошибка или отчёт), а не в комментариях «ну тут вроде должно быть ок».

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