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:
Displayco 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:
- Nadpisać całą listę subskrybentów (
czujnik.NaZmiane = mojaMetoda- zamiast dopisać siebie, wyrzucasz wszystkich pozostałych) - 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 poEventArgs. Jeśli zdarzenie nie ma dodatkowych danych, używasz po prostuEventArgs(zEventArgs.Emptyjako 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/catchalbo 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;
Actiondlavoid,Func<..., TResult>dla metod zwracających wartość,Predicate<T>to wariantFunc<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 sufiksemEventArgs, wzorzecprotected virtual OnXxxdo 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
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
Relacyjne bazy danych w C# - architektura RDBMS, schemat tabel, mapowanie typów SQL
Architektura silnika RDBMS (Query Optimizer, Storage Engine, Transaction Log), projektowanie tabel z PK/FK, mapowanie typów SQL Server na typy C# (INT, NVARCHAR, DECIMAL, DATETIME2, DATE). Dwudziesta druga część kursu C#.
🔗 Linkują tu