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:
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.
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>:
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 ;)
#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:
#include <iostream>
#include <stdexcept>
void funkcja()
{
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';
throw;
}
catch( int )
{
std::cout << "Mowilem, zebys nie rzucal int >:/\n";
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:
throw std::runtime_error( "Cos poszlo nie tak :(" );
Proba pierwsza
Runtime error: Cos poszlo nie tak :( (ale nic nie szkodzi)
throw std::logic_error( "Cos poszlo nie tak :(" );
Proba pierwsza
Inny standardowy wyjatek: Cos poszlo nie tak :(
Blad logiczny programu: Cos poszlo nie tak :(
Proba pierwsza
Mowilem, zebys nie rzucal int >:/
Blad logiczny programu: Nieoczekiwany int
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:
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:
#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:
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:
void f1() throw(); void f2() throw( 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.
#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()
{
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';
}