1. Wprowadzenie
W pewnym sensie subskrypcja na zdarzenie w C# — to jak zapisanie się na mailing z memami od znajomego: wiadomości przychodzą, dopóki nie powiesz "dość" i się nie wypiszesz. W programowaniu to szczególnie ważne, bo zapomniana subskrypcja to nie tylko "jeszcze jeden mem", lecz wyciek pamięci!
Wyobraź sobie, że masz formularz w aplikacji (np. dodatkowe okno ustawień). On subskrybuje zdarzenie głównego okna, żeby reagować na zmiany. Użytkownik zamyka formularz, myśląc, że został zniszczony, a handler nadal jest zapisany! Formularz nadal żyje w pamięci, bo obiekt główny trzyma do niego referencję poprzez zdarzenie.
Wniosek: Jeśli obiekt-subskrybent zapisał się na zdarzenie wydawcy i "zapomniał" się wypisać, to garbage collector nie usunie go z pamięci, dopóki żyje wydawca.
Powtórzmy operator += i pokażemy -=
- += — subskrypcja: dodaje handler do listy wywołań zdarzenia.
- -= — wypisanie: usuwa handler z listy wywołań.
Wygląda to mniej więcej tak:
worker.WorkCompleted += handler; // subskrypcja
worker.WorkCompleted -= handler; // wypisanie
Jeśli handler został dodany dwa razy, trzeba go usunąć tyle samo razy, żeby faktycznie zniknął z listy wywołań.
Trochę wnętrza
Za kulisami zdarzenie w C# to pole-delegat (albo lista delegatów), a operator += faktycznie wywołuje Delegate.Combine, a -= — Delegate.Remove. Obiekt, który się zapisał na zdarzenie, staje się częścią grafu referencji. Dlatego zapomniana subskrypcja = wyciek pamięci.
2. Wycieki pamięci przez zdarzenia: jak to działa
Klasyczna sytuacja
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// Symulacja: wysyłamy powiadomienie do wszystkich subskrybentów
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm reaguje na aktualizację okna");
}
}
Zróbmy to krok po kroku:
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm reaguje
// Użytkownik zamknął formularz. Tracimy wszystkie referencje do niego:
settingsForm = null;
// Ale obiekt SettingsForm NIE zostanie usunięty przez GC, dopóki window żyje,
// ponieważ window.Updated nadal trzyma referencję do metody OnWindowUpdated,
// a więc i do samego obiektu SettingsForm.
Co robić?
Wypisać się:
// Do tego potrzebujemy, żeby pozostała nam referencja do handlera lub obiektu:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // Teraz obiekt będzie mógł zostać usunięty
Tabela: kto trzyma referencję do kogo
| Akcja | Kto trzyma referencję | Czy można zwolnić pamięć? |
|---|---|---|
| Subskrypcja na zdarzenie (+=) | Wydawca do subskrybenta | Nie, dopóki wydawca żyje |
| Wypisanie (-=) | Nie | Tak, po usunięciu wszystkich zewnętrznych referencji |
| Brak subskrypcji | Nie | Tak |
3. Jak prawidłowo zorganizować wypisanie
Jawne usunięcie handlera
Można to zrobić np. w momencie zamknięcia okna lub formularza:
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // wypisujemy się!
// tutaj kod zamykania (np. Dispose, GC.SuppressFinalize itp.)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// Obsługa zdarzeń
}
}
Jeśli SettingsForm jest niszczony przez przycisk "zamknij", ważne, żeby nie zapomnieć wywołać metody, gdzie wykonywane jest wypisanie (np. Close()).
Użycie interfejsu IDisposable
Dla bardziej złożonych obiektów, które subskrybują zdarzenia i kontrolują swój cykl życia, wygodnie jest zaimplementować interfejs IDisposable. W metodzie Dispose() wykonuje się wszystkie potrzebne wypisania.
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// Tutaj też zwalniamy inne zasoby
}
}
Teraz SettingsForm można używać w bloku using, wywoływać jawnie Dispose() albo zautomatyzować zwalnianie zasobów (np. przez GC.SuppressFinalize w typach z finalizerem).
4. Interakcja z wyrażeniami lambda: niebezpieczeństwa i sztuczki
Jeśli subskrybujesz zdarzenie za pomocą wyrażenia lambda, ale nie zapisujesz lambdy do zmiennej, nie będziesz w stanie się wypisać!
// Subskrypcja — anonimowa lambda
window.Updated += (s, e) => Console.WriteLine("Lambda wywołana!");
// Jak się teraz wypisać? — Nie da się!
window.Updated -= (s, e) => Console.WriteLine("Lambda wywołana!"); // To inny delegat!
Co robić?
Zapisuj lambdę do zmiennej-delegata:
EventHandler handler = (s, e) => Console.WriteLine("Lambda wywołana!");
window.Updated += handler;
// ... teraz można się wypisać!
window.Updated -= handler;
5. Przydatne niuanse
Szczegóły dotyczące cyklu życia obiektów i zdarzeń
Jeszcze jeden często spotykany problem — krzyżujące się referencje przez zdarzenia między dwoma "długowiecznymi" obiektami. Na przykład jedno okno subskrybuje zdarzenie drugiego, oba są aktywnie używane, nie są usuwane — i pamięć rośnie coraz bardziej.
Rekomendacja: Zawsze pamiętaj, kto na kogo subskrybuje i kiedy trzeba się wypisać. Jeśli subskrypcja ma taki sam cykl życia jak wydawca — ok. Jeśli subskrybent może żyć krócej niż wydawca, zaimplementuj jawne wypisanie.
Uniwersalna zasada: "Jeśli się zapisałeś — wypisz się!"
- Dla długożyjących wydawców (np. globalnych, singletonów, głównych okien) — zawsze zaimplementuj wypisanie po stronie subskrybentów.
- Dla tymczasowych obiektów (np. jednorazowych powiadomień lub zdarzeń, gdzie subskrybent żyje dłużej niż wydawca) — można być luźniejszym, ale i tak pilnuj kontekstu.
- Używaj podejść typu WeakEvent (słabe zdarzenia) lub specjalnych frameworków, jeśli nie chcesz zarządzać wypisaniem ręcznie.
6. Typowe błędy przy pracy z wypisaniem
Nieudane wypisanie: metoda-handler musi być ta sama
Bardzo ważne, żeby przy wypisaniu podawać dokładnie ten sam handler, co przy subskrypcji. Inaczej wypisanie nie zadziała.
Błąd:
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // Nie zadziała! To inna instancja obiektu i inny delegat!
Poprawnie:
window.Updated -= settingsForm.OnWindowUpdated;
Jeśli przy subskrypcji użyto anonimowej lambdy bez zapisu referencji do delegata, wypisać się od niej nie da, bo to będzie już inny egzemplarz delegata:
// Subskrypcja
window.Updated += (s, e) => Console.WriteLine("Lambda!");
// Próba wypisania — nie zadziała!
window.Updated -= (s, e) => Console.WriteLine("Lambda!");
"Zapomniane" wypisanie
Bardzo często wypisanie po prostu się zapomina, zwłaszcza gdy subskrybent żyje dłużej niż wydawca albo gdy deweloper nie do końca rozumie mechanizm działania zdarzeń. W rezultacie obiekty-subskrybenci pozostają w pamięci dłużej niż trzeba, co prowadzi do wycieków pamięci i problemów z wydajnością.
GO TO FULL VERSION