JavaRush /Курсы /C++ SELF /RAII + исключения

RAII + исключения

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

1. Почему при исключении ресурсы освобождаются сами

Если до этого момента исключения казались вам чем-то вроде «прыжка в портал», то вы не одиноки: исключение действительно прерывает обычный поток выполнения. Но C++ при этом ведёт себя довольно дисциплинированно: он «разматывает» стек вызовов, и на пути назад вызывает деструкторы всех локальных объектов. Именно поэтому RAII работает так хорошо — не по магии, а по расписанию.

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

Мини-пример, чтобы почувствовать механику:


#include <iostream>
#include <stdexcept>

struct Guard {
    ~Guard() { std::cout << "Guard: cleanup\n"; }
};

void demo() {
    Guard g;
    std::cout << "Before throw\n";
    throw std::runtime_error("boom");
    // сюда мы не дойдём
}

int main() {
    try {
        demo();
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << '\n';
    }
}

Здесь при исключении Guard всё равно «уберётся». Это и есть базовая причина, почему RAII спасает ресурсы: файл закроется, мьютекс разблокируется, память освободится — потому что у владельца ресурса вызовется деструктор.

2. Ресурсная корректность и логическая корректность

Самая неприятная ловушка для новичка в современном C++ выглядит так: «Ну раз RAII, значит можно не бояться — всё будет хорошо». И действительно, ресурсно часто будет хорошо. Но «ресурсно хорошо» не означает «данные не испортились».

Давайте договоримся о терминах на уровне здравого смысла.

Ресурс — это то, что нужно освободить: память, дескриптор файла, блокировка, сетевое соединение. RAII говорит: «ресурс должен жить внутри объекта-владельца; разрушился владелец — освободился ресурс».

Состояние — это то, что должно быть осмысленным: поля класса, элементы контейнера, связи между ними. Состояние может быть полностью освобождено без утечек… и при этом быть логически сломанным.

Удобная мини-табличка:

Что защищает RAII Пример Что это гарантирует Что НЕ гарантирует
Ресурсы
std::vector, std::string, std::unique_ptr
«не утечёт» «смысл операции сохранился»
Логику инварианты, проверки, порядок обновления «объект в корректном состоянии» автоматически не возникает

И вот тут начинается самое интересное: исключение может вылететь между двумя присваиваниями, и вы получите объект, где половина полей уже обновилась, а половина — нет.

Частичное обновление как источник проблем

Большинство реальных багов вокруг исключений — это не «мы забыли поймать исключение». Это «мы поймали / не поймали — не важно, но после исключения объект остался в странном состоянии, и через 20 минут оно аукнулось».

Схема типичной проблемы такая: метод делает несколько шагов, и один из шагов может бросить. Шаги до броска уже успели поменять состояние, а шаги после броска — нет, потому что до них не дошли.

Нарисуем это как простую блок-схему жизненного цикла «опасного» метода:

flowchart TD
    A[Начало метода] --> B[Изменили часть полей]
    B --> C[Опасная операция: может бросить]
    C -->|успех| D[Изменили оставшиеся поля]
    D --> E[Конец: состояние согласовано]
    C -->|исключение| F[Выход из метода через исключение]
    F --> G[Объект жив, но состояние может быть несогласованным]

Ключевая мысль: исключение “режет” выполнение по живому. Поэтому вопрос дня: что остаётся истинным про объект, если метод оборвался исключением?

3. Инвариант: правила корректного объекта

Чтобы не говорить «странное состояние», программисты любят слово «инвариант». Звучит как что-то из матанализа, но в программировании это обычно очень простая мысль: инвариант — это правила, которым объект обязан соответствовать всегда, когда его видят снаружи.

Например:

  • «у пользователя имя не пустое»
  • «вектор задач и счётчик задач показывают одно и то же количество»
  • «индекс по id согласован с основным списком»

Смысл инварианта — не в красоте. Он нужен, чтобы вы могли честно сказать: «ОК, метод мог упасть исключением, но объект всё равно остался валидным».

Пока без формальных “гарантий” (их мы будем обсуждать в следующих лекциях дня), зафиксируем минимум: после исключения объект должен оставаться таким, что его можно безопасно дальше использовать или хотя бы безопасно уничтожить.

Пример: контейнер валиден, но операция применена частично

Чтобы увидеть проблему без классов, возьмём std::vector. Он хорош тем, что после исключения обычно остаётся валидным (то есть не разваливается). Но он может оказаться в “неожиданном” состоянии с точки зрения вашей операции.

#include <stdexcept>
#include <vector>

