Rozdział 8. Obsługa wyjątków.

msm

Trzeba sobie uświadomić, że błędy w aplikacjach są — niczym śnieg na biegunie — elementem nieodłącznym. Wielu programistów, często z pośpiechu, lecz także z braku wystarczających umiejętności, marginalizuje problem błędów w aplikacjach. Wiele firm, mimo ogromnego zaangażowania setek programistów oraz dużych nakładów finansowych, wciąż nie jest w stanie pozbyć się wszystkich błędów (np. Microsoft) i bez przerwy publikuje nowe poprawki do swoich produktów. Tylko program zawierający trzy linie kodu źródłowego może być pozbawiony jakichkolwiek błędów, lecz w przypadku skomplikowanych, rozbudowanych aplikacji uniknięcie niedoskonałości jest niemożliwe. Dzieje się tak dlatego, że programista jest tylko człowiekiem i po prostu się myli. Pozornie aplikacja może zachowywać się normalnie, a błąd może tkwić gdzieś indziej. Nie mówię tutaj bowiem o błędach wykrywanych w czasie kompilacji, najłatwiejszych do usunięcia, które sprowadza się najczęściej do drobnych poprawek, często bardzo śmiesznych — na przykład dopisanie średnika na końcu wyrażenia. Najtrudniejsze do wykrycia są błędy zagnieżdżone w kodzie, kiedy program pozornie działa prawidłowo, lecz nie do końca wykonuje operacje, których oczekuje użytkownik. Warto, abyś w tym momencie dobrze zapamiętał stwierdzenie, iż program zawsze działa prawidłowo, a jeśli nie działa zgodnie z naszymi oczekiwaniami — jest to zwyczajnie wina projektanta.

Pomijam tutaj błędy kompilatora, bo te również są tworzone przez ludzi i również mogą zawierać błędy. Kompilator jest jednak produktem podwyższonego ryzyka: nie można pozwolić, aby zawierał choćby najdrobniejsze błędy.

Błąd w aplikacji często jest określany mianem bug (z ang. robak, pluskwa). Termin ten wziął się z czasów, gdy komputery zajmowały duże pomieszczenia i pobierały tyle energii co małe osiedle, a ich obsługą zajmował się sztab ludzi. Robaki, które zalęgły się gdzieś w zakamarkach ogromnej maszyny, czasami powodowały zwarcie instalacji elektrycznej. Od tamtej pory błędy nazywa się bugami, a proces ich wykrywania — debugowaniem (z ang. debugging).

Częstą przyczyną „uwidocznienia” błędu jest czynnik ludzki. Załóżmy na przykład, że piszesz skomplikowaną aplikację biurową, z której będzie korzystało wielu ludzi. Nie każdy z nich jest informatykiem, nie każdy posiada odpowiednią wiedzę, aby wystarczająco dobrze obsłużyć Twój program. Ty oczywiście powinieneś dostarczyć wraz z aplikacją podręcznik użytkownika oraz projektować interfejsy w sposób przejrzysty, lecz nie jesteś w stanie uniknąć sytuacji, w której użytkownik obsłuży ją nieprawidłowo — na przykład w danym polu tekstowym wpisze tekst zamiast liczby. Taki z pozoru błahy błąd może spowodować niespodziewane efekty i dziwne zachowania Twojego programu, dlatego nowoczesne języki programowania dostarczają odpowiednie mechanizmy, dzięki którym jesteśmy w stanie odpowiednio zareagować na takie przypadki.

1 Czym są wyjątki
2 Obsługa wyjątków
     2.1 Blok finally
     2.2 Zagnieżdżanie wyjątków
3 Klasa System.Exception
     3.3 Selektywna obsługa wyjątków
     3.4 Wywoływanie wyjątków
4 Własne klasy wyjątków
     4.5 Deklarowanie własnej klasy
     4.6 Przykładowa aplikacja
5 Przepełnienia zmiennych
6 Podsumowanie

Czym są wyjątki

Wyjątkiem nazywamy mechanizm kontroli przepływu występujący w językach programowania i służący do obsługi zdarzeń wyjątkowych. Zdarzenia wyjątkowe to w szczególności błędy, jak np. dzielenie przez zero.
Dzielenie liczby przez zero w programowaniu nie jest dopuszczalne. Nowoczesne kompilatory starają się wykryć takie sytuacje i nie dopuścić do kompilacji. Przykład:

int X;
X = 10 / 0;

