CodeGym /Kursy /C++ SELF /RAII + wyjątki

RAII + wyjątki

C++ SELF
Poziom 54, Lekcja 0
Dostępny

1. Dlaczego przy wyjątku zasoby zwalniają się same

Jeżeli do tej pory wyjątki wydawały się wam czymś w rodzaju „skoku w portal”, to nie jesteście sami: wyjątek faktycznie przerywa zwykły przepływ wykonania. Ale C++ zachowuje się przy tym dość zdyscyplinowanie: „odwija” stos wywołań i na drodze powrotnej wywołuje destruktory wszystkich obiektów lokalnych. To właśnie sprawia, że RAII działa tak dobrze — nie przez magię, lecz przez porządek działania.

Wyobraźcie sobie, że funkcja to pokój, a zmienne lokalne to przedmioty, które wnieśliście do środka. Kiedy wychodzicie z pokoju, powinniście zabrać przedmioty ze sobą. Wyjątek to sytuacja, gdy zostajecie nagle „ewakuowani”: nie wyszliście drzwiami w standardowy sposób, ale i tak was usunięto, a przedmioty po drodze są „zebrane” automatycznie (destruktory).

Krótki przykład, żeby poczuć mechanikę:


#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");
    // tutaj nie dotrzemy
}

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

Tutaj przy wyjątku Guard i tak „posprząta”. To jest podstawowy powód, dla którego RAII chroni zasoby: plik zostanie zamknięty, mutex odblokowany, pamięć zwolniona — ponieważ właściciel zasobu uruchomi destruktor.

2. Poprawność zasobowa i poprawność logiczna

Najbardziej podszyta pułapka dla początkującego w nowoczesnym C++ wygląda tak: „Skoro RAII, to nie ma się czego bać — wszystko będzie dobrze”. I rzeczywiście, pod względem zasobów często będzie dobrze. Ale „dobrze pod względem zasobów” nie oznacza „dane nie zostały zepsute”.

Ustalmy terminy na poziomie zdrowego rozsądku.

Zasób — to, co trzeba zwolnić: pamięć, deskryptor pliku, blokada, połączenie sieciowe. RAII mówi: „zasób powinien żyć wewnątrz obiektu-właściciela; obiekt zniszczony — zasób zwolniony”.

Stan — to, co powinno być sensowne: pola klasy, elementy kontenera, powiązania między nimi. Stan może być kompletnie zwolniony bez wycieków… i jednocześnie logicznie zepsuty.

Przydatna mini-tabela:

Co chroni RAII Przykład Co to gwarantuje Co NIE gwarantuje
Zasoby
std::vector, std::string, std::unique_ptr
„nie będzie wycieku” „sens operacji zachowany”
Logikę inwarianty, walidacje, kolejność aktualizacji „obiekt w poprawnym stanie” nie pojawia się automatycznie

I tu zaczyna się najciekawsze: wyjątek może wystąpić pomiędzy dwoma przypisaniami, i otrzymacie obiekt, w którym połowa pól została już zaktualizowana, a połowa — nie.

Częściowa aktualizacja jako źródło problemów

Większość prawdziwych błędów związanych z wyjątkami to nie „zapomnieliśmy złapać wyjątku”. To „złapaliśmy / nie złapaliśmy — nieważne, ale po wyjątku obiekt pozostał w dziwnym stanie, a po 20 minutach to się zemściło”.

Schemat typowego problemu jest taki: metoda wykonuje kilka kroków, i jeden z kroków może rzucić. Krok przed rzuconym wyjątkiem już zdążył zmienić stan, a kroki po — nie, bo do nich nie doszło.

Narysujmy to jako prosty schemat życia „niebezpiecznej” metody:

flowchart TD
    A[Start metody] --> B[Zmieniono część pól]
    B --> C[Operacja niebezpieczna: może rzucić]
    C -->|sukces| D[Zmieniono pozostałe pola]
    D --> E[Koniec: stan spójny]
    C -->|wyjątek| F[Wyjście z metody przez wyjątek]
    F --> G[Obiekt żyje, ale stan może być niespójny]

Kluczowa myśl: wyjątek „ucina” wykonanie w miejscu. Dlatego pytanie dnia: co pozostaje prawdziwe o obiekcie, jeśli metoda została przerwana wyjątkiem?

3. Inwariant: reguły poprawnego obiektu

