Przejdź do głównej zawartości

[C#] Czym są delegaty?


Delegat jest typem reprezentującym metody o danej sygnaturze. Najprościej wyjaśnić to na przykładzie.

public delegate void TextHandler(string input);

W powyższej linijce definiujemy nowy typ - podobnie jak definiowalibyśmy typ za pomocą słowa class czy struct. Obiekty należące do typu TextHandler będą przechowywały w sobie referencję do dowolnej metody zwracającej void, a przyjmującej string jako parametr. Przyjrzyjmy się poniższemu programowi:

 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
    public delegate void TextHandler(string input);

    class Printer
    {
        public void PrintToConsole(string input)
        {
            Console.WriteLine(input);
        }

        public void PrintToConsoleInBlue(string input)
        {
            Console.BackgroundColor = ConsoleColor.Blue;
            Console.WriteLine(input);
            Console.BackgroundColor = ConsoleColor.Black;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var printer = new Printer();
            TextHandler textHandler = new TextHandler(printer.PrintToConsoleInBlue);
            textHandler("siemano na niebiesko");
            textHandler = new TextHandler(printer.PrintToConsole);
            textHandler("siemano");

            Console.ReadKey();
        }
    }

W linijce 1 definiujemy delegat TextHandler. Klasa Printer posiada dwie metody zgodne z sygnaturą delegatu - PrintToConsole i PrintToConsoleInBlue. A zatem obiekt typu TextHandler może w sobie przechowywać zarówno referencję do PrintToConsole, jak i do PrintToConsoleInBlue. Gdyby któraś z tych metod przyjmowała inne parametry niż string, albo zwracała inny typ niż void, nie byłaby już zgodna z typem TextHandler.

W linijce 23 tworzymy obiekt delegatu. Konstruktor delegatu przyjmuje jako parametr metodę, której sygnatura musi być zgodna z tym, co określiliśmy definiując delegat - dlatego możemy jako parametr przekazać printer.PrintToConsoleInBlue. W linijce 25 podmieniamy wartość obiektu textHandler na nowy obiekt delegatu - tym razem do konstruktora przekazując printer.PrintToConsole. Wynikiem działania programu jest:

siemano na niebiesko 
siemano

Ważną cechą delegatów jest to, że możemy do nich "podpiąć" więcej niż jedną metodę. Służy do tego wygodny i intuicyjny operator +=. Oczywiście możemy też usuwać metody za pomocą operatora -=. Kiedy do delegatu "podpięta" jest więcej niż jedna metoda, w momencie zawołania go po prostu wywołają się wszystkie metody, jedna po drugiej.

    public delegate void TextHandler(string input);

    class Printer
    {
        public void PrintToConsole(string input)
        {
            Console.WriteLine(input);
        }

        public void PrintToConsoleInBlue(string input)
        {
            Console.BackgroundColor = ConsoleColor.Blue;
            Console.WriteLine(input);
            Console.BackgroundColor = ConsoleColor.Black;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var printer = new Printer();
            TextHandler textHandler = new TextHandler(printer.PrintToConsoleInBlue);
            textHandler += printer.PrintToConsole;
            textHandler("siemano");

            Console.WriteLine();

            textHandler -= printer.PrintToConsoleInBlue;
            textHandler("siemano po raz drugi");

            Console.ReadKey();
        }
    }

Zastanówmy się jaki będzie wynik tego programu. Za pierwszym zawołaniem textHandlera mamy do niego podpięte dwie metody: PrintToConsoleInBlue i PrintToConsole. Po tym zawołaniu odpinamy  metodę PrintToConsoleInBlue. Zostaje tylko metoda PrintToConsole. Dlatego za drugim razem tekst wypisuje się tylko raz:

siemano
siemano

siemano po raz drugi

Przy okazji pragnę zwrócić uwagę na pewien drobiazg. Ten zapis:

var textHandler = new TextHandler(printer.PrintToConsole);

Jest jednoznaczny z:

var textHandler = printer.PrintToConsole;

ReSharper zasugeruje nam skrócenie zapisu, jeśli wprost użyjemy konstruktora delegatu. Jest to oczywiście dobry pomysł, dlatego w dalszej części posta będę posługiwać się krótszym zapisem.

Chcę jeszcze wspomnieć, że delegat - jak każdy typ referencyjny w .NET - dziedziczy z klasy bazowej Object. A wchodząc głębiej w szczegóły: delegat dziedziczy z klasy MulticastDelegate, która dziedziczy z Delegate, która zaś dziedziczy z Object. W czasie kompilacji do Common Intermediate Language (CIL) kompilator przekształca nasz delegat w klasę. Znajdują się w niej między innymi metody
  • Invoke - wywołująca metodę do której referencję posiada obiekt delegatu. Metoda Invoke przyjmuje takie same parametry jak delegat. Metoda ta działa synchronicznie względem głównego wątku programu
  • BeginInvoke - analogiczna do Invoke, działa jednek w sposób asynchroniczny

Praktyczny przykład

Rozważmy praktyczny przykład, kiedy użycie delegatów może być pomocne. Wyobraźmy sobie, że chcemy napisać program, który zawiera w sobie listę gór. Chcemy, aby program ten wypisał nazwy i wysokości gór w trzech kategoriach: małych, średnich i dużych.

Jeśli zdecydowalibyśmy się napisać ten program bez użycia delegat, wyglądałby on na przykład tak:

 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
53
    public class Mountain
    {
        public string Name { get; set; }
        public int Height { get; set; }
    }

    class Program
    {
        static void Main(string[] args)
        {            
            List<Mountain> mountains = new List<Mountain>()
            {
                new Mountain {Name = "Rysy", Height = 2499},
                new Mountain {Name = "K2", Height = 8611},
                new Mountain {Name = "Kilimandżaro", Height = 5895},
                new Mountain {Name = "Ben Nevis", Height = 1344},
                new Mountain {Name = "Nanga Parbat", Height = 8126},
            };
            
            DisplayMountains("Małe:", mountains.Where(m=> IsSmall(m)));
            DisplayMountains("Średnie:", mountains.Where(m=> IsMedium(m)));
            DisplayMountains("Duże:", mountains.Where(m=> IsBig(m)));

            Console.Read();
        }
        
        static void DisplayMountains(string title, IEnumerable<Mountain> mountains)
        {
            Console.WriteLine(title);

            foreach (var m in mountains)
            {
                Console.WriteLine($"{m.Name} ma {m.Height} metrów");              
            }

            Console.WriteLine();
        }
        
        static bool IsSmall(Mountain m)
        {
            return m.Height < 3000;
        }
        
        static bool IsMedium(Mountain m)
        {
            return m.Height >= 3000 && m.Height < 6000;
        }
        
        static bool IsBig(Mountain m)
        {
            return m.Height >= 6000;
        }
    }

Program ten działa poprawnie i wypisuje taki wynik:

Małe:
Rysy ma 2499 metrów
Ben Nevis ma 1344 metrów

Średnie:
Kilimandżaro ma 5895 metrów

Duże:
K2 ma 8611 metrów
Nanga Parbat ma 8126 metrów

Jeśli idzie o estetykę i jakość kodu, moje wątpliwości budzą linijki 20-22, gdzie trzy razy używamy instrukcji "Where".Wyobraźmy sobie, że chcemy odwrócić działanie programu - przekształcić go tak, by pokazywał te góry, które nie spełniają filtra (niemałe, nieśrednie, nieduże). Wtedy będziemy musieli dokonać zmiany w trzech linijkach:

            DisplayMountains("Niemałe:", mountains.Where(m => !IsSmall(m)));
            DisplayMountains("Nieśrednie:", mountains.Where(m => !IsMedium(m)));
            DisplayMountains("Nieduże:", mountains.Where(m => !IsBig(m)));

Spróbujmy przekształcić ten program tak, by korzystał z delegat:

 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
53
54
55
    public class Mountain
    {
        public string Name { get; set; }
        public int Height { get; set; }
    }

    class Program
    {
        public delegate bool FilterDelegate(Mountain p);

        static void Main(string[] args)
        {            
            List<Mountain> mountains = new List<Mountain>()
            {
                new Mountain {Name = "Rysy", Height = 2499},
                new Mountain {Name = "K2", Height = 8611},
                new Mountain {Name = "Kilimandżaro", Height = 5895},
                new Mountain {Name = "Ben Nevis", Height = 1344},
                new Mountain {Name = "Nanga Parbat", Height = 8126},
            };
            
            DisplayMountains("Małe:", mountains, IsSmall);
            DisplayMountains("Średie:", mountains, IsMedium);
            DisplayMountains("Duże:", mountains, IsBig);

            Console.Read();
        }
        
        static void DisplayMountains(string title, List<Mountain> mountains, FilterDelegate filter)
        {
            Console.WriteLine(title);

            foreach (var m in mountains.Where(mountain => filter(mountain)))
            {
                Console.WriteLine($"{m.Name} ma {m.Height} metrów");              
            }

            Console.WriteLine();
        }
        
        static bool IsSmall(Mountain m)
        {
            return m.Height < 3000;
        }
        
        static bool IsMedium(Mountain m)
        {
            return m.Height >= 3000 && m.Height < 6000;
        }
        
        static bool IsBig(Mountain m)
        {
            return m.Height >= 6000;
        }
    }

Widzimy, że zniknęło brzydkie powtarzanie instrukcji "Where". Gdybyśmy postanowili odwrócić działanie programu, tym razem wystarczy poprawka w jednym miejscu, a nie w trzech:

foreach (var m in mountains.Where(mountain => !filter(mountain)))

Delegaty anonimowe

Rozważmy program, który dokonuje transformacji tekstu. W programie tym istnieje klasa TextTransformer, która posiada dwie metody: ToUpperCase, która przerabia litery tekstu na wielkie, oraz ToSpacious, która wstawia spacje między poszczególnymi literami. O tym, jaka transformacja ma zostać dokonana, decyduje metoda GetTextTransformation. Możliwa jest także opcja niewybrania żadnej transformacji - tekst ma wtedy pozostać niezmieniony.

 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
53
54
55
56
57
58
59
60
61
62
using System;

namespace CSharpPractices
{
    public class TextTransformer
    {
        public static string ToUpperCase(string input)
        {
            return input.ToUpper();
        }

        public static string ToSpacious(string input)
        {
            var chars = input.ToCharArray();
            return string.Join(" ", chars);
        }
    }

    public enum TransformType
    {
        ToUpper,
        ToSpacious,
        None
    }

    class Program
    {
        public delegate string TextTransformation(string text);

        public static TextTransformation GetTextTransformation(TransformType transformType)
        {
            switch (transformType)
            {
                case TransformType.ToUpper:
                    return TextTransformer.ToUpperCase;
                case TransformType.ToSpacious:
                    return TextTransformer.ToSpacious;
                case TransformType.None:
                    return delegate (string input)
                    {
                        return input;
                    };
                default:
                    throw new ArgumentException("Invalid transform type!");
            }
        }

        static void Main(string[] args)
        {
            var toUpperCaseTransform = GetTextTransformation(TransformType.ToUpper);
            Console.WriteLine(toUpperCaseTransform("pieseł"));

            var toSpaciousTransform = GetTextTransformation(TransformType.ToSpacious);
            Console.WriteLine(toSpaciousTransform("koteł"));

            var noTransform = GetTextTransformation(TransformType.None);
            Console.WriteLine(noTransform("wyżeł"));

            Console.ReadKey();
        }
    }
}

Wynikiem działania tego programu jest oczywiście:

PIESEŁ
k o t e ł
wyżeł

Przyjrzyjmy się bliżej linijce 38. Kiedy wybrano typ transformacji "None" chcemy zwrócić delegat do funkcji, która nie przekształci tekstu w żaden sposób. Jednak pisanie takiej funkcji wyglądałoby raczej dziwnie i mogłoby sprawić, że czytelnik kodu podniósł by nieco brwi do góry:

    public class TextTransformer
    {
        public static string NoChange(string input)
        {
            return input;
        }

W tym przypadku lepiej jest użyć delegaty anonimowej. Nie posiada ona nazwy ani nie zaśmieca swoją obecnością klasy TextTransformer. 

                case TransformType.None:
                    return delegate (string input)
                    {
                        return input;
                    };

Delegaty są także kluczowym elementem mechanizmu eventów. Więcej o eventach można poczytać tutaj.

Komentarze