Taki kod nie zostanie skompilowany, ponieważ kompilator wykryje próbę dzielenia przez zero. Można go jednak łatwo oszukać, podstawiając wartości pod zmienne:

int X, Y, Z;
X = 10;
Y = 0;
Z = X / Y;

Powyższy kod zostanie skompilowany, lecz już w trakcie działania taki program się „wysypie”, wyświetlając błąd. Mechanizm wyjątków pozwala nam przechwycić takie niedopuszczalne i wyjątkowe sytuacje i odpowiednio zareagować, np. wyświetlając komunikat: Panie, nie wiesz, że nie można dzielić przez zero?.

Kompilator jest jednak tylko głupim programem i musimy go odpowiednio uświadomić, jakie miejsce kodu jest narażone na wystąpienie błędu.

Obsługa wyjątków

W języku C#, jak i w wielu innych popularnych językach, kod narażony na wystąpienie nieprzewidzianych sytuacji musimy oznaczyć słowem kluczowym try. Kod występujący w bloku try będzie „obserwowany” i w razie wystąpienia nieprzewidzianych sytuacji będzie można odpowiednio zareagować:

try
{
// tutaj kod
}

Sam blok try nie wystarczy, musimy gdzieś zawrzeć kod, który będzie wykonywany w razie wystąpienia błędu. W tym celu używamy słowa kluczowego catch:

try
{
// kod 
}
catch
{
// kod w razie wystąpienia wyjątku
}

Przykładowo, aby odpowiednio zareagować na próbę dzielenia, możemy zastosować następujące instrukcje:

int X, Y, Z;
            
X = 10;
Y = 0;

try
{
    Z = X / Y;
}
catch
{
    Console.WriteLine("Prosimy nie dzielić przez zero!");
}

Spróbuj uruchomić taki kod. Wskutek jego działania na konsoli wyświetlony zostanie tekst Prosimy nie dzielić przez zero!.

Uruchamiając projekt z poziomu środowiska Visual C# Express Edition, możesz nie zaobserwować działania wyjątków. Wszystko dlatego, że to środowisko odpowiada w takim przypadku za obsługę błędów. Aby lepiej zobrazować działanie wyjątków, uruchamiaj swoje programy bez użycia debuggera. W Visual C# Express Edition odpowiada za to skrót klawiaturowy Ctrl+F5.

Blok finally

Blok catch jest opcjonalny. Równie dobrze w miejsce słowa kluczowego catch możemy wpisać finally. Różnica jest spora: kod zawarty w bloku finally zostanie wykonany zawsze, bez względu na to, czy wyjątek wystąpił, czy też nie. Jest to dobre miejsce na wykonanie instrukcji, które muszą być wykonane przed zamknięciem programu. Dobrym przykładem są operacje na plikach. Po otwarciu pliku dokonujemy odczytu danych i skomplikowanych operacji. Dodajmy do tego, że plik jest otwarty na wyłączność naszego programu. Chcemy, aby w razie wystąpienia błędu plik został zamknięty.

Dobrą praktyką jest łączenie bloku try-catch-finally — dzięki temu możemy odpowiednio zareagować na wystąpienie wyjątku oraz wykonać kod niezbędny przed zamknięciem aplikacji. Oto przykład:

try
{
    Z = X / Y;
}
catch
{
    Console.WriteLine("Prosimy nie dzielić przez zero!");
}
finally
{
    Console.WriteLine("Kod z bloku finally"); 
}

Po uruchomieniu takiej aplikacji oba komunikaty zostaną wyświetlone tylko wtedy, jeżeli dzielenie spowoduje błąd (w najlepszym przypadku zostanie wyświetlony jeden z nich, z bloku finally).

Zagnieżdżanie wyjątków

Bloki wyjątków można w miarę potrzeb dowolnie zagnieżdżać:

try
{
    // kod

    try
    {
        // kod 
        try
        {

        }
        finally
        {
            // finally
        }
    }
    catch
    {
        // jeszcze inny błąd
    }
}
catch
{
    // błąd
}
finally
{
    // blok finally 
}

Wystąpienie wyjątku w zagnieżdżonym bloku nie oznacza, że wykonany zostanie również kod z „zewnętrznych” bloków catch.

Klasa System.Exception

Nieobsłużone wyjątki, czyli takie, których nie obsługuje nasza aplikacja, są obsługiwane przez system. Owocuje to najczęściej wystąpieniem komunikatu o błędzie, często niejasnym, informującym np. o nieprawidłowym odwołaniu do pamięci. To już jednak są ekstremalne sytuacje.

