Szkoła C# - Delegaty i wyrażenia lambda

W tym wpisie chciałbym przedstawić Ci, czym są delegaty oraz wyjaśnić korzystanie z wyrażeń lambda w języku C#

Szkoła C# - Delegaty i wyrażenia lambda
Photo by Nubelson Fernandes / Unsplash

W tym wpisie chciałbym przedstawić Ci, czym są delegaty oraz wyjaśnić korzystanie z wyrażeń lambda w języku C#. Zanim zajmę się wyrażeniami lambda,  zacznę od przedstawiania delegatów.

Mała wstawka. Jeżeli chcesz dowiedzieć się więcej o C# możesz sprawdzić mój inny artykuł.

Szkoła C# – Metody rozszerzające
Programistą Być - W tym wpisie dowiesz się czym są metody rozrzerzające w języku C#. Przedstawię Ci to na prostych i jasnych przykładach.

Czym są delegaty?

Jeżeli znasz choć trochę idee wskaźników w języku C lub C++ to wiesz, że służą one do przechowywania adresu pewnego obszaru pamięci. Podobnie możemy postrzegać delegaty – są pewnego rodzaju wskaźnikami na metodę lub grupę metod. To czy daną metodę możemy przypisać do delegata, zależy od jego typu zwrotnego oraz parametrów, które przyjmuje. Do utworzenia definicji delegata, używamy słowa kluczowego delegate. Spójrzmy na przykład:

delegate int Operation(int x);

Do tego typu delegacyjnego możemy przypisać metody, które przyjmują jeden parametr typu int oraz zwracają wartość typu int. Przypisanie metody wygląda następująco:

int Square(int a) => a \* a; // Zwracanie wartości z użyciem wyrażenia lambda Operation op = Square; // Utworzenie egzemplarza delegata

Teraz po utworzeniu egzemplarze możemy wywołać go jak zwykłą metodę:

op(10) // 100

Warto zaznaczyć, że zapis:

Operation op = Square;

Jest skróconą formą zapisu z użyciem operatora new:

Operation op = new Operation(Square);

Zgodność typów zwrotnych

Do danego delegata, możemy przypisać metodę, której typ zwracany jest bardziej konkretny aniżeli ten, które widnieje przy deklaracji delegata. Możemy np. przypisać do delegata, którego wartością zwracaną jest typ object, metodę której wartością zwracaną jest typ string. Jest to zgodę z ideą polimorfizmu i jest to jak najbardziej naturalne.

Mówi się, że typy zwrotne delegatów są kowariantne.

Zgodność parametrów

Jeżeli chodzi o parametry, sytuacja wygląda tu nieco inaczej. Otóż inaczej niż przy typach zwracanych, parametry metody, które przypisujemy do delegata, mogą być mniej konkretne, aniżeli wynika to z definicji delegata. Możemy, więc metodę, która przyjmuje jako swój parametr typ object przypisać do delegata, który przyjmuje jako swój parametr typ string.

Jest to nazywane kontrawariancją.

Praktyczne zastosowanie

Dzięki nim możemy pisać metody, które przyjmują jako jeden ze swoich parametrów, właśnie delegat. Co nam do daje? Otóż dzięki temu nasze metody staną się bardziej uniwersalne pod tym względem, że wynik operacji w nich realizowanych będzie zależny od tego, jaką metodę przekażemy jako parametr.

Spójrzmy na przykład:

class DelegatesReview  
{  
    private delegate int Operation(int x);  
  
    static int Transform(int value, Operation operation)  
    {  
       int result = operation(value);  
       return result;  
    }  
      
    private static int Cube(int a) => a \* a \* a;  
    private static int Square(int a) => a \* a;   
      
    private static void Main(string\[\] args)  
    {  
       int squareResult = Transform(5, Square); //25  
       int cubeResult = Transform(5, Cube); //125  
    }  
}

Przeanalizujmy kod krok po kroku. Na samym początku widzimy definicję delegata. Poniżej metodę Transform(), która przyjmuje dwa parametry, jedynym z nich jest właśnie delegat typu Operation. To on umożliwia nam wstrzyknięcie metody, która dokona przekształcenia liczby przekazywanej jako pierwszy parametr metody Transform(). W metodzie Main, nie dzieje się nic nadzwyczajnego, wywołujemy dwukrotnie metodę Transform(), za pierwszym razem przekazujemy metodę Square, która podniesie nam liczbę do kwadratu, natomiast za drugim razem przekazujemy metodę, która podniesie nam liczbę do potęgi trzeciej.

