Wstęp do programowania funkcyjnego w C# (csharp)

Spis treści

  • Delegaty: Func oraz Action
  • Kompozycja funkcji
  • Pipe w akcji!
  • Praca na kolekcjach nigdy nie była prostsza

W ostatnim wpisie dotyczącym programowania funcyjnego poruszone zostały podstawowe zagadnienia związane z tym tematem. Przed rozpoczęciem czytania tego posta zachęcam do zapoznania się z poprzednij postem ponieważ część rzeczy będzie łatwiej zrozumieć.

Zapinamy pasy i lecimy w kierunku programowania funkcyjnego w C#

Delegaty: Func oraz Action

Programując funkcyjnie opieramy się na … funkcjach. Wrzucamy coś na wejście, przepuszczamy przez szereg funkcji, a następnie na wyjściu otrzymujemy wynik.

Język csharp (C#) dostarcza delegaty Func oraz Action. Dzięki delegatom możemy całkiem sprytnie zacząć programować funkcyjnie w c#. Odpowiedzmy jeszcze na pytanie – co to jest delegat? Można powiedzieć, że delegat to po prostu wskaźnik do metody (funkcji / procedury).

Func – użyjemy jeśli chcemy zadeklarować delegat do funkcji, musi ona zwracać wartość i może ale(!) nie musi przyjmować parametrów. Upraszczając definicję: funkcja to metoda, która coś zwraca.

Func<R> foo # prezentuje bezparametrową funkcję oraz zwraca wartość jako typ R
Func<T1, R> foo # delegat do funkcji, która pobiera parametr T1 i zwraca wartość R
Func<T1, T2, R> # działa identycznie jak delegat wyżej, lecz przyjmuje 2 parametry T1 oraz T2

Action – interesuje nas kiedy chcemy wywołać metodę, która nic nie zwraca (procedura). Z racji tego, że w programowaniu funkcyjnym zawsze będziemy chcieli coś mieć zwrócone to Action nam się nie przyda.

Action action # to delegat do metody, która nic nie zwraca oraz nie przyjmuje żadnych argumentów
Action<T1> action # prezentuje metodę, która również nic nie zwraca ale przyjmuje parametr T1
Action<T1, T2> action # jak się można domyśleć, nic nie zwraca ale przyjmuje 2 parametry T1 oraz T2

Mając tę wiedzę jesteśmy w stanie utworzyć sobie delegat do prostej funkcji, która również zwróci delegat, lecz do innej. Idąc tym tokiem zadeklarujemy sobie tzw. funkcję wyższego rzędu (Higher-order function)

Func<int, Func<int>> foo = (i) =>
{
  Func<int> addOne = () => i + 1;
  return addOne;
};

Console.WriteLine(foo(1)); # zwraca tym czym jest addOne czyli delegatem System.Func'1[System.Int32]
Console.WriteLine(foo(1)()); # wykonuje addOne i dostajemy na wyjściu 2

# mozemy powyższy przykład zapisać również w taki sposób:

int AddOne(int i)
{
    return i + 1;
}
Func<int, int> Foo()
{
    return AddOne;
}
Console.WriteLine(Foo()); # System.Func'1[System.Int32] czyli delegat do AddOne
Console.WriteLine(Foo()(1)); # wykonanie AddOne daje nam 2

Kompozycja funkcji

Kompozycja to nic innego jak składanie wielu prostych funkcji w jedną, bardziej skomplikowaną mechanikę.

Najprościej rzecz biorąc to jest kompozycja:

private int Add(int i) { return i + 1; }
Add(Add(0)); #=> wynik: 2, najpierw wywołujemy Add z parametrem 0 i dostajemy 1 nastepnie znów Add lecz z wynikiem 2

Jakie korzyści mamy z używania kompozycji?

  • Łatwiejsze zrozumienie skomplikowanej logiki
  • Rozbicie problemy na mniejsze
  • Rozwiązywanie mniejszych problemów jednego po drugim
  • Złożenie rozwiązań małych problemów w całościowy wynik

Jeśli byśmy chcieli złączyć więcej funkcji to otrzymamy taki łańcuch wywołań:

Add(Add(Add(Add(Add(Add(Add(0))))))));