Aby nie mówić „dziwny stan”, programiści wolą słowo „inwariant”. Brzmi jak coś z analizy matematycznej, ale w programowaniu to zwykle bardzo prosta myśl: inwariant to reguły, którym obiekt musi zawsze odpowiadać, kiedy jest widoczny z zewnątrz.

Na przykład:

  • „użytkownik ma niepuste imię”
  • „wektor zadań i licznik zadań pokazują tę samą liczbę”
  • „indeks po id jest zgodny z główną listą”

Sens inwariantu nie leży w estetyce. Potrzebny jest, byście mogli uczciwie powiedzieć: „OK, metoda mogła upaść wyjątkiem, ale obiekt i tak pozostał ważny”.

Na razie bez formalnych „gwarancji” (o tym porozmawiamy w kolejnych wykładach), ustalmy minimum: po wyjątku obiekt powinien pozostać taki, że można go bezpiecznie dalej używać albo przynajmniej bezpiecznie zniszczyć.

Przykład: kontener jest ważny, ale operacja zastosowana częściowo

By zobaczyć problem bez klas, weźmy std::vector. Jest dobry w tym, że po wyjątku zwykle pozostaje ważny (czyli nie rozsypuje się). Ale może być „nieoczekiwany” z punktu widzenia waszej operacji.

#include <stdexcept>
#include <vector>

void push_then_fail(std::vector<int>& v) {
    v.push_back(1);                  // dodaliśmy element
    throw std::runtime_error("fail"); // operacja przerwana
}

Tutaj nie będzie wycieku pamięci: vector zarządza pamięcią sam. Ale logicznie mogło być założenie „albo dodaliśmy oba elementy, albo żaden”, a faktycznie — dodano jeden i polecieliśmy z wyjątkiem. Wektor jest ważny, ale „historia” danych stała się inna.

To ważne odczucie: RAII chroni przed wyciekami, ale nie chroni przed częściowo zastosowanymi zmianami.

4. Przykład szkoleniowy: TaskManager i inwarianty

Aby nie zostawać na poziomie „sferycznego wektora w próżni”, kontynuujemy nasz scenariusz aplikacji szkoleniowej: mały konsolowy menedżer zadań. Do tej pory trzymaliśmy zadania w std::vector, wypisywaliśmy je, znajdowaliśmy po id i zmienialiśmy status.

Dodajmy model:

#include <string>

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

Teraz zrobimy klasę-opakowanie:

#include <vector>

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

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

I sformułujmy inwariant, jak najprościej po ludzku: next_id_ zawsze powinien być „następnym wolnym id”, czyli większym niż jakiekolwiek już wydane id. To wygodne, bo wtedy add_task zawsze zwraca unikalne id.

Zła wersja add_task: inwariant zostaje złamany przy wyjątku

Teraz specjalnie napiszemy kod, który wygląda rozsądnie, ale przy wyjątku zostawia obiekt w dziwnym stanie. Potem będzie łatwiej rozpoznać takie rzeczy wzrokiem.

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

class TaskManager {
public:
    int add_task_bad(std::string title) {
        const int id = next_id_;
        ++next_id_; // "zarezerwowaliśmy" id zawczasu

        tasks_.push_back(Task{ id, std::move(title), false }); // może rzucić
        return id;
    }

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

Dlaczego push_back może rzucić? Na przykład z powodu alokacji pamięci (bardzo rzadko, ale możliwe), a w szerszym sensie — z powodu wyjątków przy kopiowaniu/przenoszeniu pól, gdyby typy były bardziej złożone. Nawet podstawowy scenariusz braku pamięci prowadzi do std::bad_alloc jako „wyjątku pamięciowego”.

Co się stanie, jeśli push_back rzuci? Już zwiększyliśmy next_id_. W rezultacie zadanie nie zostanie dodane, ale licznik id poszedł do przodu. Wydaje się drobnostką? Czasem tak. A czasem chcemy, żeby id szły bez dziur, albo używamy id jako wskaźnika liczby operacji. I wtedy logika „odpływa”.

Najważniejsze: obiekt żyje, tasks_ jest ważny, nie ma wycieków. Ale inwariant „next_id_ = max_id + 1” może być złamany.

Dobra wersja add_task: najpierw operacja niebezpieczna, potem zatwierdzenie

Teraz naprawimy metodę prostym zabiegiem: nie zmieniamy kluczowych pól stanu dopóki nie przejdą wszystkie kroki, które mogą rzucić. W praktyce zmieniamy po prostu kolejność wierszy.

#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 }); // może rzucić
        ++next_id_; // jeśli tu dotarliśmy — zadanie zostało faktycznie dodane

