JavaRush /Курсы /C++ SELF /Ошибки I/O — исключения vs проверка состояния

Ошибки I/O — исключения vs проверка состояния

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

1. Почему I/O «работает», а потом внезапно ломается

Файлы — это такой вид «реальности», которая не обязана быть доброй. Программа может быть идеальной, но пользователь запустил её не из той папки, файл оказался пустым, права доступа запретили чтение, диск закончился, а в середине файла вместо числа внезапно записано слово cat. И вот у вас уже не «чтение данных», а маленький детектив.

Проблема в том, что ввод/вывод в C++ исторически устроен так: по умолчанию поток не обязан устраивать истерику (исключение) при проблеме. Чаще всего он просто выставляет внутренние флаги «мне плохо» и перестаёт читать/писать. Поэтому начинающий разработчик часто видит симптом: «после чтения ничего не происходит», «вектор пустой», «сумма ноль», но ошибок нет. Это классика жанра.

Кстати, сам факт, что в стандартной библиотеке есть большой пласт про ввод/вывод (внутренне называемый input.output), видно даже по редакторским отчётам стандарта: там регулярно чинят формулировки и структуру этого раздела.

Два стиля обработки ошибок в потоках

В C++ у нас есть два основных подхода, и важно не пытаться одновременно “сидеть на двух стульях”, особенно в одном и том же куске кода.

Первый стиль — ручные проверки состояния. Вы открыли файл, проверили if (!in), читаете в цикле while (in >> x) или while (getline(in, line)), а после цикла анализируете: это EOF (нормально) или ошибка (ненормально). Этот стиль чуть более многословный, зато хорошо контролируемый: вы явно отличаете «файл закончился» от «данные сломаны».

Второй стиль — исключения от потоков. Вы говорите потоку: “если тебе станет плохо — кидай исключение”, настраивая маску exceptions(...). Тогда код становится линейнее: меньше if-ов, больше «обычного» кода, а обработка ошибок уезжает в try/catch. Но у этого стиля есть тонкости: например, можно нечаянно превратить нормальный EOF в исключение и получить «ошибку» там, где всё штатно.

Сегодня мы разберём оба подхода, а затем аккуратно встроим их в наш мини-проект.

2. Стиль 1: проверка состояния потока

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

Начинаем всегда одинаково: открыли — проверили. Причём проверка if (!in) удобнее, чем is_open(), потому что учитывает состояние потока целиком, а не только факт «дескриптор открыт». Далее читаем в условии цикла, а не через while (!eof()) (почему — уже обсуждали на прошлой лекции).

Мини-шаблон для чтения строк:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream in("tasks.txt");
    if (!in) {
        std::cerr << "Не смог открыть tasks.txt\n";
        return 1;
    }

    std::string line;
    while (std::getline(in, line)) {
        std::cout << "Прочитано: " << line << '\n';
    }
}

Здесь ключевое: std::getline возвращает поток, который в условии превращается в bool. Если чтение удалось — true, если нет — false.

4. good/eof/fail/bad: как понять, почему чтение остановилось

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

Смысловой словарь примерно такой:

Проверка Что означает по-человечески Типичный пример
stream.good()
«всё хорошо» (ошибок нет) вы только что успешно прочитали/записали
stream.eof()
«дошли до конца» файл закончился, это часто норма
stream.fail()
«операция не удалась» (часто формат/парсинг) пытались прочитать int, а там cat
stream.bad()
«серьёзная I/O проблема» условно: чтение с диска реально сломалось

Самая практичная штука: после цикла чтения можно понять, почему он закончился. Например, при построчном чтении обычно ожидаем eof() == true. Если EOF не наступил, но цикл завершился — значит что-то пошло не так.

Пример: читаем строки, а потом проверяем, что остановка была именно по EOF:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream in("tasks.txt");
    if (!in) return 1;

    std::string line;
    while (std::getline(in, line)) {
        // обработка
    }

    if (!in.eof()) {
        std::cerr << "getline остановился НЕ из-за EOF\n";
        return 2;
    }
}

