niedziela, 20 października 2019

Exception - kompendium wiedzy

Kolejnym ciekawym tematem, który chciałbym omówić są wyjątki w .NET. Ostatnie wersje języka C# przyniosły zmiany na które warto zwrócić uwagę.

Anomalie w kodzie można podzielić na trzy kategorie:

  1. Bugs -  błędy wprowadzone przez programistę (np. wyjście poza zakres tablicy)
  2. User Errors - błędy związane z danymi wprowadzanym przez użytkownika. Np. w pole numer pesel użytkownik wpisuje "Jan Kowalski"
  3. Exceptions - błędy, które trudno przewidzieć podczas pisania aplikacji. Przykładami takich błędów może być brak połączenia z bazą danych, usunięty plik z dysku, nieodpowiadający serwis itp.
C# od wersji 7.0 pozwala rzucać wyjątki zarówno w przypadku instrukcji (statement) jak i wyrażeń (expression). Czym się różnią? Najlepiej ilustruje to poniższy schemat:


W skrócie:
  • instrukcja - nie zwraca wartości
  • wyrażenie - po wykonaniu musi zwrócić jednoznaczną wartość
Wszystkie wyjątki w .NET dziedziczą po klasie System.Exception. Dodatkowo istnieje dodatkowa konwencja podziału wyjątków (o niej trochę więcej później w części poświęconej tworzeniu własnych wyjątków):
  • System level, dziedziczą po klasie System.SystemException. Wyjątki te rzucane są przez platformę .NET np. IndexOutOfRangeException
  • Application level, dziedziczą po klasie System.ApplicationException. Wyjątki zdefiniowane przez aplikację. Trzeba nadmienić, że większość programistów tworząc własne wyjątki dziedziczy z klasy System.Exception i jest to jak najbardziej poprawne technicznie.

Przykładowe rzucenie wyjątku i przechwycenie go celem wyświetlenia w konsoli:


            try
            {
                throw new Exception("My exception");
            }
            catch(Exception ex)
            {
                Console.WriteLine(ex);
            }



Klasa bazowa wyjątku System.Exception udostępnia kilka ciekawych właściwości z których możemy skorzystać. Zobaczmy je w akcji:


            try
            {
                var exception = new Exception("My exception")
                {
                    HelpLink = "http://OurPage.com/help"
                };
                exception.Data.Add("ExceptionTime", DateTime.UtcNow);
                throw exception;
            }
            catch(Exception ex)
            {
                Console.WriteLine(ex);
                foreach (var key in ex.Data.Keys)
                {
                    Console.WriteLine($"{key} : {ex.Data[key]}");
                }
                Console.WriteLine($"Look to documentation at : {ex.HelpLink}");
            }


Mamy tutaj do czynienia z dodatkowymi polami jak:
  • HelpLink - możemy przekazać użytkownikowi gdzie może szukać informacji jak rozwiązać dany problem
  • Data - kolekcja typu IDictionary przechowująca dane w postaci klucz wartość

Powyższe kawałki kodu prezentują podstawowy blok obsługi wyjątków. Zobaczmy na pełną wersję możliwości tego bloku:


            try
            {
                //Block that could throw exception
            }
            catch (Exception ex) when (1 == 1) //filter which can catch exception
            {
                //block that is executed only if error is thrown
            }
            finally
            {
                //block that will always execute - when exception is thrown and when not
            }


Słowa kluczowe

Słowa kluczowe które mamy do dyspozycji:
  • try - rozpoczyna blok kodu, w którym może zostać rzucony wyjątek
  • catch - bloku kodu który zostanie wywołany w momencie rzucenia wyjątku. Uwaga C# pozwala definiować catch bez zmiennej przyjmującej exception (try {} catch {}), nie otrzymujemy żadnych informacji o wyjątku jednak jest to jak najbardziej poprawna konstrukcja. 
  • when - nowość od C# 6.0 pozwala filtrować wyjątki. Blok ten może być zarówno prostą instrukcją warunkową bądź też metodą zwracającą wartość typu prawda / fałsz
  • finally - blok który zostanie wykonany niezależnie od tego czy wyjątek został zgłoszony czy nie. Służy przede wszystkim zamknięciu uchwytów do plików, połączeń do bazy danych i zwalnianiu innych zasobów które implementują interfejs IDispose 

Definiowanie własnych typów wyjątków

Definiując własne klasy wyjątków powinniśmy pamiętać o kilku podstawowych aspektach:
  • klasa wyjątku powinna mieć nazwę kończącą się na Exception np. MySuperException
  • klasa powinna być oznaczona modyfikatorem public, gdyż często zdarza się, że wyjątek będzie przesłany poza assembly w którym został wywołany
  • klasa powinna dziedziczyć po Exception/ApplicationException
  • klasa może posiadać dowolną ilość dodatkowych składowych (metod, pól, właściwości itp.)
  • klasa powinna być oznaczona atrybutem [System.Serializable]
  • klasa powinna posiadać domyślny konstruktor
  • powinien istnieć konstruktor przyjmujący parametr message i ustawiający ją w klasie bazowej
  • powinien istnieć konstruktor obsługujący serializacje
  • powinien istnieć konstruktor przyjmujący wewnętrzny wyjątek
