Menu

Zdarzenia w C#: słowo kluczowe event, EventHandler, subskrypcja i wywołanie

Jak działają zdarzenia w C#: deklarowanie zdarzenia z EventHandler i EventHandler<T>, własne klasy EventArgs, subskrybowanie i anulowanie subskrypcji przez += i -=, bezpieczne wywoływanie zdarzenia przez ?.Invoke, dlaczego zdarzenie jest lepsze niż publiczne pole delegatu i wyciek pamięci przez zapomniane anulowanie subskrypcji.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Zdarzenie pozwala klasie ogłosić, że coś się stało, bez wiedzy o tym, kto słucha. Inne obiekty subskrybują je przez +=, a gdy klasa wywołuje zdarzenie, uruchamia się metoda każdego subskrybenta.

Wynik:

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop nie wie, że istnieją paragony czy prezenty. Wywołuje OrderPlaced, a to, co je zasubskrybowało, reaguje. Dodanie nowej reakcji (punkty lojalnościowe, analityka) oznacza dodanie subskrybenta, a nie edycję PlaceOrder.

Deklarowanie zdarzenia

Deklaracja zdarzenia to składowa typu delegatu ze słowem kluczowym event:

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

Typ delegatu ustala sygnaturę metody obsługi. Kod .NET niemal zawsze używa jednego z dwóch typów wbudowanych, a trzymanie się tej konwencji sprawia, że twoje zdarzenia wyglądają znajomo dla każdego innego programisty .NET:

  • EventHandler: void (object sender, EventArgs e), dla zdarzeń, które nie niosą danych. Wywołuj je z EventArgs.Empty.
  • EventHandler<TEventArgs>: void (object sender, TEventArgs e), gdzie TEventArgs to twoja klasa z danymi.

Konwencje wokół nich:

  • sender to obiekt, który wywołał zdarzenie, zwykle this.
  • Klasa danych ma nazwę {Something}EventArgs, dziedziczy po EventArgs (od .NET 4.5 opcjonalnie, ale nadal zwyczajowo) i udostępnia właściwości tylko do odczytu.
  • Nazwa zdarzenia mówi, co się stało: OrderPlaced, Closed, PriceChanged. Nazwy z końcówką ...ing (Closing) mają zdarzenia wywoływane przed akcją, często z możliwością jej anulowania.
  • W klasach przeznaczonych do dziedziczenia wywołanie przechodzi przez metodę protected virtual void OnOrderPlaced(OrderPlacedEventArgs e), żeby klasy pochodne mogły się podpiąć.

Subskrybowanie i anulowanie subskrypcji

+= dodaje metodę obsługi, a -= ją usuwa. Metodą obsługi może być metoda lub lambda. Żeby później usunąć lambdę, trzymaj ją w zmiennej, bo ponowne napisanie tej samej lambdy tworzy inny delegat, którego -= nie znajdzie:

Wynik:

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

Usunięcie metody obsługi, której nigdy nie dodano, nie jest błędem; po prostu nic nie robi. EventHandler<int> pokazuje, że we współczesnym .NET argument typu nie musi dziedziczyć po EventArgs, choć osobna klasa zostawia miejsce na późniejsze dodanie pól bez psucia subskrybentów.

Metody obsługi działają synchronicznie, w kolejności subskrypcji, w wątku, który wywołał zdarzenie. Jeśli jedna z nich rzuci wyjątek, pozostałe się nie wykonają, a wyjątek trafi do kodu, który wywołał zdarzenie.

Bezpieczne wywoływanie zdarzenia

Zdarzenie bez subskrybentów ma wartość null. Bezpośrednie wywołanie rzuciłoby NullReferenceException, więc wywołuj je przez operator warunkowy null:

OrderPlaced?.Invoke(this, args);

Przed C# 6 wzorzec polegał na skopiowaniu pola do zmiennej lokalnej, sprawdzeniu jej i wywołaniu kopii. Kopia ma znaczenie w kodzie wielowątkowym: sprawdzenie OrderPlaced != null, a potem wywołanie OrderPlaced(...) odczytuje pole dwa razy, a inny wątek mógłby w międzyczasie usunąć ostatnią metodę obsługi. ?. odczytuje je raz, więc jest i krótsze, i poprawne.

Dlaczego event, a nie publiczne pole delegatu

Bez słowa kluczowego event publiczne pole delegatu działa przy subskrybowaniu, ale daje każdemu subskrybentowi pełną kontrolę:

Wynik:

