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 (wskaźnik) na pierwszy
element kolekcji uporządkowanej,
end( ) przekazuje iterator (wskaźnik) 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
wskaźnika.
Można zdefiniować własny iterator dla określonej kolekcji
używając zdefiniowanej w bibliotece STL klasy
iterator. Na
przykład:
vector <string> svec;
// powyżej deklaracja obiektu svec klasy vector do
// przechowywania napisów (obiektów klasy string)
vector<string>::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 wskaźnika
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<<iter->size( )<<”: „<<*iter<<endl;
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};
vector<int>ivec(ia, ia+asize);
list <int> 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
//wskaźników wbudowanych do tablicy wbudowanej
vector <int>:: 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 <int>:: 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 wskaźnik 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 wskaźnika 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, zaprzyjaźnionej 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<typ>, gdzie typ jest typem standardowym, lub typem
klasy wbudowanej,
87
- sześć
relacyjnych
obiektów
funkcyjnych,
implementujących
wszystkie
operacje
relacji,
np.
greater<typ>,
- trzy logiczne obiekty funkcyjne, np. logical_not<typ>.
Aby użyć pierwotnie zbudowanych obiektów funkcyjnych
musimy dołączyć do programu następujący plik nagłówkowy
#include<functional>
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<int>( ));
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 <map>
#include <string>
map<string,int> words;
// utworzenie słownika o nazwie words
Dla naszego problemu zliczania ilości wystąpień słów w
tekście zapiszemy:
string tword;
while( cin>>tword ) (1)
words[tword]++;
Zauważmy, że jest to zapis o tej samej funkcjonalności co
kod na str. 73 dla tablicy asocjacyjnej wbudowanej (ale
znacznie prostszy).
Wyrażenie words[tword] wyszukuje w słowniku words typu
map wartości związanej z napisem znajdującym się w
obiekcie tword. Wartość ta jest następnie inkrementowana o
jeden. Jeśli wczytany napis nie znajduje się w słowniku,
zostanie on do kolekcji words wprowadzony z wartością 0, po
czym
wartość
ta
zostanie
w
naszym
programie
zinkrementowana do 1.
Odbywać się też będzie automatycznie, w miarę potrzeb,
zwiększanie obszaru pamięci zajmowanej przez kolekcję.
89
Zapiszmy teraz polecenie drukowania zawartości słownika
words z użyciem predefiniowanych w klasie map składowych
first oraz second oraz odpowiedniego iteratora:
map<string,int>::iterator it=words.begin( );
for( ; it!=words.end( ); ++ it )
cout<<”klucz: ” <<it->first
<<” wartość: ” <<it->second<<endl;
Mamy dwie możliwości wyszukiwania w słowniku według
pierwszego obiektu pary, tj. klucza.
Pierwszym sposobem będzie wykorzystanie przeciążonego
operatora indeksowania [ ]:
int count=0;
if( !(count=words[”napis”]) ) //”napis” nie występuje
Po wykonaniu operacji zmienna count otrzyma wartość
drugiej części pary (jeśli ”napis” występuje w słowniku), lub
wartość 0 (jeśli nie występuje). Niestety, efektem ubocznym
będzie wstawienie poszukiwanego napisu do słownika (z
wartością drugiej części pary równą 0 ). Ogólnie – wartość
domyślna drugiej części pary, przy wstawianiu nowej pary,
zależy od typów elementów pary. Dla pary typów <string,int>
wynosi ona 0.
Drugą możliwością będzie użycie specjalnie zaprojektowanej
dla kolekcji map metody count( ), która zwraca liczbę
wystąpień przekazanego jej elementu w słowniku:
int count=0;
string search_word(”napis”);
if( words.count(search_word) )// OK - występuje
count=words[search_word];
90
W słowniku map może znajdować się tylko pojedynczy
egzemplarz klucza pierwszej części pary. Jeżeli chcemy
przechowywać wiele par z tym samym egzemplarzem klucza,
to musimy użyć kolekcji multimap (wielosłownika).
Kolekcje typu
set
(zbiory)
Kontener set jest po prostu kolekcją kluczy. Najczęściej jest
używany, gdy chcemy wiedzieć, czy występują pewne obiekty
z większego zbioru obiektów. Na przykład, w algorytmie
przeglądania
grafu,
kolekcji
set
możemy
użyć
do
przechowywania węzłów już odwiedzonych.
W rozważanym przykładzie zliczania wystąpień wyrazów
wprowadźmy zbiór wyrazów często odwiedzanych w celu
wykluczenia ich z wyszukiwania w tablicy asocjacyjnej: :
#include<set>
#include<string>
set<string>word_exlusion;
Teraz pętla while (1) ze strony 88 będzie miała postać:
string tword;
while( cin>>tword )
{
if( word_exlusion.count( tword ) )
continue;
words[tword]++;
}
Jeśli wczytany wyraz znajduje się w zbiorze word_exlusion
metoda count( ) klasy set zwróci wartość różną od 0 i wykona
się polecenie continue przenoszące sterowanie na koniec
bloku stanowiącego instrukcję wewnętrzną pętli while. W
91
przeciwnym przypadku wykona się polecenie inkrementacji
drugiej części pary dla klucza tword.
W zbiorze set znajduje się zawsze tylko pojedynczy
egzemplarz każdej wartości klucza. Aby móc przechowywać
wiele egzemplarzy kluczy musimy użyć wielozbioru multiset.
Elementy w zbiorze są domyślnie uporządkowane przy użyciu
operatora mniejszy niż. W podanym niżej przykładzie:
int ival;
int ia[10]={1,3,5,8,5,3,1,5,8,1};
vector<int> vec(ia, ia+10);
set<int> iset(vec.begin( ), vec.end( ) );
elementami znajdującymi się w zbiorze iset są {1,3,5,8}.
Poszczególne elementy dodajemy do zbioru za pomocą
metody insert( ), np.: iset.insert(ival); W celu wypisania
elementów zbioru użyjemy iteratora:
int ival;
set<int>::iterator it= iset.begin( );
for( ; it!=iset.end( ); ++it) cout << *it <” ”;
cout << endl;
Krótkie
omówienie
pozostałych
paradygmatów
programowania
1. Programowanie zdarzeniowe
(zwane też programowaniem wizualno-zdarzeniowym)
Istotą tego podejścia do programowania jest sterowanie
przebiegiem programu za pomocą zdarzeń pochodzących
z zewnątrz. W najprostszym przypadku są to zdarzenia
pochodzące z urządzeń zewnętrznych typu: klawiatura,
mysz, joystick. Tworzenie oprogramowania opartego na
92
silnym powiązaniu wyświetlanych wyników działania
programu ze zdarzeniami pochodzącymi z tego typu
urządzeń jest zgodne z paradygmatem programowania
wizualno-zdarzeniowego.
Przykładem
języka
wspierającego
programowanie
wizualno-zdarzeniowe jest Visual C++.
2. Programowanie imperatywne
Jest to najbardziej pierwotny sposób programowania, w
którym program postrzegany jest jako ciąg poleceń dla
komputera, zgodnie z przebiegiem algorytmu, który
realizuje.
Ten sposób patrzenia na programy związany jest ściśle z
klasyczną budową jednoprocesorowego komputera o
architekturze
von
Neumanna,
w
której
rozkazy
wykonywane są sekwencyjnie. Większość języków
wysokiego poziomu, mimo iż posługuje się różnego rodzaju
abstrakcjami, w podstawowej warstwie wykonawczej wciąż
działa
zgodnie
z
paradygmatem
programowania
imperatywnego.
3. Programowanie w logice (logiczne, albo deklaratywne)
Programowanie sprowadza się do kompletowania zbioru
zależności (przesłanek) w celu wykazania prawdziwości
pewnego stwierdzenia (celu). Obliczenia wykonywane są
niejako „przy okazji” dowodzenia celu.
4. Programowanie funkcyjne
Nie ma tutaj imperatywnych z natury, zmiennych, ani też
tradycyjnie rozumianych pętli (bo te wymagają zmiennych
do sterowania ich przebiegiem). Nie ma też żadnych
efektów ubocznych.
93
Konstruowanie programów to składanie funkcji, zazwyczaj
z istotnym wykorzystaniem rekurencji. Charakterystyczne
jest również definiowanie funkcji wyższego rzędu, czyli
takich, dla których argumentami i których wynikami mogą
być funkcje (a nie tylko „proste” dane jak liczby lub
napisy). Podobnie jak w programowaniu funkcyjnym, nie
„wydajemy rozkazów”, a jedynie opisujemy, co wiemy i co
chcemy uzyskać.
K o n i e c w y k ł a d u