Zdarza się, że podczas wykonywania operacji dochodzi do sytuacji nieprzewidzianych. Na przykład podczas komunikacji przez sieć, gdy urządzenie utraci połączenie. Biblioteka odpowiedzialna za komunikację w takiej sytuacji najprawdopodobniej przerwie wykonywaną operację poprzez zgłoszenie wyjątku. Jest to najczęściej stosowana strategia obsługi zdarzeń problematycznych. Spotkałem się również z rozwiązaniami, które w przypadku błędu zwracają wartość np. liczbową określającą przyczynę problemu. Takie rozwiązanie zmusza programistę do zachowania szczególnej ostrożności, tak aby wszystkie stany zostały obsłużone. Działanie może prowadzić do mało czytelnego kodu, a co jest z tym związane problemów podczas późniejszej obsługi. W języku C# powszechnie stosowanym mechanizmem są wyjątki. W sytuacji, gdy zgłaszany jest wyjątek, wykonywana operacja jest przerywana, po czym następuje przejście do najbliższej sekcji odpowiedzialnej za obsługę błędów. Mechanizm sprzyja separacji kodu realizującego działania od obsługi wyjątków, dzięki czemu kod pozostaje czytelny.
Część bibliotek .NET zapewnia możliwość wyboru pomiędzy informowaniem o błędzie poprzez wartość wynikową a zgłoszeniem wyjątku. Przykładem może być metoda int.Parse
, która pobiera łańcuch znaków i konwertuje go na liczbę. W sytuacji, gdy wprowadzona zostanie wartość niereprezentująca liczby całkowitej w formie tekstowej, zgłoszony zostanie wyjątek typu FormatException
. Jeżeli chcemy uniknąć obsługi wyjątku mając świadomość, że przekazana wartość może spowodować błąd, możemy użyć metody int.TryParse
, która w przypadku niepowodzenia zwraca wartość false. Przetwarzanie danych nie jest jedynym miejscem, w którym występuje wzorzec Parse
oraz TryParse
dający możliwość wyboru pomiędzy wyjątkiem a wartością wynikową. Innym miejscem jest, chociażby próba pobrania wartości ze słownika. W przypadku niewystępowania klucza zgłoszony zostanie wyjątek. Istnieje jednak możliwość "bezpiecznego" zweryfikowania czy dany klucz znajduje się w słowniku poprzez użycie metody TryGetValue
. Nasuwa się pytanie kiedy unikać zgłaszania wyjątków?
Nie należy używać kodów błędów. Podstawowym sposobem raportowania błędów są wyjątki.
Zastosowanie wzorca TryParse należy rozważyć w odniesieniu do składowych, które mogą zgłaszać wyjątki w powszechnie stosowanych rozwiązaniach, aby uniknąć problemów związanych z wydajnością obsługi wyjątków.
Najprościej sprawę ujmując, jeżeli wykonanie operacji trwa krócej niż zgłoszenie oraz obsługa wyjątku, warto skorzystać z metody zwracającej wartość wynikową. W przetoczonym tekście pojawia się fragment o wydajności obsługi wyjątków. Czy jest zatem czym się martwić? Obsługa wyjątków, zazwyczaj mieści się w okolicy milisekund. Zatem nie są dramatycznie wolne. Z drugiej strony notoryczne zgłaszanie wyjątków może doprowadzić do zbędnego zwolnienia. Po omówieniu zagadnień związanych z wyjątkami wrócimy do kwestii weryfikacji opóźnienia wynikającej ze zgłaszania wyjątków.
Przyjrzyjmy się poniższemu przykładowi. Jeżeli w katalogu Windows na dysku C: istnieje plik WindowsUpdate.log oraz aplikacja posiada prawa umożliwiające odczyt i żaden inny proces nie zarezerwował dostępu do pliku, a dodatkowo nie wystąpi żaden inny problem, wyświetlona zostanie zawartość pliku.
using System;
using System.IO;
namespace ReadFile
{
class Program
{
static void Main(string[] args)
{
using (var sr = new StreamReader(@"C:\Windows\WindowsUpdate.log"))
{
while (!sr.EndOfStream)
Console.WriteLine(sr.ReadLine());
}
Console.ReadLine();
}
}
}
Trochę dużo tych warunków jak na kilka linii kodu. A co jeżeli wskazany plik nie istnieje? Konstruktor klasy StreamReader
zgłosi wyjątek typu FileNotFoundException
. W sytuacji gdy debugujemy kod zgłoszony wyjątek zatrzyma działanie, a Visual Studio wyświetli okno dialogowe. Jeżeli jednak wyjątek pojawi się w trakcie działania aplikacji, a nasz kod nie posiada obsługi wyjątku, działanie zostanie przerwane poprzez zamknięcie. Poniżej zamieszczam komunikat zawierający informację odnośnie przyczyny wystąpienia wyjątku oraz stosu wywołań w chwili jego zgłoszenia. Stos wywołań często stanowi informację umożliwiającą lokalizację miejsca zgłoszenia wyjątku. Analizę zacznijmy od ostatniej linii stosu, jednocześnie śledząc kod. Wyjątek rozpoczyna się od przestrzeni nazw ReadFile klasa Program metoda Main, dalej mamy konstruktor klasy StreamReader. Skąd wiemy, że wyjątek zgłaszany jest przez konstruktor? Wskazuje na to fragment .ctor. Innym charakterystycznymi elementami są get_ oraz set_ wskazującymi na odczyt / zapis wartości właściwości.
Wyjątek nieobsłużony: System.IO.FileNotFoundException: Nie można odnaleźć pliku 'C:\Windows\WindowsUpdate.log'.
w System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)
w System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, ...)
w System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, ...)
w System.IO.StreamReader..ctor(String path, Encoding encoding, ...)
w System.IO.StreamReader..ctor(String path)
w ReadFile.Program.Main(String[] args) w C:\Users\troska\source\repos\HelloEx\HelloEx\Program.cs:wiersz 10
Mając wiedzę kiedy wyjątek może zostać zgłoszony oraz jak wygląda, przejdźmy do najważniejszej części. Jak go obsłużyć. Kod odpowiedzialny za obsługę składa się z dwóch sekcji. Pierwsza try
to blok przetwarzania. W sytuacji pojawienia się wyjątku CLR przekaże go, do drugiej sekcji catch
odpowiadającej za obsługę. Poniżej wcześniejszy przykład, rozszerzony o obsługę wyjątku FileNotFoundException
.
try
{
using (var sr = new StreamReader(@"C:\Windows\WindowsUpdate.log"))
{
while (!sr.EndOfStream)
Console.WriteLine(sr.ReadLine());
}
}
catch (FileNotFoundException)
{
Console.WriteLine("File could not be found");
}
Tym razem zgłoszony wyjątek nie przerywa działania aplikacji. CLR przechodzi w górę stosu wywołań szukając kodu odpowiedzialnego za obsługę. W naszym przypadku ograniczamy się do wyświetlania informacji o problemie. Oczywiście obsługa może być dowolna.
Przyjrzyjmy się obiektowi typu FileNotFoundException
, który dziedziczy po klasie bazowej Exception
. Język C# umożliwia zgłaszanie wyłącznie wyjątków typów pochodnych klasy Exception
. Podstawowe informacje, jakie udostępnia wyjątek to tekstowy opis problemu Message oraz StackTrace zawierający stos wywołań prezentowany w postaci tekstowej. Ponadto dostępna jest właściwość InnerException reprezentująca pierwotny wyjątek. W większości przypadku właściwość InnerException pozostanie pusta. Nie mniej zdarzają się przypadki, w których wyjątek zostanie przechwycony, ale przekazany dalej. W celu zachowania "ciągłości" przekazuje się referencje do pierwotnego wyjątku. Każdy z wyspecjalizowanych wyjątków może posiadać zestaw dodatkowych informacji charakterystycznych dla zgłaszanego problemu. Jeżeli potrzebujemy uzyskać dostęp do informacji wyjątku, należy przekazać referencje poprzez określenie identyfikatora. Poniżej przykład przekazania referencji do wyjątku w celu uzyskania dostępu do właściwości FileName.
try
{
...
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File: {0} could not be found", ex.FileName);
}
W prezentowanym przykładzie obsłużony został jeden z kilku wymienionych wyjątków, jakie mogą mieć miejsce podczas próby odczytania zawartości pliku. W sytuacji, gdy chcemy obsłużyć różne typy wyjątków, należy dodać kolejny blok catch
. Ważne, aby kolejność została ustawiona od najbardziej szczegółowego do najbardziej ogólnego wyjątku, czyli Exception. W przykładzie poniżej typ wyjątku FileNotFoundException
dziedziczy z IOException
, przez co kolejność jest zachowana. W sytuacji gdy popełnimy błąd, kompilator poinformuje nas o tym.
try
{
...
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File: {0} could not be found", ex.FileName);
}
catch (IOException)
{
Console.WriteLine("Error IO");
}
Warto zauważyć, że obsługa wszystkich zgłoszonych wyjątków poprzez dodanie bloku catch (Exception)
nie jest najlepszym pomysłem. Takie działanie może doprowadzić do ukrycia problemu, co z kolei może doprowadzić do jeszcze większych problemów. Wyjątek powinniśmy obsługiwać, w sytuacji gdy rzeczywiście jesteśmy w stanie zareagować.
C# umożliwia zagnieżdżanie bloków try / catch
. W poniższym przykładzie obsługa wyjątku IOException
została przeniesiona do środka kodu odpowiedzialnego za odczytanie pliku. W takiej sytuacji, w przypadku wystąpienia problemu, mamy możliwość zareagowania, np. poprzez próbę ponownego odczytu. Jeżeli zgłoszony wyjątek będzie typu innego niż IOException
CLR będzie wychodzić z kodu, szukając bloku obsługującego zgłoszony wyjątek. Bloki try / catch
można umieścić również w sekcji catch
. Wyobraźmy sobie scenariusz, w którym podczas wystąpienia wyjątku chcemy go zaraportować poprzez zapisanie informacji na dysku. Również podczas takiego raportowania może wystąpić wyjątek.
try
{
using (var sr = new StreamReader(@"C:\Windows\WindowsUpdate.log"))
{
try
{
while (!sr.EndOfStream)
Console.WriteLine(sr.ReadLine());
}
catch (IOException)
{
Console.WriteLine("Error IO");
}
}
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File: {0} could not be found", ex.FileName);
}
Omówiliśmy działanie try / catch
, ale obsługa wyjątku udostępnia jeszcze jedną sekcję finally
. Kod tej sekcji zostanie zrealizowany niezależnie czy podczas wykonywania działania pojawił się wyjątek, czy też nie. Blok finally
gwarantuje wykonanie kodu nawet w sytuacji wyjścia z użyciem return
czy goto
. W przypadku gdy nie możemy skorzystać z bloku using
np. podczas pracy z obiektami typu COM, sekcja finally
rozwiązuje ten problem. Poniżej przykład użycia bloku finally
.
try
{
...
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File: {0} could not be found", ex.FileName);
}
finally
{
Console.WriteLine("End work");
}
Mając wiedzę jak obsługiwać wyjątki, spróbujmy sami go zgłosić. W tym celu użyjemy słowa kluczowego throw
, po którym tworzymy instancję typu dziedziczącego z Exception
. Przyjmijmy, że naszym zadaniem jest przygotowanie prostej metody WriteWeight, której zadaniem jest wyświetlenie wagi przekazanej w parametrze weight typu object
. Dodatkowo nasza metoda ma zgłaszać wyjątek typu ArgumentNullException
w sytuacji gdy przekazana wartość jest null'owa, wraz z nazwą "felernego" parametru.
void WriteWeight(object weight)
{
if (weight == null)
throw new ArgumentNullException(nameof(weight));
Console.WriteLine("Current weight: {0}", weight);
}
W naszym przykładzie ograniczyliśmy się do przekazania nazwy parametru. A zatem skąd biorą się wartości Message oraz StackTrace w zgłoszonym wyjątku? Nasza praca ogranicza się do zgłoszenia wyjątku, resztę wykona za nas CLR. Gromadzi on potrzebne informacje o miejscu zgłoszenia, a następnie odszukuje najbliższą sekcję catch
do której przekazany zostanie wyjątek.
Kolejnym zagadnieniem jest powtórne zgłaszanie wyjątku. Wyobraźmy sobie sytuację, w której chcemy zaraportować problem, a następnie przekazać go dalej. W przykładzie poniżej należy zwrócić szczególną uwagę na użycie throw
. W sytuacji, gdyby kod wyglądał tak: throw ex;
, utracony zostałby kontekst wyjątku. CLR uznałby, że zgłoszony jest nowy wyjątek. Jest to często popełniany błąd, który prowadzi do utraty informacji dotyczących pierwotnego miejsca zgłoszenia wyjątku. Należy jednak pamiętać, że throw;
w tej postaci możemy użyć wyłącznie wewnątrz bloku catch
.
try
{
...
}
catch (IOException ex)
{
LogEx(ex);
throw;
}
Zdarzają się również sytuacje, w których chcemy opakować zgłoszony wyjątek w inny typ, pokazuje to przykład poniżej. W wyniku tej operacji ustawiona zostanie właściwość InnerException, a CLR zapamięta lokalizację tworzonego wyjątku.
try
{
...
}
catch (FileNotFoundException ex)
{
throw new IOException(string.Format("File: {0} could not be found", ex.FileName), ex);
}
Czasami obsługa zgłoszonego wyjątku to za mało. Wyobraźmy sobie sytuację, w której dochodzi do awarii. Oczywiście wyjątek został obsłużony, ale uszkodzony element nie jest w stanie prawidłowo funkcjonować, co może doprowadzić do kolejnych problemów np. do zapisania nieprawdziwej informacji w bazie danych. W tak krytycznych sytuacjach natychmiastowe zatrzymanie aplikacji może okazać się "bezpieczniejszym" rozwiązaniem. W celu natychmiastowego zatrzymania aplikacji możemy skorzystać z metody FailFast
klasy Environment
.
Na koniec wracamy do tematu wydajności wyjątków. Poniżej udostępniam kod odpowiedzialny za przeprowadzenie pomiarów. Pomiary wykonywane będą poza Visual Studio, dzięki czemu będziemy mierzyć wyłącznie obsługę wyjątku, a nie czas potrzebny na obsługę w środowisku programistycznym, gdzie dodatkowym obciążeniem jest śledzenie kodu. Aplikacja została skompilowany jako Release, podczas uruchomienia aplikacji wykonamy 10 pomiarów korzystając z klasy Stopwatch
z przestrzeni System.Diagnostics
. W każdym z pomiarów 1000 razy uruchomiona zostanie metoda Try lub ThrowEx. Czas pomiaru metody ThrowEx na moim urządzeniu to 22 milisekundy. Oczywiście czas wykonania będzie się różnił w zależności od mocy obliczeniowej urządzenia. W sytuacji, gdy skorzystamy z właściwości StackTrace czas potrzebny na realizację wzrasta do 41 milisekund. Spowodowane jest to optymalizacją po stronie .NET. W chwili zgłoszenia wyjątku zapamiętywane są informacje umożliwiające przygotowanie danych dla właściwości StackTrace. Natomiast samo przygotowanie jest na tyle kosztowne, że odroczone zostało do chwili właściwego pobrania wartości.
static void Main(string[] args)
{
var times = new List();
for (int tryIdx = 0; tryIdx < 10; tryIdx++)
{
Stopwatch s = new Stopwatch();
s.Start();
for (int i = 0; i < 1000; i++)
ThrowEx(); //or Try();
s.Stop();
Console.WriteLine($"Try: {tryIdx}, Milliseconds: {s.ElapsedMilliseconds}");
times.Add(s.ElapsedMilliseconds);
}
Console.WriteLine($"Total milliseconds: {times.Average()}");
Console.ReadLine();
}
private static void Try()
{
int iValue;
int.TryParse("text", out iValue);
}
private static void ThrowEx()
{
try
{
int iValue = int.Parse("text");
}
catch (Exception ex)
{
// string st = ex.StackTrace;
}
}
W notatce przedstawiłem zagadnienia związane z zgłoszeniem oraz obsługą wyjątków. Pozostało jeszcze kilka praktycznych informacji, które zostały opisane tutaj. Zapraszam.
Troska Robert