« Wyjątki, lekcja »
Rozdział 52. Wyjątki i sytuacje wyjątkowe w C++ (lekcja)
Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Zarejestruj się!
Autor: pekfos
Kurs C++

Wyjątki

[lekcja] Rozdział 52. Wyjątki i sytuacje wyjątkowe w C++

Wstęp

Wyjątki, jak nazwa wskazuje, służą do obsługi sytuacji wyjątkowych (w odróżnieniu od sytuacji zwykłych, które dzieją się bez przerwy). Dobrym przykładem sytuacji wyjątkowej jest niewystarczająca ilość pamięci. Jeśli zażądamy operatorem new więcej pamięci niż możemy dostać, zostanie wyrzucony wyjątek typu std::bad_alloc:
C/C++
int main()
{
    while( true )
         new char[ 1024 * 1024 * 1024 ];
   
}
W tym programie w pętli nieskończonej wycieka dość duża ilość pamięci - wycieki są dobrym sposobem na to, by pamięć dostępna dla programu się skończyła. Jeśli spróbujemy to uruchomić, program zakończy się błędem:
terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc
Taki komunikat pojawia się w konsoli dla programu skompilowanego pod GCC (5.1.0). Nie ma go pod Visual Studio i może go nie być pod innymi kompilatorami, czy wersjami GCC. Nie powinieneś polegać w pisanych przez siebie programach na tym, jak dokładnie zakończy się program w wyniku niezłapanego wyjątku. Debugger jest najlepszą metodą na lokalizowanie źródła niezłapanych wyjątków.
To mówi dwie rzeczy: został wyrzucony wyjątek std::bad_alloc i nie był nigdzie obsłużony, więc program został zakończony poprzez wywołanie funkcji std::terminate() (zdefiniowanej w <exception>).
Po zapoznaniu się z tą lekcją, będziesz wiedzieć jak zapewnić obsługę tego, lub innego wyjątkowego zdarzenia w Twoim programie.

Wyjątki

Najpierw zajmijmy się tym, czym tak na prawdę jest wyjątek: jest to komunikat, wartość jakiegoś typu - bo jak przyjdzie do faktycznej obsługi błędu, dobrze wiedzieć coś więcej niż tylko to, że błąd gdzieś się pojawił. Do wyrzucenia wyjątku służy instrukcja throw.
C/C++
int main()
{
    throw 0;
}
terminate called after throwing an instance of 'int'
Wyrzucić możemy wartość praktycznie dowolnego typu, jednak zaleca się używanie typów zdefiniowanych w <stdexcept>:
TypOpis znaczenia
std::logic_errorOgólny błąd - naruszono założenie, niezmiennik itp.
std::runtime_errorOgólny bład - bład czasu wykonania (możliwy do wykrycia tylko w czasie wykonania)
std::invalid_argumentPrzekazano nieprawidłowy argument
std::domain_errorBład dziedziny operacji (w sensie matematycznym). Np dzielenie przez zero
std::length_errorPrzekroczenie maksymalnego dozwolonego rozmiaru czegoś
std::out_of_rangeArgument poza dopuszczalnym zakresem
std::range_errorPrzekroczenie zakresu w obliczeniach. Np wynik obliczeń jest poza zakresem docelowego typu
std::overflow_errorPrzepełnienie liczby
std::underflow_errorNiedopełnienie liczby
Wiele funkcjonalności w bibliotece standardowej używa tych typów do zgłaszania wyjątków, co nie znaczy, że do każdego swojego wyjątku musisz koniecznie szukać najlepiej pasującego typu z tych wymienionych. W praktyce wystarczy jak będziesz używać zawsze std::runtime_error i ewentualnie std::logic_error - to i tak wielki upgrade względem rzucania zwykłym intem ;)
C/C++
#include <stdexcept>

int main()
{
    throw std::runtime_error( "Moj pierwszy cywilizowany wyjatek" );
}
terminate called after throwing an instance of 'std::runtime_error'
  what():  Moj pierwszy cywilizowany wyjatek
Standardowe wyjątki przenoszą informację tekstową, oraz definiują metodę what(), pozwalającą odczytać te napis.

Obsługa wyjątków

