://blog. ypro.tech
Wybrane zagadnienia programowania funkcyjnego do zastosowania w C#
Wybrane zagadnienia programowania funkcyjnego do zastosowania w C#
2023-01-17

Wstęp

Nie będzie to opis Haskella ani teoretycznych podstaw rachunku lambda. Nie chodzi także o całkowite porzucenie paradygmatu obiektowego na rzecz funkcyjnego. Prezentuję jedynie wybrane zagadnienia związane z programowaniem funkcyjnym, które można zastosować w C#, nawet w istniejących już aplikacjach, bez radykalnego ich przebudowy.

Podstawowe problemy

Istnieją pewne powtarzające się rodzaje problemów, które mają negatywny wpływ na jakość oprogramowania w językach obiektowych (w tym w C#), takie jak:

  • użycie if
  • użycie null
  • wyjątki
  • atrybuty

Użycie if

Gdyby nie można było użyć instrukcji warunkowych nie powstałby żaden problem komputerowy, więc użycie if nie może być problemem? Oczywiście, to prawda, podstawą działania komputerów jest podejmowanie decyzji na podstawie dostępnych danych i to od najniższego poziomu, czyli poziomu rozkazów języka maszynowego, gdzie instrukcja skoku jest podstawową instrukcją procesorów. Nie chodzi jednak o zupełne eliminowanie instrukcji warunkowych, a jedynie o takie projektowanie algorytmów i danych, aby ograniczać niepotrzebne ich użycie. Przykład w następnym punkcie.

Użycie null

Problem użycia null częściowo wiąże się z opisanym wyżej użyciem instrukcji warunkowych. Obiecany przykład: załóżmy, że istnieje metoda zwraca listę obiektów, a następnie otrzymana z tej metody lista jest wyświetlana na ekranie. Częstą praktyką jest zwracanie z takiej metody null w przypadku, gdy wspomniana metoda nie ma czego zwrócić. Powoduje to powstanie defensywnego kodu, sprawdzającego czy cokolwiek zostało rzeczywiście zwrócone, aby uniknąć NullReferenceException .

Wyjątki

Z rzuceniem wyjątku wiąże się przerwanie wykonywania kodu w miejscu jego zgłoszenia i przejście do innego miejsca, w którym dany wyjątek zostanie złapany. Zakładając, że zostanie złapany i nie spowoduje nagłego zakończenia programu. Po pierwsze rzucenie wyjątków jest nieefektywne z punktu widzenia wydajności. A po drugie zaburza wnioskowanie lokalne. Niektórzy twierdzą, że użycie wyjątku jest gorsze od instrukcji goto, ponieważ w goto z góry znany jest cel skoku, a w miejscu rzucenia wyjątku nie jest on znany. Jest to stwierdzenie nieco przesadzone, ale obrazuje problem. Czytanie kodu w miejscu użycia instrukcji throw, nie daje pełnej informacji o przebiegu programu. Z punktu widzenia czytelności i jakości kodu dobrze jest aby wnioskowanie lokalne zachodziło, czyli aby w dało się określić co dany kod robi patrząc tylko na ten kod.

Atrybuty

Atrybuty, podobnie jak opisane wyżej wyjątki, naruszają regułę wnioskowania lokalnego. Patrząc tylko na atrybut nie można powiedzieć jaki kod zostanie wykonany z powodu użycia tego atrybutu. Potrzebna jest do tego dodatkowa wiedza, zastosowane konwencje, konfiguracja aplikacji itp.

Czy tylko problemy?

Oczywiście, nie jest tak, że jakiekolwiek użycie null, if, throw czy atrybutu jest bezwzględnie złe. Wszystkie wskazane elementy języka mają swoje zastosowanie i jak najbardziej należy ich używać. Należy tylko pamiętać, że czasem inne podejście może poprawić jakość kodu lub choćby jego czytelność.

Programowanie funkcyjne

Czym jest programowanie funkcyjne? Odpowiedź nie jest wbrew pozorom ani taka prosta ani taka oczywista. Nie zamierzam tworzyć ani przytaczać konkretnej definicji. Poprzestanę za sformułowaniu, że mamy z nim do czynienia tam, gdzie funkcje mogą być parametrami lub wartościami zwracanymi z innych funkcji.

A czym są funkcje? Dla uproszczenia niech pod pojęciem funkcji kryją się wszystkie konstrukcje językowe, stanowiące taki fragment kodu, który potrafi przyjąć pewne parametry i zwrócić określone wartości, czyli metody wyrażenia lambda itp.

Cechy funkcji

Mówiąc o funkcjach w programowaniu funkcyjnym spodziewamy się, że będą one spełniały określone kryteria. Najbardziej znanym z nich jest czystość funkcji.

Czystość funkcji

O czystości funkcji mówimy wtedy, gdy zwracany wynik zależy tylko od przekazanych parametrów, a nie zależy od żadnych innych czynników (w tym stanu bazy danych, czasu, plików na dysku itp.). Mówiąc inaczej mówiąc funkcja jest czysta, gdy jej wykonanie nie niesie ze sobą efektów ubocznych.

To wymaganie jest bardzo istotne w językach czysto funkcyjnych. Do opisywanego przez mnie przybliżonego podejścia nie jest konieczne ścisłe jego przestrzeganie.

Niemutowalność danych

Przyjęcie założenia, że wszystkie zmienne są tylko jednokrotnie zmienne, powaga w zapanowaniu nad przepływem danych. Najczęściej zmienne deklaruje się w miejscu pierwszego (tego jedynego) podstawienia danych co pozwala czytelnie określić skąd pochodzą konkretne dane. Czyste funkcje nie zmieniają swoich parametrów - takie działanie byłoby efektem ubocznym.

Szczerość funkcji

Jako szczerość funkcji należy określić możliwość określenia tego, co robi dana funkcja tylko na podstawie jej sygnatury. I to dla wszystkich możliwych do przekazania parametrów czyli dla całej jej dziedziny. Np. jeśli funkcja nie zwraca wyniku dla wszystkich parametrów, ponieważ dla niektórych z nich wyrzuca wyjątek, to funkcja nie spełnia kryterium szczerości.

Łączenie funkcji

Programowanie funkcyjne polega w zasadzie na łączeniu funkcji w pewien potok, w którym dane są po kolei przetwarzane przez poszczególne funkcje reprezentujące poszczególne kroki algorytmu. Złączenie tych funkcji jest w zasadzie nową funkcją, którą można łączyć z innymi komponując w ten sposób cały program. Tak powstają programy w językach funkcyjnych. W językach obiektowych nie ma konieczności, ani nawet potrzeby, aby od razu stosować podejście funkcyjne w całej aplikacji. Można zacząć od jego stosowania w mniejszych fragmentach kodu, nawet w pojedynczych metodach.

Minimum teorii

Postaram się przybliżyć pojęcie monady bez stosowania funkcyjnej teorii, ograniczając się do niezbędnego minimum. Powszechnie znana i używana jest klasa System.Collections.Generic.List<T>. Jest ona kontenerem elementów typu T. Dla takiej listy można użyć metody Select, której parametrem jest recepta na przekształcenie T na inny typ np. R np.:

var l = new List<int>() {1, 2, 3};
var l2 = l.Select(x => 0.5 * x);

Symbolicznie:

List<T>::Select(T => R) → List<R>

A jeśli parametrem Select będzie recepta przekształcająca T w List<R>. Wówczas:

List<T>::Select(T => List<R>) → List<List<R>>

Ok, ale co zrobić aby wynikiem nie był List<List<R>> a List<R>? Należy użyć SelectMany:

List<T>::SelectMany(T => List<R>) → List<R>

Metoda SelectMany pozwala „spłaszczyć” typ o jeden poziom List.

Właśnie opisałem monadę. Rezygnując z dalszej teorii przyjmuję, że jeśli dany typ ma zdefiniowane metody rodzaju Select i SelectMany to będzie to monada. W różnych językach i bibliotekach tego rodzaju przekształcenia mogą być różnie nazwane. Select najczęściej nazywane jest Map. SelectMany nazywane jest Bind lub najczęściej FlatMap. Nie chodzi o nazwy, chodzi o fakt istnienia możliwości przekształcenia.

M<T>::Map(T => R) → M<R>
M<T>::Bind(T => M<R>) → M<R>

Przykłady monad

Option

Option<T> to prosta monada opisująca możliwość nieistnienia wartości typu T. W zastosowaniu przypomina Nullable<T>, ale zapewnia większe bezpieczeństwo. Może być zastosowana do typów wartościowych i referencyjnych. Option<T> może znajdować się w dwóch stanach: None i Some<T>. None opisuje fakt nieprzypisania wartości, a Some<T> opisuje pewność przypisania tej wartości.

Either

Również Either<L,R> opisuje dwa przeciwstawne stany najczęściej określane jako Left i Right. Najczęściej stosowana jest jako wynik zwracany z funkcji i Left jest opisem nieudanego przetwarzania (np. błędu), a Right jest opisem spodziewanej i poprawnej wartości.

Jak zacząć?

Stosowanie monad Option<T> i Either<L,R> pozwala wyeliminować stosowanie null jako wartości opisujących brak wartości. W skutek tego nie ma konieczności stosowania defensywnego if dla każdej takiej zmiennej. Ponieważ dobrze skonstruowania monada nie ma możliwości bezpośredniego pobrania przechowywanej w niej wartości (np. Option<T>::Get<T>() → T), to użycie monady zmusza do zastosowania następnego przekształcenia do monady (Map lub Bind) lub pełnego przekształcenia do wartości. Takim pełnym przekształceniem może być metoda Match, która np. dla monady Option<T> przyjmuje dwa parametry. Jednym jest przepis stosowany gdy monada opisuje Some, a drugi gdy None. W ten sposób już na poziomie kompilacji istnieje pewność, że wszystkie możliwe stany monady zostaną obsłużone. Wszelkie wyjścia z monady (takie jak opisywana metoda Match) powinny być tylko na styku ze światem „poza monadycznym”. Przygodę z światem funkcyjnym można rozpocząć od jednej metody opisującej niezbyt skomplikowane flow. Przykład poniżej. Algorytm składa się z czterech kroków MethodA do MethodD. Poszczególne kroki łączone są przy pomocy Map lub Bind w zależności od tego, czy metody kroków zwracają monadę czy nie.

public int ExampleFlow()
{
    return MethodA()
        .Map(MethodB)
        .Bind(MethodC)
        .Bind(MethodD)
        .Match(
            some: x => x.AnyFromD,
            none: () => 0
        );
}

Option<A> MethodA()
{
    // returns Option<A>: None lub Some<A>
}

B MethodB(A a)
{
    // returns B
}

Option<C> MethodC(B b)
{
    // returns Option<C>: None lub Some<C>
}

Option<D> MethodD(C c)
{
    // returns Option<D>: None lub Some<D>
}

Zastosowane podejścia funkcyjnego choćby w jednej metodzie (podobnie do powyższego ExampleFlow) wymusza przemyślenie przepływu danych między poszczególnymi krokami algorytmu oraz znalezienie i obsłużenie operacji, które mogą się nie udać. Takie podejście będzie działać i przynosić korzyści nawet jeśli metody składowe algorytmów (w przykładzie MethodA do MethodD) nie będą funkcjami czystymi. Oczywiście powinno się dążyć do tego, aby funkcji czystych i pracujących na niemutowalnych danych było jak najwięcej, ale jest to konieczne aby zacząć. Należy jednak unikać rzucania wyjątków z ich wnętrza (zachowanie szczerości), gdyż jeden rzucony wyjątek przerywa wykonanie i niweluje zysk zastosowanego podejścia. Istnieje monada Try<T>, która owija kod mogący wygenerować wyjątek, ale to już inny temat.


Pytania lub dyskusja możliwe są przez GitHub Discussions