1. Jak poprawnie zwalniać zasoby w C#?
Wyobraź sobie system operacyjny jako surowego bibliotekarza. Wziąłeś książkę (otworzyłeś plik/strumień), czytasz, a potem... zapomniałeś oddać! Bibliotekarz się wkurza: "Jak to — książka wciąż u ciebie?!" Tak samo jest ze strumieniami. Otwarty strumień zajmuje zasoby: deskryptor pliku, kawałek pamięci, a do tego blokuje plik dla innych aplikacji.
Jeśli nie zamkniesz strumienia, efekt może być od "coś nie działa" do "wszystko się wywaliło, nikt nie może zapisać do tego pliku". A jeśli w programie jest dużo niezamkniętych strumieni — system może zacząć "wyciekać" z zasobów i po prostu przestanie działać.
Na czym polega niebezpieczeństwo?
- Plik nie jest zamknięty, polecenia nie trafiają na dysk (np. przy zapisie — dane mogą zostać w buforze).
- Plik jest zablokowany dla innych procesów — koledzy i inne programy się wkurzają.
- Limit deskryptorów: na Windows/Linux procesy mają limity otwartych plików/strumieni.
Interfejs IDisposable
Każda klasa, która pracuje z niezarządzanymi zasobami (strumienie, pliki, bazy danych, sockety), musi implementować interfejs IDisposable.
public interface IDisposable
{
void Dispose();
}
W środku metody Dispose() zwykle następuje zwolnienie wszystkich biednych zasobów: plik w końcu się zamyka, połączenia są zrywane, pamięć jest zwalniana.
Strumienie — to obiekty, które trzymają w łapach różne ważne zasoby: pliki, połączenia, pamięć. Jeśli ich nie zamkniesz, plik może zostać zablokowany (twój Word powie "plik otwarty przez inną aplikację!"), a system — bez pamięci. Dlatego mega ważne jest zwalnianie strumienia po zakończeniu pracy.
W .NET strumienie implementują interfejs IDisposable. To znaczy: trzeba je zamykać, wywołując Dispose() (albo po prostu wrzucać w blok using).
2. Sposoby zamykania strumienia: od ryzykownych do pewnych
Wariant 1. "Ręczne" zamykanie: nie rób tak!
To stary sposób. I chociaż pokazywałem go w poprzednich przykładach, w nowoczesnym kodzie prawie nikt już tak nie robi :P
var stream = new FileStream("file.txt", FileMode.Open);
// Pracujemy ze strumieniem
stream.Close(); // albo stream.Dispose()
Problem: jeśli między otwarciem a zamknięciem pojawi się błąd/wyjątek, plik zostanie otwarty i zablokowany. To jakbyś wybiegł z biblioteki z książką, bo poczułeś zapach pizzy...
Wariant 2. Używamy try...finally
FileStream stream = null;
try
{
stream = new FileStream("file.txt", FileMode.Open);
// Pracujemy ze strumieniem
}
finally
{
if (stream != null)
stream.Dispose();
}
Taki wariant jest pewny: finally wykona się nawet przy błędzie. Ale, przyznajmy, pisać tak się nie chce.
Wariant 3. Ładnie i bezpiecznie: operator using
Klasyczna składnia (using ( ... ) { ... })
using (var stream = new FileStream("file.txt", FileMode.Open))
{
// Pracujemy ze strumieniem
}
// Tutaj stream.Dispose() wywoła się automatycznie!
Kluczowa myśl — wszystko, co w bloku using, pracuje ze strumieniem, a gdy blok się kończy — plik się zamyka nawet jeśli coś poszło nie tak (np. poleciał wyjątek).
Wariant 4. Nowoczesna składnia
Nowoczesna składnia (using var)
using var stream = new FileStream("file.txt", FileMode.Open);
// Pracujemy ze strumieniem
// ... Dispose wywoła się automatycznie, gdy zmienna wyjdzie poza zakres
Super! Nie trzeba robić dodatkowych wcięć i nawiasów klamrowych.
Jak to działa "pod maską"?
Operator using kompilator zamienia na tego samego try...finally, tylko robi to za ciebie. Żart: "using pisze czysty kod za ciebie — może niedługo zacznie pić kawę i przeglądać Stack Overflow?".
Różnica między klasycznym a nowoczesnym using
| Klasyczny blok using | using var (deklaracja) | |
|---|---|---|
| Wygląd | |
|
| Zakres działania | Wewnątrz nawiasów klamrowych bloku | Do końca bieżącego bloku (metody, pętli, itd.) |
| Zwięzłość | Trochę bardziej rozwlekle | Zwięźle, mniej wcięć |
| Od wersji | C# 1.0 | C# 8.0 i wyżej |
3. Co to są using-deklaracje?
5 lat temu świat zobaczył nowy, zwięzły sposób pracy z obiektami IDisposable.
using-deklaracja — to gdy zamiast bloku deklarujesz zmienną ze słowem kluczowym using, i ona zostanie automatycznie zwolniona na końcu bieżącego bloku (np. metody lub pętli), a nie na końcu nawiasów klamrowych dodatkowego bloku.
using var stream = new FileStream("file.txt", FileMode.Open);
// Pracujemy ze strumieniem
Console.WriteLine(stream.Length);
// Tutaj plik wciąż jest otwarty!
// ... koniec metody
// stream.Dispose() wywoła się tutaj automatycznie
Kluczowe różnice względem klasycznego using:
- Nie trzeba nawiasów klamrowych, nie powstaje zagnieżdżony blok kodu.
- Zmienna jest dostępna do końca całego bloku, w którym została zadeklarowana (zwykle — metoda, czasem — pętla, klasa, jeśli zadeklarowana na poziomie klasy).
- Zwolnienie zasobu nastąpi dopiero, gdy blok wykonania się zakończy.
Dlaczego to jest fajne?
- Mniej poziomów zagnieżdżenia — kod jest dużo krótszy i czytelniejszy.
- Łatwiej pracować z wieloma zasobami — deklaruj kilka using-zmiennych pod rząd, i wszystko się zwolni, gdy skończy się metoda.
- Mniej szans na błąd — nie przegapisz miejsca, gdzie trzeba było wywołać Dispose().
4. Porównanie: klasyczny vs. nowoczesny using
Zobaczmy porównanie w kodzie.
Klasyczny sposób
using (var reader = new StreamReader("input.txt"))
{
using (var writer = new StreamWriter("output.txt"))
{
string line;
while ((line = reader.ReadLine()) != null)
{
writer.WriteLine(line.ToUpper());
}
}
} // Tutaj oba pliki zostaną zamknięte
Nowoczesny sposób (C# 8+)
using var reader = new StreamReader("input.txt");
using var writer = new StreamWriter("output.txt");
string line;
while ((line = reader.ReadLine()) != null)
{
writer.WriteLine(line.ToUpper());
}
// Oba pliki zamkną się tutaj, przy wyjściu z metody
Wygląda prościej, prawda? Zwłaszcza jeśli robić jeszcze większe zagnieżdżenia — nowoczesny sposób mocno ułatwia życie.
5. Kiedy i gdzie wywołuje się Dispose()?
Tu studenci często popełniają błąd: myślą, że Dispose wywoła się zaraz po linii użycia — ale to nie tak!
Zobacz ten przykład:
void MyMethod()
{
using var fileStream = new FileStream("data.bin", FileMode.Open);
// ... dużo kodu, może nawet pętle i zagnieżdżone wywołania
// fileStream wciąż jest otwarty!
// Tutaj mamy dostęp do fileStream
}
// Tutaj, na } metody, wywoła się fileStream.Dispose()
Ważny moment: Jeśli zadeklarujesz using-zmienną w pętli, Dispose wywoła się po każdej iteracji.
foreach (var path in filePaths)
{
using var reader = new StreamReader(path);
// pracujemy z reader
} // reader.Dispose() wywoła się po każdej iteracji (zamknie plik)
6. Błędy przy przenoszeniu starego kodu
Czasem przenosisz stary kod albo kopiujesz przykład z klasycznym using, a zmienna potrzebuje dłuższego “życia” niż zakres nawiasów. Wtedy klasyczny wariant się nie nada, a using-deklaracje — idealnie.
Ale są niuanse. Na przykład, jeśli w pętli masz dwa zasoby, ale jeden z nich musi "żyć" dłużej niż drugi — zadeklaruj je w odpowiedniej kolejności:
using var resource1 = ...;
for (int i = 0; i < 10; i++)
{
using var resource2 = ...;
// resource2 żyje jedną iterację
// resource1 — całą funkcję
}
7. Praktyka
Rozwijamy dalej naszą edukacyjną apkę — mały symulator zamawiania kawy, który ulepszałeś ostatnio. Niech teraz potrafi zapisywać historię zamówień do pliku tekstowego i czytać ją przy starcie.
Krok 1: Zapisujemy zamówienie do pliku
using var writer = new StreamWriter("orders.txt", append: true);
writer.WriteLine("Kawa: Latte; Mleko: Owsiane; Rozmiar: Duży");
// Drugi parametr konstruktora StreamWriter append: true oznacza, że chcemy dopisywać, a nie nadpisywać plik.
Krok 2: Czytamy historię zamówień
using var reader = new StreamReader("orders.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine($"Zamówienie: {line}");
}
I jak tylko program zakończy wykonywanie tej metody, pliki zamkną się automatycznie.
8. Najlepsze praktyki pracy z using-deklaracjami
1. Zawsze używaj using dla obiektów implementujących IDisposable
W .NET większość klas do pracy z plikami, strumieniami, zasobami implementuje ten interfejs. To sygnał: zwolnij mnie przez using!
2. Pamiętaj o zakresie: nie deklaruj using-var tam, gdzie zmienna może "przeszkadzać"
Jeśli zmienna jest potrzebna tylko przez kilka linii — używaj jej tam, gdzie trzeba, i nie wcześniej.
3. Nie zapominaj o kolejności zwalniania
Jeśli zadeklarujesz kilka using-zmiennych pod rząd — Dispose wywoła się w odwrotnej kolejności:
using var first = new Resource("First");
using var second = new Resource("Second");
// ... praca
// Najpierw Dispose dla second, potem dla first
To czasem ważne, jeśli jeden zasób zależy od drugiego (np. strumień zapisu powinien się zwolnić przed plikiem).
4. Nie używaj using-deklaracji poza metodą
using-deklaracje są zabronione na poziomie klasy (np. dla pól). Działają tylko wewnątrz metod, konstruktorów itp.
5. Łącz z obsługą błędów
Pamiętaj, że nawet z using nie wszystkie wyjątki są wygodne — warto dodać try-catch, jeśli chcesz mieć kontrolę nad błędami odczytu/zapisu.
GO TO FULL VERSION