Eventy są mechanizmem, który pozwala na przesyłanie między obiektami informacji o tym, że wystąpiło pewne zdarzenie.
Obiekt klasy, który wysyła informację o zdarzeniu, nazywamy publisherem eventu.
Obiekt lub obiekty, które tę informację otrzymują i na nią reagują, nazywamy subscriberami eventu.
Rozważmy bardzo prosty przykład. Chcemy napisać program, w którym cały czas tyka metronom. Tworzymy klasę Metronome, która co sekundę wypisuje na ekran "Tik!". Chcemy, żeby w programie była inna klasa, której odpowiedzialnością będzie nasłuchiwanie metronomu i wypisywanie na ekran informacji, że usłyszała tyknięcie.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | using System; using System.Threading; namespace CSharpPractices { public class Metronome { public event EventHandler Tick; public void Run() { while (true) { Thread.Sleep(1000); Console.WriteLine("Tik!"); Tick(this, EventArgs.Empty); } } } public class MetronomeListener { public MetronomeListener(Metronome metronome) { metronome.Tick += OnMetronomeTick; } private void OnMetronomeTick(object sender, EventArgs e) { Console.WriteLine("Słyszałem to!"); } } class Program { static void Main() { Metronome metronome = new Metronome(); var metronomeListener = new MetronomeListener(metronome); metronome.Run(); } } } |
Wynikiem działania tego programu będzie pokazująca się co sekundę para komunikatów: najpierw 'Tik!" wypisywany przez Metronome, a tuż za nim "Słyszałem to!" wypisywany przez MetronomeListener.
Tik! Słyszałem to! Tik! Słyszałem to! Tik! Słyszałem to! Tik! Słyszałem to! Tik! Słyszałem to!
W przykładzie tym:
- tyknięcie metronomu jest eventem
- Metronome jest publisherem tego eventu
- MetronomeListener jest subscriberem eventu
public event EventHandler Tick;
Czym jest EventHandler? Jest to delegat zdefiniowany w namespace System. Więcej o delegatach poczytać można w tym poście. Uwaga: zrozumienie działania delegatów jest kluczowe do zrozumienia eventów.
Pełna definicja delegatu EventHandler to:
delegate void EventHandler(object sender, System.EventArgs e);
EventHandler jest więc delegatem, który przechowywać będzie w sobie referencję do funkcji zwracającej void, a jako parametry przyjmującej Object i System.EventArgs. To właśnie do tego delegatu będą podpinać się funkcje z subscriberów - funkcje, w których subscriber będzie reagował na wystąpienie eventu.
Parametrami tego delegatu są:
- Object sender - jest to obiekt, który jest publisherem eventu. W linijce 16 widzimy, że Metronome woła Tick z parametrem "this". Ma to sens - to Metronome jest publisherem - zatem "senderem" eventu.
- System.EventArgs e - są to dodatkowe informacje, jakie chcemy przesłać z publishera do subscribera. W tym przypadku nie chcemy wysłać nic więcej, więc w linijce 16 ten argument przybiera wartość EventArgs.Empty.
Wróćmy jednak jeszcze do linijki 8:
public event EventHandler Tick;
Co właściwie mówi ta linijka? Skoro EventHandler jest delegatem, mówi ona:
W tej klasie będzie istniał publiczny obiekt Tick, typu delegat, a zatem obiekt trzymający w sobie referencję do funkcji. Wiemy już, że funkcja ta będzie zwracać void, a jako parametry przyjmować będzie Object i EventArgs.
Ale widzimy tam też słowo kluczowe "event". Co ono właściwie oznacza?
Zastanówmy się najpierw, jak używany jest obiekt eventu Tick. W klasie MetronomeListener "podpinamy" do niego metodę OnMetronomeTick. Zaś w linijce 16, wewnątrz klasy Metronome, wołamy Tick jak metodę, co jak wiemy jest typowe dla delegat - je też możemy wywoływać jak zwykłe metody. "event" zachowuje się więc jak zwyczajny delegat. Na czym polega różnica?
Różnica ta polega tylko - i aż - na tym, w jaki sposób inne obiekty mogą odwoływać się do delegatu Tick. W ramach eksperymentu spróbujmy zawołać delegat Tick z konstruktora klasy MetronomeListener. Delegat ten jest publiczny - co może pójść nie tak?
public MetronomeListener(Metronome metronome) { metronome.Tick(this, EventArgs.Empty); }
Okazuje się, że kod przestaje się budować. Błąd, który pokazuje kompilator brzmi "The event 'Metronome.Tick' can only appear on the left hand side of += or -= (except when used from within the type 'Metronome')". A zatem z klasy innej niż właściciel eventu możemy tylko podpinać lub odpinać do/od niego metody.
Dlaczego tak ważne jest, aby subscriber miał ograniczony dostęp do delegaty? Ponieważ bez tego mechanizmu ktoś mógłby "wyczyścić" delegatę, odpinając od niej wszystkich subscriberów.
public MetronomeListener(Metronome metronome) { metronome.Tick = OnMetronomeTick; }
Także niepożądane byłoby zawołanie delegatu Tick poza klasą publishera. W końcu zawołanie eventu jest w tym kontekście równoznaczne z krzyknięciem "Uwaga, uwaga, subscriberzy, nastało to zdarzenie, którym wszyscy się interesujecie! Tako rzeczę ja, publisher!". Byłoby to co najmniej dziwne, gdyby "wołała" to inna klasa niż właśnie publisher.
Tak więc słowo kluczowe event jest rodzajem ograniczenia, które sprawia, że delegat przestaje być zwykłym delegatem. Staje się delegatem wyjątkowym - eventem - który spoza klasy będącej jego właścicielem można używać w bardzo ograniczonym stopniu. Właściwie z poziomu subscribera możemy jedynie powiedzieć "witam, chciałbym, żeby zawołała się moja funkcja kiedy ten event wystąpi", oraz "ach, jednak już nie chcę być informowany o tym evencie. Proszę odpiąć moją funkcję z listy funkcji, które zawołają się, kiedy nastąpi ten event".
Spróbujmy teraz nieco przerobić program. Wyobraźmy sobie, że chcemy, aby Metronome przesyłało do MetronomeListenera informację o tym, które z rzędu tyknięcie ma teraz miejsce. Możemy to zrobić na dwa sposoby.
Po pierwsze, możemy wykorzystać parametr sender z EventHandlera. Sender jest referencją do obiektu, który opublikował event - a zatem do publishera. Zobaczmy, jak wyglądałby program w tym przypadku:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | using System; using System.Threading; namespace CSharpPractices { public class Metronome { public event EventHandler Tick; public int TicksCount { get; set; } public void Run() { while (true) { Thread.Sleep(1000); Console.WriteLine("Tik!"); ++TicksCount; Tick(this, EventArgs.Empty); } } } public class MetronomeListener { public MetronomeListener(Metronome metronome) { metronome.Tick += OnMetronomeTick; } private void OnMetronomeTick(object sender, EventArgs e) { var publisher = sender as Metronome; Console.WriteLine($"Słyszałem to! Było to tyknięcie numer {publisher.TicksCount}"); } } class Program { static void Main() { Metronome metronome = new Metronome(); var metronomeListener = new MetronomeListener(metronome); metronome.Run(); } } } |
Wygląda na to, że program działa poprawnie:
Tik! Słyszałem to! Było to tyknięcie numer 1 Tik! Słyszałem to! Było to tyknięcie numer 2 Tik! Słyszałem to! Było to tyknięcie numer 3 Tik! Słyszałem to! Było to tyknięcie numer 4 Tik! Słyszałem to! Było to tyknięcie numer 5
Poza dodaniem licznika tyknięć w klasie Metronome, zmieniło się to, w jaki sposób MetronomeListener wypisuje informację o tym, że usłyszał tyknięcie metronomu. Wykorzystuje on parametr sender. Rzutuje go on na obiekt typu Metronome (sender zawsze jest typu Object), a następnie dostaje się do property TicksCount.
Rozwiązanie to budzi pewne wątpliwości:
- niekoniecznie chcemy udostępniać TicksCount jako property z publicznym getterem
- jeszcze mniej chcemy rzutować object na Metronome. Zakładamy tutaj, że wiemy na pewno, że parametr jest naprawdę typu Metronome. Takie założenia są zawsze trochę ryzykowne, a poza tym rzutowania lepiej unikać, jeśli mamy taką możliwość
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | using System; using System.Threading; namespace CSharpPractices { public class MetronomeTickEventArgs : EventArgs { public int TicksCount { get; set; } } public class Metronome { public event EventHandler<MetronomeTickEventArgs> Tick; private int _ticksCount; public void Run() { while (true) { Thread.Sleep(1000); Console.WriteLine("Tik!"); ++_ticksCount; Tick(this, new MetronomeTickEventArgs { TicksCount = _ticksCount }); } } } public class MetronomeListener { public MetronomeListener(Metronome metronome) { metronome.Tick += OnMetronomeTick; } private void OnMetronomeTick(object sender, MetronomeTickEventArgs e) { Console.WriteLine($"Słyszałem to! Było to tiknięcie numer {e.TicksCount}"); } } class Program { static void Main() { Metronome metronome = new Metronome(); var metronomeListener = new MetronomeListener(metronome); metronome.Run(); } } } |
Co się zmieniło? Przede wszystkim stworzyliśmy własną klasę reprezentującą argumenty eventu:
public class MetronomeTickEventArgs : EventArgs { public int TicksCount { get; set; } }
Zmieniliśmy EventHandler Tick w taki sposób, by korzystał z nowej klasy MetronomeTickEventArgs:
public event EventHandler<MetronomeTickEventArgs> Tick;
Oraz zmieniliśmy kod wysyłający do subscriberów informację o tym, że sdarzył się event:
Tick(this, new MetronomeTickEventArgs { TicksCount = _ticksCount });
Dzięki tym zmianom bardzo jasno określamy, jakie informacje chcemy udostępnić subscriberom eventu.
Uwaga: przy używaniu eventów należy pamiętać o tym, że jeśli żadne funkcja nie została do eventu podpięta, lub gdy wszystkie zostały odpięte, wartość eventu to null. By to zobrazować przeróbmy konstruktor MetronomeListenera:
public MetronomeListener(Metronome metronome) { // metronome.Tick += OnMetronomeTick; }
W ten sposób w żadnym momencie programu do eventu Tick nie jest dodawana żadna funkcja. Skutkuje to oczywiście wyjątkiem:
Dlatego przed użyciem eventu warto dodać sprawdzanie, czy jego wartość nie jest nullem:
Tick?.Invoke(this, EventArgs.Empty);
Eventy a wycieki pamięci
Poruszając temat eventów, warto wspomnieć o ryzyku wycieków pamięci, jakie za sobą niosą.Czym jest wyciek pamięci? Jest to sytuacja, w której jakiś obiekt nie jest już używany, ale wciąż zajmuje miejsce w pamięci.
Rozważmy ten problem w kontekście eventów. Jako przykładem posłużę się klasycznym problemem, z jakim spotykają się programiści aplikacji okienkowych. Moja aplikacja napisana będzie w Windows Forms. Znajomość tej technologii nie jest konieczna do zrozumienia problemu.
Mamy następującą aplikację:
Jest to trywialna aplikacja, której celem jest tylko pokazanie problemu z wyciekiem pamięci w kontekście eventów. Program ten posiada przycisk "Otwórz okienko", który, jak można się zapewne domyślić, otwiera nowe okienko. Jest tam jeszcze drugi przycisk, którym zajmiemy się później - na razie nic nie robi.
Klasę reprezentującą okienko z przyciskami nazwijmy dla uproszczenia "ParentForm", zaś okienko, które się otworzy "ChildForm". Taki widok zastaniemy po naciśnięciu przycisku "Otwórz okienko".
Teraz zaczyna się zabawa. Chcemy zaimplementować następującą funkcjonalność: kiedy okienko "rodzica" zostanie pomniejszone lub powiększone, kolor "dziecka" ma zmienić się na zielony.
Zrealizowanie tego nie jest wcale trudne. Najpierw przyjrzyjmy się klasie ParentForm. Jest ona dość podstawowa i zawiera tylko obsługę przycisku otwierającego ChildForm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | using System; using System.Windows.Forms; namespace WinFormsSampleApp { public partial class ParentForm : Form { public ParentForm() { InitializeComponent(); } private void openChildFormButton_Click(object sender, EventArgs e) { var otherForm = new ChildForm(this); otherForm.Show(); } private void debugButton_Click(object sender, EventArgs e) { //na razie nic ciekawego } } } |
W klasie Childform wystarczy do eventu "ResizeBegin" z klasy ParentForm podpiąć odpowiednią metodę.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | using System; using System.Drawing; using System.Windows.Forms; namespace WinFormsSampleApp { public partial class ChildForm : Form { public ChildForm(ParentForm parentForm) { InitializeComponent(); parentForm.ResizeBegin += ChangeColorToGreen; } private void ChangeColorToGreen(object sender, EventArgs e) { this.BackColor = Color.Green; } } } |
Wygląda na to, że wszystko działa. W momencie zmiany rozmiaru okienka rodzica, kolor dziecka zmienia się na zielony.
Wydawać by się mogło, że wszystko jest w porządku.
Niestety, nie jest! Żeby to wykazać, musimy jednak zastosować pewien trik. W poście o Garbage Collectorze pisałam, że gdy sprzątane są zasoby, Garbage Collector oznacza te obiekty, do których wciąż istnieje jakaś referencja, a następnie zwalnia pamięć wszystkich innych obiektów. Istnieje jednak specjalna klasa WeakReference, która także przechowuje w sobie referencję do obiektu, jednak Garbage Collector nic sobie z tego nie robi. Obiekt ten nie zostanie przez niego oznaczony jako "obiekt do którego istnieje jakaś referencja". Pamięć zajmowana przez ten obiekt zostanie zwolniona. W klasie WeakReference istnieje property typu bool "IsAlive" mówiące, czy obiekt "żyje", czy został już sprzątnięty przez Garbage Collector.
Wróćmy do naszego programu. W momencie zamknięcia okienka ChildForm, zasoby przez nie zajmowane powinny zostać zwolnione. Garbage Collector nie gwarantuje nam kiedy to się stanie, ale możemy użyć metody GC.Collect, by wymusić pracę Garbage Collectora.
Sprawdźmy, czy faktycznie okienko zostaje sprzątnięte. Przerobię nieco klasę ParentForm.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using System; using System.Windows.Forms; namespace WinFormsSampleApp { public partial class ParentForm : Form { private WeakReference _weakReferenceToChildForm; public ParentForm() { InitializeComponent(); } private void openChildFormButton_Click(object sender, EventArgs e) { var otherForm = new ChildForm(this); _weakReferenceToChildForm = new WeakReference(otherForm); otherForm.Show(); } private void debugButton_Click(object sender, EventArgs e) { GC.Collect(); GC.WaitForPendingFinalizers(); var isChildFormAlive = _weakReferenceToChildForm.IsAlive; } } } |
Zapisujemy referencję do ChildForm w obiekcie typu WeakReference. Dodaliśmy także obsługę przycisku do debugowania. W metodzie obsługującej go pobieramy wartość z WeakReference.
Zastanówmy się, co się stanie, jeśli otworzymy i zamkniemy ChildForm, a następnie zatrzymamy debugger w linijce 28 w klasie ParentForm. Wymuszamy tam pracę Garbage Collectora, a nastepnie sprawdzamy, czy obiekt ChildForm wciąż "żyje". Skoro okienko dziecko jest zamknięte, to możemy się spodziewać, że Garbage Collector sprzątnie zasoby tego obiektu, a flaga "IsAlive" będzie ustawiona na "false".
Niestety, tak się nie dzieje.
Gdzie popełniliśmy błąd?
Żeby odpowiedzieć sobie na to pytanie, musimy cofnąć się do momentu, kiedy zasubskrybowaliśmy metodę "ChangeColorToGreen" z ChildForm do eventu "ResizeBegins" z ParentForm. Skoro do eventu w Parent Form podpięliśmy metodę z ChildForm, to znaczy, że w ParentForm musi istnieć referencja do ChildForm (bo w jakiś sposób musimy się dostać do subscriberów, kiedy wysyłamy im informację o tym, że wydarzył się event). A zatem tak długo jak ParentForm istnieje, istnieje też referencja do ChildForm. A skoro istnieje referencja, to Garbage Collector nie zwolni pamięci zajmowanej przez ten obiekt. Mimo zamknięcia okienka ChildForm, jego zasoby nie zostaną sprzątnięte.
Może to eskalować w ogromny problem. Wyobraźmy sobie aplikację, na której ktoś pracuje 8 godzin dziennie. W tym czasie otwiera i zamyka dziesiątki lub setki okienek. Ciągle rezerwowane są dla nich nowe zasoby, a nigdy nie są zwalniane. Użytkownicy się frustrują, bo po kilku godzinach aplikacja zaczyna "zamulać". Programiści się frustrują, bo nie wiedzą, czemu zużycie pamięci tak koszmarnie rośnie.
Na szczęście istnieje dość proste rozwiązanie. Wystarczy przed zamknięciem okienka wypisać się ze wszystkich eventów:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using System; using System.Drawing; using System.Windows.Forms; namespace WinFormsSampleApp { public partial class ChildForm : Form { private ParentForm _parentForm; public ChildForm(ParentForm parentForm) { _parentForm = parentForm; InitializeComponent(); _parentForm.ResizeBegin += ChangeColorToGreen; } protected override void OnFormClosing(FormClosingEventArgs e) { _parentForm.ResizeBegin -= ChangeColorToGreen; base.OnFormClosing(e); } private void ChangeColorToGreen(object sender, EventArgs e) { this.BackColor = Color.Green; } } } |
Wygląda na to, że pomogło:
Pamiętajmy: podpięcie metody subscribera do publishera eventu tworzy w sposób niejawny referencję z publishera do subscribera. O ile istnieje cień szansy, że publisher będzie żył dłużej niż subscriber, zawsze usuńmy podpięcie metody subscribera do eventu w momencie gdy kończymy używać subscribera.
Na koniec przepraszam za polsko-angielską nowomowę, ale staram się zachować konsekwencję w nazewnictwie. Skoro zdefiniowaliśmy jasno czym są subscriber i publisher, to równie dobrze możemy korzystać w tych słów bez oporów, o ile pozwoli to na precyzyjne wyjaśnienie problemu.
W tym poście korzystałam z:
- https://stackoverflow.com/questions/724085/events-naming-convention-and-style
- https://stackoverflow.com/questions/18385967/c-sharp-event-keyword-advantages
Dobry, jasny artykuł. Brakuje mi wzmianki o dość ważnej rzeczy, jaką jest wartość null delegaty zdarzenia, gdy nikt go nie obserwuje („subscribe'uje”). Dobrą praktyką jest sprawdzenie tego zanim wywołamy zdarzenie, żeby nie dostać NullReferenceException.
OdpowiedzUsuńCiekawa sprawa z tym wyciekiem pamięci, szkoda że niepotrzebny obiekt musi się sam odpinać od zdarzenia. Na oko się wydaje, że twórcy .NET mogliby uniknąć tego problemu gdyby odniesienia do obserwatorów były postaci WeakReference i przed wysłaniem do nich zdarzenia sprawdzane by było, czy dalej istnieją. Jeśliby nie istnieli, to by można ich usunąć z listy subskrybentów. Pewnie jednak są jakieś dobre powody, by tak nie robić.
Dzięki za miłe słowa :). Masz rację, fakt, że event jest nullem, jeśli nikt się do niego nie podepnie, może nie być dla każdego oczywisty. Dodałam tę informację pod koniec sekcji o metronomie. Pozdrawiam!
Usuń