analytics ping
analytics ping

Jedno = wpisane zamiast += po cichu usunęło dwie wcześniejsze metody obsługi, a kod zewnętrzny mógł wywołać "kliknięcie" bez żadnego kliknięcia. Oznaczenie pola jako event zamienia obie te linie w błędy kompilacji:

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

Wewnątrz Button zdarzenie nadal zachowuje się jak zwykłe pole delegatu, więc klasa może je wywołać i sprawdzić, czy jest null.

Wyciek przez zapomniane anulowanie subskrypcji

Gdy subskrybujesz, zdarzenie zapisuje delegat, a delegat przechowuje referencję do obiektu subskrybenta. Dopóki wydawca żyje, a metoda obsługi jest podpięta, subskrybent nie może zostać usunięty przez odśmiecacz pamięci i dalej otrzymuje zdarzenia, choć już go nie potrzebujesz:

Wynik:

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

Ustawienie leaky na null nic nie zmieniło dla źródła danych: jego lista metod obsługi nadal wskazuje ten widżet, więc "zamknięty" widżet dalej się aktualizuje i zostaje w pamięci. Widżet w bloku using anulował subskrypcję w Dispose i przestał otrzymywać zdarzenia. To jeden z najczęstszych wycieków pamięci w kodzie .NET, zarówno desktopowym, jak i serwerowym, zwłaszcza przy zdarzeniach statycznych i usługach działających w całej aplikacji, które żyją aż do końca procesu.

Zasada: kto subskrybuje zdarzenie obiektu, który żyje dłużej od niego, musi anulować subskrypcję, zwykle w Dispose (zobacz instrukcję using). Gdy wydawca i subskrybent mają ten sam czas życia, jak formularz i jego własne przyciski, anulowanie subskrypcji nie jest potrzebne.

Własne akcesory add i remove

Zdarzenie może zdefiniować, co robią += i -=, podobnie jak get i set we właściwości. To rzadkie, ale w ten sposób frameworki przechowują metody obsługi we wspólnym słowniku albo przekazują je do innego obiektu:

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

Przy własnych akcesorach klasa wywołuje zdarzenie przez pole pomocnicze (closed?.Invoke(this, EventArgs.Empty)), bo samo zdarzenie nie ma już własnego miejsca na dane.

Najczęściej zadawane pytania

Czym jest zdarzenie w C#?

Zdarzenie to składowa klasy, która pozwala innym obiektom poprosić o powiadomienie, gdy coś się stanie. Opiera się na delegacie multicast, ale kod zewnętrzny może tylko dodawać metody obsługi (+=) i je usuwać (-=). Wywołać zdarzenie może tylko klasa, która je deklaruje.

Czym różni się zdarzenie od delegatu w C#?

Delegat to typ, który wskazuje metody. Zdarzenie to składowa zadeklarowana z typem delegatu i słowem kluczowym event, które ogranicza dostęp: spoza klasy nie można go wywołać, odczytać listy jego metod obsługi ani przypisać przez =, a jedynie zasubskrybować i anulować subskrypcję. Publiczne pole delegatu pozwala na to wszystko, więc dowolny subskrybent mógłby usunąć pozostałych albo sam wywołać zdarzenie.

Jak przekazać dane w zdarzeniu C#?

Utwórz klasę pochodną od EventArgs z danymi jako właściwościami tylko do odczytu (public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }) i zadeklaruj zdarzenie jako EventHandler<OrderPlacedEventArgs>. Wywołaj je przez OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total));.

Jak bezpiecznie wywołać zdarzenie w C#?

Użyj MyEvent?.Invoke(this, args);. Zdarzenie bez subskrybentów ma wartość null, więc bezpośrednie wywołanie rzuca NullReferenceException. Operator ?. odczytuje pole raz, co zapobiega też wyścigowi, w którym inny wątek usuwa ostatnią metodę obsługi między sprawdzeniem null a wywołaniem.

Czy zdarzenia w C# mogą powodować wycieki pamięci?

Tak. Subskrypcja zapisuje delegat, który wskazuje subskrybenta, więc wydawca utrzymuje subskrybenta przy życiu tak długo, jak sam żyje. Długo żyjący wydawca (zdarzenie statyczne, usługa działająca w całej aplikacji) z krótko żyjącymi subskrybentami, którzy nigdy nie anulują subskrypcji, trzyma każdego z nich w pamięci. Anuluj subskrypcję przez -=, gdy subskrybent kończy pracę, zwykle w Dispose.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