Wszystkie wpisy

Delegaty i zdarzenia w C# - Action, Func, event, EventArgs

Delegaty jako bezpieczne wskaźniki na metody, gotowe Action/Func/Predicate, wzorzec publisher-subscriber, słowo kluczowe event oraz oficjalny standard .NET z EventHandler i EventArgs. Szósta część kursu C#.


W poprzednim wpisie Entity Framework Core zobaczyłeś, jak EF Core pozwala odpytywać prawdziwą bazę przez ten sam LINQ, którego używasz dla list w pamięci. Teraz wracamy do C# samego w sobie i poznajemy mechanizm, na którym opierają się wszystkie interaktywne aplikacje .NET - od WinForms po SignalR: delegaty i zdarzenia.

W tej części zobaczysz, dlaczego delegaty są “bezpiecznymi wskaźnikami na metody”, jak korzystać z gotowych Action/Func/Predicate, czym różni się zdarzenie od zwykłego pola delegata i jaki oficjalny standard ustanowił Microsoft dla wszystkich zdarzeń w bibliotece BCL - EventHandler plus klasy pochodne od EventArgs.

1. Czym jest delegat #

Co to jest: delegat to typ przechowujący referencję do metody o określonej sygnaturze. Microsoft opisuje go w dokumentacji jako “type that represents references to methods with a particular parameter list and return type” - czyli bezpieczny typowo wskaźnik na funkcję. Skojarzenie: zmienna typu int przechowuje liczbę; zmienna typu delegata przechowuje “wizytówkę z numerem”, pod którym kryje się metoda.

Dlaczego istnieje: w wielu sytuacjach chcemy przekazać sposób działania jako argument: “posortuj listę używając tej funkcji porównującej”, “wywołaj tę metodę, gdy użytkownik kliknie przycisk”, “filtruj tym warunkiem”. Bez delegatów musielibyśmy każdorazowo deklarować interfejs z jedną metodą i tworzyć dedykowaną klasę implementującą go. Delegat redukuje to do jednej linii.

Historyczna deklaracja własnego delegata #

// Deklaracja typu delegata - jak deklaracja klasy, tylko że bez ciała
public delegate int Operacja(int x, int y);

// Metoda pasująca sygnaturą
static int Dodaj(int a, int b) => a + b;
static int Mnoz(int a, int b) => a * b;

// Tworzymy "zmienne" tego typu i przypisujemy im metody
Operacja op = Dodaj;
int wynik = op(3, 4);          // 7 - wywołanie metody przez delegata
op = Mnoz;
wynik = op(3, 4);              // 12 - teraz wywoła Mnoz

Niuans: w kontekście przeciążenia metod sygnatura nie obejmuje typu zwracanego. W kontekście delegatów - tak. Metoda przypisywana do delegata Operacja musi zwracać int; metoda zwracająca double zostanie odrzucona przez kompilator.

W praktyce w nowym kodzie rzadko deklarujemy własne typy delegatów - od C# 3.0 Microsoft dostarcza zestaw gotowych typów generycznych.

2. Gotowe delegaty generyczne: Action, Func, Predicate #

Action - metoda bez zwracanej wartości #

Action<T1, T2, ...> reprezentuje metodę, która zwraca void. W nawiasach ostrych podajesz typy parametrów (do 16). Wariant bezparametrowy to zwykły Action.

// Bez parametrów
Action hello = () => Console.WriteLine("Cześć!");
hello();                                  // wywołanie
hello.Invoke();                           // dokładnie to samo, bardziej formalnie

// Z jednym parametrem
Action<string> log = tekst => Console.WriteLine($"[INFO] {tekst}");
log("System uruchomiony");

// Z trzema parametrami
Action<string, int, bool> raportuj = (nazwa, kod, ok) =>
    Console.WriteLine($"{nazwa}: {kod} (ok: {ok})");

Func - metoda zwracająca wartość #

Func<T1, T2, ..., TResult> reprezentuje metodę, która coś zwraca. Ostatni parametr typu to zawsze typ zwracany.

// Func<int, int, int> - przyjmuje dwa int, zwraca int
Func<int, int, int> dodawanie = (a, b) => a + b;
int suma = dodawanie(5, 10);             // 15

// Func<string> - bez parametrów, zwraca string
Func<string> rollDie = () =>
{
    int rolled = new Random().Next(1, 7);
    return $"Wyrzuciłeś: {rolled}";
};