Sporo zasad i dobra wiadomość dla użytkowników Visual Studio, który definiuje pomocny szablon tworzenia klas wyjątku. Wystarczy wpisać Exception i wcisnąć dwukrotnie przycisk tabulatora aby został stworzony szablon wyjątku:




Może nasuwać się pytanie po co tworzyć własne klasy wyjątków? Bardzo często służy to nie dodawaniu nowych pól czy właściwości. Chodzi o stworzenie silnie typowanego typu, który jednoznacznie identyfikuje dany rodzaj błędu dzięki czemu programista w kodzie może lepiej i łatwiej obsłużyć dany wyjątek.  

Kolejność przetwarzania wyjątków

Wyjątki przetwarzane są w kolejności którą zdefiniujemy. W przypadku gdy mamy wiele bloków catch wyjątki powinny być uszeregowane od najbardziej szczegółowego do najbardziej generycznego. Przykładowo:


Powyższy kod nie będzie kompilował się poprawnie. Wyjątek ArgumentNullException jest bardziej szczegółowy. Kolejność przechwytywania powinna zatem zostać zmieniona. 

Ponowne rzucenie wyjątkiem

Czasami zdarza się, że po przechwyceniu wyjątku chcemy go mimo wszystko rzucić ponownie. Przykładowo tworzymy bibliotekę, która ma za zadanie pobrać dane z serwisu pogody. Może zdarzyć się problem z połączeniem do serwera (np. jest chwilowo niedostępny). Łapiemy więc wyjątek np. TimeoutException, logujemy jego dane do pliku i mimo wszystko chcemy dać programiście informacje, że z serwerem jest coś nie tak. Przykład:


            try
            {
                //Check weather service
            }
            catch(TimeoutException ex)
            {
                System.Diagnostics.Debug.WriteLine("Weather servie is unavailable");
                throw;
            }


W tym miejscu ważna uwaga. Słówko kluczowe throw występuje samo w tym kontekście. Warto wiedzieć że możemy w kodzie znaleźć także drugą wersję throw ex; Czym one się różnią?
  • throw - rzuca wyjątek i zachowuje oryginalny stack trace wywołania
  • throw ex - zawija stack trace i tworzy go od nowa (od aktualnie wykonywanej linijki)
Co to oznacza w praktyce? Używając konstrukcji throw ex tracimy informację o oryginalnym miejscu gdzie został rzucony wyjątek. Warto wiedzieć o tym, gdyż jest to bardzo częste pytanie na rekrutacjach. Dodatkowo może powodować wiele niejasności i wprowadzać deweloperów w błąd co do miejsca gdzie błąd występuje. 

Przekazywanie wewnętrznych wyjątków

W niektórych sytuacja dochodzi do sytuacji, w której pomimo dobrych chęci w bloku catch zostanie rzucony wyjątek. Co w takim przypadku powinniśmy zrobić? Powinniśmy ten wyjątek zwrócić jako wewnętrzny wyjątek pierwotnego. Jak możemy to zrobić? Za pomocą konstruktora:


            try
            {
                //Some code throwin MySuperException
            }
            catch (MySuperException ex)
            {
                try
                {
                    //Do something that throws exception
                }
                catch (ArgumentNullException ex2)
                {
                    throw new MySuperException(ex.Message, ex2);
                }
            }


Błędy których nie jesteśmy w stanie przechwycić

Są to specjalne typy błędów, które mimo najlepszych bloków catch nie jesteśmy w stanie przechwycić. Na nasze szczęście nie jest takich wyjątków zbyt dużo:
  • StackOverflowException - błąd przepełnienia stosu. Rzucenie tego wyjątku powoduje automatyczne wykrzaczenie aplikacji. Nie ma w tym nic dziwnego, jeżeli skończyło się miejsce na stosie to nie ma nawet pamięci aby obsłużyć błąd.
  • ThreadAbortException- w tym przypadku mamy możliwość przechwycenia wyjątku, jednak pod koniec bloku błąd zostanie automatycznie rzucony ponownie czy tego chcemy czy nie. Istnieje jedna możliwość przechwycenia tego wyjątku - użycie metody Thread.ResetAbort() w bloku catch. Nie jest to zalecane i powinno się raczej przemyśleć architekturę aplikacji.
  • AccessViolationException - błąd zgłaszany gdy zostanie nadpisana pamięć podczas pracy nad zasobami niezarządzanymi. Błąd ten mimo wszystko można przechwycić, korzystając z atrybutu [HandleProcessCorruptedStateExceptionsAttribute] który musi być zadeklarowany w metodzie rzucającej ten typ wyjątku

Blok using

W tym miejscu trudno pominąć bloku using. Blok prezentuje się następująco:

            using(var file = new StreamWriter(@"c:\Pliki\plik.txt"))
            {
                file.WriteLine("Some text");
            }


Jak wiadomo blok using zapewnia nam wyczyszczenie zasobów niezarządzanych w przypadku problemów z kodem. W tym przypadku jeżeli nie udałoby się zapisać pliku, na zmiennej file zostanie wywołana metoda Dispose.

Dekompilując powyższy kod możemy łatwo zobaczyć że jest on kompilowany do postaci try { } finally {} zapewniając wywołanie metody Dispose. Ot cała magia :)



Brak komentarzy:

Prześlij komentarz