Od rzucania do obsługi
Poprzednia strona pokazuje, jak rzucić wyjątek instrukcją throw, gdy coś pójdzie nie tak. Rzucanie to jednak tylko połowa historii: wyjątek, którego nikt nie złapie, wywołuje std::terminate i program się wysypuje. Instrukcja try/catch pozwala obsłużyć to, co zostało rzucone, i działać dalej.
Schemat jest prosty: ryzykowny kod umieszczasz w bloku try, a po nim dajesz jeden lub więcej bloków catch, które reagują na konkretne typy błędów. Jeśli blok try wykona się bez problemów, każdy catch jest pomijany. W chwili, gdy coś zostanie rzucone, sterowanie przeskakuje prosto do pierwszego pasującego catch.
Zauważ, że "after" nigdy się nie wypisuje. Gdy tylko zadziała throw, reszta bloku try zostaje porzucona, a wykonanie wznawia się w pasującym catch. Po zakończeniu catch program normalnie działa dalej poniżej.
Łap przez referencję do const
Najważniejszy nawyk w obsłudze błędów w C++: łap wyjątki przez referencję do const, a nie przez wartość.
Łapanie przez wartość kopiuje wyjątek, a co gorsza, przycina go (slicing). Wyjątki standardowe tworzą hierarchię (runtime_error i logic_error dziedziczą po std::exception), więc złapanie wyjątku pochodnego jako wartości bazowej odcina część pochodną. Łapanie przez referencję zachowuje obiekt w całości i polimorficznie:
Tutaj rzucamy out_of_range, ale łapiemy go jako const exception&. Ponieważ out_of_range dziedziczy po exception, handler klasy bazowej pasuje, a dzięki referencji e.what() nadal zwraca prawdziwy komunikat. Zapis catch (exception e) (przez wartość) przyciąłby obiekt do zwykłego exception i konkretny komunikat mógłby przepaść.
Wiele bloków catch
Po jednym try może następować kilka bloków catch, każdy dla innego typu wyjątku. C++ sprawdza je od góry do dołu i wykonuje pierwszy pasujący, więc ustawiaj je od najbardziej szczegółowego do najbardziej ogólnego.
invalid_argument jest bardziej szczegółowy niż exception, więc musi być pierwszy. Po odwróceniu kolejności catch (const exception&) na górze połykałby każdy wyjątek, a handler invalid_argument pod nim stałby się martwym kodem, który nigdy się nie wykona. Wiele kompilatorów ostrzega przed tym, ale język cię nie powstrzyma.
catch (...) i ponowne rzucanie
Czasem potrzebujesz siatki bezpieczeństwa na wszystko, czego nie przewidziano. Handler catch (...) pasuje do każdego typu wyjątku, także do takich, które nie dziedziczą po std::exception (ktoś może napisać throw 42; albo throw "oops";).
Haczyk polega na tym, że nie dostajesz żadnego obiektu: nie ma e do sprawdzenia. Dlatego catch (...) najlepiej traktować jako ostatnią deskę ratunku: zapisać w logu, że coś się nie udało, albo posprzątać i rzucić wyjątek dalej.
Aby ponownie rzucić bieżący wyjątek, czyli przekazać go wyżej do zewnętrznego handlera po lokalnym sprzątaniu lub logowaniu, użyj samego throw; bez argumentu. Zachowuje to oryginalny wyjątek (jego prawdziwy typ i komunikat), w przeciwieństwie do throw e;, które rzuciłoby przyciętą kopię:
Wewnętrzny handler loguje i rzuca dalej, a zewnętrzny handler w main zajmuje się resztą. Używaj do tego samego throw;, nigdy throw e;.
Zwijanie stosu i RAII
Gdy wyjątek wydostaje się z bloku try, C++ wykonuje zwijanie stosu (stack unwinding): każdy obiekt lokalny między throw a pasującym catch ma wywoływany destruktor, w kolejności odwrotnej do tworzenia. To właśnie czyni wyjątki bezpiecznymi: zasoby trzymane przez obiekty na stosie są zwalniane automatycznie.
Dlatego zasoby warto trzymać w typach RAII (takich jak std::vector, std::string i inteligentne wskaźniki), a nie w surowym new/delete. Zobacz, co się dzieje, gdy wyjątek przecina ręczną alokację:
void leaky() {
int* buffer = new int[1000];
mightThrow(); // jeśli to rzuci wyjątek, następna linia się nie wykona...
delete[] buffer; // ...i bufor wycieknie
}
throw przeskakuje nad delete[], więc pamięć przepada. Inteligentny wskaźnik rozwiązuje to za darmo, bo jego destruktor uruchamia się podczas zwijania stosu:
void safe() {
auto buffer = std::make_unique<int[]>(1000);
mightThrow(); // jeśli to rzuci wyjątek, destruktor buffer i tak zwolni pamięć
} // bez ręcznego delete i bez wycieku, nawet na ścieżce wyjątku
Wniosek: nie łap wyjątku tylko po to, żeby coś usunąć przez delete. Sprzątanie zostaw destruktorom, a catch zachowaj na decyzje o tym, jak się podnieść po błędzie.
Częste błędy i pułapki
Kilka pułapek powraca raz za razem:
Nie używaj wyjątków do zwykłego sterowania przepływem. Rzucanie i zwijanie stosu jest dużo wolniejsze niż zwykły if. Zachowaj wyjątki na naprawdę wyjątkowe sytuacje błędów, a nie na przypadek "użytkownik wpisał pusty napis".
Pusty blok catch ukrywa błędy. Napisanie catch (...) {}, żeby uciszyć błąd, sprawia, że awarie znikają bez śladu. Przynajmniej zaloguj problem, a zwykle rzuć wyjątek dalej albo obsłuż go porządnie.
Destruktor, który rzuca wyjątek, jest niebezpieczny. Jeśli destruktor rzuci wyjątek w trakcie zwijania stosu (gdy inny wyjątek jest już w locie), program wywołuje std::terminate. We współczesnym C++ destruktory są niejawnie noexcept: nigdy nie pozwól, by wyjątek się z nich wydostał.
catch widzi tylko to, co obejmuje try. Wyjątek rzucony przed wejściem do try albo w innej funkcji, która nie leży na ścieżce wywołań wewnątrz niego, nie zostanie tu złapany. catch chroni tylko kod wykonywany wewnątrz własnego bloku try (bezpośrednio lub w wywoływanych z niego funkcjach).
Dalej: niezdefiniowane zachowanie
Wyjątki to zdefiniowany sposób, w jaki C++ informuje, że coś poszło nie tak: rzucasz, łapiesz, a zachowanie jest przewidywalne. C++ ma jednak także ciemniejszy zakątek, w którym język niczego nie obiecuje: dereferencja wiszącego wskaźnika, odczyt poza końcem tablicy, przepełnienie liczby całkowitej ze znakiem. Następna strona omawia niezdefiniowane zachowanie (undefined behavior): co je wywołuje, dlaczego może pozornie "działać" aż do katastrofalnej awarii i jak nie dopuścić go do swojego kodu.
Najczęściej zadawane pytania
Jak działa try-catch w C++?
Kod, który może rzucić wyjątek, umieszczasz w bloku try { }. Jeśli wyjątek zostanie rzucony, program przerywa wykonywanie reszty bloku try i przeskakuje do pierwszego pasującego bloku catch, w którym obsługujesz błąd. Jeśli nic nie zostanie rzucone, bloki catch są całkowicie pomijane.
Dlaczego w C++ łapie się wyjątki przez referencję do const?
Łapanie przez referencję (catch (const std::exception& e)) unika kopiowania obiektu wyjątku i, co kluczowe, zachowuje polimorfizm: wyjątek pochodny złapany jako typ bazowy nadal wywołuje właściwe what(). Łapanie przez wartość (catch (std::exception e)) odcina część pochodną (slicing) i może zgubić informacje.
Jak złapać dowolny wyjątek w C++?
Użyj catch (...): wielokropek łapie każdy wyjątek niezależnie od typu. To przydatna ostatnia deska ratunku, ale nie dostajesz żadnego obiektu do sprawdzenia, więc umieść ten blok po konkretnych blokach catch i używaj go głównie do logowania lub ponownego rzucenia wyjątku.