To te same Func, których używasz w LINQ - Where, Select i reszta operatorów przyjmuje argumenty typu Func<T, ...>. Wyrażenie lambda jest pod spodem konwertowane właśnie na obiekt delegata.

Predicate - test logiczny #

Predicate<T> to wariant Func<T, bool> - przyjmuje jeden parametr typu T i zwraca bool. Sygnatura identyczna, ale są to różne typy - kompilator nie zamieni jednego na drugi automatycznie. W nowym kodzie preferuje się Func<T, bool> (właśnie ze względu na LINQ), Predicate<T> spotkasz głównie w starszych API typu List<T>.Find.

Predicate<int> isAdult = age => age >= 18;
bool sprawdz = isAdult(20);        // true

// To samo jako Func<T, bool>:
Func<int, bool> isAdultFunc = age => age >= 18;

3. Multicast - łańcuchy delegatów #

Co to jest: delegat w .NET dziedziczy po MulticastDelegate. Można “skleić” wiele metod w jeden delegat - przy wywołaniu uruchomione zostaną kolejno wszystkie. Łączysz operatorem +=, rozłączasz przez -=.

Action<string> logger = msg => Console.WriteLine($"[CONSOLE] {msg}");
logger += msg => Console.WriteLine($"[FILE]    {msg}");
logger += msg => Console.WriteLine($"[REMOTE]  {msg}");

logger("System startuje");
// [CONSOLE] System startuje
// [FILE]    System startuje
// [REMOTE]  System startuje

Pułapka: dla Func<...> z wieloma zarejestrowanymi metodami widoczna jest tylko wartość zwracana przez ostatnią. Pozostałe są wywołane, ale ich wyniki nikną. Dlatego multicast praktycznie zawsze stosujemy z Action<...> (zdarzenia, callbacki, powiadomienia) - tam nie ma wartości do “zgubienia”.

4. Zdarzenia: wzorzec publisher-subscriber #

Wyobraź sobie problem: obiekt TemperatureSensor i obiekty Display oraz Alarm. Skąd Display ma wiedzieć, że temperatura się zmieniła?

  • Złe rozwiązanie: Display co sekundę pyta czujnik “zmieniło się?”. Marnuje zasoby, dodaje opóźnienie, sklada się z brzydkiej pętli aktywnego czekania.
  • Dobre rozwiązanie: czujnik ogłasza zmianę. Wszyscy zainteresowani subskrybują to ogłoszenie i reagują wtedy, gdy ono przychodzi.

To wzorzec wydawca-subskrybent (publisher-subscriber, znany też jako observer). Dokumentacja Microsoftu wprost odwołuje się do tego wzorca, opisując zdarzenia jako mechanizm, w którym “the class that sends (or raises) the event is called the publisher and the classes that receive (or handle) the event are called subscribers”.

Słowo kluczowe event - bezpieczna nakładka na delegata #

Surowe pole delegata działa, ale ma dwa problemy. Z zewnątrz klasy każdy mógłby:

  1. Nadpisać całą listę subskrybentów (czujnik.NaZmiane = mojaMetoda - zamiast dopisać siebie, wyrzucasz wszystkich pozostałych)
  2. Sztucznie wywołać zdarzenie (czujnik.NaZmiane(50) - kazać czujnikowi udawać alarm, choć nic się nie stało)

Słowo kluczowe event zamyka oba scenariusze. Dla świata zewnętrznego widoczne są tylko operatory += (subskrybuj) oraz -= (wypisz się). Wywołanie i nadpisanie listy są zarezerwowane dla wnętrza klasy publishera.

public class TemperatureSensor
{
    // event = bezpieczna nakładka na delegata
    public event Action<int> OnTemperatureChanged;

    public void SetTemperature(int newValue)
    {
        Console.WriteLine($"Sensor: temperature {newValue} degrees");

        // ?.Invoke - jeśli nikt nie subskrybuje, pole jest null
        OnTemperatureChanged?.Invoke(newValue);
    }
}

public class Display
{
    public void UpdateScreen(int t) =>
        Console.WriteLine($"[SCREEN] {t}°C");
}

