77
Obsługa błędów i wyjątków
Terminy błąd i wyjątek mogą być używane zamiennie,
oznaczają one zawsze sytuację wyjątkową, która powinna
być obsłużona w sposób szczególny.
Znane mechanizmy obsługi błędów i wyjątków to:
1. propagacja błędu wstecz .
2. skorzystanie z predefiniowanych zmiennych globalnych
(np. erno ) przechowujących numery błędów.
Ad.1: Metoda ta polega na wbudowaniu w kod instrukcji
wykrywania błędów. Po wykryciu błędu, przerywana jest
dalsza realizacja algorytmu, a funkcja zamiast wyniku
przekazuje do miejsca wywołania sygnał błędu. Tam z kolei
zamiast obliczeń zachodzi dalsza propagacja błędu wstecz aż
do miejsca jego wizualizacji użytkownikowi.
Programując musimy jednak wystąpienie każdej takiej
sytuacji wyjątkowej przewidzieć i "ręcznie" zaprojektować
obsługę każdego błędu. Metoda sprawdza się bardzo dobrze w
programach o bardzo zwartych, nie zmieniających się kodach
(układy sterowania, kompilatory), natomiast jest nie do
przyjęcia w dużych, otwartych systemach.
Ad.2: Metoda ta ma wartość jedynie historyczną. Polega na
zastawieniu pułapki w miejscu narażonym na wystąpienie
określonego błędu, poprzez śledzenie wartości zmiennych
typu erno. Jeśli błąd wystąpi, obsługuje się go tylko w miejscu
wystąpienia. Na ogół bardzo trudno (a w dużych, otwartych
systemach wręcz niemożliwym jest) przewidzenie i obsłużenie
wszystkich skutków błędu, lub sytuacji wyjątkowej.
78
Metoda try throw - catch
Jest to najczęściej dziś używana metoda obsługi błędów i
wyjątków, chociaż trudno jej przypisać pełną doskonałość.
Zasada ogólna omawianej metody:
" Funkcja napotykająca problem, z którym nie może sobie
poradzić automatycznie zgłasza ( throw ) go do miejsca,
skąd została wywołana.
" Funkcja obsługująca problemy sygnalizuje chęć wyłapania
wyjątków ( catch ).
Poniżej przykład klasy z obsługą błędu przekroczenia zakresu
tablicy:
class WEKTOR
{ int * p; int rozm;
public:
class ZAKRES ; // deklaracja klasy wyjątku
int & operator [ ] ( int i ); // operator indeksowania
// . . .};
Poniżej definicja operatora [ ] ze zgłoszeniem obsługi wyjątku
int & WEKTOR:: operator[ ] ( int i )
{ if( 0 <= i < rozm ) return p[ i ] ;
throw ZAKRES ( ) ;
}
Poniżej przykład użycia opisywanego mechanizmu:
int main( )
{ // . . .
try { . . . rob_cos( w ); . . . }
catch ( WEKTOR:: ZAKRES )
{ // tutaj kod obsługi błędu zakresu }
79
Działanie:
Jeżeli wywołanie jakiejkolwiek funkcji wewnątrz try{ }, np.
rob_cos(w) (lub też innej przez nią wywołanej) spowoduje że
wywołanie operatora [ ] nastąpi z niewłaściwym indeksem, to
procedura catch wyłapie wyjątek i zostanie wykonany kod
obsługi błędu.
Konstrukcji catch( ) można używać tylko natychmiast po
bloku poprzedzonym słowem kluczowym try. W nawiasach
specyfikujemy typ obiektu klasy wyjątku, z którym wchodzi
się do procedury obsługi błędu catch( ). Obiekt taki tworzony
jest automatycznie.
Poniżej definicje przykładowych funkcji zewnętrznych,
prowadzące do wystąpienia błędu zakresu:
void rob_cos( WEKTOR & w )
{ // . . .
krach( w );
// . . . }
void krach( WEKTOR & w )
{ w[ w.jaki_rozm( )+10 ]; }
Bardziej szczegółowy opis mechanizmu funkcjonowania
obsługi wyjątków:
Jeżeli funkcja ( np. operator [ ] ) zgłasza wyjątek, następuje,
od punktu zgłoszenia, przeglądanie łańcucha wywołań w
poszukiwaniu procedury obsługi wyjątku. Jednocześnie
zwijany jest stos wywołań aż do zrębu stosu funkcji
wyłapującej ( w naszym przykładzie jest to funkcja main). Po
drodze wywoływane są destruktory i niszczone wszystkie
obiekty lokalne.
80
Po wyłapaniu wyjątku, inne procedury obsługi, jeżeli nawet
istnieją dla tego wyjątku, już go nie będą dotyczyć.
Przykład:
int ff( WEKTOR & w ) {
try{ f(w); }
catch( WEKTOR:: ZAKRES ) // ( 1 )
{ /* . . . */ }
}
Ponieważ funkcja f sama obsługuje wyjątki, obsługa
wyjątków ( 1 ) zapisana w funkcji ff nigdy nie będzie wołana.
Jeżeli w procesie zwijania łańcucha wywołań nie
napotkana zostanie odpowiednia procedura obsługi
wyjątku, wykonanie programu zostanie przerwane z
błędem.
Powyższy mechanizm obsługuje tylko zdarzenia
synchroniczne. Zdarzenia asynchroniczne (np.
przerwania od klawiatury) nie mogą być obsługiwane w
ten sposób.
Zalety omawianego mechanizmu obsługi błędów i wyjątków:
duża czytelność, wynikająca z oddzielenia kodu obsługi
od "zwykłego" kodu,
większa efektywność,
bardziej regularny styl programowania, chociaż nie jest
on jeszcze dostatecznie strukturalny.
81
Paradygmat programowania uogólnionego
Paradygmat programowania uogólnionego zakłada skupienie
się na ogólnych mechanizmach manipulowania strukturami
danych bez skupiania się na różnego rodzaju szczegółach
implementacyjnych, przede wszystkim uzależnienia kodu
programu od użytych typów danych.
Pierwszym, niesłychanie istotnym krokiem w tym kierunku
było powstanie (omówionych wcześniej) szablonów klas
(typów sparametryzowanych). Pozwalają one na budowanie
klas opisujących byty ogólne (stosy, kolejki, tablice
asocjacyjne, itd.) bez określania typów obiektów w tych
strukturach przechowywanych.
Dalszym krokiem było zbudowanie uogólnionych iteratorów
i algorytmów. Doprowadziło to do zbudowania ( i
zatwierdzenia w 1998 roku) standardowej biblioteki
szablonów STL (Standard Template Library). Biblioteka ta
zawiera trzy rodzaje bytów ogólnych:
uogólnione kolekcje danych (inaczej: kontenery)
uogólnione iteratory,
algorytmy uogólnione.
Kolekcje (Kontenery)
Kontener jest strukturą danych zawierającą obiekty tego
samego typu, ułożone w jednolity dla danego typu kontenera
sposób. Kontener zapewnia narzędzia dostępu. W zależności
od przyjętej organizacji, poszczególne kontenery różnią się
wydajnością poszczególnych operacji.
Bardziej złożone kontenery charakteryzować może
specyficzna organizacja przechowywanych danych lub
istnienie dodatkowych operacji do manipulowania
zawartością.
82
Kontenery nakładają czasem pewne ograniczenia na dane,
które mogą być w nich przechowywane. Ograniczenia te
wynikają zazwyczaj z przyjętej struktury przechowywania
danych, zależności pomiędzy danymi itp.
Biblioteka STL zawiera następujące kontenery:
" deque - kolejki dwustronne
" list - listy liniowe
" map i multimap - tablice asocjacyjne
" set i multiset - zbiory i multizbiory
" stack - stosy
" queue i priority_queue - kolejki i kolejki priorytetowe
" vector - tablice dynamiczne
" Kontenery w STL zaimplementowano jako szablony klas,
zaopatrzone w metody.
" Większość metod jest wspólnych dla wszystkich
kontenerów, choć mogą one być różnie
zaimplementowane dla poszczególnych kontenerów.
Operatory wspólne dla wszystkich kolekcji
Następujące operatory są wspólne dla wszystkich kolekcji (w
tym dla klasy string):
operatory == oraz != przekazują wartość true lub false,
operator przypisania = kopiuje jedną kolekcje do drugiej
empty ( ) przekazuje wartość true gdy w kolekcji nie ma
przechowywanych elementów,
size( ) przekazuje liczbę elementów bieżąco przecho-
wywanych w kolekcji,
clear( ) usuwa wszystkie elementy kolekcji,
83
begin( ) przekazuje iterator (wskaznik) na pierwszy
element kolekcji uporządkowanej,
end( ) przekazuje iterator (wskaznik) na pierwszy adres
za ostatnim elementem kolekcji uporządkowanej,
insert( ) dodaje jeden element (lub pewien zbiór
elementów) do kolekcji,
erase( ) usuwa jeden element (lub pewien zbiór
elementów) z danej kolekcji.
Zachowanie dwóch ostatnich operacji zależy od tego, czy
kolekcja jest uporządkowana, czy asocjacyjna.
Iteratory
Iterator to obiekt klasy iteratora używany do wskazywania
obiektu z kontenera. Iterator to swego rodzaju uogólnienie
wskaznika.
Można zdefiniować własny iterator dla określonej kolekcji
używając zdefiniowanej w bibliotece STL klasy iterator. Na
przykład:
vector
svec;
// powyżej deklaracja obiektu svec klasy vector do
// przechowywania napisów (obiektów klasy string)
vector::iterator iter=svec.begin;
Iterator iter został tu zdefiniowany jako iterator dla wektora
napisów i zainicjowany tak, aby wskazywał na pierwszy
element tego wektora. Można go używać, tak jak wskaznika
wbudowanego, a więc dopuszczalne będzie użycie
operatorów: ++ * = = != ->.
84
Przykład użycia tak zdefiniowanego iteratora:
for( ; iter!=svec.end( ); iter++)
cout<size( )<< : <<*iter<Algorytmy uogólnione
Algorytmy uogólnione są uogólnionymi funkcjami,
używanymi do rozwiązywania najróżniejszych często
spotykanych, problemów. Typowe algorytmy, to: znalezienie
elementu w kontenerze, wstawienie elementu do ciągu
elementów w kontenerze, usunięcie elementu z ciągu,
modyfikacja elementu, porównywanie elementów, sortowanie
ciągu, itd.
Nieomalże wszystkie algorytmy do wskazywania zakresu
elementów, na których działają, używają iteratorów. Pierwszy
iterator wskazuje pierwszy element zakresu, drugi iterator
pierwszy element poza zakresem. Zakłada się, że zawsze
można dojść do iteratora drugiego poprzez inkrementację
iteratora pierwszego.
Algorytmów uogólnionych można używać zarówno do
kolekcji z biblioteki STL, jak i tzw. kolekcji wbudowanych, tj.
zdefiniowanych przez programistę.
Przykłady użycia omówionych bytów ogólnych:
const int asize=8;
int ia[asize]={1,2,3,4,5,6,7,8};
vectorivec(ia, ia+asize);
list ilist(ia, ia+asize);
//użyto konstruktorów klas vector i list z inicjacją obu
//obiektów wszystkimi elementami tablicy ia
85
int *pia = find( ia, ia+asize, 13 );
if( pia != ia+asize ) //znaleziono
//powyżej wywołanie algorytmu uogólnionego find z użyciem
//wskazników wbudowanych do tablicy wbudowanej
vector :: iterator it;
it = find( ivec.begin( ), ivec.end( ), 13 );
if( it != ivec.end( ) ) //znaleziono
//wywołanie funkcji find dla kolekcji w postaci wektora ivec z
//użyciem iteratora it
list :: iterator iter;
it = find( ilist.begin( ), ilist.end( ), 13 );
if( iter != ilist.end( ) ) //znaleziono
//wywołanie funkcji find dla kolekcji w postaci listy ilist z
//użyciem iteratora iter
W implementacji funkcji find użyto operatora przyrównania
dla typu elementu int. Jeżeli dla innego typu nie można użyć
operatora równości, lub jeżeli użytkownik chciałby inaczej
zdefiniować to przyrównanie, to trzeba szukać rozwiązania
bardziej ogólnego.
Są dwie możliwości rozwiązania tego problemu:
- użycie funkcji przekazywanej jako wskaznik do funkcji,
- użycie obiektu funkcyjnego (funktora).
Obok algorytmu uogólnionego find( ) istnieje jeszcze
algorytm uogólniony find_if( ), który zapewnia pożądaną
przez nas elastyczność przez przekazywanie wskaznika do
funkcji, lub obiektu funkcyjnego, zamiast używania operatora
przyrównania dla podstawowego typu elementu.
86
Funktory
wykorzystujące przeciążanie operatora wywołania funkcji ( )
Każdy obiekt klasy, zawierającej definicję operatora
wywołania funkcji, nazywamy funktorem. Przypomnijmy, że
operator wywołania funkcji ( ) jest dwuargumentowym
operatorem, dla którego lewym argumentem jest nazwa
funkcji, a prawym lista argumentów funkcji. Może być
przeciążany tak jak każdy inny operator.
Ponadto:
" Może zwracać daną dowolnego typu i mieć dowolną
liczbę parametrów.
" Podobnie jak operator przypisania, może być przeciążany
tylko jako metoda klasy.
" Funktor jest obiektem zachowującym się jakby był
funkcją (sic!!!).
" Kiedy funktor jest używany, jego parametry stają się
parametrami operatora wywołania funkcji.
Obiekty funkcyjne
Przypomnijmy, że obiekt funkcyjny jest obiektem specjalnej
klasy iteratora, zaprzyjaznionej z klasą posiadającą strukturę
typu tablica, lista. Taka klasa iteratora dostarcza przeciążonej
wersji operatora wywołania funkcji (patrz str. 74 w części IV
wykładu).
W bibliotece standardowej STL znajduje się:
- sześć arytmetycznych obiektów funkcyjnych,
implementujących podstawowe operacje arytmetyczne, np.
plus, gdzie typ jest typem standardowym, lub typem
klasy wbudowanej,
87
- sześć relacyjnych obiektów funkcyjnych,
implementujących wszystkie operacje relacji, np.
greater,
- trzy logiczne obiekty funkcyjne, np. logical_not.
Aby użyć pierwotnie zbudowanych obiektów funkcyjnych
musimy dołączyć do programu następujący plik nagłówkowy
#include
Przykłady użycia:
Algorytm sort będzie porządkował elementy kolekcji w
kolejności malejącej, jeśli przekażemy mu jako trzeci
parametr obiekt funkcyjny greater tak jak to przykładowo
pokazano poniżej
sort( ivec.begin( ), ivec.end( ), greater( ));
Ostatni argument wywołania powoduje utworzenie i
przekazanie do algorytmu sort( ) nie nazwanego obiektu
funkcyjnego klasy greater dla typu całkowitego. Wywołanie
algorytmy sort( ) bez trzeciego argumentu powoduje
domyślne użycie obiektu funkcyjnego less (mniejszy) i
posortowanie w kolejności rosnącej.
Mapy i multimapy
Na str.72 w części IV wykładu została omówiona klasa
tablicy asocjacyjnej z przeciążonym operatorem indeksowania
[ ]. Przedstawiono tam szczegółowo implementację tego
przeciążonego dla tablicy asocjacyjnej operatora.
Wzorzec tablicy asocjacyjnej (często zwany słownikiem)
został zaimplementowany w bibliotece STL jako tzw. mapa.
Przy czym odgrywający tutaj zasadniczą rolę operator [ ] ma
implementację zgodną z przestawioną w IV części wykładu.
88
Rozważmy jeszcze raz przykład, analizowanego przy okazji
omawiania tablicy asocjacyjnej, problemu zliczania słów w
tekście. Tym razem skorzystamy z kolekcji map.
Użyte w tablicy asocjacyjnej pary składać się będą z klucza
do indeksowania typu string oraz wartości typu int:
#include