Обратите внимание на важную психологическую деталь: eof() — это не «я где-то рядом с концом». Обычно EOF становится истинным после попытки чтения за концом. Поэтому паттерн while (!in.eof()) почти всегда приводит к «лишней итерации» и странным багам.

А теперь — пример с чтением чисел, где нам важно различить: «файл закончился» или «внутри мусор»:

#include <fstream>
#include <iostream>

int main() {
    std::ifstream in("nums.txt");
    if (!in) return 1;

    long long sum = 0;
    int x = 0;

    while (in >> x) {
        sum += x;
    }

    if (in.bad()) {
        std::cerr << "Серьёзная I/O ошибка\n";
        return 2;
    }
    if (in.fail() && !in.eof()) {
        std::cerr << "Ошибка формата: ожидали число\n";
        return 3;
    }

    std::cout << "sum=" << sum << '\n'; // sum=...
}

Здесь мы делаем важное разделение: fail() вместе с !eof() — это почти всегда «не тот формат», а bad() — это уже «что-то реально нехорошее» на уровне устройства/потока.

5. Стиль 2: включаем исключения у потока

Исключения в потоках — это вариант «если что-то пошло не так, не заставляй меня каждый раз спрашивать “как ты себя чувствуешь?” — просто кричи». Это может сделать код короче и прямолинейнее, особенно если ошибка действительно считается нештатной: например, “конфиг обязателен”, “лог должен записаться”, “файл результата должен создаться”.

У потоков есть метод exceptions(mask), который задаёт: при каких флагах состояния поток будет бросать исключение типа std::ios_base::failure. Дальше вы ловите это исключение в catch и решаете, что делать (обычно: печатаем сообщение и выходим/прерываем операцию).

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

Мини-пример: «файл обязателен, иначе считаем это ошибкой выполнения сценария»:

#include <fstream>
#include <iostream>

int main() {
    try {
        std::ifstream in;
        in.exceptions(std::ifstream::failbit | std::ifstream::badbit);

        in.open("config.txt"); // если не открылся — будет исключение

        int mode = 0;
        in >> mode;            // если там не число — тоже исключение
        std::cout << "mode=" << mode << '\n';
    } catch (const std::ios_base::failure& e) {
        std::cerr << "I/O exception: " << e.what() << '\n';
        return 1;
    }
}

Да, это выглядит компактно. Но тут и спрятана «мина»: если вы включили failbit, то любая ошибка формата тоже станет исключением. Это иногда хорошо (строгий формат), а иногда неудобно (частично повреждённый файл, best-effort чтение и т.п.). Как обычно: удобство покупается правилами использования.

6. Маска exceptions(): failbit, badbit и ловушки EOF

Самое частое место, где новички «включают исключения и ловят боль», — это выбор неправильной маски. Очень хочется поставить «всё и сразу», а потом оказывается, что нормальные события стали «ошибками». Давайте проговорим это спокойно.

badbit почти всегда относится к настоящим проблемам ввода/вывода. Его логично превращать в исключение: если поток реально сломался, продолжать обычно бессмысленно.

failbit — более коварный. Он ставится, когда операция чтения не удалась. И это включает как «формат не тот», так и «мы попытались прочитать после конца файла». То есть failbit нередко участвует в штатном завершении чтения.

eofbit сам по себе — не трагедия, чаще это «всё, данных больше нет». Поэтому делать EOF исключением обычно странно (если только у вас нет специфичного сценария “файл обязан содержать ровно N байт/строк”).

Полезная практическая идея для начинающего: если вы хотите попробовать “исключения в потоках”, начните с badbit. Это даст вам «падаем только при реально плохом I/O», но не превратит EOF и мелкие форматные промахи в поток исключений.

