Delegaty i zdarzenia — przekazywanie zachowania

Metoda też może być danymi: da się ją zapisać w zmiennej, przekazać jako argument i wywołać, nie wiedząc, która to metoda. Na tym stoi LINQ i cała obsługa okien.

C# Func Action lambda event 55 min
CEL LEKCJI

Czego się dziś nauczysz

  • Zapiszesz metodę w zmiennej typu delegata i wywołasz ją przez tę zmienną
  • Dobierzesz Func albo Action do kształtu metody
  • Zapiszesz wyrażenie lambda w postaci krótkiej i w klamrach
  • Napiszesz metodę przyjmującą zachowanie jako parametr
  • Ogłosisz zdarzenie i zapiszesz do niego kilka reakcji

Przygotowanie: lekcje 01–52. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.

TEORIA

Zmienna, w której siedzi metoda

Do tej pory zmienna przechowywała dane: liczbę, napis, obiekt. Delegat to typ zmiennej, która przechowuje metodę — i pozwala ją wywołać, nie wiedząc, która to metoda.

Program.cs
delegate int Operacja(int a, int b);   // deklaracja typu delegata

static int Dodaj(int a, int b) => a + b;
static int Pomnoz(int a, int b) => a * b;

Operacja dzialanie = Dodaj;
Console.WriteLine(dzialanie(3, 4));   // 7

dzialanie = Pomnoz;                    // ta sama zmienna, inna metoda
Console.WriteLine(dzialanie(3, 4));   // 12

Deklaracja delegata mówi tylko o kształcie metody: jakie przyjmuje parametry i co zwraca. Pasuje do niej każda metoda o takim kształcie — własna, statyczna, z obiektu.

Gotowe typy: Func i Action

W praktyce rzadko deklaruje się własny delegat. Biblioteka .NET ma dwa gotowe zestawy:

ZapisZnaczenie
Actionmetoda bez parametrów, nic nie zwraca
Action<int>przyjmuje int, nic nie zwraca
Action<string, int>przyjmuje dwa parametry, nic nie zwraca
Func<int>bez parametrów, zwraca int
Func<int, bool>przyjmuje int, zwraca boolostatni typ to zawsze wynik
Func<int, int, int>przyjmuje dwa int, zwraca int
Program.cs
Func<int, int, int> dzialanie = Dodaj;
Action<string> pokaz = Console.WriteLine;

pokaz($"wynik: {dzialanie(3, 4)}");
TEORIA

Lambda — metoda bez nazwy

Jeśli metoda ma być użyta w jednym miejscu, nie warto nadawać jej nazwy. Wyrażenie lambda zapisuje ją w miejscu użycia:

Program.cs
Func<int, int, int> dodaj  = (a, b) => a + b;
Func<int, bool>      parzysta = x => x % 2 == 0;
Action<string>       powitaj = imie => Console.WriteLine($"Czesc, {imie}");

Console.WriteLine(dodaj(3, 4));       // 7
Console.WriteLine(parzysta(10));      // True
powitaj("Ala");                        // Czesc, Ala

Strzałka => oddziela parametry od ciała. Typy parametrów są wywnioskowane z typu delegata, więc nie trzeba ich pisać. Gdy ciało ma więcej niż jedno wyrażenie, obejmujemy je klamrami i używamy return:

Program.cs
Func<int, string> ocena = punkty =>
{
    if (punkty >= 90) return "bardzo dobry";
    if (punkty >= 50) return "dostateczny";
    return "niedostateczny";
};

Po co to wszystko: metoda jako argument

Prawdziwy zysk pojawia się, gdy metoda przyjmuje zachowanie jako parametr. Jedna metoda filtrująca zamiast dziesięciu:

definicja i użycie
static List<int> Filtruj(int[] dane, Func<int, bool> warunek)
{
    List<int> wynik = new List<int>();
    foreach (int x in dane)
    {
        if (warunek(x)) wynik.Add(x);
    }
    return wynik;
}

int[] punkty = { 12, 4, 18, 10, 7 };

var duze     = Filtruj(punkty, x => x >= 10);
var parzyste = Filtruj(punkty, x => x % 2 == 0);
var jedno    = Filtruj(punkty, x => x == 7);

To jest fundament LINQ

Metody Where, Select i OrderBy z następnej lekcji to dokładnie takie metody: przyjmują Func i wywołują je dla kolejnych elementów. Gdy zrozumiesz Filtruj, zrozumiesz LINQ.

