poniedziałek, 19 listopada 2018

Metody inline

Wcześniejszy wpis wprowadzał w zagadnienie delegatów. W tej notatce zaprezentowany zostanie inny sposób tworzenia delegatów, taki, w którym nie ma potrzeby pisania odrębnych metod. A wszystko dzięki inline, czyli definicji metody wewnątrz innej metody, nazywanymi również funkcjami anonimowymi. Zacznijmy podobnie jak wcześniej. Przeszukamy tablicę typu int w poszukiwaniu parzystych wartości. Użyjemy metody FindAll udostępnionej przez klasę Array.

static void Main(string[] args)
{
    var arr = new int[] { 1, 2, 3, 4, 7, 8 };
    var result = Array.FindAll(arr, delegate (int value) { return value % 2 == 0; });
}

W przypadku prostych metod takie rozwiązanie upraszcza kod, ale nie to jest największą zaletą metod inline. To, co sprawia, że są użyteczne, to fakt, że zawierają kontekst. Jest nim obiekt docelowy, dla którego realizowana jest metoda. Dzięki czemu metoda inline ma dostęp do wszystkich zmiennych z zachowaniem zakresu, które były dostępne w deklaracji metody. C# udostępnia dwa sposoby definiowania metod inline. Pierwszy z nich korzysta ze słowa kluczowego delegate, został już zaprezentowany. Zapis ten przypomina składnię definicji metody, w której wewnątrz nawiasów umieszczona jest lista parametrów, a tuż za nią blok zawierający metodę. Jedyną różnicą jest to, że zamiast nazwy metody pojawia się słowo kluczowe delegate. W przypadku metod inline kompilator automatycznie określa typ wartości wynikowej. W tym przypadku drugim parametrem metody FindAll jest Predicate<T>, który wymusza zwrócenie typu bool.

W wersji .NET 3.5 dodana została składnia, która obrazuje, jak dużo kompilator potrafi wywnioskować, na podstawie posiadanych informacji. Składnia użyta poniżej nazwana jest wyrażeniem lambda. W tym przypadku zamiast słowa kluczowego delegate pojawia się token =>. Kompilator wie, że musi pobrać wartość typu int, a zatem nie ma potrzeby jawnego określenia typu. Ograniczamy się do podania nazwy zmiennej, w tym przypadku jest to value.

static void Main(string[] args)
{
    var arr = new int[] { 1, 2, 3, 4, 7, 8 };
    var result = Array.FindAll(arr, value => value % 2 == 0);
}

W przypadku prostych metod, składających się z jednego wyrażenia, możliwe jest pominięcie bloku kodu oraz instrukcji return. Dzięki czemu uzyskujemy naprawdę zwięzłą formę wyrażenia. C# umożliwia dostosowanie formy wyrażenia lambda w zależności od potrzeb. W sytuacji, gdy wymagane jest przekazanie kilku parametrów, umieszczenie ich w nawiasach jest obowiązkowe. Opcjonalne jest natomiast podanie typów parametrów. Jeżeli zdecydujemy się na określenie bloku kodu, konieczne będzie użycie instrukcji return. Poniżej zamieszczam kilka przykładów wyrażeń lambda.

Array.FindAll(arr, value => value % 2 == 0);
Array.FindAll(arr, (value) => value % 2 == 0);
Array.FindAll(arr, (int value) => value % 2 == 0);
Array.FindAll(arr, value => { return value % 2 == 0; });
Array.FindAll(arr, (value) => { return value % 2 == 0; });
Array.FindAll(arr, (int value) => { return value % 2 == 0; });

Możliwe jest również tworzenie wyrażeń lambda, które nie pobierają żadnych argumentów.

Func<bool> isPm = () => DateTime.Now.Hour >= 12;
var isAm = new Func<bool>(() => DateTime.Now.Hour < 12);

Wyrażenia lambda udostępniają elastyczną, a zarazem zwięzłą składnię. Metody anonimowe w niektórych sytuacjach bywają równie zwięzłe, gdyż pozwalają na pominięcie listy argumentów. Przykład poniżej tworzy delegata typu EventHandler, którego metoda docelowa pobiera dwa argumenty: object oraz EventArgs.

EventHandler click = delegate { Console.WriteLine("Click"); };

W przypadku wyrażeń lambda pominięcie listy argumentów nie jest możliwe.

