Читать книгу Programowanie funkcyjne w języku C#. Jak pisać lepszy kod - Enrico Buonanno - Страница 23
1.3.2 Reprezentacja funkcji w C#
ОглавлениеW C# istnieje kilka konstrukcji języka, których można użyć do reprezentowania funkcji:
■ metody,
■ delegaty,
■ wyrażenia lambda,
■ słowniki.
Każdy, kto zna te pojęcia, może pominąć podane objaśnienia. Poniżej krótkie odświeżenie pojęć.
Metody
Metody to najpopularniejsza i idiomatyczna reprezentacja funkcji w języku C#. Na przykład klasa System.Math obejmuje metody reprezentujące popularne funkcje matematyczne. Metody nie tylko mogą reprezentować funkcje, lecz pasują także do paradygmatu zorientowanego obiektowo – można ich używać do implementacji interfejsów, można je przeciążać i tak dalej.
Konstrukty, które naprawdę pozwalają nam programować w stylu funkcyjnym, to delegaty i wyrażenia lambda.
Delegaty
Delegaty to wskaźniki do funkcji bezpieczne pod względem typu (type-safe). Bezpieczeństwo typu oznacza tu, że delegat jest silnie typowalny, typy wartości na wejściu i na wyjściu funkcji są znane w czasie kompilacji, a ich spójność jest wymuszana przez kompilator.
Tworzenie delegatów to proces dwuetapowy: najpierw deklarujemy typ delegatu, a następnie zapewniamy implementacje. (Jest to analogiczne do napisania interfejsu (interface), a potem utworzenia instancji klasy class implementującej ten interfejs).
Pierwszy krok wykonujemy, stosując słowo kluczowe delegate i dostarczając sygnaturę delegatu. Na przykład .NET obejmuje następującą definicję delegatu Comparison<T>.
namespace System
{
public delegate int Comparison<in T>(T x, T y);
}
Listing 1.5 Deklarowanie delegatu
Jak widać, delegat Comparison<T> może otrzymać dwie wartości typu T, dając w wyniku int wskazujący, która wartość jest większa.
Gdy mamy już typ delegatu, możemy utworzyć jego instancję, dostarczając implementację jak niżej.
var list = Enumerable.Range(1, 10).Select(i => i * 3).ToList();
list // => [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]
Comparison<int> alphabetically = (l, r)
=> l.ToString().CompareTo(r.ToString());
Zapewnia implementację Comparison
list.Sort(alphabetically);
list // => [12, 15, 18, 21, 24, 27, 3, 30, 6, 9]
Używa delegatu Comparison jako argumentu dla Sort
Listing 1.6 Tworzenie instancji i używanie delegatu
Jak możemy zobaczyć, delegat jest po prostu obiektem (w sensie technicznym), który reprezentuje działanie – w tym przypadku porównanie. Podobnie jak w przypadku innego obiektu, możemy użyć delegatu jako argumentu innej metody, jak na listingu 1.6, więc delegaty są elementem języka, która sprawiają, że funkcje w języku C# są elementami pierwszoklasowymi klasy.
Delegaty Func i Action
.NET Framework obejmuje kilka „rodzin” delegatów, które mogą reprezentować funkcje dowolnego typu:
■ Func<R> reprezentuje funkcję, która nie przyjmuje żadnych argumentów, a zwraca wynik typu R.
■ Func<T1, R> reprezentuje funkcję, która przyjmuje argument typu T1, a zwraca wynik typu R.
■ Func<T1, T2, R> reprezentuje funkcję, która przyjmuje argumenty T1 i a T2, a zwraca wynik typu R.
I tak dalej. Są delegaty reprezentujące funkcje o różnej „argumentowości” (patrz opis „Argumentowość funkcji”).
Od czasu wprowadzenia Func stosowanie niestandardowych delegatów jest rzadkie. Na przykład zamiast deklarowania niestandardowego delegatu jak ten:
delegate Greeting Greeter(Person p);
możemy po prostu wpisać:
Func<Person, Greeting>
Typ Greeter w poprzednim przykładzie jest równoważny lub „zgodny z” Func<Person, Greeting>. W obu przypadkach jest to funkcja, która pobiera Person i zwraca Greeting.
Mamy podobną rodzinę delegatów, która reprezentuje działania – funkcje, które nie mają wartości zwrotnej, jak metody typu void:
■ Action reprezentuje działanie bez argumentów wejściowych.
■ Action<T1> reprezentuje działanie z argumentem wejściowym typu T1.
■ Action<T1, T2> i tak dalej reprezentują działanie z kilkoma argumentami wejściowymi.
.NET Framework trzyma się z daleka od niestandardowych delegatów na rzecz bardziej ogólnych delegatów Func i Action. Weźmy na przykład reprezentację predykatu4:
■ W .NET 2 wprowadzono delegat Predicate<T>, który jest na przykład używany w metodzie FindAll wykorzystywanej do filtrowania listy List<T>.
■ W .NET 3 metoda Where jest także używana do filtrowania, ale jest zdefiniowana na bardziej ogólnym typie IEnumerable<T> i nie pobiera Predicate<T>, ale po prostu Func<T, bool>.
Oba typy funkcji są równoważne. Zalecane jest używanie Func, aby uniknąć rozprzestrzeniania się typów delegatów, które reprezentują tę samą sygnaturę funkcji, ale wciąż można powiedzieć coś na korzyść wyrazistości niestandardowych delegatów: moim zdaniem Predicate<T> jaśniej wyraża intencje niż Func<T, bool> i jest bliższa języka mówionego.
Argumentowość funkcjiArgumentowość to zabawne słowo odnoszące się do liczby argumentów, które przyjmuje funkcja:■ Funkcja bezargumentowa nie ma żadnych argumentów.■ Funkcja jednoargumentowa ma jeden argument.■ Funkcja dwuargumentowa ma dwa argumenty.■ Funkcja trójargumentowa ma trzy argumenty.I tak dalej. W rzeczywistości wszystkie funkcje można rozpatrywać jako jednoargumentowe, gdyż przekazanie n argumentów jest równoważne przekazaniu n-krotki jako jedynego argumentu. Na przykład dodawanie (jak każde inne arytmetyczne działanie dwuargumentowe) to funkcja, której dziedziną jest zbiór wszystkich par liczb. |
Wyrażenia lambda
Wyrażenia lambda są używane do deklarowania funkcji w miejscu. Przykładowo sortowanie listy liczb rosnąco lub malejąco można wykonać za pomocą poniższego wyrażenia lambda.
var list = Enumerable.Range(1, 10).Select(I => I * 3).ToList();
list // => [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]
list.Sort((l, r) => l.ToString().CompareTo(r.ToString()));
list // => [12, 15, 18, 21, 24, 27, 3, 30, 6, 9]
Listing 1.7 Deklarowanie funkcji w miejscu za pomocą wyrażenia lambda
Jeśli nasza funkcja jest krótka i nie musimy używać jej ponownie w innym miejscu, wyrażenia lambda oferują najbardziej atrakcyjną notację. Zauważmy także, że w powyższym przykładzie kompilator nie tylko wnioskuje o typach x i y jako o int, lecz także przekształca wyrażenie lambda na typ delegatu Comparison<int>, którego spodziewa się metoda Sort, jeśli podane wyrażenie lambda jest zgodne z tym typem.
Delegaty i wyrażenia lambda, podobnie jak metody, mają dostęp do zmiennych w zakresie, w którym zostały zadeklarowane. Jest to szczególnie użyteczne, gdy wykorzystujemy domknięcia w wyrażeniach lambda5. Oto przykład.
var days = Enum.GetValues(typeof(DayOfWeek)).Cast<DayOfWeek>();
// => [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
IEnumerable<DayOfWeek> daysStartingWith(string pattern)
Do zmiennej pattern odwołujemy się z wnętrza wyrażenia lambda i dlatego jest ona ujęta w domknięciu
=> days.Where(d => d.ToString().StartsWith(pattern));
daysStartingWith("S") // => [Sunday, Saturday]
Listing 1.8 Wyrażenia lambda mają dostęp do zmiennych w zawierającym je zakresie
W tym przykładzie Where oczekuje funkcji, która pobiera DayOfWeek i zwraca bool. W rzeczywistości funkcja wyrażona przez wyrażenie lambda także używa wartości pattern, która jest przechwycona w domknięciu, aby obliczyć wynik.
Jest to interesujące. Gdyby spojrzeć na funkcję w postaci wyrażenia lambda bardziej matematycznym okiem, można by powiedzieć, że jest to w istocie funkcja dwuargumentowa, która przyjmuje na wejściu DayOfWeek i łańcuch string (wzorzec) oraz daje w wyniku wartość typu bool. Jednak jako programiści zwykle jesteśmy zainteresowani sygnaturą funkcji, więc spojrzymy na nią raczej jak na funkcję jednoargumentową, przekształcającą DayOfWeek na bool. Obie perspektywy są poprawne: funkcja musi być dostosowana do swojej sygnatury jednoargumentowej, a jej działanie zależy od dwóch wartości.
Słowniki
Słowniki są także nazywane odwzorowaniami (lub tablicami mieszającymi). To struktury danych, które zapewniają bardzo bezpośrednią reprezentację funkcji. Zawierają dosłowne powiązania kluczy (elementów z dziedziny) z wartościami (odpowiadającymi im elementami z przeciwdziedziny).
Zwykle traktujemy słowniki jako dane, więc wzbogacająca jest chwilowa zmiana perspektywy i potraktowanie ich jak funkcji. Słowniki są odpowiednie do reprezentowania funkcji, które są całkowicie arbitralne, gdzie odwzorowania nie można wyznaczyć i muszą być w całości zapamiętane. Na przykład odwzorowanie wartości boolowskich na ich nazwy w języku francuskim może wyglądać jak niżej.
var frenchFor = new Dictionary<bool, string>
{
[true] = "Vrai",
Składnia inicjalizatora słownika w C# 6
[false] = "Faux",
} ;
frenchFor[true]
Zastosowanie funkcji wykonywanej przy wyszukiwaniu
// => "Vrai"
Listing 1.9 Funkcja może być wyczerpująco reprezentowana za pomocą słownika
Fakt, że funkcje mogą być reprezentowane przez słowniki, sprawia też, że istnieje możliwość optymalizacji kosztownych funkcji przez zapamiętanie obliczonych wyników w słowniku zamiast wyznaczania ich za każdym razem na nowo.
Dla wygody w pozostałej części książki będę używał terminu funkcja w celu wskazania jednej z reprezentacji funkcji w C#. Pamiętajmy więc, że nie jest to całkiem zgodne z matematycznym znaczeniem tego terminu. Więcej informacji o różnicy między funkcjami matematycznymi a programistycznymi można znaleźć w rozdziale 2.