        return id;
    }

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

Teraz jeżeli push_back zakończy się wyjątkiem, next_id_ nie zmieni się, a więc logika nie „ucieknie”. Ta mała zmiana to styl myślenia, którego potrzebujecie dziś: wyjątek może wystąpić w każdym „niebezpiecznym” punkcie, i stan musi pozostać spójny.

5. Podwójne struktury i ryzyko desynchronizacji

Do tej pory mieliśmy jeden kontener i jedno pole. W praktyce często chcemy przyspieszyć wyszukiwanie: przechowywać na przykład także indeks id -> pozycja w wektorze. Przerabialiśmy już unordered_map, więc pomysł nie jest nowy.

Pseudo-projekt:

  • tasks_ — główna lista
  • index_ — szybkie wyszukiwanie po id
#include <unordered_map>
#include <vector>

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

Tu inwariant staje się bardziej rozbudowany: dla każdego Task w tasks_ powinna istnieć pozycja w index_, i powinna wskazywać poprawną pozycję.

I właśnie wtedy wyjątki zaczynają naprawdę „gryźć”: dodaliście do tasks_, potem dodajecie do index_, a przy wstawieniu do unordered_map też może wystąpić alokacja pamięci, czyli potencjalny wyjątek. W rezultacie otrzymacie zadanie w tasks_, ale bez wpisu w index_. Nie ma wycieków. Ale logika jest złamana: „wyszukiwanie po id” nagle przestaje znajdować istniejące zadanie.

Na poziomie tego wykładu ważne jest nie tyle napisanie idealnej implementacji (omówimy bardziej systemowe podejścia później), ile nauczenie się dostrzegać ryzyko: gdy tylko macie kilka struktur, które muszą być zgodne, wyjątek pomiędzy aktualizacjami niemal gwarantuje desynchronizację, o ile nie przemyśleliście kolejności działań.

6. Praktyczne techniki utrzymywania inwariantów

Chciałoby się sięgnąć po try/catch i zacząć „cofać zmiany”. Czasem to rzeczywiście potrzebne, ale dziś ćwiczymy inny odruch: sprawić, żeby nic odwoływać nie trzeba było, albo żeby „niebezpieczne” operacje nie psuły sensownego stanu.

Pierwsza i najtańsza technika — walidacja danych wejściowych przed zmianą stanu. Jeżeli zakazujecie pustego tytułu zadania, sprawdzajcie to zanim cokolwiek zmienicie:

#include <stdexcept>
#include <string>

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

Druga technika — aktualizować stan we właściwej kolejności: najpierw wszystko, co może rzucić, potem to, co zatwierdza fakt udanej operacji. Pokazaliśmy to już w add_task.

Trzecia technika — minimalizować „podwójny stan”, gdy ta sama informacja jest przechowywana w dwóch miejscach. Im więcej kopii prawdy, tym większa szansa, że wyjątek zostawi jedną kopię zaktualizowaną, a drugą — nie. Czasem lepiej poświęcić wydajność i obliczać coś na bieżąco, niż trzymać dodatkowy „cache”, który potem trudno utrzymać spójny.

Czwarta technika — traktować inwariant jako myśl, a nie nadzieję. W projektach szkoleniowych bardzo pomaga prosta wewnętrzna kontrola:

#include <cassert>