Niezbyt zgrabnie to wygląda, a jeszcze gorzej się to utrzymuje. W językach funkcyjnych takie kompozycje wykorzystuje się cały czas, ponieważ „produkt” funkcji przekazujemy dalej do funkcji aby znów go przetworzyć itd. W c# możemy sobie ułatwić kompozycję przez napisanie prostego rozszerzenia, które przyjmie funkcję, wywoła ją, a wynik przekaże dalej do następnej:

static class F
{
    public static Func<T, TR2> Compose<T, TR1, TR2>(this Func<TR1, TR2> foo1, Func<T, TR1> foo2)
    {
        return x => foo1(foo2(x));
    }
}

Użyć wyżej zadeklarowanej metody Compose możemy w taki sposób:

Func<int, int> foo = (i) => i;
Func<int, int> addOne = (i) => i + 1;
Console.WriteLine(addOne.Compose(foo)(1));

Co się tutaj stało? Połączyliśmy funkcję w łańcuch. Najpierw się wykona funkcja foo, która zwróci i a następnie wartość zostanie przekazana do addOne gdzie i+1 po czym funkcja zwróci wynik i na konsoli dostaniemy wynik: 2

Zobaczmy jeszcze dwa inne przykłady:

Func<int, int> addOne = (i) => i + 1;
Func<int, int> negation = (i) => i * -1;

Console.WriteLine(addOne.Compose(addOne).Compose(addOne).Compose(negation)(1));
Console.WriteLine(negation.Compose(addOne).Compose(addOne).Compose(addOne)(1));

Pierwszy wynik to 2 ponieważ bierzemy 1 a nastepnie robimy jej negację i ucuhamamiamy 3 razy addOne, więc cały przepływ możemy zapisać w taki sposób:
1 =negation=> -1 =addOne=> 0 =addOne=> 1 =addOne=> 2

Drugi wynik da nam -4, ponieważ do 1 wywołujemy trzy razy addOne a następnie negujemy:
1 =addOne=> 2 =addOne=> 3 =addOne=> 4 =negation=> -4

Problemem jest tutaj czytanie od końca co jest szalenie nienaturalne, lecz i z tym możemy sobie z poradzić. Zaimplementujemy nową pomocniczą metodę: Pipe

  public static E Pipe<A, B, C, D, E>(this A obj, Func<A, B> func1, Func<B, C> func2, Func<C, D> func3, Func<D, E> func4) => func4(func3(func2(func1(obj))));

Pipe w akcji!

Pipe jest bardzo prostą metodą, która bierze obiekt typu A wrzuca go do funkcji func1 przekształca go na typ B, następnie typ B przekształca na typ C itd. aż do E. Jeśli nie potrzebujemy takiego długiego łańcucha funkcji to wystarczy skrócić implementację do B czy C:

public static C Pipe<A, B, C>(this A obj, Func<A, B> func1, Func<B, C> func2) => func2(func1(obj));

Gotowa implementacja Pipe :

public static class F
{
    public static B Pipe<A, B>(this A obj, Func<A, B> func1) => func1(obj);
    public static C Pipe<A, B, C>(this A obj, Func<A, B> func1, Func<B, C> func2) => func2(func1(obj));
    public static D Pipe<A, B, C, D>(this A obj, Func<A, B> func1, Func<B, C> func2, Func<C, D> func3) => func3(func2(func1(obj)));
    public static E Pipe<A, B, C, D, E>(this A obj, Func<A, B> func1, Func<B, C> func2, Func<C, D> func3, Func<D, E> func4) => func4(func3(func2(func1(obj))));
    public static F Pipe<A, B, C, D, E, F>(this A obj, Func<A, B> func1, Func<B, C> func2, Func<C, D> func3, Func<D, E> func4, Func<E, F> func5) => func5(func4(func3(func2(func1(obj)))));
}

Jeśli brakuje Wam możliwości złączenia 8 czy nawet 20 funkcji to można sobie dopisać. Schemat jest prosty tylko trzeba uważać aby się nie pomylić 🙂

Zobaczmy jak możemy teraz zapisać wcześniejsze przykłady przy użyciu Pipe:

0.Pipe( # zwróćcie uwagę, tutaj jest 0.Pipe
    i => i, # nic się tutaj nie dzieje, po prostu 0 jest przekazywane dalej
    i => i + 1, # wpada nam zero i dodajemy do niego jeden 
    i => { Console.WriteLine(i); return 0; }); # przekazujemy wynik poprzedniej operacji - jeden, na wyjście konsoli (funkcja musi zwracać jakiś wynik stąd to return 0; w późniejszych postach dowiemy się jak to zlikwidować)
