Menu

Wyjątki w C++: throw, what() i obsługa błędów

Wyjątki zgłaszają błędy, których funkcja nie potrafi obsłużyć lokalnie. Dowiedz się, jak używać throw, jakie są standardowe typy wyjątków, czym jest komunikat what() i dlaczego przy poważnych błędach wyjątki wygrywają z kodami powrotu.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Po co istnieją wyjątki

Na poprzedniej stronie enum class posłużył do nadania stanom błędów znaczących nazw. To świetne rozwiązanie dla wyników, których funkcja się spodziewa i które wywołujący ma sprawdzić. Niektóre awarie są jednak inne: funkcja głęboko w stosie wywołań odkrywa, że plik się nie otwiera albo że argument nie ma sensu, i nie ma pojęcia, co program powinien z tym zrobić. Zwrócenie kodu błędu działa tylko wtedy, gdy każdy wywołujący w łańcuchu pamięta, żeby go sprawdzić i przekazać wyżej. Pomiń jedno sprawdzenie, a program popłynie dalej ze śmieciami.

Wyjątki to rozwiązują. Gdy coś pójdzie nie tak, rzucasz obiekt przez throw. Wykonanie natychmiast się zatrzymuje, stos się zwija (każdy obiekt lokalny między miejscem rzucenia a obsługą ma wywołany destruktor), a sterowanie przeskakuje do najbliższego pasującego catch. Nieobsłużonego wyjątku nie da się po cichu zignorować: jeśli nic go nie przechwyci, program wywołuje std::terminate i przerywa działanie.

Ta strona skupia się na stronie rzucającej, czyli na samych obiektach błędów. Następna strona szczegółowo omawia mechanizm try/catch.

Rzucanie i komunikat what()

Technicznie możesz rzucić przez throw dowolną wartość (throw 42; czy throw "oops"; są legalne), ale tego nie rób. Konwencja, której wszyscy przestrzegają, to rzucanie obiektu wyprowadzonego z std::exception. Ta klasa bazowa deklaruje jedną wirtualną metodę, what(), zwracającą opis problemu jako const char*. Trzymanie się konwencji oznacza, że jeden catch (const std::exception& e) obsłuży wszystko.

Nagłówek <stdexcept> daje gotowe typy, których konstruktor przyjmuje komunikat:

Zauważ, że what() zwraca dokładnie ten napis, z którym skonstruowano wyjątek. Zauważ też, że przechwyciliśmy go przez const exception&, choć rzuciliśmy runtime_error. To działa, bo runtime_error jest std::exception (tę relację rozpoznasz ze strony o dziedziczeniu).

Standardowa hierarchia wyjątków

Zanim napiszesz własny typ wyjątku, sprawdź, czy biblioteka standardowa nie ma już pasującego. Wszystkie dziedziczą po std::exception i w <stdexcept> dzielą się na dwie rodziny:

  • logic_error: błąd w logice programu, który w zasadzie dałoby się wykryć przed uruchomieniem. Podtypy to m.in. invalid_argument, out_of_range, domain_error i length_error.
  • runtime_error: awaria, która ujawnia się dopiero w czasie działania i nie jest sama w sobie błędem programisty. Podtypy to m.in. range_error, overflow_error i underflow_error.

Wiele funkcji bibliotecznych rzuca je za ciebie. Na przykład at() kontenera vector sprawdza zakres i rzuca out_of_range, zamiast pozwolić ci czytać za końcem:

To at() jest bezpiecznym odpowiednikiem v[9]. Zwykły operator[] nie sprawdza zakresu: odczyt v[9] jest tu niezdefiniowanym zachowaniem, a nie wyjątkiem. Wybór at() to sposób na zamianę cichego uszkodzenia danych w błąd, który da się przechwycić.

Wybierz typ, który opisuje błąd: invalid_argument, gdy wywołujący przekazuje coś bezsensownego, out_of_range przy problemach z indeksem lub kluczem, runtime_error, gdy "zawiódł świat zewnętrzny".

Własny typ wyjątku

Gdy żaden standardowy typ nie pasuje (chcesz dołączyć dodatkowe dane albo przechwytywać przez catch właśnie swój błąd i nic innego), zdefiniuj klasę dziedziczącą po std::exception (lub jednym z jej podtypów) i nadpisz what(). Dziedziczenie po std::runtime_error to najprostsza droga, bo ta klasa już przechowuje komunikat i implementuje za ciebie what():

