Backend

W kierunku lepszego programowania funkcyjnego w Javie. Poznaj bibliotekę Vavr

Paradygmat funkcyjny w programowaniu liczy już przeszło 62 lata, pierwszy raz został przedstawiony w języku Lisp w roku 1958. W kontekście języka Java zaczęło się o nim mówić szerzej stosunkowo niedawno, bo dopiero od wersji 1.8, czyli od sześciu lat. Czym zatem jest programowanie funkcyjne? Czy w Javie możemy programować funkcyjnie? Czy możemy pogodzić ten paradygmat z OOP? Jak w tym wszystkim może nam pomóc biblioteka Vavr? Odpowiedzi na te pytania znajdziecie w artykule.

… ale od początku

Zanim jednak przejdziemy do omówienia samej biblioteki, przypomnijmy pokrótce, czym charakteryzuje się programowanie funkcyjne i jakie ma założenia.

Najważniejszym założeniem programowania funkcyjnego jest brak tzw. efektów ubocznych, przy czym istnieje wiele podejść, rozwiązań czy cech języka, które przybliżają nas do tego celu i są to m.in.:

  • niezmienność obiektów,
  • leniwa inicjalizacja,
  • deklaratywny język programowania,
  • funkcje jako wartości,
  • funkcje wyższego rzędu.

Wyjaśnienie co oznacza każde z nich wykracza poza ramy tego artykułu, natomiast dla zainteresowanych zamieszczam w źródłach książki, w których można zgłębić definicje i znaczenia podanych tu zagadnień.

Mówiąc o filarze FP chodzi o sytuację, w której dane wywołanie funkcji nie powoduje efektu ubocznego w systemie i zachowuje tzw. Referential Transparency. Oznacza to nie mniej, nie więcej, że powinniśmy być w stanie zastąpić wywołanie naszej funkcji dla danych parametrów wejściowych jej zwracaną wartością. Innymi słowy, funkcja powinna być deterministyczna dla podanych dla niej argumentów i zwracać zawsze ten sam wynik nie powodując przy tym żadnej zmiany stanu systemu poza obrębem funkcji.

Nie oznacza to jednak tego, że stosując paradygmat funkcyjny musimy całkowicie zrezygnować z operacji modyfikujących, wszak niemożliwym byłoby napisać system w oparciu o wymagania biznesowe bez bazy danych, cache czy zapytań http. Chodzi przede wszystkim o to, aby akcje zmiany stanu były jawnie oznaczone, wyekstrahowane jak najdalej od naszej logiki i były wykonywane w jednym punkcie. Idealnie w klasie zawierającej metodę main lub wydzielonym osobno pakiecie, dla przykładu w pakiecie infrastruktury, która logicznie jest odseparowana od naszego kodu biznesowego.

Programowanie funkcyjne w Javie – czy to ma w ogóle jakiś sens?

Częstym zarzutem wobec Javy jest jej prosta składnia i brak tzw. cukru składniowego. Ilość kodu potrzebnego do napisania danej funkcjonalności, w porównaniu z językami które w swoich fundamentach stawiają paradygmat funkcyjny na pierwszym miejscu, jest zdecydowanie większa. Prawdą jest również to, że języki te mają większy próg wejścia od Javy, zdecydowanie łatwiej znaleźć programistę Javy niż Clojure czy Scali, co ma nierzadko wpływ na główny język, w którym będzie pisany dany projekt.

Ponadto Java stanowi ogromny procent obecnego rynku, wiele projektów zostało napisanych, czy też jest w trakcie pisania w Javie i często niemożliwym byłoby przepisanie go na nowy język z lepszymi konstrukcjami językowymi tak, aby cieszyć się z benefitów, które stoją za stosowaniem paradygmatu funkcyjnego. Mówiąc wprost – czasami nie mamy możliwości zmiany języka, a mimo to, chcielibyśmy spróbować pracy w innym podejściu. Dlatego począwszy od Javy w wersji 8 oraz dzięki konstrukcjom zaimplementowanym w bibliotece Vavr, chęć ta staje się możliwa do zrealizowania.

