Metody realizacji języków programowania/MRJP Wykład 10

From Studia Informatyczne

Spis treści

Wyjątki

  • Pojęcie wyjątek oznacza błąd (nietypową, niepożądaną sytuację).
  • Obsługa wyjątków oznacza reakcję programu na wykryte błędy.
  • Funkcja, która napotkała problem zgłasza (rzuca) wyjątek.
  • Wyjątek jest przekazywany do miejsca wywołania funkcji, gdzie może być wyłapany i obsłużony albo przekazany wyżej. Innymi słowy poszukiwania bloku obsługi wyjątku dokonywane są po łańcuchu DL.
  • Przy wychodzeniu z funkcji i bloków usuwane są obiekty automatyczne.


Składnia

Dla ustalenia uwagi przyjmiemy składnię C++ (składnia Javy jest w tej kwestii bardzo podobna)

Zgłoszenie wyjątku

 throw <wyrażenie>

Obsługa wyjątków

 try {
   <instrukcje>
 } catch(<parametr 1>) {
   <obsługa wyjątku 1>
 //...
 } catch(<parametr n>) {
   <obsługa wyjątku n>
 }


Semantyka

  • Gdy któraś z instrukcji w części try przekazała wyjątek, przerywamy wykonanie tego ciągu i szukamy catch z odpowiednim parametrem.
  • Jeśli znajdziemy, to wykonujemy obsługę tego wyjątku, a po jej zakończeniu instrukcje po wszystkich blokach catch.
  • Jeśli nie znajdziemy, przechodzimy do miejsca wywołania (usuwając obiekty automatyczne bieżącej funkcji) i kontynuujemy poszukiwanie.
  • Jeśli nie znajdziemy w żadnej z aktywnych funkcji, wykonanie programu zostanie przerwane.

Implementacja obsługi wyjątków

Obsługę wyjątków można zrealizować na wiele różnych sposobów. Bardzo istotne jest jednak to, aby narzut (dodatkowy czas wykonania programu i zużyta pamięć) przy normalnym (tj. bez wystąpienia wyjątków) wykonaniu programu był minimalny, a w miarę możności zerowy. Z tego punktu widzenia realizacje wymagające wykonania dodatkowych czynności na początku i końcu bloku try można uznać za nieefektywne.

Bliższe spojrzenie na semantykę wyjątków ujawnia, że w momencie zgłoszenia wyjątku muszą zostać wykonane następujące czynności:

  • stwierdzenie, czy nastąpiło ono wewnątrz bloku try
  • identyfikacja aktywnych bloków try - może być więcej niż jeden, np.
void h() 
{
  try {
    f();
    try {
      g(); // tu wyjątek
    }
    catch E1 { ... }
  }
  catch E2 { ... }
}  
  • rozpoznanie typu zgłoszonego wyjątku
  • próba dopasowania do typu wyjątku jednego z bloków catch
  • w wypadku powodzenia wykonanie tego bloku
  • w przeciwnym wypadku przekazanie wyjątku w górę DL

Identyfikacja aktywnych bloków try

Dla identyfikacji aktywnych bloków try, w czasie wykonania programu musi być dostępna informacja (struktura danych), która dla każdej instrukcji pozwoli ustalić czy i jakie bloki try ją otaczają. Jeżeli chcemy uniknąć narzutu dla 'prawidłowego' przebiegu programu, informacja taka musi być w całości wygenerowana w czasie kompilacji.

Prostą i efektywną metodą spełniającą te wymagania jest użycia tablicy indeksowanej adresami instrukcji (a raczej zakresami adresów, w przeciwnym bowiem przypadku tablica miałaby rozmiar zbliżony do całkowitej liczby instrukcji programu). Elementami tej tablicy będą listy odpowiednich bloków try lub listy odpowiednich bloków catch

Przykład

Rozważmy przytoczony wcześniej przykład funkcji:

void h() 
{
  try {
    f();
    try {
      g(); // tu wyjątek
    }
    catch E1 { i1(); }
  }
  catch E2 { i2(); }
  i3();
} 

gdzie i1, i2, i3 są instrukcjami niezgłaszającymi wyjątków