void push_then_fail(std::vector<int>& v) {
    v.push_back(1);                  // добавили элемент
    throw std::runtime_error("fail"); // операция прервалась
}

Здесь не будет утечки памяти: vector управляет памятью сам. Но логически могло подразумеваться «либо добавили оба элемента, либо ничего», а по факту — добавили один и упали. Вектор валиден, но “история” данных стала другой.

Это очень важное ощущение: RAII спасает от утечек, но не спасает от частично применённых изменений.

4. Учебный пример: TaskManager и инварианты

Чтобы не оставаться на уровне «сферический вектор в вакууме», продолжим нашу сквозную идею учебного приложения: маленький консольный менеджер задач. До этого момента мы могли хранить задачи в std::vector, печатать их, находить по id и менять статус.

Добавим модель:

#include <string>

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

Теперь сделаем класс-обёртку:

#include <vector>

class TaskManager {
public:
    int add_task(const std::string& title);

private:
    std::vector<Task> tasks_;
    int next_id_ = 1;
};

И давайте сформулируем инвариант, максимально по-человечески: next_id_ всегда должен быть «следующим свободным id», то есть больше любого уже выданного id. Это удобно, потому что тогда add_task всегда даёт уникальный id.

Плохая версия add_task: инвариант ломается при исключении

Сейчас мы специально напишем код, который выглядит разумно, но при исключении оставляет объект в странном состоянии. После этого станет намного легче узнавать такие вещи глазами.

#include <string>
#include <utility>
#include <vector>

class TaskManager {
public:
    int add_task_bad(std::string title) {
        const int id = next_id_;
        ++next_id_; // "забронировали" id заранее

        tasks_.push_back(Task{ id, std::move(title), false }); // может бросить
        return id;
    }

private:
    std::vector<Task> tasks_;
    int next_id_ = 1;
};

Почему push_back может бросить? Например, из-за выделения памяти (очень редко, но возможно), а в более общем случае — из-за исключений при копировании/перемещении полей, если бы у вас там были более сложные типы. Даже базовый сценарий с нехваткой памяти приводит к идее std::bad_alloc как «исключение про память».

Что случится, если push_back бросит? Мы уже увеличили next_id_. В итоге задача не добавлена, но счётчик id уехал вперёд. Кажется мелочью? Иногда да. А иногда вы хотите, чтобы id шли без дырок, или вы используете id как косвенный показатель количества операций. И вот уже логика «поплыла».

Главное: объект жив, tasks_ валиден, утечек нет. Но инвариант «next_id_ = max_id + 1» может нарушиться.

Хорошая версия add_task: сначала опасное, потом фиксация результата

Сейчас мы исправим метод очень простым приёмом: не меняем важные поля состояния до тех пор, пока не прошли все шаги, которые могут бросить. По сути, мы просто меняем порядок строк.

#include <string>
#include <utility>
#include <vector>

class TaskManager {
public:
    int add_task(std::string title) {
        const int id = next_id_;

        tasks_.push_back(Task{ id, std::move(title), false }); // может бросить
        ++next_id_; // если дошли сюда — значит задача реально добавлена

        return id;
    }

private:
    std::vector<Task> tasks_;
    int next_id_ = 1;
};

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

5. Двойные структуры и риск рассинхронизации

До сих пор у нас был один контейнер и одно поле. В реальности часто хочется ускорить поиск: хранить, например, ещё и индекс id -> позиция в векторе. Мы уже проходили unordered_map, так что идея не новая.

Псевдо-дизайн:

  • tasks_ — основной список
  • index_ — быстрый поиск id
#include <unordered_map>
#include <vector>

class TaskManager {
public:
    // ...
private:
    std::vector<Task> tasks_;
    std::unordered_map<int, std::size_t> index_; // id -> position
    int next_id_ = 1;
};

Здесь инвариант становится богаче: для каждого Task в tasks_ должна существовать запись в index_, и она должна указывать на правильную позицию.

И вот тут исключения начинают реально «кусаться»: вы добавили в tasks_, потом добавляете в index_, и на вставке в unordered_map тоже может случиться выделение памяти, а значит — потенциальное исключение. В результате вы получите задачу в tasks_, но без записи в index_. Утечек нет. Но логика сломана: “поиск по id” внезапно перестаёт находить существующую задачу.

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

6. Практические приёмы удержания инвариантов

Сейчас хочется схватиться за try/catch и начать «отменять изменения». Иногда это действительно нужно, но сегодня мы тренируем другой рефлекс: делать так, чтобы отменять было нечего, или чтобы “опасные” операции не ломали смысловое состояние.

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

