środa, 10 października 2018

Delegaty

Delegat to nic innego jak referencja do metody. Zacznijmy od prostego przykładu, w którym 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, IsEven);
}

static bool IsEven(int value)
{
    return value % 2 == 0; 
}

Metoda FindAll przyjmuje dwa parametry. Pierwszy to tablica typu <T>, natomiast drugi to delegat Predicate<T>, czyli referencja do metody, która zostanie wywołana w celu sprawdzenia, czy element spełnia określone kryteria. Słowo predykat (ang. predicate) oznacza funkcję sprawdzającą warunek. Przyjrzyjmy się bliżej definicji Predicate<T>.

public delegate bool Predicate<in T>(T obj);

Zaczynając od modyfikatora public, za którym występuje słowo kluczowe delegate, określające typ delegata. Dalsza część wygląda jak deklaracja metody, zwracana wartość, nazwa, wewnątrz nawiasów kątowych przekazana informacja o typie ogólnym posiadającym jeden kontrawariantny argument. Na koniec w nawiasach okrągłych parametry, w tym przypadku jeden parametr typu <T>.

To, na co warto zwrócić uwagę, to sposób dopasowania. Jeżeli dostępna jest niejawna konwersja pomiędzy typami, można użyć bardziej ogólnej metody. Dla przykładu, do przeszukania tablicy typu string możliwe będzie wykorzystanie metody Predicate<object>. Należy również pamiętać, że nie istnieje niejawna konwersja typu int do object, a mechanizm pakowania nie ma tutaj zastosowania.

Wcześniejszy przykład przekazywał referencję poprzez wskazanie nazwy metody, a czy można inaczej? Oczywiście. Delegat tworzymy, korzystając z operatora new, natomiast w miejscu konstruktora podajemy nazwę metody zgodnej z delegatem.

var predicate = new Predicate<int>(IsEven);

Jeżeli kompilator będzie w stanie wywnioskować typ delegatu, operator new możemy pominąć. W naszym przykładzie nie jest to możliwe. Wyrażenie IsEven jest zgodne z Predicate<T>, lecz istnieją inne zgodne z nim typy delegatów. Dodatkowo zadeklarowanie zmiennej za pomocą var wymusza wskazanie typu delegata.

Gdy kompilator wie, jaki typ delegata zastosować, wykonywana jest automatyczna konwersja nazwy metody na odpowiedni typ. Przykład poniżej jawnie określa typ delegata.

Predicate<int> predicate = IsEven;

Przedstawione sposoby określenia delegata realizowane są przez kompilator w ten sam sposób. We wcześniejszym przykładzie kod znajduje się w zakresie metody, możliwe jest również posłużenie się kwalifikowaną nazwą metody. Przykład poniżej korzysta zarówno z metody statycznej, jak i metody instancji obiektu.

class Program
{
    static void Main(string[] args)
    {   
        var arr = new int[] { 1, 2, 3, 4, 7, 8 };
        var isEven = Array.FindAll(arr, ValidationNumbers.IsEven);

        var validationNumbers = new ValidationNumbers();
        var isOdd = Array.FindAll(arr, validationNumbers.IsOdd);
    }
}

class ValidationNumbers
{
    internal static bool IsEven(int value)
    {
        return value % 2 == 0;
    }

    internal bool IsOdd(int value)
    {
        return value % 2 != 0;
    }
}

Wiemy, że możliwe jest odwołanie do metody instancji obiektu. Możliwe jest również utworzenie delegata poprzez podanie nazwy metody. Przykład poniżej dostarcza klasę AgeLimit wraz z metodą GetCheckIsAdult, zwracającą typ Predicate<int>, poprzez odwołanie do nazwy metody. To, czego nie zobaczymy w tym przykładzie, a co kompilator C# wykonuje, to dostarczenie niejawnej referencji this. Dzięki czemu utworzony delegat może korzystać ze składowych instancji obiektu AgeLimit.

public class AgeLimit
{
    public int Limit { get; set; }

    public bool CheckIsAdult(int value)
    {
        return value >= Limit;
    }

    public Predicate<int> GetCheckIsAdult()
    {
        return CheckIsAdult;
    }
}

static void Main(string[] args)
{
    var ageLimit = new AgeLimit() { Limit = 18 };
    bool isAdult = ageLimit.CheckIsAdult(12);

    isAdult = ageLimit.CheckIsAdult(21);

    ageLimit.Limit = 10; 
    isAdult = ageLimit.CheckIsAdult(12);
}