Załóżmy przy tym, że wygenerowany dla niej został następujący kod maszynowy:

 0: enter
 1: call f
 2: call g
 3: jmp 7
 4: call i1
 5: jmp 7
 6: call i2
 7: call i3
 8: leave
 9: ret
 

Tablica, o której mowa wyglądać będzie następująco

Od Do Bloki catch
1 1 C1
2 2 C1, C2
3 6 C1


Ponadto dla każdego bloku catch potrzebujemy informacji o typie obsługiwanego wyjątku oraz adresie jego kodu:

Catch Typ Adres
C1 E1 4
C2 E2 6

Zauważmy, że obie tablice mogą łatwo zostać wygenerowane w czasie kompilacji. Uzbrojeni w nie, możemy przejść do następnego etapu obsługi wyjątku.

Dopasowanie bloku catch do typu wyjątku

W wielu językach wyjątkiem może być dowolna wartość (obiekt). Dla każdego obiektu musi zatem istnieć możliwość stwierdzenia w czasie wykonania, czy jest on określonego typu (biorąc pod uwagę także dziedziczenie). Z tej (między innymi) przyczyny języki wspierające wyjątki zwykle udostępniają także informacje o typach w czasie wykonania (ang RunTime Type Information, RTTI)

Rozważmy nasz przykład poszerzony o następujące definicje:

 class E1 {};
 class E2 {};
 class E3 : public E2 {};
 class K {}; 

 void g()
 {
   K k;
   throw (new E3());
 }
 
 void h() 
 {
  try {
    f();
    try {
      g(); // zgłasza wyjątek e
    }
    catch E1 { i1(); }
  }
  catch E2 { i2(); }
  i3();
 } 

Wywołanie funkcji g powoduje zgłoszenie wyjątku. Nazwijmy jego wartość e. W poprzedniej fazie obsługi ustaliliśmy, że w tym momencie aktywne są bloki C1, C2. Przystępujemy zatem do dopasowania typów:

  • C1 obsługuje typ E1; czy e jest typu E1? NIE.
  • C2 obsługuje typ E2; czy e jest typu E2? TAK (jest klasy E3, która jest podklasą E2).

Wykonany powinien zostać blok C2, czyli skok pod adres 6.

Zwijanie stosu

Jeśli nie został odnaleziony żaden pasujący do typu wyjątku blok catch (w szczczególności, jeśli nie byliśmy w żadnym bloku try), kontynuujemy poszukiwanie wzdłuż łańcucha DL, usuwając po drodze wszystkie obiekty automatyczne. W naszym przykładzie

 void g()
 {
   K k;
   throw (new E3());
 }

należy usunąć obiekt k i kontynuować poszukiwania w miejscu wywołan ia funkcji g (czyli w funkcji h).

Najprostszą metodą realizacji takiego zachowania jest ustawienie flagi oznaczającej wyjątek, a potem zachowanie takie, jak przy powrocie z funkcji. Kod dla wywołania funkcji musi po powrocie sprawdzić flagę wyjątku i w razie potrzeby podjąć poszukiwania bloku obsługi dla tego wyjątku.

Zauważmy, że rozwiązanie to wprowadza pewien dodatkowy koszt także w sytuacjach, kiedy nie został zgłoszony żaden wyjątek (flagę wyjątku trzeba sprawdzać po każdym wywołaniu funkcji). Jest on jednak bardzo niewielki (1-2 instrukcje procesora na wywołanie), co wydaje się akceptowalne.

Jeżeli chcemy uniknąć tego kosztu, musimy zaimplementować pełne zwijanie stosu. W tym celu musimy przechowywać (poza stosem maszynowym, np. na dodatkowym stosie albo na stercie) listę obiektów automatycznych. Przy zgłoszeniu wyjątku poszukujemy po łańcuchu DL ramki stosu zawierającej odpowiedni blok obsługi wyjątku, po czym usuwamy kolejno wszystkie obiekty automatyczne aż do tej ramki. Pewnej staranności wymaga rozstrzygnięcie, które obiekty z tej ostatniej ramki powinny zostać usunięte. Szczegółowy opis tej metody można znaleźć np. w dokumencie [[1]].