Пример: включаем исключения только на badbit, а формат/EOF обрабатываем в стиле проверок:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    try {
        std::ifstream in("tasks.txt");
        in.exceptions(std::ifstream::badbit); // только серьёзные I/O проблемы

        std::string line;
        while (std::getline(in, line)) {
            std::cout << line << '\n';
        }

        if (!in.eof()) {
            std::cerr << "Остановились не по EOF\n";
            return 2;
        }
    } catch (const std::ios_base::failure& e) {
        std::cerr << "I/O exception: " << e.what() << '\n';
        return 1;
    }
}

И ещё маленький нюанс из “жизни”: вывод тоже буферизуется, и иногда полезно принудительно сбросить буфер через flush() (или std::flush / std::endl, но с ним осторожнее). Само существование и обсуждение basic_ostream::flush() как отдельной операции хорошо видно даже по редакторским правкам стандарта.

7. Где «ловить» ошибки: границы ответственности

Одна из самых частых проблем — не сам выбор стиля, а отсутствие границ: половина кода проверяет if (!in), другая половина внезапно включает exceptions(...), а третья вообще игнорирует всё и «надеется на лучшее». В итоге программа ведёт себя как кот: иногда ласковая, иногда царапает — и непонятно почему.

Хорошая ментальная модель такая: внутри маленькой функции вы выбираете доминирующий стиль и придерживаетесь его. Если функция возвращает bool и строку ошибки — она не должна внезапно бросать исключения «где-то в середине». Если функция бросает исключения — пусть это будет частью её контракта, и ловить эти исключения нужно на понятной границе (например, в main, или в верхнем обработчике команды CLI).

Ниже — простая блок-схема, которая помогает выбрать стиль без философии:

flowchart TD
    A[Операция с файлом] --> B{Ошибка ожидаема?}

    B -->|Да, это нормально| C[Проверяем состояние потока: if / while / eof / fail / bad]

    B -->|Нет, сценарий сломан| D{Хотим линейный код?}

    D -->|Да| E[Включаем exceptions badbit или failbit плюс try/catch]
    D -->|Нет или сомневаемся| C

Если вы видите, что ошибка “может быть почти всегда” (например, пользователь ввёл неверный путь к файлу) — проверки состояния обычно дружелюбнее. Если ошибка “почти невозможна” (например, внутренний файл результата, который вы сами создаёте) — исключения часто удобнее.

8. Мини-рефакторинг: загрузка и сохранение задач

Сделаем маленький, но жизненный шаг в нашем учебном приложении: TaskList CLI (условное название). Пока у нас есть список задач в памяти: std::vector<std::string> tasks;. Мы хотим уметь сохранить его в файл и загрузить обратно. Формат максимально простой: одна строка — одна задача. Без “супер-валидации” и сложных контрактов — это будет в следующей лекции.

Загрузка в стиле проверок

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

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

bool load_tasks(const std::string& filename,
                std::vector<std::string>& tasks,
                std::string& error) {
    std::ifstream in(filename);
    if (!in) { error = "Не удалось открыть файл"; return false; }

    tasks.clear();
    std::string line;
    while (std::getline(in, line)) tasks.push_back(line);

    if (!in.eof()) { error = "Ошибка чтения (не EOF)"; return false; }
    return true;
}

Здесь важно, что мы различаем “прочитали всё” и “сломались в процессе”. Да, сообщение пока общее — сейчас нам важен сам паттерн.

Сохранение в стиле проверок и «контрольная точка»

Теперь запись. Тут новичковая боль: “я записал, но файл пустой”. Часто это не магия, а то, что программа упала раньше, чем буфер сбросился, или запись реально не удалась (например, нет прав). Мы сделаем простую «контрольную точку»: после записи проверим поток.

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

bool save_tasks(const std::string& filename,
                const std::vector<std::string>& tasks,
                std::string& error) {
    std::ofstream out(filename);
    if (!out) { error = "Не удалось открыть файл на запись"; return false; }

    for (const auto& t : tasks) out << t << '\n';
    out.flush(); // контрольная точка

    if (!out) { error = "Ошибка записи"; return false; }
    return true;
}