public class Alarm
{
    public void Check(int t)
    {
        if (t > 50)
            Console.WriteLine($"[ALARM] Fire! {t}°C");
    }
}
var sensor = new TemperatureSensor();
var ekran   = new Display();
var alarm   = new Alarm();

// Subskrybcje - przekazujemy SAMĄ NAZWĘ metody, bez nawiasów
sensor.OnTemperatureChanged += display.UpdateScreen;
sensor.OnTemperatureChanged += alarm.Check;

sensor.SetTemperature(25);    // ekran reaguje, alarm nie
sensor.SetTemperature(80);    // ekran + alarm reagują

Czujnik nie ma pojęcia, kto go słucha - ekran, alarm, logger bazy danych czy klient SMS. Po prostu wysyła sygnał. Reszta systemu sama decyduje, jak na niego zareagować. To jest luźne sprzężenie - publisher i subscriber nie znają się wzajemnie, łączy je tylko abstrakcyjny kontrakt sygnatury delegata.

5. Oficjalny standard .NET: EventHandler + EventArgs #

Dla szybkich przykładów Action<int> jako typ zdarzenia jest OK. Ale w bibliotece BCL i prawie każdym frameworku .NET spotkasz inną, oficjalną konwencję - zdarzenia oparte na delegacie EventHandler i klasach pochodnych od EventArgs.

Sygnatura: (object sender, EventArgs e) #

EventHandler to delegat o sygnaturze:

public delegate void EventHandler(object sender, EventArgs e);

Dwa parametry, zawsze w tej kolejności:

  • sender - obiekt, który wywołał zdarzenie. Pozwala handlerowi rozpoznać, kto zawiadamia (jeden handler może obsługiwać zdarzenia z wielu źródeł).
  • e - obiekt z danymi zdarzenia, dziedziczący po EventArgs. Jeśli zdarzenie nie ma dodatkowych danych, używasz po prostu EventArgs (z EventArgs.Empty jako wartością).

Dla zdarzeń z konkretnymi danymi istnieje wariant generyczny:

public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);

TEventArgs to twoja własna klasa pochodna od EventArgs.

Konwencja: klasy EventArgs zawsze z sufiksem #

Microsoft pilnuje, by wszystkie klasy z danymi zdarzeń kończyły się sufiksem EventArgs: MouseEventArgs, PropertyChangedEventArgs, SerialDataReceivedEventArgs. Twoja własna powinna to naśladować - np. TemperatureChangedEventArgs.

// 1. Klasa z danymi konkretnymi dla tego zdarzenia
public class TemperatureChangedEventArgs : EventArgs
{
    public int Temperature { get; set; }
    public int Difference { get; set; }
}

// 2. Publisher z eventem na EventHandler<T>
public class TemperatureSensor
{
    private int _temperature;
    public event EventHandler<TemperatureChangedEventArgs> OnChange;

    public void SetTemperature(int newValue)
    {
        int diff = newValue - _temperature;
        _temperature = newValue;

        // Wywołanie przez protected virtual - patrz niżej
        OnTemperatureChanged(new TemperatureChangedEventArgs
        {
            Temperature = newValue,
            Difference = roznica
        });
    }

    // 3. Wzorzec OnXxx - opakowanie wywołania
    protected virtual void OnTemperatureChanged(TemperatureChangedEventArgs e)
    {
        OnChange?.Invoke(this, e);
    }
}

Wzorzec protected virtual OnXxx #

Zauważ specjalną metodę OnTemperatureChanged(...) opakowującą wywołanie. To standard .NET - dokumentacja Microsoftu pisze: “the event sender typically wraps the invocation in a protected virtual method named On<EventName>. Po co?

  • Klasy pochodne mogą nadpisać tę metodę, by dodać własną logikę przed/po wysłaniu (np. logowanie, walidacja). Muszą wtedy wywołać base.OnChange(e), by oryginalna logika (rozesłanie do subskrybentów) zadziałała.
  • Pojedyncze miejsce wywołania - jeśli kiedyś będziesz musiał dodać lock, try/catch albo zmienić sposób wywołania (np. asynchronicznie), zmienisz to w jednym miejscu.
// Subscriber z sygnaturą zgodną z EventHandler<T>
static void Log(object sender, TemperatureChangedEventArgs e)
{
    string znak = e.Difference >= 0 ? "+" : "";
    Console.WriteLine($"[LOG] {e.Temperature}°C ({znak}{e.Difference})");
}

