Wszystkie wpisy

Wyjątki i IDisposable w C# - try-catch-finally, własne wyjątki, using

Obsługa błędów w C# - try-catch-finally, throw i throw vs throw ex, filtry wyjątków, własne klasy Exception, garbage collector kontra zasoby niezarządzane, IDisposable, klasyczny i nowoczesny using. Siódma część kursu C#.


W poprzednim wpisie Delegaty i zdarzenia w C# poznałeś mechanizm publisher-subscriber - obiekty informujące siebie nawzajem o czymś istotnym. Teraz zajmiemy się sytuacją, gdy coś istotnego idzie nie tak: wyjątki oraz deterministyczne sprzątanie zasobów, które po sobie zostawiamy.

W tej części poznasz pełny model obsługi błędów w C#: konstrukcję try-catch-finally, jak prawidłowo rzucać wyjątki (i jakich nigdy nie rzucać), jak projektować własne klasy wyjątków zgodnie z konwencjami Microsoftu oraz dlaczego Garbage Collector nie wystarczy do plików, gniazd i połączeń z bazą - tu wkraczają IDisposable i using.

1. Czym jest wyjątek #

Co to jest: wyjątek (exception) to obiekt opisujący zdarzenie odbiegające od normalnego przebiegu programu. Microsoft określa to wprost: mechanizm wyjątków pomaga deal with any unexpected or exceptional situations that occur when a program is running.

Dlaczego istnieje: kod może w każdej chwili zderzyć się z rzeczywistością, na którą nie ma wpływu - plik nie istnieje, sieć padła, baza wyrzuca timeout, dzielnik okazał się zerem. Zamiast zwracać “magiczne” wartości typu -1 (które wywołujący może pomylić z legalnym wynikiem), C# pozwala odbić się od ścieżki głównej i pójść specjalnym torem obsługi błędu.

Hierarchia: wszystko od System.Exception #

Każdy wyjątek w .NET dziedziczy ostatecznie z System.Exception. Typy konkretne (ArgumentNullException, FileNotFoundException, InvalidOperationException) wywodzą się z tej jednej klasy bazowej. Dzięki temu jeden blok catch (Exception) jest w stanie złapać dosłownie wszystko.

Mechanizm: rozwijanie stosu (stack unwinding) #

Gdy gdzieś poleci wyjątek, CLR zaczyna rozwijać stos - od miejsca rzucenia idzie w górę przez metody wywołujące, szukając pasującego bloku catch. Pierwszy znaleziony przejmuje kontrolę. Jeśli nikt nie złapie, program kończy działanie z błędem.

Ważna zasada od Microsoftu (cytat): Don’t catch an exception unless you can handle it and leave the application in a known state. Łapanie tylko po to, żeby ukryć błąd, robi więcej szkody niż pożytku - aplikacja zostaje w nieokreślonym stanie, a debugowanie staje się koszmarem.

2. try-catch-finally w praktyce #

try
{
    Console.Write("Podaj liczbę, przez którą podzielić 100: ");
    int dzielnik = int.Parse(Console.ReadLine());
    int wynik = 100 / dzielnik;
    Console.WriteLine($"Wynik: {wynik}");
}
catch (FormatException)
{
    Console.WriteLine("Błąd: wpisz poprawną liczbę całkowitą.");
}
catch (DivideByZeroException)
{
    Console.WriteLine("Błąd: nie dzielimy przez zero.");
}
catch (Exception ex)
{
    // "Wór" na wszystko inne - ZAWSZE na końcu listy
    Console.WriteLine($"Nieoczekiwany błąd: {ex.Message}");
}
finally
{
    Console.WriteLine("Zakończono operację.");
}

Trzy reguły, których łatwo nie złamać #

  1. Kolejność catch: od najbardziej szczegółowych do najogólniejszych. CLR wybiera pierwszy pasujący blok. Gdyby catch (Exception) był na początku, połknąłby wszystko, a późniejsze catch (FormatException) były nieosiągalne. Kompilator zresztą zgłosi to jako błąd.
  2. finally zawsze się wykonuje. Niezależnie od tego, czy try zakończył się normalnie, został przerwany wyjątkiem, czy nawet zawiera return. To gwarantowany blok sprzątający.
  3. Nie łap, gdy nie wiesz, co zrobić. Łapanie po to, żeby zignorować błąd, to anti-pattern “swallowing exceptions”. Jeśli nie masz planu na obsługę, pozwól wyjątkowi polecieć dalej - może wyższy poziom będzie wiedział, co robić.