Ponieważ NetworkError niesie kod statusu, kod obsługi może na niego zareagować: ponowić próbę przy 5xx, poddać się przy 4xx. Goły napis z błędem by tego nie umożliwił. Własny typ pozwala też, by catch (const NetworkError&) łapał tylko problemy sieciowe, a wszystko inne zostawiał ogólniejszej obsłudze poniżej.

Jeśli kiedykolwiek dziedziczysz bezpośrednio po std::exception (a nie po runtime_error), pamiętaj, aby samodzielnie nadpisać what() i oznaczyć je jako noexcept, zgodnie z sygnaturą bazową:

class ParseError : public std::exception {
public:
    const char* what() const noexcept override {
        return "failed to parse input";
    }
};

Rzucaj przez wartość, łap przez referencję

To najważniejsza reguła wyjątków w C++ i ta, którą początkujący łamią najczęściej. Rzucaj obiekty przez wartość, a przechwytuj je przez referencję const.

throw runtime_error("oops");            // przez wartość: poprawnie
catch (const runtime_error& e) { ... }  // przez referencję const: poprawnie

Przechwytywanie przez wartość, czyli catch (std::exception e), kopiuje wyjątek do obiektu klasy bazowej i odcina część pochodną. Po takim przycięciu (object slicing) e.what() wywołuje implementację bazową, a nie twoją nadpisaną, więc starannie przygotowany komunikat znika:

try {
    throw NetworkError(503, "service unavailable");
} catch (std::exception e) {       // przez wartość: przycięcie obiektu!
    std::cout << e.what();         // ogólny komunikat, status() zniknęło
}

Referencja (&) zachowuje prawdziwy typ dynamiczny, więc wirtualne what() wywołuje się poprawnie, a do składowych klasy pochodnej nadal masz dostęp. Dodaj const, bo wyjątek tylko odczytujesz, a nie modyfikujesz. Nigdy nie rzucaj wskaźnika (throw new runtime_error(...)): przechwytujący musiałby wywołać na nim delete, tylko na której ścieżce kodu? To dokładnie ten wyciek, przed którym wyjątki mają chronić.

Dalej: try-catch

Umiesz już tworzyć i rzucać przez throw poprawnie zbudowane wyjątki oraz dobierać właściwy typ standardowy do każdej awarii. Druga połowa historii to strona przechwytująca. Następna strona omawia try/catch w całości: kolejność wielu bloków catch od najbardziej szczegółowego do najogólniejszego, łapiący wszystko catch (...), ponowne rzucanie gołym throw; i to, jak RAII (przypomnij sobie inteligentne wskaźniki) gwarantuje zwolnienie zasobów podczas zwijania stosu.

Najczęściej zadawane pytania

Czym jest wyjątek w C++?

Wyjątek to obiekt sygnalizujący błąd, którego bieżąca funkcja nie potrafi obsłużyć sama. Rzucasz go przez throw, stos się zwija (po drodze niszcząc obiekty lokalne), a pasujący blok catch wyżej przejmuje kontrolę. Oddziela to kod, który wykrywa problem, od kodu, który decyduje, co z nim zrobić.

Jaka jest różnica między throw a return przy błędach?

Wartość zwracaną przez return musi sprawdzić wywołujący, a łatwo o tym zapomnieć: program po prostu działa dalej na błędnych danych. Rzuconego wyjątku nie da się zignorować: jeśli nikt go nie przechwyci, program się zakończy. Wyjątki służą do prawdziwych awarii (plik się nie otwiera, dane wejściowe są niepoprawne); wartości zwracane nadal są właściwe dla zwykłych wyników, w tym oczekiwanych przypadków "nie znaleziono".

Co robi metoda what() w wyjątkach C++?

Każda klasa wyprowadzona z std::exception udostępnia wirtualną metodę what(), która zwraca const char* z opisem błędu. Po przechwyceniu wyjątku wywołanie e.what() daje czytelny komunikat, który możesz zalogować lub wypisać. Standardowe typy wyjątków ustawiają go na podstawie napisu przekazanego do konstruktora.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