Przyjrzymy się dziś metodzie, która widnieje na tytułowym obrazku. Szybki rzut oka: przecież nic skomplikowanego – zaledwie parę linijek kodu. Na pewno jest generyczna, bo wszędzie tylko typy: TInput, TResult… Mimo wszystko trochę mi zajęło, żeby ją zrozumieć i efektywnie z niej korzystać. Ponieważ bardzo rzadko sam tworzę takie metody, postanowiłem rozbić ją na czynniki pierwsze.
W trakcie rozgrzebywania całkiem pokaźnych zasobów biblioteki Accord.net natknąłem się na często występujący wzorzec takiej metody. W moich zastosowaniach w projekcie Flover służy przede wszystkim do aplikowania jakiejś konkretnej funkcji na każdym pikselu obrazu. Przyjmuje tablicę pikseli np. typu int[][], modyfikuje piksele i zwraca również int[][] albo double[][]. Jak to – nie wiadomo jaki typ nam zwróci? Ano to jest właśnie magia metod generycznych. Przyjrzyjmy się tej metodzie w jej całej krasie.
public static TResult[] Apply<TInput, TResult>(this TInput[] vector, Func<TInput, int, TResult> func) { TResult[] result = new TResult[vector.Length]; for (int i = 0; i < vector.Length; i++) result[i] = func(vector[i], i); return result; }
Poniżej przykład jej wywołania. Tutaj każdemu pikselowi nadaje się kolor segmentu do jakiego ten piksel należy. Wykorzystuję to np. w algorytmie tworzenia superpikseli SLIC.
pixels = pixels.Apply((x,i)=>kmeans.Clusters.Centroids[idx[i]]);
Mogę już ujawnić, że nasza metoda jest jednocześnie:
- Rozszerzająca
- Generyczna
- Przyjmująca generyczny delegat typu Func
- Wywoływana z delegatem wyrażonym funkcją lambda
Brzmi okropnie i strasznie! W takim razie po kolei.
1. Metoda rozszerzająca
Zajmijmy się kluczowym słowem this widniejącym przed pierwszym parametrem metody Apply. Dzięki niemu kompilator wie, że ma do czynienia z tzw. metodą rozszerzającą. Metody te są pomocne gdy chcemy przeprowadzić operacje na jakimś obiekcie (np. dodaj do stringa parametr typu int) albo uzyskać o nim jakieś informacje (np. policz w stringu litery ‘a’). Niektóre z takich operacji jak String.Length czy String.StartsWith() są oczywiście już dostępne w frameworku. Jednak często chcemy przecież dopisać nasze własne funkcje. Pierwszą, najczęstszą opcją jest podanie obiektu jako argumentu metody. Dodajmy na przykład do stringa “Score” parametr typu int, chcąc wypisać dla danego ucznia ocenę z klasówki z matematyki.
public static string AddIntScore(string str, int parameter) { return str + ": " + parameter.ToString(); }
Wywołanie takiej metody poniższym kodem
string s = "Score"; string result = ""; result = AddIntScore(s, 5); Console.WriteLine(result);
zwróci nam rezultat:
Score: 5
Jednak czyż nie fajnie byłoby wywołać taką metodę w ten sposób?
result = s.AddIntScore(5);
Zazwyczaj nie możemy bezpośrednio modyfikować klasy obiektu – tutaj typu string. Ale możemy rozszerzyć ją o dodatkową metodę przekazując ten obiekt poprzedzając go słowem this. Należy pamiętać, że taka metoda musi być statyczna.
public static string AddIntScore(this string str, int parameter) { return str + ": " + parameter.ToString(); }
Rezultat będzie identyczny:
Score: 5
2. Metoda generyczna
Załóżmy, że chcielibyśmy przekazywać parametry różnego typu w naszej funkcji wypisującej ocenę ucznia. Czy aby przekazać tym razem typ double musimy pisać bliźniaczą metodę różniącą się tylko jednym słowem? Na pomoc przychodzą typy generyczne. W definicji metody zamiast nazwy typu (tutaj int) możemy wprowadzić niezdefiniowany na razie typ wymieniony po nazwie metody w ukośnych nawiasach. Najczęściej do określenia typu generycznego używamy nazwy T. Później zobaczymy, że równie często występują nazwy TInput lub TResult. Zmieńmy zatem metodę AddIntScore na bardziej uniwersalną.
public static string AddGenericScore<T>(this string str, T parameter) { return str + ": " + parameter.ToString(); }
Teraz możemy ją wywołać zarówno z parametrem typu int jak i np. double chcąc wypisać tym razem średnią ucznia ze wszystkich przedmiotów. Zauważmy, że przy wywołaniu metody możemy ale nie musimy narzucić metodzie typ, z którego będzie korzystała wypisując go po nazwie metody w nawiasach ukośnych.
result = s.AddGenericScore(5); result = s.AddGenericScore(5.3); result = s.AddGenericScore<double>(5.3);
Otrzymany wynik to kolejno:
Score: 5
Score: 5.3
Score: 5.3
3. Delegat typu Func
Teraz chcielibyśmy wypisać listę pięciu ocen naszego ucznia z ostatniego miesiąca. Mamy dostępną tablicę nazw wyników names oraz tablicę wyników scores. Każdej nazwie odpowiada kolejno wynik zapisany w tablicy typu int.
string[] names = new string[5] { "Score 1", "Score 2", "Score 3", "Score 4", "Score 5" }; int[] scores = new int[5] { 2, 4, 5, 1, 3 }; string[] results = new string[5];
Aby przeprowadzić taką operację dla pojedynczego elementu z tablicy names wystarczyłaby funkcja
string AddFunc(string str, int i) { return str + ": " + scores[i].ToString(); }
Przekażmy zatem tę funkcję jako delegat metodzie AddGenericScore
results = names.AddGenericScore(AddFunc);
Jak przyjąć taki delegat? Trzeba go samemu zdefiniować? W wielu przypadkach nie musimy już tego robić, gdyż mamy do dyspozycji specjalną delegację Func określoną w przestrzeni nazw System. Może przyjmować od 0 do 16 parametrów wejściowych i zawsze zwraca jeden obiekt. Widzimy poniżej jedną z jej definicji (2 parametry wejściowe T1 i T2, zwraca obiekt typu TResult). A więc znów mamy typy generyczne co pozwala nam często używać tego delegatu.
public delegate TResult Func<in T1, in T2, out TResult>( T1 arg1, T2 arg2 )
Metoda realizująca nasze zadanie wygląda następująco:
public static TResult[] AddGenericScore<TInput, TResult>(this TInput[] input, Func<TInput, int, TResult> func) { TResult[] result = new TResult[input.Length]; for (int i = 0; i < input.Length; i++) result[i] = func(input[i], i); return result; }
Metoda zwraca nam tym razem również typ generyczny TResult, co więcej jest to tablica tego właśnie typu. Skoro doszedł nam ten typ, musimy go dopisać w nawiasach <> po nazwie metody. Idźmy dalej: przyjmujemy tablicę this TInput[] – czyli jest to metoda rozszerzona dla typu TInput[] – to już było. Pod koniec sygnatury metody jest wreszcie nasz delegat Func. Przyjmuje TInput oraz int a zwraca TResult.
W naszym zastosowaniu TInput jak i TResult będzie stringiem. Dopisując ciało metody – małą pętlę po elementach tablicy input otrzymujemy wynik:
Score 1: 2
Score 2: 4
Score 3: 5
Score 4: 1
Score 5: 3
Dla potwierdzenia generyczności tego rozwiązania wywołajmy tym razem metodę AddGenericScore z TInput jako double i TResult jako string. Zgodnie z definicją metoda Func będzie w takim razie przyjmowała kolejno: double i int a odpowiadała stringiem. Możemy sobie wyobrazić przykład, w którym chcemy wypisać te same oceny ale pomnożone przez wagi typu double:
double[] weights = new double[5] { 2.3, 4.5, 5.4, 1.3, 3.1 };
Zdefiniujmy nową funkcję, która będzie przekazana jako delegat
string AddFunc2(double num, int i) { return (num * integers[i]).ToString(); }
Teraz wywołanie:
results = doubles.AddGenericScore(AddFunc2);
W tablicy results znajdą się liczby reprezentowane przez typ string odpowiednio: 4.6, 18, 27, 1.3, 9.3
4. Func przekazywany wyrażeniem lambda
Metodę Func możemy równie dobrze przekazać używając wyrażenia lambda. Jest ona w naszym przypadku bardzo prosta, więc czemu by jej od razu nie zdefiniować wywołując AddGenericScore. Używając pierwszego przykładu (wypisanie kolejno ocen typu int) możemy zatem ją wywołać tak:
results = names.AddGenericScore((x, i) => x + ": " + scores[i]);
Doszliśmy do identycznej metody i jej wywołania jak tej wymienionej na początku postu. Przeznaczenie jest trochę inne, lecz mechanizm taki sam. Poszczególne etapy budowania tej konstrukcji, komplikowanie jej krok po kroku ułatwiło mi jej zrozumienie. Jeśli chcecie “pójść tą drogą” 🙂 zachęcam do zabawy z realnym kodem w środowisku, kompilator błyskawicznie wychwyci wszystkie błędy, które gdzieś tam się mogą czaić. Cały kod z powyżej przedstawionymi testami znajdziecie tutaj.