Istnieje jeszcze jeden sposób na tworzenie delegatów, poprzez wykorzystanie statycznej metody CreateDelegate udostępnionej przez klasę Delegate. W parametrach przekazujemy typ delegata, instancję obiektu oraz nazwę metody. Kod poniżej korzysta z opisanego sposobu w celu utworzenia delegata dla metody IsOdd instancji obiektu ValidationNumbers z wcześniejszego przykładu.

var predIsOdd = Delegate.CreateDelegate(typeof(Predicate<int>), new ValidationNumbers(), "IsOdd");

Klasa Delegate udostępnia metodę Combine, tworzącą delegat odwołujący się do więcej niż jednej metody. Cecha ta może zostać użyta w mechanizmie powiadomień, gdzie występuje potrzeba przekazania informacji do wielu metody o wystąpieniu zdarzenia. Warto się zastanowić, co dzieje się w takim przypadku z wynikami zwróconymi przez poszczególne metody? Otóż wszystkie za wyjątkiem ostatniego wyniku są ignorowane. Kod poniżej umożliwia przetestowanie opisanego działania. Do delegata przekazywana jest wartość 8, jednak wewnątrz metody IsEven inkrementowana jest wartość zmiennej Count. O ile w tym przykładzie wartość zmiennej result będzie wynosić false, to po dodaniu kolejnego delegata zwrócona zostanie wartość true. Warto również zapamiętać, że zgłoszenie wyjątku przerywa wywołanie pozostałych metod.

static void Main(string[] args)
{
    var p1 = new Predicate<int>(IsEven);
    var p2 = new Predicate<int>(IsEven);
    var p3 = new Predicate<int>(IsEven);

    var multicast = Delegate.Combine(new[] { p1, p2, p3 });
    var result = multicast.DynamicInvoke(8);
}

static int Count { get; set; } = 0;

static bool IsEven(int value)
{
    Count++;
    return (value + Count) % 2 == 0;
}

Najczęściej jednak metoda Combine wywoływana jest niejawnie, za każdym razem, gdy korzystamy ze wbudowanej obsługi łączenia delegatów przy użyciu operatorów + oraz +=.

var multicast = p1 + p2 + p3;
var multicast = p1;
multicast += p2;
multicast += p3;

Możliwe jest również korzystanie z operatorów - oraz -=, które generują nowy delegat stanowiący kopię z wyłączeniem wskazanego delegata/ów. Za jawne usuwanie delegatów odpowiada metoda Remove klasy Delegate. Należy zwrócić uwagę na sposób usuwania grupy delegatów, gdyż operacja zakończy się sukcesem wyłącznie, gdy zachowana zostanie sekwencja oraz kolejność delegatów. Bezpieczniej jest wykonać pojedyncze usuwanie.

W jednym z powyższych przykładów użyta została metoda DynamicInvoke, umożliwiająca wywołanie delegata. Należy pamiętać, że metoda nie weryfikuje przekazanych parametrów na poziomie kompilacji kodu. O tym, że popełniliśmy błąd, dowiemy się dopiero podczas działania aplikacji. Metoda wykorzystuje mechanizm późnego wiązania, dodany wraz z obiektami dynamicznymi w wersji C# 4.0.

Zazwyczaj jednak wywołanie delegata odbywa się znacznie prościej, co prezentuje przykład poniżej.

var predicate = new Predicate<int>(IsEven);
var result = predicate(8);

Pamiętacie rozwiązanie, w którym metoda DynamicInvoke zwracała wyłącznie wynik ostatniego wykonania? Korzystając z metody GetInvocationList, zwracającej tablicę typu Delegate, możliwe jest napisanie rozwiązania, w którym przechwycimy każdą zwróconą wartość.

private static List<bool> Invoke(Predicate<int> multicast, int value)
{
    var result = new List<bool>();
    foreach (Predicate<int> predicate in multicast.GetInvocationList())
    {
        result.Add(predicate(value));
    }

    return result;
}

.NET Framework udostępnia zestaw delegatów, z których można skorzystać bez potrzeby tworzenia własnych. Grupa tych delegatów nosi nazwę Action i została wykonana według wzorca.

public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
public delegate void Action<in T1, in T2, in T3>(T1 arg1, T2 arg2, T3 arg3);

