JavaRush /Курси /C++ SELF /RAII + винятки

RAII + винятки

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

1. Чому під час винятку ресурси звільняються самі

Якщо досі винятки здавалися вам чимось на кшталт «стрибка в портал», ви не самотні: виняток справді перериває звичний потік виконання. Але C++ навіть у такій ситуації поводиться доволі дисципліновано: він «розмотує» стек викликів і дорогою назад викликає деструктори всіх локальних обʼєктів. Саме тому RAII працює так добре — не завдяки магії, а завдяки чіткому порядку.

Уявіть, що функція — це кімната, а локальні змінні — предмети, які ви занесли всередину. Коли ви виходите з кімнати, то маєте винести ці предмети назад. Виняток — це ситуація, коли вас раптово «евакуювали»: ви не дійшли до дверей звичайним способом, але вас однаково вивели, а предмети дорогою автоматично «зібрали» (деструктори).

Мініприклад, щоб відчути цю механіку:


#include <iostream>
#include <stdexcept>

struct Guard {
    ~Guard() { std::cout << "Guard: прибирання\n"; }
};

void demo() {
    Guard g;
    std::cout << "Перед throw\n";
    throw std::runtime_error("бум");
    // сюди ми не дійдемо
}

int main() {
    try {
        demo();
    } catch (const std::exception& e) {
        std::cout << "Спіймано: " << 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("збій"); // операція перервалася
}

Тут не буде витоку памʼяті: 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 -> позиція
    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("порожній заголовок задачі");
    }
}

Другий прийом — оновлювати стан у правильному порядку: спочатку все, що може викинути виняток, а потім те, що фіксує факт успішної операції. Саме це ми вже зробили в 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("Купити молоко");
        tm.add_task(""); // припустімо, ви перевіряєте дані і кидаєте invalid_argument
    } catch (const std::exception& e) {
        std::cout << "ПОМИЛКА: " << e.what() << '\n'; // ПОМИЛКА: порожній заголовок задачі
    }

    // Важливо, що сюди ми дійшли, і tm має залишатися в нормальному стані.
}

Сенс не в тому, щоб «спіймати все й жити щасливо». Сенс у тому, що якщо ми вже ловимо виняток на верхньому рівні й продовжуємо роботу, то наші обʼєкти мають лишатися достатньо коректними, щоб це продовження не перетворилося на лотерею.

8. Деструктори й дуже погані сценарії

Зараз буде коротка, але важлива ремарка. Коли виняток поширюється вгору, деструктори викликаються автоматично. Якщо при цьому деструктор сам викине виняток назовні, ситуація стає аварійною: програма зазвичай завершиться через механізм аварійного завершення — terminate — у контексті обробки винятків.

На рівні цієї лекції вам достатньо запамʼятати практичне правило: деструктор має бути максимально «безпечним» і не намагатися повідомляти про помилки винятками назовні. Деструктор — це прибиральник. Прибиральник не повинен вибігати з будівлі з криком «У НАС ТУТ ПРОБЛЕМА!», роняючи швабру на пожежну сигналізацію.

9. Типові помилки

Помилка №1: думати, що RAII автоматично робить код повністю «безпечним щодо винятків».
RAII справді допомагає не втрачати ресурси й не створювати витоки під час раннього виходу з функції, але не гарантує, що ваші поля й контейнери залишаться в узгодженому логічному стані. Якщо метод устиг змінити половину полів і впав, обʼєкт може бути технічно валідним, але логічно некоректним.

Помилка №2: змінювати «ключові» поля стану до виконання операцій, які потенційно можуть викинути виняток.
Класичний приклад — заздалегідь збільшувати next_id_, size_, balance_, version_, а потім виконувати push_back або іншу операцію, яка може викинути виняток. Під час винятку ви отримуєте «ознаку зміни» без самої зміни. Дуже часто це виправляється банальною перестановкою рядків: спочатку небезпечна операція, потім фіксація результату.

Помилка №3: зберігати одну й ту саму інформацію у двох місцях без надійного плану синхронізації.
Щойно ви додаєте кеш, індекс, «швидкий доступ» або другий контейнер, який має відображати перший, ви стаєте вразливими до винятку між двома оновленнями. Це не означає, що так робити не можна, але означає, що інваріант стає складнішим, а ризики — цілком реальними.

Помилка №4: ловити виняток «щоб не падало», але продовжувати роботу з обʼєктом, не розуміючи, у якому він стані.
Іноді розробник пише catch (...) {} або просто друкує помилку й іде далі, припускаючи, що «раз програма не впала, то все гаразд». Якщо при цьому обʼєкт міг лишитися частково оновленим, наступний код починає працювати на мінному полі. Правильне питання після винятку таке: «Які властивості обʼєкта я все ще можу вважати істинними?»

Помилка №5: відсутність явного інваріанта як ідеї.
Якщо ви не можете одним-двома реченнями пояснити, що таке «коректний обʼєкт» для вашого класу, то майже гарантовано не зможете написати методи, які зберігають коректність під час винятків. Інваріант — це не бюрократія, а спосіб не обманювати самого себе.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