Мы явно вызываем flush(), чтобы “дожать” буфер, и только затем проверяем if (!out).

Та же загрузка, но в стиле исключений

Теперь — версия для тех случаев, когда вы хотите линейный код и согласны, что “ошибка = исключение”. Например, вы делаете внутреннюю команду --import, и при ошибке вы хотите просто прервать команду.

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

std::vector<std::string> load_tasks_throw(const std::string& filename) {
    std::ifstream in;
    in.exceptions(std::ifstream::failbit | std::ifstream::badbit);
    in.open(filename);

    std::vector<std::string> tasks;
    std::string line;
    while (std::getline(in, line)) tasks.push_back(line);
    return tasks;
}

Это компактно, но помните тонкость: если в конце чтения getline “споткнётся” об EOF и при этом поток выставит failbit, то при включённом failbit вы можете получить исключение на нормальном окончании. На практике такой код часто допиливают (например, меняют маску, или проверяют eof() в catch). Сейчас нам важно увидеть идею и понять, что “просто включить всё” — не всегда правильный шаг.

Одна точка обработки ошибок в main

Наконец, сделаем маленький сценарий “загрузили — показали количество задач”. Обратите внимание: обработка ошибок сосредоточена в одном месте.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks;
    std::string error;

    if (!load_tasks("tasks.txt", tasks, error)) {
        std::cerr << "Ошибка загрузки: " << error << '\n';
        return 1;
    }

    std::cout << "Задач: " << tasks.size() << '\n'; // Задач: ...
}

Это тот самый “тонкий main”, который вам пригодится и дальше: минимум логики, максимум управления сценарием.

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

Ошибка №1: «Я проверил eof(), значит всё нормально».
eof() — это не индикатор “ошибок нет”. Это индикатор того, что поток дошёл до конца. Можно получить fail() из-за ошибки формата вообще без eof(), а можно получить fail() вместе с eof() при попытке чтения за концом. Поэтому после цикла чтения полезно думать: “мы остановились по EOF или по ошибке?”, а не просто “ну раз eof(), значит победа”.

Ошибка №2: паттерн while (!in.eof()) и «лишняя итерация».
Такой цикл почти всегда делает одну попытку чтения лишней, потому что EOF становится истинным только после неудачного чтения. В итоге вы либо обрабатываете последнюю строку дважды, либо получаете пустую строку в конце, либо начинаете писать костыли. Правильный паттерн — читать прямо в условии: while (getline(in, line)) или while (in >> x).

Ошибка №3: включили exceptions(failbit | badbit) и получили исключение на EOF.
Это классическая ловушка: вы хотели «чтобы поток ругался», а он начал ругаться даже там, где чтение просто закончилось. Выход обычно один: либо не включать failbit для сценариев, где EOF штатный, либо ловить исключение и отдельно проверять eof(), либо менять структуру чтения так, чтобы EOF не считался ошибкой вашего контракта.

Ошибка №4: смешали два стиля без правил и получили “иногда return, иногда throw”.
Когда часть кода возвращает false, а часть бросает исключения, очень легко забыть обработать какой-то путь. Например, open() бросило исключение, а вы ожидали if (!in); или наоборот, вы ловите исключение, а функция в другом месте вернула false и ошибка «потерялась». Лучше заранее решить: в этой функции мы работаем через проверки, а в этой — через исключения, и придерживаться решения.

Ошибка №5: запись в файл без проверки результата (и без контрольной точки).
Операции << могут выглядеть “успешными”, но запись может не состояться (права, диск, ошибки устройства). Если вам важен результат, делайте осмысленную проверку: либо после ключевого блока записи, либо после flush(). И да, std::endl тоже делает flush, но использовать его везде — дорогая привычка, которую потом придётся отучивать.

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