TEORIA

Zdarzenia — powiadamianie zainteresowanych

Klasa, która coś obserwuje — licznik, czujnik, przycisk — nie powinna wiedzieć, co ma się stać po zmianie. Ona tylko ogłasza, że coś się wydarzyło. Kto chce, ten się zapisuje.

definicja typu
class Magazyn
{
    private int stan;
    private readonly int prog;

    public event Action<int>? StanPonizejProgu;    // zdarzenie

    public Magazyn(int stanPoczatkowy, int prog)
    {
        stan = stanPoczatkowy;
        this.prog = prog;
    }

    public void Wydaj(int ile)
    {
        stan -= ile;

        if (stan < prog)
        {
            StanPonizejProgu?.Invoke(stan);        // ogloszenie
        }
    }
}
użycie
Magazyn m = new Magazyn(10, 5);

m.StanPonizejProgu += stan => Console.WriteLine($"UWAGA: zostalo {stan} sztuk");
m.StanPonizejProgu += stan => Console.WriteLine("Wyslano zamowienie do dostawcy");

m.Wydaj(3);   // nic - stan 7
m.Wydaj(3);   // oba komunikaty - stan 4
wynik w konsoli
UWAGA: zostalo 4 sztuk
Wyslano zamowienie do dostawcy
  • event przed typem delegata ogranicza to, co wolno robić z zewnątrz: można się zapisać (+=) i wypisać (-=), ale nie da się podmienić całej listy ani wywołać zdarzenia spoza klasy.
  • Zapisanych metod może być wiele; wywołanie uruchamia je wszystkie w kolejności zapisania.
  • ?.Invoke(...) chroni przed wyjątkiem, gdy nikt się nie zapisał — zdarzenie bez subskrybentów jest null.

Tu zaczyna się aplikacja okienkowa

W WPF i WinForms cały interfejs działa na zdarzeniach: przycisk.Click += ..., okno.Loaded += .... Kod, który napiszesz w oknie edytora graficznego, to metoda zapisana do zdarzenia kontrolki. Ta lekcja to przygotowanie do programowania aplikacji desktopowych.

PRZYKŁAD

Przykład: kalkulator sterowany delegatami

Program.cs
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        Dictionary<string, Func<double, double, double>> dzialania =
            new Dictionary<string, Func<double, double, double>>
            {
                ["+"] = (a, b) => a + b,
                ["-"] = (a, b) => a - b,
                ["*"] = (a, b) => a * b,
                ["/"] = (a, b) => b == 0 ? double.NaN : a / b
            };

        double x = 12, y = 5;

        foreach (KeyValuePair<string, Func<double, double, double>> para in dzialania)
        {
            Console.WriteLine($"{x} {para.Key} {y} = {para.Value(x, y):F2}");
        }

        Console.Write("Podaj znak dzialania: ");
        string znak = Console.ReadLine() ?? "";

        if (dzialania.TryGetValue(znak, out Func<double, double, double>? wybrane))
        {
            Console.WriteLine($"Wynik: {wybrane(x, y):F2}");
        }
        else
        {
            Console.WriteLine("Nieznane dzialanie.");
        }
    }
}
wynik w konsoli
12 + 5 = 17,00
12 - 5 = 7,00
12 * 5 = 60,00
12 / 5 = 2,40
Podaj znak dzialania: *
Wynik: 60,00

Co dzieje się po kolei

  • Słownik przechowuje nie liczby, tylko działania. Dodanie potęgowania to jeden wiersz — bez ruszania switch i bez zmieniania pętli wypisującej.
  • para.Value(x, y) wywołuje metodę zapisaną pod danym kluczem.
  • Dzielenie przez zero zwraca double.NaN zamiast zgłaszać wyjątek — dla typu double to wartość dozwolona (lekcja 06). Sprawdzisz ją metodą double.IsFinite.
  • Porównaj to z zadaniem „menu programu” z lekcji 15: tam każda opcja wymagała nowej gałęzi switch.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