class TaskManager {
public:
    void check_invariant() const {
        assert(next_id_ >= 1);
        // W prawdziwym kodzie asercji może być więcej.
    }

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

Tak, assert to nie „obsługa błędów użytkownika”. To wasz alarm „jeśli to się zdarzy — jako deweloper chcę się dowiedzieć natychmiast”. Szczególnie przydatne podczas nauki, gdy chcecie szybko złapać moment, w którym obiekt stał się niepoprawny.

7. Mały test w main

Dla utrwalenia pomysłu pokażmy przykład, gdzie wywołujemy metodę mogącą rzucić, łapiemy wyjątek, ale chcemy potem kontynuować pracę (np. wypisać błąd i iść dalej).

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

int main() {
    TaskManager tm;

    try {
        tm.add_task("Buy milk");
        tm.add_task(""); // załóżmy, że walidujemy i rzucamy invalid_argument
    } catch (const std::exception& e) {
        std::cout << "ERROR: " << e.what() << '\n'; // ERROR: empty task title
    }

    // Ważne, że tu doszliśmy, i tm powinien pozostać w normalnym stanie.
}

Sens nie polega na „łapiemy wszystko i żyjemy szczęśliwie”. Sens polega na tym, że jeśli już łapiemy wyjątek na wyższym poziomie i kontynuujemy pracę, nasze obiekty muszą być wystarczająco poprawne, aby kontynuacja nie zamieniła się w loterię.

8. Destruktory i bardzo złe scenariusze

Krótka, ale ważna uwaga. Gdy wyjątek leci w górę, destruktory są wywoływane automatycznie. Jeśli przy tym destruktor sam rzuci wyjątek na zewnątrz — robi się awaria (program zwykle kończy się poprzez mechanizm terminate w kontekście wyjątków).

Na poziomie tego wykładu wystarczy zapamiętać praktyczną zasadę: destruktor powinien być maksymalnie „bezpieczny” i nie próbować zgłaszać błędów wyjątkami na zewnątrz. Destruktor to sprzątacz. Sprzątacz nie powinien wychodzić z budynku krzycząc „MAMY PROBLEM!”, przewracając mop na alarm pożarowy.

9. Typowe błędy

Błąd nr 1: myśleć, że RAII automatycznie sprawia, że kod jest „całkowicie bezpieczny przy wyjątkach”.
RAII rzeczywiście pomaga nie tracić zasobów i unikać wycieków przy wczesnym wyjściu z funkcji, ale nie gwarantuje, że pola i kontenery pozostaną w logicznie spójnym stanie. Jeśli metoda zdążyła zmienić połowę pól i upadła, obiekt może być technicznie ważny, ale bezsensowny pod względem danych.

Błąd nr 2: zmieniać „kluczowe” pola stanu przed wykonaniem potencjalnie rzucających operacji.
Klasyczny przykład — zawczasu zwiększać next_id_, size_, balance_, version_, a potem wykonywać push_back lub inną operację, która może rzucić. Przy wyjątku otrzymujecie „fakt zmiany” bez samej zmiany. Często leczy się to prostym przestawieniem wierszy: najpierw operacja ryzykowna, potem zatwierdzenie.

Błąd nr 3: przechowywać tę samą informację w dwóch miejscach bez żelaznego planu synchronizacji.
Gdy dodajecie cache, indeks, „szybki dostęp” lub drugi kontener, który ma odzwierciedlać pierwszy, stajecie się podatni na wyjątek pomiędzy dwiema aktualizacjami. To nie znaczy, że nie można tak robić, ale oznacza, że inwariant staje się trudniejszy, a ryzyko — realniejsze.

Błąd nr 4: łapać wyjątek „żeby nie padło”, ale dalej pracować z obiektem, nie wiedząc, w jakim jest stanie.
Czasem deweloper pisze catch (...) {} albo wypisuje błąd i idzie dalej, zakładając „skoro program nie padł — wszystko OK”. Jeśli obiekt mógł pozostać częściowo zaktualizowany, kolejny kod pracuje na polu minowym. Prawidłowe pytanie po wyjątku brzmi: „jakie własności obiektu nadal mogę uważać za prawdziwe?”

Błąd nr 5: brak jawnego inwariantu jako koncepcji.
Jeśli nie potraficie w jednym-dwóch zdaniach wyjaśnić, co to znaczy „poprawny obiekt” dla waszej klasy, to prawie na pewno nie będziecie w stanie napisać metod, które zachowują poprawność przy wyjątkach. Inwariant to nie biurokracja, to sposób, by nie oszukiwać siebie samego.

1
Zadanie
C++ SELF, poziom 54, lekcja 0
Niedostępne
Ślady w podziemiach
Ślady w podziemiach
1
Zadanie
C++ SELF, poziom 54, lekcja 0
Niedostępne
Turniket przepustowy
Turniket przepustowy
1
Zadanie
C++ SELF, poziom 54, lekcja 0
Niedostępne
Numeracja bez luk
Numeracja bez luk
1
Zadanie
C++ SELF, poziom 54, lekcja 0
Niedostępne
Katalog z przywracaniem stanu
Katalog z przywracaniem stanu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION