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ć #
- Kolejność
catch: od najbardziej szczegółowych do najogólniejszych. CLR wybiera pierwszy pasujący blok. Gdybycatch (Exception)był na początku, połknąłby wszystko, a późniejszecatch (FormatException)były nieosiągalne. Kompilator zresztą zgłosi to jako błąd. finallyzawsze się wykonuje. Niezależnie od tego, czytryzakończył się normalnie, został przerwany wyjątkiem, czy nawet zawierareturn. To gwarantowany blok sprzątający.- 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:
- Metoda nie może wykonać swojej funkcji (np. niewłaściwy argument) →
ArgumentException,ArgumentNullException. - Wywołanie niezgodne ze stanem obiektu (np. próba zapisu do pliku tylko-do-odczytu) →
InvalidOperationException. - 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ówiSystem.SystemException- również zarezerwowanySystem.NullReferenceException- rzuca go runtime przynull-referencesSystem.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:
- Bezparametrowy - dla scenariuszy, gdy wystarczy sam fakt, że problem wystąpił.
- Z wiadomością - najczęstszy przypadek; pozwala opisać sytuację.
- 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śćInnerExceptioni zachowuje swój ślad stosu.
Kiedy własny, a kiedy wbudowany #
| Sytuacja | Wybór |
|---|---|
Argument metody jest null | ArgumentNullException |
| Argument poza prawidłowym zakresem | ArgumentOutOfRangeException |
| Obiekt w stanie uniemożliwiającym operację | InvalidOperationException |
| Operacja nie jest wspierana | NotSupportedException |
| 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 x | nawiasy bloku {} | do końca otaczającego zakresu (metoda) |
| Wcięcie | tak, dodatkowy poziom | nie |
| Kiedy wybierać | gdy zasób potrzebny krótko | gdy 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;
catchod szczegółowych do ogólnych whenfiltry wyjątków - warunek dołączony docatch, bez tracenia kontekstu, gdy filtr nie przejdziethrowkontrathrow ex- to drugie resetuje stack trace; do ponownego rzucania wcatchużywaj samegothrow;- 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 generujefinallyzDispose()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
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
Relacyjne bazy danych w C# - architektura RDBMS, schemat tabel, mapowanie typów SQL
Architektura silnika RDBMS (Query Optimizer, Storage Engine, Transaction Log), projektowanie tabel z PK/FK, mapowanie typów SQL Server na typy C# (INT, NVARCHAR, DECIMAL, DATETIME2, DATE). Dwudziesta druga część kursu C#.
🔗 Linkują tu