Liczba dostępnych argumentów zależy od wykorzystywanej wersji. Na przykład, w wersji .NET 3.5 dostępny był typ Action mający 4 argumenty, a już .NET 4.5 dostępne były wersje posiadające 16 argumentów. Rzeczywistym ograniczeniem jest brak możliwości zwrócenia wyniku przez typ Action. Dostępna jest jednak podobna grupa Func, która pozwala na określenie typu wartości wynikowej.

public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
public delegate TResult Func<in T1, in T2, in T3, out TResult>(T1 arg1, T2 arg2, T3 arg3);

Te dwie grupy delegatów, najprawdopodobniej zaspokajają większość potrzeb. Pytanie więc, dlaczego mimo to występują inne delegaty? Część z takich jak np. Predicate<int> pojawiła się, zanim udostępnione zostały delegaty ogólnego przeznaczenia. Nie mniej, zasugerowanie przeznaczenia jak w przypadku nazwy Predicate jest również ważne.

Warto zaznaczyć, że typy delegatów nie dziedziczą po sobie, a każdy delegat będzie dziedziczył po MulticastDelegate. Mimo to, obsługiwana jest niejawna konwersja delegatów typów ogólnych z wykorzystaniem kowariancji oraz kontrawariancji. Przykład takiego działania prezentuje kod poniżej.

static void Main(string[] args)
{
    Predicate<object> predObj = IsNull;
    Predicate<string> predStr = predObj;

    var result = predStr("HelloWorld");
}

public static bool IsNull(object o)
{
    return o == null;
}

Mogłoby się również wydawać, że delegaty wyglądające podobnie, powinny być ze sobą zgodne. Dla przykładu, delegat Predicate<int> powinien móc odwoływać się do metody określonej przez Func<int, bool>.

Predicate<object> predObj = IsNull;
Func<object, bool> funcObj = predObj;

Próba kompilacji powyższego kodu zakończy się błędem. Reguły zgodności typów delegatów nie były projektowane z myślą o takich sytuacjach.

Pozostaje stworzenie delegata odwołującego się do tej metody, pod warunkiem, że tworzony typ będzie zgodny ze pierwotnym. Warto się jednak zastanowić, czy to, co wydaje się być prostym rozwiązaniem, na pewno takim jest.

Predicate<object> predObj = IsNull;
Func<object, bool> predStr = new Func<object, bool>(predObj);

W rzeczywistości kod powyżej nie jest efektywny, ze względu na wprowadzenie nadmiarowego odwołania. Delegat predStr nie odwołuje się bezpośrednio do metody IsNull, lecz do delegata predObj. Dzieje się tak za sprawą CLR, który korzysta z metody Invoke udostępnionej przez delegata, przeznaczonej do wykonania metody. Powyższy kod jest tożsamy z poniższym.

Predicate<object> predObj = IsNull;
Func<object, bool> predStr = new Func<object, bool>(predObj.Invoke);

Kod poniżej korzysta z metody CreateDelegate, tworząc delegata, pobierając obiekt docelowy oraz metodę. Dzięki czemu wywołanie metody delegata predStr następuje bezpośrednio. Przykład nie obsługuje delegatów zbiorowych. Jeżeli chcemy zaprojektować takie rozwiązanie, powinniśmy skorzystać z metody GetInvocationList, a następnie dla każdego elementu wywołać metodę CreateDelegate oraz wykonać łączenie przy użyciu metody Combine.

Predicate<object> predObj = IsNull;
var predStr = (Func<object, bool>) Delegate.CreateDelegate(
    typeof(Func<object, bool>), predObj.Target, predObj.Method);

Każdy delegat jest pochodną MulticastDelegate, natomiast składowe: Target, Method oraz Invoke pochodzą od Delegate. Podział na klasy MulticastDelegate oraz Delegate jest zaszłością historyczną, wpadką. Plan na wersję .NET 1.0 zakładał osobną obsługę delegatów zbiorowych oraz pojedynczych, jednak przed wypuszczeniem wersji założenia uległy zmianie. Przy ograniczonym czasie uznano, że połączenie obu klas niesie ze sobą zbyt duże ryzyko, dlatego klasy pozostały rozdzielone, choć nie ma to konkretnego zastosowania.