# tworzymy sobie funkcje na boku ponieważ użyjemy ich w Pipe
Func<int, int> addOne = (i) => i + 1;
Func<int, int> negation = (i) => i * -1;
Func<int, int> print = (i) => { Console.WriteLine(i); return 0; };

1.Pipe( # 1
    i => negation(i), # -1
    i => addOne(i), # 0
    i => addOne(i), # 1
    i => addOne(i), # 2
    i => print(i) # wypisanie na konsolę
    );

obecny kod czyta się z łatwością, wiemy dokładnie co się dzieje:
1 =negation=> -1 =addOne=> 0 =addOne=> 1 =addOne=> 2 =print=> Console.WriteLine

Rozwiążmy teraz inny problem. Dostajemy pewien ciąg znaków rozdzielony dwukropkiem. Pierwsza część przed nim zawiera nazwę firmy, a druga jej kurs giełdowy. Musimy przeformatować otrzymanego stringa w jakiś sposób. Zobaczcie jak prosto funkcyjnie możemy rozwiązać to zadanie:

var r = "appl:1234".Pipe(
    s => s.Split(':'),
    p => new { Name = p[0].ToUpper(), Price = int.Parse(p[1]) },
    c => $"Company: {c.Name} (${c.Price})"
);
Console.WriteLine(r); # Company: APPL ($1234)
Uproszczony rysunek działania kodu

Praca na kolekcjach nigdy nie była prostsza

Wyobraźmy sobie, że chcemy:

  • wygenerować kolekcję od 1 do 100
  • zsumować liczby parzyste
  • zsumować wartości elementów większych od 50

Możemy rozwiązać to zadanie pisząc kod imperatywnie (czyt. „normlanie” tak jak się programuje – darujemy sobie regułki i porównania, po prostu imperatywnie piszemy wtedy kiedy klepiemy linijka po linijce co ma się wykonać).

Zróbmy prostą metodę, która wygeneruje nam kolekcję cyfr z danego przedziału:

public static IList<int> Range(int from, int to)
{
    IList<int> list = new List<int>();

    for (int i = from; i <= to; i++)
    {
        list.Add(i);
    }

    return list;
}

A teraz napiszmy sumowanie:

List<int> list = Range(1,100);

int sum1 = 0;
foreach (var el in list)
{
    if (el % 2 == 0)
    {
        sum1 += el;
    }
}

int sum2 = 0;
foreach (var el in list)
{
    if (el > 50)
    {
        sum2 += el;
    }
}

Kod wygląda zwyczajnie, nie ma co komentować, lecimy z góry na dół liczymy i mamy wyniki.

Teraz użyjemy naszej wiedzy z programowania funkcyjnego. Jak wiemy, możemy składać funkcje w łańcuchy, więc napiszmy:

  • Funkcję filtrującą elementy, Filter
  • Funkcję sumująca elementy, Sum

Tworzymy funkcję filtrującą kolekcję:

public static IEnumerable<int> Filter(this IList<int> list, Func<int, bool> predicate)
{
    foreach (var el in list)
    {
        if (predicate(el))
        {
            yield return el;
        }
    }
}

W funkcji Filter przelatujemy po wszystkich elementach listy i na każdym elemencie wywołujemy predicate(el). Jeśli otrzymamy true to zwracamy element, jeśli nie, pomijamy. Tutaj moglibyśmy zapisywać wynik do osobnej listy tworzonej w ciele Filter, lecz ze względu na wydajność nie chcemy tworzyć za każdym razem nowej listy, będziemy zwracać tylko elementy listy. (dla zainteresowanych yield odsyłam tutaj)

Pozostała nam ostatnia funkcja do napisania: Sum

public static int Sum(this IList<int> list)
{
    int sum = 0;
    foreach (var el in list)
    {
        sum += el;
    }
    return sum;
}

Rownież przelatujemy po elementach listy i dodajemy ich wartości do zmiennej sum, która trzyma wynik. Wywołajmy te funkcje i zobaczmy jak to wygląda:

var list = MyF.Range(1, 100);
var sum1 = list.Filter(el => el % 2 == 0).Sum();
var sum2 = list.Filter(el => el > 50).Sum();

Dla osób, które już miały kontakt z LINQ pewnie się domyslają ze Filter działa jak Where, a Sum działa identycznie jak tam. W następnych postach rozłożymy sobie LINQ na części, tak więc STAY TUNED!