// Użycie
var sensor = new TemperatureSensor();
czujnik.OnChange += Log;
sensor.SetTemperature(25);   // [LOG] 25°C (+25)
sensor.SetTemperature(80);   // [LOG] 80°C (+55)

6. Pułapki produkcyjne #

Wyciek pamięci - klasyczny problem z subskrybentami #

Dopóki publisher pamięta subscriber-a w swojej liście wywołań, GC nie zwolni subscriber-a, nawet jeśli reszta aplikacji o nim zapomniała. Publisher trzyma referencję - subscriber żyje dalej.

public class Window : IDisposable
{
    public Window(TemperatureSensor czujnik)
    {
        czujnik.OnChange += PaintScreen;        // referencja od czujnika
    }

    private void PaintScreen(object s, TemperatureChangedEventArgs e) { /* ... */ }

    public void Dispose()
    {
        // ZAWSZE wypisz się, gdy obiekt znika z gry
        // (inaczej okno zostaje w pamięci tak długo, jak czujnik)
        // czujnik.OnChange -= PaintScreen;
    }
}

Zasada ogólna: jeśli długo żyjący publisher emituje zdarzenia do krótko żyjących subskrybentów (np. okien dialogowych), zawsze wypisuj subscriber-a z Dispose lub destruktora.

Wyjątek w handlerze przerywa łańcuch #

Domyślnie handlery są wywoływane synchronicznie w wątku publisher-a. Pierwszy wyjątek przerywa całą sekwencję - pozostali subskrybenci po feralnym handlerze nie zostaną wywołani.

Jeśli to problem, ręcznie iteruj po GetInvocationList() i opakuj każde wywołanie w try/catch:

foreach (var d in OnChange?.GetInvocationList() ?? Array.Empty<Delegate>())
{
    try
    {
        ((EventHandler<TemperatureChangedEventArgs>)d).Invoke(this, e);
    }
    catch (Exception ex)
    {
        Console.Error.WriteLine($"Handler failed: {ex.Message}");
    }
}

Thread safety: ?.Invoke nie jest atomowy #

OnChange?.Invoke(...) w wątkowo bezpiecznym kodzie ma teoretyczne ryzyko - między sprawdzeniem null a wywołaniem ktoś mógłby się odsubskrybować. Bezpieczniejszy wzorzec:

protected virtual void OnTemperatureChanged(TemperatureChangedEventArgs e)
{
    var handler = OnChange;       // lokalna kopia
    handler?.Invoke(this, e);
}

W praktyce dla większości aplikacji ?.Invoke(...) jest wystarczające - kompilator sam stosuje optymalizację z lokalną kopią dla wyrażeń ?..

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Delegat - bezpieczny typowo wskaźnik na metodę, oficjalnie “type that represents references to methods with a particular parameter list and return type”
  • Action / Func / Predicate - gotowe delegaty generyczne; Action dla void, Func<..., TResult> dla metod zwracających wartość, Predicate<T> to wariant Func<T, bool>
  • Multicast - łączenie metod operatorami +=/-=; wywołanie uruchamia wszystkie po kolei
  • event - nakładka ochronna na delegata; z zewnątrz widoczne tylko += i -=, wywołanie zarezerwowane dla publishera
  • Wzorzec publisher-subscriber - publisher krzyczy, subscribers reagują, nikt nikogo nie zna z imienia → luźne sprzężenie
  • Standard .NET: EventHandler + EventArgs - sygnatura (object sender, EventArgs e), konwencja nazewnicza z sufiksem EventArgs, wzorzec protected virtual OnXxx do wywoływania
  • Pułapki - wyciek pamięci przy braku -=, wyjątek w handlerze przerywa łańcuch, ostrożność z thread safety

W następnym wpisie Wyjątki i IDisposable w C# zajmiemy się sytuacją, gdy coś idzie nie tak - obsługą błędów przez try-catch-finally, projektowaniem własnych klas wyjątków oraz deterministycznym sprzątaniem zasobów (plików, połączeń) przez interfejs IDisposable i instrukcję using.

Quiz i zadanie poniżej. Zadanie używa oficjalnego standardu .NET z EventHandler<T> oraz EventArgs - czyli dokładnie tego, co spotkasz w profesjonalnym kodzie produkcyjnym.

Podobne wpisy

🔗 Linkują tu