Każdy z delegatów udostępnia parę metod BeginInvoke oraz EndInvoke, umożliwiających asynchroniczne wykonanie metody. Wraz z wykonaniem metody BeginInvoke, dodany zostanie element do kolejki zarządzanej przez CLR, który spowoduje wykonanie metody przez jeden z wątków z puli. W parametrach do metody BeginInvoke przekazujemy listę parametrów identycznie jak dla metody Invoke. Metoda BeginInvoke nie oczekuje na wykonanie metody, a nawet na jej uruchomienie. Opcjonalnie możemy przekazać delegat typu AsyncCallback, który zostanie uruchomiony przez CLR natychmiast po zakończeniu asynchronicznego przetwarzania metody. Ostatni parametr typu object umożliwia przekazanie wartości, która trafi do nas po zakończeniu przetwarzania. Delegat w żaden sposób nie korzysta z tej wartości. Możemy ją wykorzystać np. do identyfikacji operacji, w sytuacji gdy równocześnie wykonujemy wiele działań. Metoda EndInvoke umożliwia pobranie wyniku przetworzonej metody. Dodatkowo do jej sygnatury trafiają parametry wyjściowe out lub referencyjne ref. Jeżeli podczas asynchronicznego wykonywania metody zgłoszony zostanie wyjątek, to CLR przechwyci go, a następnie zgłosi podczas wywołania metody EndInvoke. W sytuacji, gdy metoda EndInvoke zostanie wywołana przed zakończeniem przetwarzania, zablokuje ona wątek do chwili zakończenia przetwarzania asynchronicznego. Zachęcam do zdebugowania kodu zamieszczonego poniżej.

static void Main(string[] args)
{
    var predicate = new Predicate<int>(IsEven);
    var async = predicate.BeginInvoke(10, null, null);

    // Move here EndInvoke

    if (!async.IsCompleted)
    {
        Console.Write("Wait");
        while (!async.IsCompleted)
        {
            System.Threading.Thread.Sleep(1000);
            Console.Write(".");
        }
    }

    var result = predicate.EndInvoke(async);

    Console.Write("\r\nEnd work");
    Console.ReadLine();
}

static bool IsEven(int value)
{
    System.Threading.Thread.Sleep(5 * 1000);
    return value % 2 == 0;
}

Korzystając z jednego delegata, możliwe jest uruchomienie wielu jednocześnie przetwarzanych operacji, a wszystko dzięki asynchroniczności. Właśnie dlatego metoda BeginInvoke zwraca obiekt IAsyncResult, umożliwiający otrzymanie wyniku dla konkretnego wywołania metody. Obiekt, który przekazujemy jako ostatni parametr metody BeginInvoke, zostaje przekazany do właściwości AsyncState interfejsu IAsyncResult. Poniżej zamieszczam kompletny przykład korzystający z asynchroniczności delegatów.

Wywołując metodę BeginInvoke, niezbędne jest wywołanie również EndInvoke, nawet w przypadku, gdy nie jest zwracana wartość. Brak wywołania EndInvoke spowoduje wyciek zasobów, co przy skali może doprowadzić do większych problemów.

using System;
using System.Linq;
using System.Runtime.Remoting.Messaging;

namespace HelloDelegate
{
    class Program
    {
        class Number 
        {
            public Number(int value)
            {
                this.Value = value;
            }

            public int Value { get; private set; }

            public bool? IsEven { get; internal set; } = null;
        }

        static bool IsEven(int value)
        {
            return value % 2 == 0;
        }

        static void Main(string[] args)
        {
            var arrNum = new[] { new Number(1), new Number(2), new Number(7), new Number(10) };

            var predicate = new Predicate<int>(IsEven);
            var asyncCallback = new AsyncCallback(Completed);

            foreach (var item in arrNum)
            {
                IAsyncResult async = predicate.BeginInvoke(item.Value, asyncCallback, item);
            }

            while(arrNum.Any(num => num.IsEven is null))
            {
                System.Threading.Thread.Sleep(10);
            }

            foreach (var item in arrNum)
                Console.WriteLine($""{item.Value,3} is even = {item.IsEven}"");

            Console.ReadLine();
        }

        static void Completed(IAsyncResult async)
        {
            var num = (Number)async.AsyncState;

            var predicate = (Predicate<int>)((AsyncResult)async).AsyncDelegate;
            num.IsEven = predicate.EndInvoke(async);
        }
    }
}

Choć we wcześniejszych latach .NET asynchroniczne przetwarzanie delegatów było często stosowane, to obecnie nie jest już popularne. W C# 2.0 dodane zostały metody inline, które znacząco ułatwiały przekazywanie wartości. Dodatkowo, .NET 4.0 wprowadził Task Parallel Library, który udostępnia bardziej efektywne rozwiązania zapewniające obsługę puli wątków.

Troska Robert