W niektórych językach programowania (na przykład: Java), nieuwzględnienie w kodzie możliwości wystąpienia wyjątku jest błędem. Każda funkcja musi tam mieć albo zapewnioną obsługę wszystkich wyjątków, albo musi listować jakie wyjątki może sama wyrzucać. C++ ma dużo lżejsze podejście do obsługi wyjątków. W tym języku, każda funkcja należy do jednej z dwóch kategorii: nie rzuca wyjątków, lub potencjalnie rzuca wyjątki. Przy czym, przynależność funkcji do jednej z tych grup nie ma zwykle większego znaczenia przy pisaniu kodu. Są oczywiście przypadki, w których to ma znaczenie, opisane później.
Żeby mieć jakieś szanse na obsłużenie wyjątku, trzeba najpierw wiedzieć, co zostało wyrzucone, a więc wyjątek należy złapać. Służy do tego konstrukcja try ... catch. Kod rzucający wyjątki, które nas interesują, umieszczamy w bloku try, po którym musi się znajdować co najmniej jedna klauzula catch:
C/C++
#include <iostream>
#include <stdexcept>


void funkcja()
{
    //throw 5;
    //throw "cos innego";
    //throw std::logic_error("Cos poszlo nie tak :(");
    throw std::runtime_error( "Cos poszlo nie tak :(" );
}

void test()
{
    try
    {
        std::cout << "Proba pierwsza\n";
        funkcja();
        std::cout << "Proba druga\n";
        funkcja();
    }
    catch( std::runtime_error & e )
    {
        std::cout << "Runtime error: " << e.what() << "  (ale nic nie szkodzi)\n";
    }
    catch( std::exception & e )
    {
        std::cout << "Inny standardowy wyjatek: " << e.what() << '\n';
        // Rzuć złapany wyjątek ponownie
        throw;
        // Nie:
        //throw e;
        // Patrz: czerwona ramka
    }
    catch( int )
    {
        std::cout << "Mowilem, zebys nie rzucal int >:/\n";
        // Rzuć nowy, inny wyjątek
        throw std::logic_error( "Nieoczekiwany int" );
    }
    catch(...)
    {
        std::cout << "Nieoczekiwany, nieznany wyjatek!\n";
        throw std::logic_error( "Zlapano nieznany wyjatek" );
    }
}