O Vavr

Vavr, poprzednio Java Slang, to biblioteka udostępniona na licencji Apache 2.0, która dostarcza nam narzędzi ułatwiających programowanie funkcyjne z wykorzystaniem Javy. Warto zaznaczyć, że Vavr nie zawiera zależności na zewnętrzne biblioteki, a opiera się wyłącznie na API oferowanym przez Javę.

Dzięki wyrażeniom lambda, interfejsom funkcyjnym, słowie kluczowym var wraz z użyciem komponentów z biblioteki Vavr programowanie funkcyjnie w Javie staje się możliwe do zrealiowania. Sama biblioteka składa się z logicznie rozdzielonych modułów: gwt, match, test, jackson, gson, render, które to zostały zaimplementowane wokół głównego modułu core i to właśnie wybranym elementom tego modułu się przyjrzymy.

Ujarzmić monady

Aby lepiej zrozumieć koncepcje zaimplementowane w bibliotece Vavr potrzebna nam będzie jeszcze jedna definicja, definicja monady. Tę złowrogo brzmiącą nazwę, mającą swoje korzenie w Teorii Kategorii. Na potrzeby tego artykułu możemy sprowadzić do prostego do zrozumienia opisu – monadą nazwiemy pewien kontener na dane, który umożliwia nam deklaratywne, wspólne dla innych monad, operacje na tych danych.

Istnieje duże prawdopodobieństwo, że jeżeli wcześniej nie zetknęliście się z tym terminem, to i tak nieświadomie korzystaliście z samych monad, np. wraz z użyciem monady wprowadzonej w JDK 1.8 – klasy Optional. W kontekście przedstawionego wcześniej opisowi, klasa Optional jest kontenerem na dane lub też ich brak oraz udostępnia zestaw API umożliwiającego deklaratywne operacje na tych danych, m.in. operacje:

  • map() – umożliwiająca wykonanie funkcji na danej,
  • flatmap() – umożliwiającej wykonanie funkcji na zagnieżdżonych danych, innymi słowy spłaszczeniu danych,
  • filter() – odfiltrowaniu nieinteresujących nas danych,
  • stream() – przejściu z monady Optional do monady Stream.

Warto podkreślić że, tak jak już wcześniej zostało to wspomniane, odpowiedniki tych operacji znajdziemy również w innych monadach, a zatem kiedy przyjdzie nam pracować z monadą dostarczaną z danej biblioteki, powinniśmy mieć możliwość wywołania funkcji na danych, spłaszczenia danych, przefiltrowania danych czy też przejścia do innej monady. Są to oczywiście tylko niektóre z operacji dostępnych na monadach, a dostępne API mocno zależy od dostawcy biblioteki lub własnej inwencji twórczej.

praca w it

Przechodząc do sedna, biblioteka Vavr dostarcza nam 5 podstawowych monad, a są to:

1. Either

Monada Either reprezentuje rezultat, w którym możemy zwrócić jedną z dwóch wartości – lewą lub prawą. Używana w sytuacji, kiedy wywołanie naszej metody może posiadać dwa przebiegi. Dla przykładu w sytuacji, kiedy wynik danej operacji może się nie powieść – przypisując mu wartość left, a w przypadku sukcesu wartość right.

2. Option

Monada używana w sytuacji, kiedy jawnie chcemy wyrazić intencję, w której wywołanie naszej metody może zwrócić wynik lub jego brak. Główną zaletą w przeciwieństwie do użycia adnotacji @Nullable lub Javadoc opisującego możliwy rezultat null jest to, że opisaliśmy sytuację przy pomocy obiektu, który jest częścią sygnatury metody, a przez to obsłużenie sytuacji, w której rezultat byłby null jest wymagane i sprawdzane przez kompilator. Najważniejszą różnicą pomiędzy monadą Option, a tą znaną z JDK Javy jest to, że funkcja mapująca zwracająca null rzuci wyjątek. Poza tym monada ta udostępnia bogatszy zestaw API, w tym np. metodę fold, która pozwolą nam na wykonanie operacji mapowania wartości w przypadku jej braku lub istnienia. Przykładowe użycia monady Option:

