CodeGym /Kursy /C# SELF /Wypisanie się z wydarzeń ( -=...

Wypisanie się z wydarzeń ( -=) i wycieki pamięci

C# SELF
Poziom 53, Lekcja 1
Dostępny

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ą.

2
Zadanie
C# SELF, poziom 53, lekcja 1
Niedostępne
Subskrypcja i wypisanie się z wydarzeń
Subskrypcja i wypisanie się z wydarzeń
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION