Niecodzienne zastosowania LINQ, czyli monady w C#

14

Witaj w trzeciej odsłonie cyklu poświęconego programowaniu funkcyjnemu! W części pierwszej omówiłem najważniejsze podstawy. Część druga skupiała się na kluczowym aspekcie tego paradygmatu programowania – tworzeniu programu poprzez komponowanie funkcji. Dzięki temu artykułowi dowiesz się, czym są monady oraz jakie są ich praktyczne zastosowania.

Monady cieszą się złą sławą w świecie programowania funkcyjnego. To pojęcie dość abstrakcyjne, które często tłumaczy się, korzystając z formalnych, matematycznych definicji. Ja chciałbym pokazać Ci monady od strony praktycznej!

LINQ oraz query expressions

Raczej nie pomylę się, zakładając, że wiesz, czym jest LINQ. Aby nie było żadnych wątpliwości, poniżej kilka słów przypomnienia.

LINQ jest rozszerzeniem języka C# wprowadzonym w wersji 3.5. Jednym z jego głównych zastosowań jest umożliwienie przeprowadzania operacji na kolekcjach w sposób deklaratywny. Poniżej przykład zastosowania LINQ w celu zamiany tablicy liczb na tablicę kwadratów tych liczb.

var numbers = new[] { 1, 2, 3, 4, 5 };
var squares = numbers.Select(x => x * x);

Jak widzisz, LINQ sam w sobie jest dość mocno funkcyjny – wykorzystuje funkcje wyższego rzędu oraz funkcje czyste.

Powyższe wyrażenie można zapisać również jako query expression. Jest to element składni języka przypominający język zapytań SQL.

var squares = from x in numbers select x * x;

Kolekcja jednoelementowa – klasa Maybe

Dość łatwo zrozumieć, jak działa wyrażenie select w powyższym przykładzie. Przyjmuje ono funkcję, która jest aplikowana na każdym z elementów kolekcji. Rezultatem wywołania jest nowa kolekcja (a w zasadzie IEnumerable) zawierająca wyniki przekształceń.

Wyobraźmy sobie teraz, że pracujemy ze specjalnym typem kolekcji. Nazwijmy go Maybe. Kolekcja Maybe może zawierać maksymalnie jeden element. Innymi słowy albo zawiera ona element (wtedy jest pełna), albo nie zawiera żadnego (wtedy jest pusta).

W jaki sposób powinno zachować się wyrażenie select zaaplikowane na takiej kolekcji? Dokładnie tak samo jak na każdej innej. Jeśli kolekcja jest pełna, powinna zostać zwrócona nowa kolekcja zawierająca wynik przekształcenia elementu przechowywanego w pierwotnej kolekcji. W przeciwnym razie powinna zostać zwrócona pusta kolekcja.

Przejdźmy zatem do implementacji. Poniższa klasa Maybe realizuje kolekcję, która może mieć od zera do jednego elementów. Gdy kolekcja jest pusta, pole hasValue ustawione jest na false, a pole value przechowuje nulla lub wartość domyślną (w przypadku value type). W przeciwnym razie hasValue ustawione jest na true, a value zawiera przechowywany element.

public class Maybe<TValue>
{
    private readonly TValue value;
    private readonly bool hasValue;
        
    internal Maybe(TValue value, bool hasValue)
    {
        this.value = value;
        this.hasValue = hasValue;
    }
}

Dodajmy teraz dwie statyczne metody służące do tworzenia instancji Maybe.

public static class MaybeFactory
{
    public static Maybe<T> Some<T>(T value) => new Maybe<T>(value, true);
    public static Maybe<T> None<T>() => new Maybe<T>(default(T), false);
}

Mamy zatem dwa sposoby tworzenia instancji Maybe – możemy utworzyć kolekcję pustą lub pełną. Dzięki statycznym importom możemy używać tych metod w całkiem wygodny sposób.

var some = Some(10); // pełne Maybe
var none = None<int>(); // puste Maybe

Dodajemy wsparcie dla LINQ

Na początku artykułu zastanawialiśmy się, jak działałaby metoda Select na Maybe. Przekonajmy się zatem i zaimplementujmy ją!

Aby nasza metoda działała z query expressions, musi ona spełniać specjalną sygnaturę (nie istnieje żaden interfejs definiujący tę sygnaturę).

Maybe<TResult> Select<TResult>(Func<TValue, TResult> mapperExpression) { /*...*/ }

Jak widać, Select przyjmuje funkcję przekształcającą przechowywany typ TValue na nowy typ TResult. Wynikiem wywołania jest nowe Maybe przechowujące TResult.

Jak będzie wyglądać implementacja? Tak, jak już opisaliśmy. Jeśli hasValue jest prawdziwe, to zwracamy nowe Maybe zawierające wynik zaaplikowania mapperExpression na value.

if (this.hasValue)
{
   return MaybeFactory.Some(mapperExpression(this.value));
}
return MaybeFactory.None<TResult>();

Pozostaje przetestować naszą nową metodę.

Maybe<int> age = Some(27);
Maybe<string> result = from x in age select string.Format("I'am {0} years old", x);

Działa!

Maybe w akcji

OK, ale po co to wszystko? Spójrzmy na przykład, w którym Maybe pozwoli nam znacznie uprościć fragment kodu.

Załóżmy, że mamy zadaną klasę PersonRepository z poniższą metodą GetPersonById:

public Person GetPersonById(Guid id) { ... }

Klasa Person wygląda następująco:

class Person
{
    public string Name { get; set; }
    public Person ReportsTo { get; set; }
}

Naszym zadaniem jest napisanie metody wyświetlającej imię szefa osoby o zadanym identyfikatorze. Implementując taką metodę, musimy pamiętać, że osoba o podanym identyfikatorze może nie istnieć. Co więcej, pole ReportsTo może być nullem, co oznacza, że dana osoba nie ma szefa.

public static string GetSupervisorName(PersonRepository repo, Guid id)
{
    var employee = repo.GetPersonById(id);
    if (employee != null)
    {
        if (employee.ReportsTo != null)
        {
            return employee.ReportsTo.Name;
        }
    }
    return null;
}

Jak widać, stworzony kod zawiera zagnieżdżone instrukcje warunkowe i nie jest specjalnie czytelny. Czy możemy w jakiś sposób go poprawić?

Metoda GetPersonById zwraca instancję klasy Person lub null, jeśli osoba o podanym identyfikatorze nie istnieje. Zmieńmy ją w taki sposób, aby wykorzystać klasę Maybe. Jeśli osoba o zadanym identyfikatorze istnieje, to zwrócimy pełne Maybe. W przeciwnym razie zwrócimy Maybe puste.

public static Maybe<Person> GetPersonById(Guid id) { /*...*/ }

Następnie dostosujmy klasę Person. Ponieważ osoba może – ale nie musi – mieć szefa, jest to ponownie dobre miejsce na zastosowanie Maybe.

class Person
{
    public string Name { get; set; }
    public Maybe<Person> ReportsTo { get; set; }
}

Możemy teraz skorzystać z możliwości klasy Maybe i przepisać metodę GetSupervisorName w następujący sposób:

public static Maybe<string> GetSupervisorName(PersonRepository repo, Guid id)
{
    return from employee in repo.GetPersonById(id)
           from supervisor in employee.ReportsTo
           select supervisor.Name;
}

Czy ten kod jest lepszy od poprzedniej wersji? Moim zdaniem zdecydowanie tak. Nie ma tutaj „szumu” wprowadzonego przez instrukcje warunkowe. Intencja programisty jest wyrażona dużo lepiej.

Co więcej, doprowadziliśmy do sytuacji, w której typy lepiej odzwierciedlają rzeczywistość. W poprzedniej wersji metoda GetPersonById mogła zwrócić null, ale nie było żadnego mechanizmu, który zmuszałby nas do obsłużenia takiego przypadku. Przy zastosowaniu Maybe nie możemy tego uniknąć!

Brakujące ogniwo

Przeklejając kod z tego posta do Visual Studio z pewnością zauważysz, że w tej chwili program się nie kompiluje. Dzieje się tak dlatego, że brakuje nam jeszcze jednego elementu.

Spójrzmy na poniższe wyrażenie:

var maybeSupervisor = maybeEmployee.Select(e => e.ReportsTo);

Jeśli spojrzysz na maybeSupervisor, to okaże się, że jest to Maybe<Maybe<Person>>. Wartość takiego typu nie jest zbyt użyteczna. Jako że chcemy uniknąć takich zagnieżdżeń, musimy zaimplementować jeszcze jedną funkcję pozwalającą na spłaszczanie zagnieżdżonych Maybe.

public Maybe<TResult> SelectMany<TIntermediate, TResult>(
    Func<TValue, Maybe<TIntermediate>> mapper,
    Func<TValue, TIntermediate, TResult> getResult
)
{
    if (this.hasValue)
    {
        var intermediate = mapper(this.value);
        if (intermediate.hasValue)
        {
            return MaybeFactory.Some(getResult(this.value, intermediate.value));
        }
    }
    return MaybeFactory.None<TResult>();
}