public interface BookModuleOperations {
 
    Option<BookDetails> findBookDetails(ISBN isbn);
}

W przedstawionym fragmencie moduł Book udostępnia operację znajdowania informacji o książce na podstawie ISBN za pomocą metody findBookDetails, która w swojej sygnaturze używa interfejsu Option. Użytkownik API jest zobligowany do obsługi sytuacji, w której dane o książce nie są dostępne poprzez typ zawarty w sygnaturze w przeciwnym razie otrzyma błąd kompilacji.

Przykładowa implementacja operacji w oparciu o API Option i Either wygląda następująco:

final class BookService implements BookModuleOperations {
 
    private final BookStoreClient bookStoreClient;
 
    BookService(final BookStoreClient bookStoreClient) {
        this.bookStoreClient = bookStoreClient;
    }
 
    @Override
    public Option<BookDetails> findBookDetails(final ISBN isbn) {
        return Option.of(isbn)
                .flatMap(bookNumber -> Option.of(bookNumber.value))
                .map(bookStoreClient::callForBookData)
                .fold(Option::none, this::handleResult);
    }
 
    private Option<BookDetails> handleResult(final Either<BookStoreRestException, BookData> callResult) {
        return callResult.toOption()
                .map(BookData::toDto);
    }
}

Gdzie:

Linie 11-12 obsługują potencjalne wartości null z obiektu ISBN przekazanego od użytkownika naszego API, aby dalej w linii 13 wywołać zapytanie http, które zwróci nam rezultat opakowany w monadę Either. Całość obsługujemy w linii 14, gdzie w przypadku braku wartości z poprzednich wywołań zwracamy pusty wynik, a w przypadku posiadania danych i wywołania zapytania obsługujemy wynik poprzez mapowanie monady Either do monady Option i wywołania metody toDto() z obiektu BookData.

3. Try

Monada Try przypomina monadę Option, z tym, że w kontraście do braku wartości przechowuje wyjątek, który mógł zostać rzucony podczas wywoływania danej funkcji. Użycie tej monady warto rozważyć zamiast używania checked-exception lub zamiast rzucania runtime exceptionów i łapania ich przy pomocy mechanizmów frameworka. Zamiast tego, możemy traktować nasze wyjątki jako standardowe Obiekty i obsługiwać błędy na bieżąco lub odwlekać ich obsługę do wybranego miejsca. Przykładowe użycie monady Try wraz z monadą Either:

final class BookStoreClient {
 
    private static final Logger logger = LoggerFactory.getLogger(BookStoreClient.class);
 
    private final ThirdPartyBookStoreLibrary thirdPartyBookStoreLibrary;
 
    private final Function<Throwable, BookStoreRestException> exceptionMapper;
 
    BookStoreClient(final ThirdPartyBookStoreLibrary thirdPartyBookStoreLibrary,
                    final Function<Throwable, BookStoreRestException> exceptionMapper) {
        this.thirdPartyBookStoreLibrary = thirdPartyBookStoreLibrary;
        this.exceptionMapper = exceptionMapper;
    }
 
    public Either<BookStoreRestException, BookData> callForBookData(final String isbn) {
        return Try.of(() -> thirdPartyBookStoreLibrary.getBookData(isbn))
                .onFailure(throwable -> logger.warn("Failed to get book data for isbn {}", isbn, throwable))
                .toEither()
                .mapLeft(exceptionMapper);
    }
}

Gdzie:

Linia 16 rozpoczyna użycie monady Try poprzez opakowanie wywołania metody getBookData z klasy ThirdPartyBookStoreLibrary z sygnaturą checked-exception. Dzięki bibliotece Vavr możemy przejść i zrezygnować ze stylu imperatywnego – definicji bloku try catch, do stylu deklaratywnego obsługując wyjątek.