W każdym razie jeśli wystąpi wyjątek, system dostarcza programiście informacji na jego temat, które może on obsłużyć wedle własnego uznania. Informacje oczywiście dostarczone są w formie obiektu klasy, której klasą bazową jest System.Exception. W rzeczywistości środowisko .NET Framework posiada całkiem pokaźną kolekcję wyjątków dziedziczonych po tej właśnie klasie.

Poniższy kod prezentuje obsługę wyjątku polegającego na wyświetlaniu dostarczonego komunikatu o błędzie:

// kod 
try
{
    Z = X / Y;
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}

Jak widzisz, parametr bloku catch jest opcjonalny, aczkolwiek dopuszczalny. Taki zapis oznacza deklarację zmiennej e typu Exception (czyli w rzeczywistości System.Exception) i przypisanie do niej informacji odnośnie do błędu. Tabela 8.1 zawiera spis najważniejszych właściwości klasy System.Exception.

Tabela 8.1. Najważniejsze właściwości klasy System.Exception

WłaściwośćOpis
`Data`Dodatkowe informacje na temat źródła wystąpienia wyjątku.
`HelpLink`Umożliwia odczytanie lub ustawienie linka (np. do pomocy) związanego z błędem.
`Message`Komunikat błędu.
`Source`Umożliwia odczytanie lub przypisanie nazwy aplikacji lub obiektu, w którym wystąpił błąd.
`TargetSite`Umożliwia odczytanie metody, w której wystąpił błąd.

Selektywna obsługa wyjątków

Czasami może zaistnieć sytuacja, w której będziemy chcieli odpowiednio zareagować w zależności od rodzaju wyjątku, jaki wystąpił. Język C# umożliwia w takich sytuacjach dodanie kolejnego bloku catch:

string[] Foo = new string[5];

try
{
    Foo[10] = "Bar";
}
catch (IndexOutOfRangeException e)
{
    Console.WriteLine("Indeks jest zbyt duży!");
}
catch
{
    Console.WriteLine("Inny błąd");
}

Jak widzisz, w tym programie popełniłem ewidentny błąd — próbuję przypisać wartość do indeksu tablicy nr 10. Wskutek takiego działania zostanie wykonany wyjątek IndexOutOfRangeException, który obsłuży pierwszy blok catch. Wszelkie inne nieobsłużone jeszcze wyjątki będą obsługiwane przez domyślny blok catch.

Wywoływanie wyjątków

W wielu językach programowania istnieje możliwość wywoływania danego wyjątku w dowolnym miejscu kodu. Jest to zasygnalizowanie aplikacji, iż w tym miejscu dochodzi do nieprzewidzianej sytuacji.
Konstrukcja jest dość prosta, służy do tego słowo kluczowe throw. Ponieważ należy użyć tego słowa w połączeniu z obiektem dziedziczonym po klasie System.Exception, będziemy musieli dodatkowo użyć słowa kluczowego new:

try
{
    throw new IndexOutOfRangeException();
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}

throw jest najczęściej używane wewnątrz bloku try, aczkolwiek dopuszczalne jest jego użycie poza nim. W takiej sytuacji aplikacja może uruchomić domyślną obsługę wyjątków, co najczęściej kończy się komunikatem o błędzie.

Własne klasy wyjątków

Na potrzeby naszego programu możemy zadeklarować w nim własne klasy obsługi wyjątków. Przykładowo, w grze w kółko i krzyżyk, w metodzie Set() przeprowadzaliśmy walidację danych, należało sprawdzić, czy użytkownik podał prawidłowe współrzędne. Dobrym rozwiązaniem byłoby zadeklarowanie wówczas własnej klasy wyjątków, która byłaby wykonywana w razie podania nieprawidłowych danych:

try
{
    FField[X, Y] = GetActive().Type;
}
catch
{
    throw new BadPointException();
}

Jeśli utworzylibyśmy nową klasę BadPointException, moglibyśmy odpowiednio zareagować na tę sytuację, nie tylko poprzez wyświetlenie odpowiedniego komunikatu, ale również poprzez podanie odnośnika (URL) — np. do opisanych zasad gry. No dobrze, być może trochę zbytnio zobrazowałemsytuację, równie dobrze można to rozwiązać w ten sposób:

try
{
    FField[X, Y] = GetActive().Type;
}
catch
{
    Console.WriteLine("Nieprawidłowe pole! Naucz się grać!");
}