int main()
{
    try
    {
        test();
    }
    catch( std::runtime_error & e )
    {
        std::cout << "Blad wykonania programu: " << e.what() << '\n';
    }
    catch( std::logic_error & e )
    {
        std::cout << "Blad logiczny programu: " << e.what() << '\n';
    }
}
W zależności od tego, które wyrzucenie wyjątku z funkcja() zostanie wykonane, otrzymamy różne rezultaty:
C/C++
throw std::runtime_error( "Cos poszlo nie tak :(" );
Proba pierwsza
Runtime error: Cos poszlo nie tak :(  (ale nic nie szkodzi)
C/C++
throw std::logic_error( "Cos poszlo nie tak :(" );
Proba pierwsza
Inny standardowy wyjatek: Cos poszlo nie tak :(
Blad logiczny programu: Cos poszlo nie tak :(
C/C++
throw 5;
Proba pierwsza
Mowilem, zebys nie rzucal int >:/
Blad logiczny programu: Nieoczekiwany int
C/C++
throw "cos innego";
Proba pierwsza
Nieoczekiwany, nieznany wyjatek!
Blad logiczny programu: Zlapano nieznany wyjatek
W nawiasie po catch, podobnie jak przy definiowaniu funkcji, definiujemy jeden (nazwany lub nie) argument, lub wielokropek. Zostanie wykonany kod z pierwszej klauzuli, do której wyjątek można przekazać przez zdefiniowany "argument". W przypadku wyjątków o złożonym typie jest prosta reguła: zawsze łapiemy przez referencję (lub referencję na stałą). Wielokropek łapie cokolwiek, ale nie mamy możliwości odwołania się do tak złapanego wyjątku. Taka klauzula, jeśli jest obecna, musi być ostatnią ze wszystkich przy danym bloku try.
W bloku podlegającym klauzuli catch znajduje się kod obsługi wyjątku danego typu. Ten kod może w pełni obsłużyć zaistniałą sytuację, albo może zająć się tylko sprzątaniem i ograniczaniem szkód, po czym znowu wyrzucić wyjątek. Wewnątrz catch można użyć throw bez argumentu, co powoduje ponowne wyrzucenie złapanego wyjątku, jaki by on nie był. Jest to przydatne na przykład w catch(...), gdzie do wyjątku nie ma się jak odwołać, a nie chcemy stracić informacji, co dokładnie zostało wyrzucone, ponieważ gdzieś dalej może być obsługa, która potrafi się tym właściwie zająć.
W przykładzie, throw bez argumentu jest użyte przy obsłudze "innego standardowego wyjątku". Ten kod się wykona, gdy odkomentujesz i zostanie wykonane wyrzucenie std::logic_error. Standardowe wyjątki tworzą hierarchię klas (więcej o tym w kursie programowania obiektowego) i możemy złapać standardowy wyjątek używając bardziej ogólnego typu, niż wyrzucony. std::exception jest najbardziej ogólny, z niego wywodzą się wszystkie standardowe typy wyjątków. Dlatego zawsze łapiemy wyjątki o złożonym typie przez referencję, ponieważ możemy operować na bardziej szczegółowym typie, niż podaliśmy, i bez referencji utracimy tę informację. Z tego samego powodu zakomentowane throw e; może nie być czymś, co chcesz zrobić - spowoduje wyrzucenie instancji std::exception, a nie złapanego, w tym wypadku, std::logic_error.

Propagacja wyjątków

W tym programie są dwie warstwy obsługi wyjątków, mamy blok try i wewnątrz niego jest drugi blok try. Podział na funkcje nie jest konieczny, ale obrazuje istotną cechę wyjątków - mogą wylecieć z funkcji. O ile bawiąc się przykładem nie wykomentowałeś wyrzucania wszystkich wyjątków, wyłącznie pierwsze wywołanie funkcja() będzie miało miejsce. W momencie wyrzucenia wyjątku, program przechodzi do pierwszej pasującej klauzuli catch, przy czym nie musi taka klauzula istnieć ani przy bieżącym bloku try, ani w ogóle. Proces przechodzenia do kodu obsługi wyjątku nazywa się odwijaniem stosu (ang. stack unwinding). Nazywa się to tak, ponieważ wykonywane jest wyłącznie wychodzenie z funkcji (kolejne funkcje są zdejmowane ze stosu wywołań). Po drodze, wszystkie zmienne lokalne, które zostały utworzone, są niszczone. To jest zaleta, ponieważ zasoby powiązane ze zmiennymi lokalnymi nie będą wyciekać. Z drugiej strony, to jest też moment, w którym może Ci zależeć na tym, by pewne fragmenty kodu nie wyrzucały wyjątków: Jeśli w trakcie propagacji wyjątku, czyli od wyrzucenia do wejścia do kodu w catch, zostanie wyrzucony drugi wyjątek (np w trakcie niszczenia jakiegoś zasobu), to to jest koniec. W tej sytuacji wywoływane jest std::terminate() i program się kończy.
Taki sposób wykonywania kodu w trakcie przekazywania wyjątku nie jest naturalnym sposobem wykonywania programu. Propagacja wyjątku jest kosztowna i nie należy wyrzucać wyjątków, jeśli można ten sam efekt uzyskać z wykorzystaniem typowych instrukcji sterujących.
Po dostarczeniu wyjątku do pasującego bloku catch, program wraca do normalnego wykonywania. To znaczy, wykonywany jest kod w catch, a potem dalszy kod za catch. Program nie wraca do miejsca, gdzie wystąpił wyjątek. Jeśli w programie pojawia się bezwarunkowo instrukcja throw, to kod za tą instrukcją jest nieosiągalny - tak samo jak kod bezpośrednio za return nigdy się nie wykona.

Obsługa wyjątków

Zapomnijmy na razie o własnoręcznym rzucaniu wyjątków i skupmy się na wyjątkach rzucanych w jakiejś bibliotece - standardowej, żeby daleko nie szukać. Jeśli mamy kod, który może wyrzucić wyjątek, to ten wyjątek raczej pochodzi z jakiejś funkcji, która coś robi. Jednak wyjątek sprawia, że ta funkcja została przerwana. Powiedzmy, że mamy kontener std::vector<> i chcemy sobie dodać nowy element metodą push_back(), jednak była potrzebna realokacja pamięci i, jak na złość, akurat zabrakło pamięci i został wyrzucony std::bad_alloc. Powiedzmy, że jakimś sposobem udało nam się zażegnać kryzys - może mieliśmy zaalokowany na wszelki wypadek blok 100MB, który zwolniliśmy. Wciąż chcemy dodać ten element, bo to może być coś krytycznego dla działania naszej aplikacji, jednak.. czy ten wektor się do czegoś nadaje? W końcu operacja na nim została przerwana i nie mamy możliwości wznowienia jej. To jest bardzo dobre pytanie. Całe szczęście, że jest na nie prosta odpowiedź - sprawdźmy to w dokumentacji! Możemy tam przeczytać, że push_back() ma silną gwarancję wyjątków (ang. strong exception guarantee). Jest to informacja o tym, jak operacja się zachowuje gdy jest przerwana wyjątkiem:
GwarancjaOpis
No-throw guaranteeOperacja nigdy nie wyrzuca wyjątku
Strong exception guaranteeOperacja jest transakcyjna - albo zostanie wykonana w całości, albo nie ma efektu
Basic exception guaranteePrzerwana wyjątkiem operacja może mieć efekty uboczne. Gwarantowane jest zachowanie niezmienników i brak wycieków zasobów
Alternatywnie, operacja może nie gwarantować niczego.
Gwarancje na używanych funkcjach są bardzo ważne, jeśli chcesz obsługiwać wyjątki w celu przywrócenia poprawnego działania programu po wystąpieniu błędu. Dotyczy to także funkcji, które sam napiszesz. Kod który używa samych operacji o silnych gwarancjach, sam nie musi mieć nawet podstawowej. Przykładowo możesz mieć kod, który utrzymuje spójną zawartość dwóch kontenerów STL. Operacja dodawania elementu będzie dodawać go do obu kontenerów, jednak jeśli drugie dodawanie wyrzuci wyjątek, to dodawany element będzie w pierwszym kontenerze, a w drugim już nie. Niezmiennik "spójna zawartość kontenerów" nie jest zachowany, więc taka operacja dodawania nie daje żadnych gwarancji.

Dawanie silnej gwarancji

Silna gwarancja oznacza, że operacja jest transakcją (to termin z dziedziny baz danych), można też powiedzieć, że operacja ma commit or rollback semantics. Jeśli operacja się powiodła, robimy commit (wprowadzamy zmiany), jeśli nie, robimy rollback, czyli odczyniamy wszystkie zmiany, jakie po drodze zrobiliśmy. To jest najogólniejsza odpowiedź, jak pisać kod o silnej gwarancji. W praktyce, często może być tak, że wprowadzanie zmian, lub ich usuwanie w przypadku błędu, albo nawet jedno i drugie dzieje się samo, niejawnie i wynika z tego, jak jest skonstruowany dany kawałek kodu. Przykładem takiej konstrukcji jest idiom "skopiuj i podmień" (ang. copy-and-swap). Rozważmy funkcję, która usuwa z wektora liczby parzyste:
C/C++
#include <iostream>
#include <vector>


void usunParzyste( std::vector < int >& wektor )
{
    std::vector < int > tmp;
    tmp.reserve( wektor.size() );
   
    for( int i = 0; i < wektor.size(); ++i )
    if( wektor[ i ] % 2 == 1 )
         tmp.push_back( wektor[ i ] );
   
    wektor.swap( tmp );
}

int main()
{
    std::vector < int > tab = { 1, 2, 3, 4, 5, 6 };
    usunParzyste( tab );
   
    for( int i = 0; i < tab.size(); ++i )
         std::cout << tab[ i ] << ' ';
   
}
Sam idiom mówi, by skopiować dane, operować na kopii, a potem wprowadzić zmiany na oryginalnych danych. Tu akurat nie ma sensu operować na kopii, same kopiowanie od razu odfiltrowuje liczby parzyste. Zwróć uwagę, że w tym kodzie nie ma nic, co poznałeś w tej lekcji, a jednak ta funkcja daje silną gwarancję wyjątków. Wyjątek może zostać wyrzucony w reserve(), jeśli zabraknie pamięci. Gdybyśmy mieli bardziej złożony typ elementu niż int, byłoby ryzyko wyjątku w push_back(), na kopiowaniu elementu. Powiedzmy, że wyjątek został wyrzucony - operacja się nie powiodła (oczywiście), wektor tmp zostanie usunięty, bo jest zmienną lokalną, po czym wyjątek sobie wyleci, a operacja nie miała efektu, bo niczego nie zmieniliśmy w argumencie wektor. Wprowadzanie zmian realizuje metoda swap(), która zamienia miejscami dwa wektory, bez tworzenia kopii pomocniczej. Same swap() nie wyrzuca żadnych wyjątków (a przynajmniej nie dla domyślnego alokatora). Implementowanie rollbacka nie było potrzebne, bo nie ma tu punktu, w którym może wyskoczyć wyjątek, a mamy zmiany, które trzeba wycofać.
Skoro już wspomniałem o przypadku bardziej złożonego typu elementu, to teoretycznie jest możliwe, że wyjątek zostanie wyrzucony po wprowadzeniu zmian. Wyjątek może zostać wyrzucony w trakcie niszczenia elementów wektora, jednak w zasadzie to nie powinno się dziać, ponieważ stwarza ryzyko wyrzucenia wyjątku w trakcie propagacji innego wyjątku.

Gwarancja nierzucania wyjątków

Taką gwarancję znacznie łatwiej "spełnić": służy do tego słowo kluczowe noexcept, wprowadzone w C++11. Możemy go użyć do jawnego określenia, że funkcja nie wyrzuca wyjątków:
C/C++
void funkcja() noexcept
{
    // ..
}
Wewnątrz takiej funkcji wciąż możemy używać kodu potencjalnie wyrzucającego wyjątki, a nawet sami je wyrzucać i nie łapać. Jednak jeśli wyjątek nie zostanie złapany w tej funkcji, wywoływane jest std::terminate(). Sens stosowania noexcept jest w tym, że jeśli w funkcji nie wyrzucającej wyjątków będziemy używać tylko takich funkcji, to kompilator nie musi uwzględniać wyjątków w generowaniu kodu. To pozwala na dodatkowe optymalizacje.
Przed C++11 podobną funkcję pełniło słowo kluczowe throw i pozwalało, w stylu Javy, jawnie wylistować możliwe typy wyjątków:
C/C++
void f1() throw(); // Nie wyrzuca wyjątków
void f2() throw( A, B, C ); // Wyrzuca tylko wyjątki typu A, B, C
W przypadku wyrzucenia wyjątku wbrew specyfikacji, wywoływana jest funkcja std::unexpected() (domyślnie wywołująca std::terminate()). Obecnie ta konstrukcja jest zdeprecjonowana i jest usuwana z języka.

Przykład

Standardowe strumienie posiadają metodę exception(), która pozwala włączyć wyrzucanie wyjątków w przypadku ustawienia flag błędów. Poniższy przykład włącza wyjątki dla flag failbit i badbit, by można było rzucić wyjątkiem przez wprowadzenie błędnej liczby. Pamiętaj jednak, że obsługa wyrzuconego wyjątku jest kosztowna, więc w rzeczywistości może lepiej byłoby włączyć wyjątek tylko dla badbit, bo to jest ten bardziej nieprzewidywalny (bardziej wyjątkowy) z dwóch.
C/C++
#include <iostream>
#include <limits>


int wczytajInt()
{
    int x = 0;
   
    while( true )
    {
        try
        {
            std::cin >> x;
            break;
        }
        catch( std::exception & )
        {
            std::cin.clear();
            std::cin.ignore( std::numeric_limits < std::streamsize >::max(), '\n' );
            std::cout << "Postaraj sie bardziej: ";
        }
    }
   
    return x;
}


int main()
{
    // Niech ustawienie fail/bad wyrzuci wyjątek
    std::cin.exceptions( std::ios::failbit | std::ios::badbit );
   
    std::cout << "Podaj liczbe nr 1: ";
    int a = wczytajInt();
    std::cout << "Podaj liczbe nr 2: ";
    int b = wczytajInt();
    std::cout << "Suma: " << a + b << '\n';
}
Poprzedni dokumentNastępny dokument
Wyrażenia lambda (C++11)Operacje bitowe