W linii 17, w przypadku wyjątku rzuconego przez metodę getBookData logujemy sytuację, dalej w linii 18 przechodzimy do monady Either, aby w w linii 19 wywołać funkcję mapLeft, w której wywołujemy translację wyjątku z biblioteki na zrozumiały wewnątrz naszego systemu.

Ponadto w linii 28 użyta została właściwość interfejsów funkcyjnych, czyli użycie kwalifikatora, dzięki czemu możemy pominąć nazwę wywoływanej metody.

4. Lazy

Monada Lazy reprezentuje wartość uzyskiwaną nie na etapie inicjalizacji, ale w momencie odwoływania się do samej wartości, w tym kontekście mówi się o tzw. leniwej inicjalizacji. Jej działanie można porównać do znanego z Javy 8 interfejsu Supplier<T>, który zwraca daną wartość, z tą różnicą, że monada ta posiada funkcjonalność memoizacji i spełnia wspomniane już wcześniej Referential transparency i dla tego samego zbioru argumentów monada oblicza wynik tylko raz i go przechowuje. Przykładowe użycie monady Lazy:

final class Library {
 
    private final Lazy<AvailableBooks> availableBooks;
 
    Library(final Lazy<AvailableBooks> availableBooks) {
        this.availableBooks = availableBooks;
    }
 
    public AvailableBooks getAvailableBooks() {
        return availableBooks.get();
    }
}

Gdzie:

W linii 5 przekazaliśmy zależność zwracającą listę dostępnych książek, sama operacja pobierania dostępnych książek została zdefiniowana poza klasą Library i jest kosztowna ponieważ wymaga wywołania wielu zapytań HTTP. Dzięki użyciu monady Lazy odwlekamy wywołanie kosztownych zapytań do pierwszego momentu wywołania metody getAvailableBooks, ponadto wynik ten zostaje zapisany dzięki wspomnianej wcześniej funkcjonalności mnemoizacji.

5. Future

Ostatnia już z monad dostarczanych w bibliotece Vavr – monada Future reprezentuje wartość, której rezultat zostanie udostępniony, w którymś miejscu w czasie, operacje na tej monadzie są nieblokujące. W swoim działaniu przypomina klasy Future/CompletableFuture znane z JDK. Przykładowe użycie monady Future:

final class AvailableBooks {
 
    private final BookService bookService;
 
    private final Executor executor;
 
    AvailableBooks(final BookService bookService, final Executor executor) {
        this.bookService = bookService;
        this.executor = executor;
    }
 
    Future<AvailableBooksResult> listAvailableBooks(final Set<ISBN> bookNumbers) {
        return bookNumbers.toStream()
                .map(this::findBookDetails)
                .collect(collectingAndThen(toList(), Future::sequence))
                .map(this::extractAvailableBooks)
                .map(AvailableBooksResult::new);
    }
 
    private Seq<BookDetails> extractAvailableBooks(final Seq<Option<BookDetails>> foundBooks) {
        return foundBooks.filter(Option::isDefined)
                .map(Option::get);
    }
 
    private Future<Option<BookDetails>> findBookDetails(final ISBN isbn) {
        return Future.of(executor, () -> bookService.findBookDetails(isbn));
    }
}

Gdzie:

Linia 13 przekształca przekazaną kolekcję Set do monady Stream. Linia 14 wywołuje metodę findBookDetails opakowującą wywołanie metody z klasy BookService w wywołaniu metody of wraz z użyciem przekazanej zależności Executor.

Linia 15 zbiera wynik, przekształcając go do listy, a następnie wynik przekazuje do metody sequence z klasy Future. Ostatnie Linie 16 oraz 17 odpowiadają za zebranie rezultatu i stworzenie na jego podstawie nowego obiektu AvailableBooksResult i zwrócenie go w monadzie Future. W ten sposób osiągnęliśmy funkcjonalność w której możemy, nie blokująco, pobrać informację o dostępnych książkach.