Ale czy koniecznie chcemy, aby komunikat błędu był wyświetlany w oknie konsoli? Może lepszym rozwiązaniem byłoby, gdybyśmy pozwolili decydować klasie wyjątku, co ma zrobić z danym komunikatem?
BadPointException("Nieprawidłowe pole! Naucz się grać!");

Niech klasa BadPointException decyduje, co program powinien w takiej chwili zrobić. Może wyświetlić komunikat na konsoli albo w nowym okienku Windows? Jeżeli będziemy dostosowywali nasz program, aby działał nie — jak dotychczas — w oknie konsoli, ale z wykorzystaniem biblioteki WinForms, wymagane poprawki będą kosmetyczne (albo w ogóle ich nie będzie).

Deklarowanie własnej klasy

Najlepszym rozwiązaniem jest skorzystanie z tego, co już jest. Po co wywarzać otwarte drzwi? Najlepiej więc będzie, gdy nasza nowa klasa będzie dziedziczyła po System.Exception. Po lekturze poprzednich rozdziałów nie powinno być z tym problemu:

public class MediumException : System.Exception
{
    public MediumException(string Message)
        : base(Message)
    {
        this.Source = "FooException";
        this.HelpLink = "http://4programmers.net/C_sharp";
    }
}

Dobrą praktyką jest, aby klasy obsługi wyjątków posiadały w nazwie słówko Exception.

W konstruktorze klasy do odpowiednich właściwości przypisywane są dane, które mogą pomóc w ewentualnym odszukaniu i naprawie usterki. Zwróć również uwagę, że konstruktor klasy MediumException dziedziczy po takim samym konstruktorze z System.Array. W bardziej rozbudowanych aplikacjach zalecane jest deklarowanie 3 konstruktorów dla klas wyjątków, każdy z innymi parametrami:

public MediumException()
{
}

public MediumException(string Message)
    : base(Message)
{
}

public MediumException(string Message, Exception inner)
    : base(Message, inner)
{
}

Przykładowa aplikacja

Aby usystematyzować wiedzę na temat wyjątków, proponuję napisanie prostej aplikacji z wykorzystaniem biblioteki WinForms. W zależności od wybranej opcji będzie ona generować dany wyjątek, a następnie odpowiednio go obsługiwać. Program w trakcie działania zaprezentowany został na rysunku 8.1.

csharp8.1.jpg
Rysunek 8.1. Program podczas działania

Do napisania takiej aplikacji użyłem komponentów Button, RadioButton oraz RichTextBox. W zależności od wybranej opcji po naciśnięciu przycisku generowany zostanie dany wyjątek.

Kod procedury zdarzeniowej wygląda następująco:

private void RunBtn_Click(object sender, EventArgs e)
{
    try
    {
        if (lowExceptionRadio.Checked)
        {
            throw new LowException("Niegroźny błąd");
        }
        if (mediumExceptionRadio.Checked)
        {
            throw new MediumException("Średni błąd");
        }
        if (HighExceptionRadio.Checked)
        {
            throw new HighException();
        }
    }
    catch (Exception ex)
    {
        RichBox.Clear();
        RichBox.Text  += String.Format(
                         "Komunikat: {0}\n" +
                         "Podzespół: {1}\n" +
                         "Metoda:  {2}\n" +     
                         "Podzespół: {3}", ex.Message, ex.Source, ex.TargetSite, ex.HelpLink);

    }

}

W bloku catch następuje przechwycenie wyjątku i wyświetlenie informacji na jego temat. Pełny kod źródłowy programu znajduje się na listingu 8.1.

Właściwość Checked (typu bool) komponentu RadioButton informuje, czy kontrolka jest zaznaczona, czy też nie.

Listing 8.1. Obsługa wyjątków w C#

using System;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;