Pierwszym parametrem SelectMany jest funkcja, która przyjmuje wartość zagnieżdżoną w Maybe i zwraca nowe Maybe z wartością innego typu.

Drugi parametr to funkcja przekształcająca wartość pierwotną oraz wartość z nowego Maybe w ostateczny wynik.

Sama implementacja jest podobna do implementacji Select. Tak długo, jak Maybe jest niepuste, przekształcamy przechowywaną wartość. W przeciwnym razie zwracamy puste Maybe.

Po zaimplementowaniu tej metody wyrażenie z poprzedniego akapitu powinno się kompilować.

To co to są te monady?

W prostych słowach monada to dowolny typ implementujący SelectMany oraz Select (oczywiście formalna definicja jest bardziej złożona). W programowaniu funkcyjnym zamiast SelectMany często używana jest nazwa bind lub flatMap.

Dlaczego ta operacja jest taka ważna? SelectMany pozwala zaimplementować dodatkowe operacje (w naszym przypadku było to sprawdzanie, czy mamy do czynienia z pustą wartością) w sposób transparentny dla użytkownika.

Maybe to tylko jeden z wielu przykładów zastosowania monad. Zupełnie innym może być Task (tak, zwykły Task z biblioteki standardowej!). Co prawda nie posiada on SelectMany, ale taką funkcję pełni ContinueWith. „Dodatkowe operacje”, które są ukryte w tym przypadku, to obsługa asynchroniczności. Kolejnym przykładem jest IEnumerable, które również jest jak najbardziej monadą.

Podsumowanie

Widać zatem, że monady to bardzo szeroka abstrakcja. Posiada ona wiele zastosowań i między innymi dlatego jest kluczowa dla programowania funkcyjnego. W językach czysto funkcyjnych – takich jak Haskel – monady umożliwiają ukrycie efektów ubocznych np. komunikacji ze światem zewnętrznym poprzez I/O.

Jeśli temat Cię zainteresował i chciałbyś zobaczyć więcej przykładów monad, to polecam bibliotekę language-ext. Monada Maybe występuje w niej pod nazwą Option.

To już wszystko na ten temat. Koniecznie daj znać, co myślisz o monadach. Czy wszystko jest dla Ciebie jasne? Kontynuujmy dyskusję w komentarzach!

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Share.

About Author

Jestem programistą full-stack, na co dzień piszącym w .NET oraz JavaScripcie. Pasjonuję się programowaniem funkcyjnym, a zwłaszcza jego zastosowaniom w tworzeniu nowoczesnych aplikacji webowych. Lubię dzielić się wiedzą na meet-upach, konferencjach oraz szkoleniach. Prowadzę bloga programistycznego.

14 Comments

  1. Ciekawy patent, przećwiczę sobie to podejście :-) Wzorzec specyfikacja do filtrowania a monady do transformacji. Dzisiaj sprawdzę czy idzie połączyć oba podejścia i stworzyć ogólny wzorzec do filtrowania i transformacji bez ograniczania się do konkretnej encji oraz, aby oddzielić zapytanie od warunku, po to aby nie tworzyć GetByName, GetBySurname czy GetSupervisorName tylko czyste Get(ISpecification entity)

  2. Piotr Baranowski on

    Czy możecie polecić jakąś “lekturę obowiązkową” na temat funkcyjnych wzorców projektowych (taki odpowiednik “bandy czterech” ze świata obiektowego)?

      • Piotr Baranowski on

        Dzięki! Wprawdzie nie pochodzę ze świata .NET ale “koncepcyjnie” na pewno skorzystam :D

      • Piotr Baranowski on

        Dzięki!
        Slajd rządzi, tylko… nie daje konkretów :D Ale cały wykład chętnie obejrzę.

        Postanowiłem “zaatakować” język Elixir, bo podoba mi się w nim wiele konceptów (między innymi model aktorów, czy możliwość modyfikacji “żywego” systemu) ale w programowaniu funkcyjnym czuję się jak przysłowiowe dziecko we mgle (owszem, miałem Lispa 20 lat temu na studiach, ale mięsień nieużywany uległ zanikowi, zresztą w czasie studiów lispa traktowałem jako nieużyteczną ciekawostkę i za bardzo się do tego tematu nie przykładałem :D).

Leave A Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.