Krótko o kolekcjach i pattern matchingu

Jeżeli chodzi o wspomnianą wielokrotnie modyfikację stanu, można mieć nieodparte wrażenie, że w świecie Javy mamy pewien problem. Tym problemem jest brak kolekcji immutable, wszystkie one modyfikują stan wewnętrzny, a jeżeli chodzi zaś o metody unmodifiable z klasy Collections, to sprawiają one jedynie, że nasza kolekcja staje się Read Only, co w wielu przypadkach może okazać się niewystarczające.

W przypadku kiedy chcielibyśmy, aby nasza kolekcja była w pełni niemodyfikowalna biblioteka Vavr dostarcza nam rozwiązanie w postaci nowo zdefiniowanych “Functional Data Structures”, które obejmują interfejsy Seq, Set oraz Map, wszystkie implementujące wspólny interfejs – Iterable. Przegląd implementacji dostępnych w bibliotece Vavr wykracza dalece poza ten artykuł, wszak w chwili pisania tego artykułu dostępnych mamy 15 implementacji, natomiast gorąco zachęcam do zapoznania się z ich dokumentacją.

Kolejnym elementem dostarczanym w bibliotece Vavr, a często będącym elementem składowym wielu języków funkcyjnych jest Pattern Matching. W dużym uproszczeniu Pattern Matching pozwala w deklaratywny sposób zdefiniować sterowanie na podstawie danego warunku tzw. “Match predicate”. Przykładowe użycie Pattern Matchingu z biblioteki Vavr, wygląda następująco:

    Number addNumber(Number value) {
        return Match(value).of(
                Case($(instanceOf(BigDecimal.class)), bigDecimal -> bigDecimal.add(BigDecimal.ONE)),
                Case($(instanceOf(BigInteger.class)), bigInteger -> bigInteger.add(BigInteger.ONE)),
                Case($(instanceOf(Integer.class)), i -> i + 1),
                Case($(instanceOf(Float.class)), f -> f + 1.0f));
    }

Gdzie:

Linia 2 Rozpoczyna Pattern Matching złożony z predykatów z linii od 2 do 5 i w przypadku gdy:

  1. Instancja obiektu number jest typu BigDecimal, wywołuje i zwraca rezultat funkcji add z klasy BigDecimal,
  2. Instancja obiektu number jest typu BigInteger, wywołuje i zwraca rezultat funkcji add z klasy BigInteger,
  3. Instancja obiektu number jest typu Integer, sumuje wartość i zwraca rezultat,
  4. Instancja obiektu number jest typu Float, sumuje wartość i zwraca rezultat.

Oprócz predykatów udostępnianych przez bibliotekę Vavr, wraz z użyciem modułu vavr-match, możliwe jest zdefiniowanie własnych predykatów i użycie ich z przedstawionym mechanizmem.

Warto dodać, że w Javie powoli implementowany zostaje Type Pattern Matching. W wersji w JDK 14 zostały dodane Switch expressions, co umożliwia używanie instrukcji switch również jako wyrażenia, a w wersji JDK 14 dodany został sam Pattern Matching dla instrukcji instance of w wersji preview, który został dalej przeniesiony do wersji JDK 15 w wersji second preview.

Podsumowanie

Mimo 62 lat na karku programowanie funkcyjne ma się bardzo dobrze i stało się de facto codziennością. Języki coraz śmielej adaptują paradygmat funkcyjny, a programiści zauważają benefity stojące za jego stosowaniem. Świat Javy oraz OOP niekoniecznie wyklucza użycie FP w istniejących lub nowych projektach, składnia Javy z biegiem lat uległa usprawnieniom przez co wraz z zastosowaniem bibliotek wspomagających programowanie funkcyjne oraz koncepcji tam zaimplementowanych już teraz możemy pokusić się o wprowadzenie nowego paradygmatu do naszego projektu.