namespace ExceptionApp
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void RunBtn_Click(object sender, EventArgs e)
        {
            try
            {
                if (lowExceptionRadio.Checked)
                {
                    throw new LowException("Niegroźny błąd");
                }
                if (mediumExceptionRadio.Checked)
                {
                    throw new MediumException("Średni błąd");
                }
                if (HighExceptionRadio.Checked)
                {
                    throw new HighException();
                }
            }
            catch (Exception ex)
            {
                RichBox.Clear();
                RichBox.Text  += String.Format(
                                 "Komunikat: {0}\n" +
                                 "Podzespół: {1}\n" +
                                 "Metoda:  {2}\n" +     
                                 "Podzespół: {3}", ex.Message, ex.Source, ex.TargetSite, ex.HelpLink);

            }

        }
    }

    public class LowException : System.Exception
    {
        public LowException(string Message) : base(Message)
        {
            this.Source = "FooException";
            this.HelpLink = "http://4programmers.net";
        }
    }

    public class MediumException : System.Exception
    {
        public MediumException(string Message)
            : base(Message)
        {
            this.Source = "FooException";
            this.HelpLink = "http://4programmers.net/C_sharp";
        }
    }

    public class HighException : System.Exception  {  }

}

Pamiętaj, aby wszelkie klasy swojego programu umieszczać w kodzie niżej niż klasa obsługi formularza (w moim wypadku — Form1). Inaczej środowisko Visual C# Express Edition ma problem z prawidłowym działaniem w trybie projektowania.

Właściwie wszystko w tym kodzie powinno być dla Ciebie zrozumiałe. W konstruktorze przypisujemy wartości właściwościom dziedziczonym po klasie System.Exception. Owe właściwości są później odczytywane w bloku catch.

Przepełnienia zmiennych

Na początku tej książki wprowadziłem pojęcie „typu danych”. Tam również wspomniałem o tym, iż każdy typ danych posiada jakiś maksymalny zakres, tj. maksymalną wartość, jaką można przypisać do zmiennej tego typu. Np. maksymalna wartość, jaką można przypisać do zmiennej typu byte, to 255. Próba przypisania większej wartości zakończy się błędem:

byte b = 256; 

OK, kompilator wykrywa takie próby, lecz nie jest w stanie wykryć próby przypisania wartości większej niż 255 w trakcie działania programu:

byte X = 250;
byte Y = 50;
byte Z = (byte) (X + Y);

W tym przykładzie zmienna Z będzie posiadać wartość 44. Dlaczego? Po przekroczeniu zakresu wartości będą ponownie numerowane od zera. Tak więc: 255+50 = 300–255 = 45. Ponieważ wartości numerowane są od zera, zmienna Z będzie miała wartość 44, a nie 45, jak mogłoby wynikać z tego rachunku matematycznego.

Użycie słowa kluczowego checked spowoduje, iż przy przepełnieniu zmiennej zgłaszany będzie wyjątek OverflowException:

using System;

namespace FooApp
{
    class Program
    {
        static void Main(string[] args)
        {
            byte X, Y, Z;

            X = 250;
            Y = 50;
            Z = 0;

            try
            {
                Z = checked((byte)(X + Y));
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }            
            Console.Write(Z);           
            
            Console.Read();
        }
    }

W wyniku zaistnienia takiego kodu na ekranie konsoli wyświetlona zostanie treść komunikatu o błędzie. Słowa kluczowego checked można użyć jako operatora (tak jak to przedstawiono w przykładzie powyżej) lub w formie konstrukcji:

checked
{
// kod narażony na przepełnienie zmiennej
}

Język C# posiada również słowo kluczowe unchecked, które powoduje, iż w przypadku przepełnienia zmiennej nie jest zgłaszany wyjątek. Jest to jednak domyślne zachowanie aplikacji pisanej w C# więc nie ma potrzeby jawnego użycia tego słowa.

Podsumowanie

Obsługa wyjątków w C# nie jest ani trudna, ani też konieczna, aczkolwiek warto się nad tym zastanowić podczas pisania aplikacji. Jeżeli piszesz kod, który może być narażony na ryzyko wystąpienia błędu, stosuj wywołanie try-catch-finally. Pozwoli Ci ono odpowiednio zareagować na ewentualne błędy oraz zwolnić wszystkie zasoby zadeklarowane w trakcie działania aplikacji.

[[C_Sharp/Wprowadzenie|Spis treści]]

[[C_Sharp/Wprowadzenie/Prawa autorskie|©]] Helion 2006. Autor: Adam Boduch. Zabrania się rozpowszechniania tego tekstu bez zgody autora.

3 komentarzy

Jasno i przejrzyście :)

Trzeba było tak od razu ;)
Właściwie to na jedno wychodzi, bo dzięki mnie ten artykuł powstał w przyśpieszonym tempie :)

@msm: to jest ksiazka "Wstep do programowania w jezyku C#". Bede sukcesywanie umieszczal tutaj elektroniczna wersje tej ksiazki - tutaj nie piszemy artykulow :)