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 zEventArgs.Empty.EventHandler<TEventArgs>:void (object sender, TEventArgs e), gdzieTEventArgsto twoja klasa z danymi.
Konwencje wokół nich:
senderto obiekt, który wywołał zdarzenie, zwyklethis.- Klasa danych ma nazwę
{Something}EventArgs, dziedziczy poEventArgs(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.