delegate W Nazwa(...)własny typ delegataOpisuje kształt metody. W praktyce zwykle wystarczą Func i Action.
Func<A, B, W>metoda zwracająca wynikOstatni parametr typu to typ wyniku.
Action<A>metoda bez wynikuOdpowiednik void.
Predicate<T>warunekTo samo co Func<T, bool>; używany m.in. przez List<T>.Find i RemoveAll.
(a, b) => wyrażenielambdaMetoda bez nazwy. Typy parametrów wynikają z typu delegata.
eventmodyfikatorOgranicza dostęp z zewnątrz do += i -=. Wywołać można tylko wewnątrz klasy.
zdarzenie?.Invoke(x)wywołanieUruchamia wszystkie zapisane metody. Operator ?. chroni przed null.
+= / -= na zdarzeniuzapis / wypisTa sama metoda zapisana dwa razy wykona się dwa razy. Wypisz się, gdy obiekt przestaje być potrzebny.
lista.Find(warunek)T?Przykład metody biblioteki przyjmującej delegat. Zwraca pierwszy pasujący element albo default.
CZĘSTE BŁĘDY

Na co uważać

ZapisProblem
Operacja d = Dodaj();Nawiasy wywołują metodę. Do delegata przypisujemy nazwę: = Dodaj;.
Func<int, int> dla metody voidFunc zawsze zwraca wynik. Dla metod bez wyniku jest Action.
zdarzenie.Invoke(x) bez ?.NullReferenceException, gdy nikt się nie zapisał.
Wywołanie zdarzenia spoza klasyBłąd kompilacji — i słusznie: ogłasza tylko właściciel zdarzenia.
Brak -= przy długo żyjących obiektachObiekt zapisany do zdarzenia nie zostanie zwolniony z pamięci, dopóki źródło zdarzenia istnieje.
Lambda z { } bez returnCiało w klamrach musi zwrócić wartość jawnie, jeśli delegat jej oczekuje.
ZADANIA

Zadania

ZAD 1Trzy działania★☆☆

Zadeklaruj Func<double, double, double> i przypisz do niej kolejno dodawanie, odejmowanie i potęgowanie zapisane jako lambdy. Po każdym przypisaniu wypisz wynik dla tych samych argumentów.

ZAD 2Action zamiast Console★☆☆

Napisz metodę PokazWszystko(int[] dane, Action<int> sposob). Wywołaj ją raz ze zwykłym wypisaniem, a raz z wypisaniem w nawiasach kwadratowych.

ZAD 3Własny Filtruj★★☆

Napisz metodę Filtruj z przykładu i użyj jej do wybrania: liczb dodatnich, liczb trzycyfrowych i liczb podzielnych przez 7. Nie zmieniaj samej metody.

ZAD 4Przekształcenie★★☆

Napisz metodę Mapuj<T, W>(T[] dane, Func<T, W> przeksztalcenie), zwracającą nową tablicę. Użyj jej do zamiany liczb na ich kwadraty oraz imion na ich długości.

ZAD 5Czujnik temperatury★★☆

Zbuduj klasę Czujnik ze zdarzeniem PrzekroczonoProg. Zapisz do niego dwie reakcje: wypisanie ostrzeżenia i zwiększenie licznika alarmów. Sprawdź, że obie działają, i wypisz licznik na końcu.

ZAD 6Menu bez switcha★★★

Napisz program z menu tekstowym, w którym opcje trzymane są w Dictionary<string, Action>: kluczem jest tekst wpisywany przez użytkownika, a wartością czynność do wykonania.

Zacznij od trzech opcji — powitanie, aktualna data i wyczyszczenie ekranu — oraz opcji kończącej program. Pętla obsługująca menu ma wyglądać tak samo niezależnie od liczby opcji: odczytaj wybór, znajdź go w słowniku metodą TryGetValue, wywołaj albo zgłoś nieznaną opcję.

Kryterium sukcesu: dodanie czwartej opcji wymaga dopisania jednego wpisu do słownika i żadnej zmiany w pętli.

PODSUMOWANIE

Co trzeba zapamiętać

  • Delegat to typ zmiennej przechowującej metodę o określonym kształcie.
  • Func zwraca wynik (ostatni parametr typu), Action nie zwraca nic.
  • Lambda (a, b) => … to metoda bez nazwy, zapisana w miejscu użycia.
  • Metoda przyjmująca delegat pozwala oddzielić co robimy od jak przechodzimy dane — na tym opiera się LINQ.
  • event pozwala z zewnątrz tylko zapisać się i wypisać; ogłasza wyłącznie właściciel zdarzenia.
  • Zdarzenie bez subskrybentów jest null — wywołuj je przez ?.Invoke(...).

Dokumentacja: Microsoft Learn — temat tej lekcji.