Multiemisja

Język C# umożliwia nam przypisywanie do jednego egzemplarza delegata wielu metod. Dzięki czemu wywołanie delegata wywoła wszystkie odnoszące się do niego metody. Należy pamiętać, że zostaną wywołane one, w takiej kolejności, w jakiej zostały dodane do egzemplarza delegata. Dołączenie metod do delegata wygląda następująco:

Operation op = Square; op += Cube; op(10);

Po wywołaniu egzemplarza delegata najpierw otrzymamy wartość 100, następnie 1000. Przy użyciu operatora -= jesteśmy w stanie usunąć delegat podany po prawej stronie operatora z delegata występującego po jego lewej stronie. Jeśli typ zwrotny delegata wielokrotnego jest inny niż void, to wywołujący otrzyma wartość z ostatniej wywołanej metody. Pozostałe wartości zwracane zostaną pominięte, ale metody zostaną w pełni wywołane.

Wyrażenia lambda

Wyrażenia lambda wprowadzano do użytku w C# 3.0 . W skrócie wyrażenia lambda możemy określić jako metody bez nazwy, które umieszczamy tam, gdzie wymagany jest delegat. Do zademonstrowania prostego wyrażenia lambda, posłużę się delegatem Operation z poprzednich przykładów.

Operation op = a => a \* a \* a \* a;

Gdy wywołamy ten delegat z pewną liczbą, wynikiem będzie liczba podniesiona do czwartej potęgi. Widzimy tu nowy operator =>, który służy do tworzenia wyrażeń lambda. Po jego lewej stronie występują parametry a po jego prawej wyrażenia lub instrukcje. Warto zaznaczyć, że gdy wyrażenie lambda przyjmuje dokładnie jeden parametr, a kompilator jest w stanie wywnioskować typ parametru, możemy pominąć nawiasy ujmujące listę parametrów – tak jak w powyższym przykładzie.

Jeżeli chcemy, aby w ciele wyrażenia lambda znalazło się więcej instrukcji, powinniśmy ująć je w nawiasy klamrowe:

Operation op = a =>   
{    
   Console.WriteLine("Wyrażenie lambda!");      
   return a \* a \* a \* a;   
};  

Poniżej przedstawiam równoważne zapisy wyrażenia lambda z poprzedniego przykładu.

Operation op1 = a => a \* a \* a \* a;  
Operation op2 = a => { return a \* a \* a \* a; };  
Operation op3 = (a) => { return a \* a \* a \* a; };  
Operation op4 = (a) => a \* a \* a \* a;  
Operation op5 = (int a) => { return a \* a \* a \* a; };  
Operation op6 = (int a) => a \* a \* a \* a;

Widać, że C# daje nam dużą dowolność w zapisie wyrażeń lambda. Gdy chcemy utworzyć wyrażenie, które nie przyjmuje żadnych parametrów, zapisujemy tylko nawiasy okrągłe:

DelegateWithoutParameters d = () => Console.WriteLine("Nie posiadam parametrów.");

W ciele wyrażenia lambda możemy odwoływać się do zmiennych spoza jego ciała. Przy czym warto zwrócić uwagę na pewne zjawisko. Wartości zmiennych zewnętrznych użytych w wyrażeniu są obliczane dopiero w momencie wywołania delegata, w którym zostało użyte wyrażenie lambda.

Spójrzmy na przykład:

private static void Main(string\[\] args)  
{  
    int speed = 10;  
    Operation op = x => x \* speed;  
    speed = 100;  
    op(5); //500  
}  
  

Warto o tym pamiętać, aby uniknąć trudnych do znalezienia błędów w operacjach, które wykonujemy.

Kilka słów na koniec

Jeżeli nie miałeś/miałaś do czynienia wcześniej z delegatami czy wyrażeniami lambda mogą Ci się wydać trudne. Rzeczywiście mogą sprawiać takie wrażenie – zwłaszcza wyrażenia lambda, aczkolwiek gdy zaczniesz ich używać na co dzień i zrozumiesz ich idee, staną się dla Ciebie czymś normalnym.