#include <stdexcept>
#include <string>

void validate_title(const std::string& title) {
    if (title.empty()) {
        throw std::invalid_argument("empty task title");
    }
}

Второй приём — обновлять состояние в правильном порядке: сначала всё, что может бросить, потом то, что фиксирует факт успешной операции. Мы это уже сделали в add_task.

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

Четвёртый приём — держать инвариант как мысль, а не как надежду. В учебных проектах очень помогает простая внутренняя проверка:

#include <cassert>

class TaskManager {
public:
    void check_invariant() const {
        assert(next_id_ >= 1);
        // В реальном коде проверок может быть больше.
    }

private:
    std::vector<Task> tasks_;
    int next_id_ = 1;
};

Да, assert — это не «обработка ошибок пользователя». Это ваша сигнализация “если это произошло — я как разработчик хочу узнать немедленно”. Особенно полезно, когда вы только учитесь и хотите быстро поймать момент, где объект становится некорректным.

7. Маленький тест в main

Чтобы закрепить идею, давайте покажем пример кода, где мы вызываем метод, который потенциально может бросить, ловим исключение, но хотим после этого продолжить работу (например, просто вывести ошибку и жить дальше).

#include <exception>
#include <iostream>
#include <string>

int main() {
    TaskManager tm;

    try {
        tm.add_task("Buy milk");
        tm.add_task(""); // допустим, вы валидируете и бросаете invalid_argument
    } catch (const std::exception& e) {
        std::cout << "ERROR: " << e.what() << '\n'; // ERROR: empty task title
    }

    // Важно, что сюда мы дошли, и tm должен оставаться в нормальном состоянии.
}

Смысл не в том, чтобы “поймать всё и жить счастливо”. Смысл в том, что если мы уже ловим исключение на верхнем уровне и продолжаем работу, то наши объекты должны быть достаточно корректными, чтобы продолжение не превратилось в лотерею.

8. Деструкторы и очень плохие сценарии

Сейчас будет короткая, но важная ремарка. Когда исключение летит вверх, деструкторы вызываются автоматически. Если при этом деструктор сам выбросит исключение наружу — ситуация становится аварийной (программа обычно завершится через механизм аварийного завершения/terminate в контекстах исключений).

На уровне этой лекции вам достаточно запомнить практическое правило: деструктор должен быть максимально “безопасным” и не пытаться сообщать об ошибках исключениями наружу. Деструктор — это уборщик. Уборщик не должен выбегать из здания с криком “У НАС ТУТ ПРОБЛЕМА!”, роняя швабру на пожарную сигнализацию.

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

Ошибка №1: думать, что RAII автоматически делает код “безопасным при исключениях” целиком.
RAII действительно помогает не терять ресурсы и не устраивать утечки при раннем выходе из функции, но он не гарантирует, что ваши поля и контейнеры останутся в согласованном логическом состоянии. Если метод успел поменять половину полей и упал, объект может быть валиден технически, но бессмысленен по данным.

Ошибка №2: менять “ключевые” поля состояния до выполнения потенциально бросающих операций.
Классический пример — заранее увеличивать next_id_, size_, balance_, version_, а потом выполнять push_back или другую операцию, которая может бросить. При исключении вы получаете «факт изменения» без самого изменения. Очень часто это лечится банальной перестановкой строк: сначала опасная операция, потом фиксация результата.

Ошибка №3: хранить одну и ту же информацию в двух местах без железного плана синхронизации.
Как только вы заводите кэш, индекс, “быстрый доступ” или второй контейнер, который должен отражать первый, вы становитесь уязвимыми к исключению между двумя обновлениями. Это не значит, что так делать нельзя, но это значит, что инвариант становится сложнее, а риски — реальнее.

Ошибка №4: ловить исключение “чтобы не падало”, но продолжать работу с объектом, не понимая, в каком он состоянии.
Иногда разработчик пишет catch (...) {} или печатает ошибку и идёт дальше, предполагая, что «ну раз программа не упала — всё норм». Если при этом объект мог остаться частично обновлённым, следующий код начинает работать на минном поле. Правильный вопрос после исключения: “какие свойства объекта я всё ещё могу считать истинными?”

Ошибка №5: отсутствие явного инварианта как идеи.
Если вы не можете одним-двумя предложениями объяснить, что такое “корректный объект” для вашего класса, то вы почти гарантированно не сможете написать методы, которые сохраняют корректность при исключениях. Инвариант — это не бюрократия, а способ не обманывать самого себя.

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