Filtry wyjątków: when (C# 6+) #

Czasem chcesz złapać wyjątek tylko w określonych warunkach. Składnia when filtruje, zanim wyjątek zostanie złapany - dzięki czemu nie tracisz informacji o nim, jeśli warunek nie pasuje:

try
{
    await PobierzDane();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine("Zasobu nie znaleziono - kontynuujemy bez niego.");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    Console.WriteLine("Brak uprawnień - przekierowuję do logowania.");
    Zaloguj();
}

Bez when musiałbyś złapać HttpRequestException w jednym bloku i wewnątrz sprawdzać if-em - tracąc czystość intencji i komplikując sytuację, gdyby nie udało się obsłużyć (musiałbyś throw; z powrotem).

3. throw - rzucanie wyjątków #

Co robi: słowo kluczowe throw przerywa normalne wykonanie i tworzy w danym miejscu obiekt wyjątku, który CLR następnie przekazuje w górę stosu.

public void PerformTransfer(int amount, int saldo)
{
    if (amount > saldo)
    {
        throw new InvalidOperationException(
            $"Brak środków: próba {amount} zł przy saldzie {saldo} zł.");
    }
    // ... reszta logiki
}

Kiedy rzucać (wytyczne Microsoftu) #

Dokumentacja podaje trzy typowe sytuacje:

  1. Metoda nie może wykonać swojej funkcji (np. niewłaściwy argument) → ArgumentException, ArgumentNullException.
  2. Wywołanie niezgodne ze stanem obiektu (np. próba zapisu do pliku tylko-do-odczytu) → InvalidOperationException.
  3. Argument metody powoduje wyjątek niższego poziomu - złap ten oryginalny, opakuj w bardziej kontekstowy z InnerException.

Krytyczna pułapka: throw; kontra throw ex; #

To różnica, na której wykładają się także doświadczeni programiści. W bloku catch masz dwa różne sposoby ponownego rzucenia:

try
{
    JakasMetoda();
}
catch (Exception ex)
{
    Log(ex);
    throw;       // POPRAWNIE - zachowuje oryginalny stack trace
    // throw ex; // ŹLE - resetuje stack trace, tracisz źródło błędu
}

throw ex; traktuje wyjątek jak nowy - ślad stosu zaczyna się od linii z throw ex. Tracisz informację, skąd błąd naprawdę przyszedł - debugowanie staje się ślepym strzelaniem. Bezparametrowe throw; ponawia oryginalny wyjątek z zachowanym śladem.

Czego NIE rzucać #

Wytyczne MS są jednoznaczne - poniższych typów nigdy nie rzucaj z własnego kodu, są zarezerwowane dla CLR-a:

  • System.Exception - zbyt ogólny, nic nie mówi
  • System.SystemException - również zarezerwowany
  • System.NullReferenceException - rzuca go runtime przy null-references
  • System.IndexOutOfRangeException - również rzuca CLR przy złych indeksach tablicy

Drugie ważne odrzucenie: nie używaj wyjątków do normalnego sterowania przepływem. Cytat MS: Don’t use exceptions to change the flow of a program as part of ordinary execution. Rzucanie i łapanie wyjątku jest drogie - poza samym kosztem operacji niesie cały ślad stosu. Do zwykłych przepływów (czy plik istnieje, czy liczba jest parsowalna) używaj wzorców TryParse, bool z out, opcjonalnych wartości.

4. Własne klasy wyjątków #

Wbudowane wyjątki .NET (ArgumentException, InvalidOperationException, FormatException) pokrywają sporo, ale w logice biznesowej często warto wprowadzić własne typy - precyzyjnie nazywające problem domenowy.

// Konwencja: nazwa kończy się sufiksem "Exception"
[Serializable]
public class InsufficientFundsException : Exception
{
    // 1. Bezparametrowy
    public InsufficientFundsException() { }

    // 2. Z wiadomością
    public InsufficientFundsException(string message)
        : base(message) { }

    // 3. Z wiadomością i wewnętrznym wyjątkiem
    public InsufficientFundsException(string message, Exception inner)
        : base(message, inner) { }
}

Trzy konstruktory - dlaczego? #

Microsoft wprost zaleca trzy konstruktory dla każdej własnej klasy wyjątku:

  1. Bezparametrowy - dla scenariuszy, gdy wystarczy sam fakt, że problem wystąpił.
  2. Z wiadomością - najczęstszy przypadek; pozwala opisać sytuację.
  3. Z wiadomością i InnerException - kluczowy, gdy opakowujesz niższy błąd w bardziej kontekstowy. Oryginalny wyjątek nie znika - zostaje dostępny przez właściwość InnerException i zachowuje swój ślad stosu.

Kiedy własny, a kiedy wbudowany #

SytuacjaWybór
Argument metody jest nullArgumentNullException
Argument poza prawidłowym zakresemArgumentOutOfRangeException
Obiekt w stanie uniemożliwiającym operacjęInvalidOperationException
Operacja nie jest wspieranaNotSupportedException
Konkretny problem domenowy (“brak środków”, “konto zablokowane”, “nieaktualny stan zamówienia”)Własny typ

Jeśli wszystko, co masz do powiedzenia, mieści się w jednym z gotowych typów - użyj go. Własny wyjątek tworzysz wtedy, gdy chcesz, by inni programiści mogli rozróżnić ten konkretny rodzaj błędu w catch.

5. Garbage Collector kontra zasoby niezarządzane #

C# to język zarządzany - ma automatycznego śmieciarza (Garbage Collector, GC), który zwalnia pamięć po obiektach pozbawionych aktywnych referencji. Dla większości obiektów to wystarcza.

Problem zaczyna się przy zasobach niezarządzanych (unmanaged resources) - obiektach reprezentujących uchwyty systemu operacyjnego, których GC nie umie zwolnić. Dokumentacja Microsoftu pisze wprost: the garbage collector is able to track the lifetime of an object that encapsulates an unmanaged resource, it doesn’t know how to release and clean up the unmanaged resource.

Najczęstsze zasoby niezarządzane:

  • Pliki na dysku (uchwyty plików w systemie operacyjnym)
  • Połączenia z bazą danych (sesje SQL na serwerze)
  • Połączenia sieciowe (gniazda TCP, klienci HTTP, websockety)
  • Uchwyty graficzne (GDI w Windows)

Jeśli otworzysz plik i pozwolisz GC zapomnieć o zmiennej, plik pozostanie otwarty dla systemu dopóki proces żyje. Inne aplikacje nie będą mogły go zmodyfikować, dysk się “zatka” otwartymi uchwytami, w skrajnych przypadkach skończy się limit deskryptorów plików.

6. IDisposable i instrukcja using #

Rozwiązaniem jest interfejs IDisposable:

public interface IDisposable
{
    void Dispose();
}

Jedna metoda - Dispose(). Każda klasa operująca na zasobach niezarządzanych powinna go implementować. Wywołanie Dispose() to umowa: “skończyłem z tobą, posprzątaj teraz”.

Klasyczne, ręczne try-finally #

Bez wsparcia językowego musiałbyś pisać:

StreamReader czytnik = null;
try
{
    czytnik = new StreamReader("plik.txt");
    Console.WriteLine(czytnik.ReadToEnd());
}
finally
{
    czytnik?.Dispose();
}

Bezpieczne, ale dużo kodu wokół trzech ważnych linii. Microsoft dał na to składnię.

Klasyczna instrukcja using #

using (var czytnik = new StreamReader("plik.txt"))
{
    string zawartosc = czytnik.ReadToEnd();
    Console.WriteLine(zawartosc);
}
// Tutaj kompilator AUTOMATYCZNIE wstawia czytnik.Dispose() -
// w bloku finally, więc plik się zamknie nawet przy wyjątku.

Pod spodem kompilator generuje dokładnie to samo try { ... } finally { czytnik.Dispose(); }. Tylko czytelniej.

Nowoczesna deklaracja using (C# 8+) #

public void Wypisz(string sciezka)
{
    using var czytnik = new StreamReader(sciezka);  // bez klamerek

    string zawartosc = czytnik.ReadToEnd();
    Console.WriteLine(zawartosc);

    // Tu kończy się metoda - tutaj kompilator wstawia czytnik.Dispose()
}

Brak nawiasów, brak wcięcia. Obiekt jest zwolniony na końcu otaczającego zakresu (zwykle metody).

Różnica między klasycznym using (...) a using var:

Klasyczny using (var x = ...) { }Deklaracja using var x = ...;
Zasięg xnawiasy bloku {}do końca otaczającego zakresu (metoda)
Wcięcietak, dodatkowy poziomnie
Kiedy wybieraćgdy zasób potrzebny krótkogdy zasób żyje przez całą metodę

Złota zasada C# #

Jeśli klasa implementuje IDisposable - zawsze owijaj ją w using (statement albo declaration). Ta jedna zasada uchroni cię przed wyciekami uchwytów plików, otwartymi połączeniami z bazą oraz mnóstwem trudnych do zlokalizowania problemów produkcyjnych.

7. Pułapki produkcyjne #

catch (Exception) - “swallowing exceptions” #

Najczęstszy anti-pattern w korporacyjnym C#:

try
{
    JakasOperacja();
}
catch (Exception)
{
    // nic albo: Console.WriteLine("blad");
}

To gorsze niż brak try. Aplikacja wygląda na działającą, podczas gdy w tle dzieją się rzeczy, których nikt nie zauważa. Bugi ujawniają się dni później, w zupełnie innym miejscu. Lepiej pozwolić wyjątkowi polecieć - przynajmniej widać, gdzie i co.

finally z return #

finally wykonuje się nawet po return z try. Drobny szczegół, ale potrafi zaskoczyć:

public int Test()
{
    try
    {
        return 1;
    }
    finally
    {
        Console.WriteLine("Wykona się PRZED faktycznym return-em");
    }
}

Konsola dostanie wiadomość, a potem metoda zwróci 1.

Wyjątki w metodach async #

Wyjątki rzucone w metodzie async nie są od razu widoczne - są przechowywane w zwracanym Task i ujawniają się dopiero przy await. Microsoft zaleca, by walidację argumentów robić synchronicznie, zanim zaczniesz część asynchroniczną - dzięki temu ArgumentException poleci od razu, a nie po fakcie.

Brak using - wyciek uchwytów #

// ŹLE - plik zostaje otwarty dopóki GC nie posprząta obiektu
var czytnik = new StreamReader("plik.txt");
var zawartosc = czytnik.ReadToEnd();
// Brak Dispose() - uchwyt pliku zostaje w OS

Najgorsze, że kod działa. Tylko że stopniowo wycieka. W aplikacjach serwerowych potrafi to wywrócić proces po kilku godzinach. Zawsze using.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Wyjątek to obiekt opisujący sytuację nieoczekiwaną; cała hierarchia od System.Exception
  • try-catch-finally - blok ryzykowny, handler typu, gwarantowane sprzątanie; catch od szczegółowych do ogólnych
  • when filtry wyjątków - warunek dołączony do catch, bez tracenia kontekstu, gdy filtr nie przejdzie
  • throw kontra throw ex - to drugie resetuje stack trace; do ponownego rzucania w catch używaj samego throw;
  • Czego nie rzucać - Exception, SystemException, NullReferenceException, IndexOutOfRangeException; nie używaj wyjątków do normalnego sterowania
  • Własne wyjątki - sufiks Exception, trzy konstruktory (puste, message, message + inner), atrybut [Serializable]
  • Garbage Collector zwalnia pamięć managed, nie zasoby niezarządzane (pliki, sockety, połączenia DB)
  • IDisposable - jeden kontrakt: Dispose() na żądanie konsumenta
  • using - statement (z klamerkami) i declaration (using var, C# 8+); kompilator generuje finally z Dispose() automatycznie

W następnym wpisie Refleksja i atrybuty w C# wchodzimy na poziom meta - kod, który czyta sam siebie w trakcie wykonania. Zobaczysz System.Reflection, Type, Activator, projektowanie własnych atrybutów i mechanizm stojący za [Authorize] w ASP.NET Core, [Fact] w xUnit czy [Key] w EF.

Quiz i zadanie poniżej. Zadanie łączy oba tematy z dzisiejszego wpisu: własny wyjątek z trzema konstruktorami plus logger implementujący IDisposable używany przez using var. Czyli dokładnie to, co spotkasz w profesjonalnym kodzie produkcyjnym.

Podobne wpisy

🔗 Linkują tu