EventHandler click = (obj, e) => { Console.WriteLine("Click"); };

Przejdźmy do kontekstu dostarczanego przez kompilator C#, dzięki któremu wewnątrz metod inline możliwe jest użycie zmiennych dostępnych w metodzie zewnętrznej. Jest to szalenie wygodna funkcjonalność. Metoda poniżej zapewnia tę samą funkcjonalność co przykład AgeLimit zamieszczony w notatce dotyczącej delegatów, z tą różnicą, że zamiast klasy ograniczamy się do metody inline.

static void Main(string[] args)
{
    int age = 10;
    Func<bool> checkIsAdult = () => age >= 18;

    bool isAdult = checkIsAdult();

    age = 21;
    isAdult = checkIsAdult();
}

Oprócz opisanego zachowania kompilator C# potrafi nieco więcej. Zastanówmy się nad działaniem kompilatora w stosunku do zmiennej age metody CheckIsAdult użytej w wyrażeniu lambda. W sytuacji, gdy metoda inline korzysta z argumentów lub zmiennych lokalnych, kompilator generuje specjalną klasę umożliwiającą przechowywanie niezbędnych wartości. Obiekt tej klasy może istnieć nawet po zakończeniu metody, która go utworzyła, w naszym przykładzie metodą tworzącą jest CheckIsAdult. Właśnie dzięki takiemu zachowaniu stwierdzenie, że kompilator kopiuje lokalne zmienne typów wartościowych na stos, nie jest prawdziwe (oczywiście jest to sytuacja wyjątkowa). Warto wiedzieć, że kompilator zapewnia dostęp do wszystkich dostępnych składowych.

static void Main(string[] args)
{
    var checkIsAdult = CheckIsAdult(18);

    bool isAdult = checkIsAdult(10);
    isAdult = checkIsAdult(21);
}

public static Func<int, bool> CheckIsAdult(int age)
{
    return value => value >= age;
}

Jeżeli możliwy jest odczyt wartości zmiennej, to czy możliwa jest również modyfikacja? Tak, prezentuje to przykład, który zlicza parzyste liczby.

static void Main(string[] args)
{
    var arr = new int[] { 1, 2, 3, 4, 7, 8 };
    int count = 0;
    var result = Array.FindAll(arr, value => {
        count++;
        return value % 2 == 0;
    });

    Console.WriteLine($"even numbers = {count}");
    Console.ReadLine();
}

Korzystając z opisanej funkcjonalności należy wiedzieć, że może ona doprowadzić do problemów, wynikających z zakresu pętli for. Do niedawna problem dotyczył również pętli foreach, jednak Microsoft zdecydował się na zmianę sposobu działania pętli. Problem pętli for prezentuje kod poniżej.

static void Main(string[] args)
{
    var funcs = new Func<int, bool>[4];
    for(int i = 0; i < funcs.Length; i++)
    {
        funcs[i] = value => value == i;
    }

    Console.WriteLine(funcs[2](2));
    Console.ReadLine();
}

Przykład tworzy tablicę delegatów, sprawdzających, czy przekazana wartość jest równa licznikowi pętli. Można oczekiwać, że wyświetlona zostanie wartość true, gdyż dla elementu o indeksie 2 przekazana wartość zostanie porównana z wartością 2. W rzeczywistości wyświetlony zostanie false. Przedstawiony kod tworzy tablicę delegatów, z których każdy porównuje argument z liczbą 4. Wiemy, że kompilator C# tworzy specjalną klasę do przechowywania zmiennych niezbędnych dla działania delegata. W tym przypadku pętla for deklaruje zmienną i, która jest używana przez każdego z delegatów oraz samą metodę. Kompilator tworzy wyłącznie jedną instancję tej klasy dla wszystkich delegatów, a zatem każda iteracja pętli zmienia wartość zmiennej i. Aby obejść problem, musimy wprowadzić dodatkową zmienną current, dzięki której kompilator utworzy instancję klasy dla każdego z delegatów.

static void Main(string[] args)
{
    var funcs = new Func<int, bool>[4];
    for (int i = 0; i < funcs.Length; i++)
    {
        int current = i;
        funcs[i] = value => value == current;
    }

    Console.WriteLine(funcs[2](2));
    Console.ReadLine();
}

Troska Robert