Pozwoli nam to lepiej abstrahować efekty uboczne, pomoże w stworzeniu kodu odpornego na bolączki związane z wielowątkowością, kodu będącego bardziej przewidywalnym, a przez co lepiej utrzymywanym z biegiem czasu. W artykule zawarłem podstawowe koncepcje dotyczące programowania funkcyjnego oraz krótko przedstawiłem zastosowanie wybranych elementów biblioteki Vavr, które są jedynie krótkim wstępem do całej gamy rozwiązań i podejść możliwych do zaimplementowania.

Gorąco zachęcam, tych którzy po raz pierwszy zetknęli się z opisanymi tu zagadnieniami, do dalszego zgłębienia przedstawionych tu materiałów oraz definicji, ponieważ wiedza ta w przyszłości z pewnością zaprocentuje, pomoże rozszerzyć swój wachlarz umiejętności i da odmienny punkt widzenia na problemy napotkane na co dzień w projektach.


Ważne linki oraz informacje:

  • Dla zainteresowanych zamieszczam pracę opisującą historię języków funkcyjnych,
  • Szczegółowe rozwinięcie przedstawionych terminów związanych z FP znajdziecie w książkach z serii “Functional Programming in” autorstwa Pierre-Yves Saumont. Dostępne są pozycje z Javy, ale również Scali,
  • W świecie JVM językami stawiającymi paradygmat funkcyjny na pierwszym miejscu są Scala czy Clojure,
  • Dla języka Java istnieją inne biblioteki ułatwiające programowanie funkcyjne takie jak Cyclops czy Functional Java natomiast artykuł skupia się wyłącznie na bibliotece Vavr,
  • Wyrażenia lambda zostały dodane w JDK 1.8, JEP 126,
  • Interfejsy Funkcyjne zostały dodane w JDK 1.8,
  • Słowo kluczowe var zostało dodane w JDK 11, JEP 286. Nie jest wymagane przy podejściu funkcyjnym natomiast pomaga przy czytaniu mocno zagnieżdżonych monad,
  • Dla zainteresowanych zamieszczam, moim zdaniem, ciekawą pozycję autorstwa Bartosza Milewskiego, który opisuje Teorię Kategorii w swojej książce “Category Theory for Programmers”. Książka w wersji pdf wydana na licencji GNU GPL v.3,
  • W kontekście praw monad, klasa Optional, nie spełnia wszystkich kryteriów potrzebnych do uznania jej za prawdziwą monadę, natomiast na potrzeby tego artykułu przyjąłem uproszczone porównanie,
  • Metoda dodana w JDK 9,
  • Na potrzeby artykułu przyjąłem, że klasa Stream, spełnia prawa monad, w praktyce klasa ta nie jest pełnoprawną monadą, więcej można przeczytać w tym to artykule,
  • Co prawda obecne IDE są w stanie wychwycić błędy wynikające z braku obsługi wartości null oraz istnieją pluginy, które potrafią analizować nasze adnotacje pozwalając wychwycić takie miejsca na etapie budowania, natomiast są to zewnętrzne narzędzia i należy się zatroszczyć o odpowiednią ich konfigurację,
  • Przykładem gdzie Pattern Matching jest elementem składowym języka jest Scala czy Haskell,
  • Switch expressions: JEP 361,
  • Pattern Matching for instance of: JEP 305 oraz JEP 375.

Zdjęcie główne artykułu pochodzi z unsplash.com.

Na co dzień pracuje w technologiach z obszaru JVM, rozwiązaniach opartych na chmurze i w kulturze DevOps. Zagorzały zwolennik czystego kodu i automatyzacji procesów wytwarzania oprogramowania gdzie tylko jest to możliwe. Prywatnie fan fantastyki naukowej, technologii przyszłości oraz biohackingu.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/biblioteka-vavr" order_type="social" width="100%" count_of_comments="8" ]