Dzisiaj na warsztat trafia piąta biblioteka z cyklu Biblioteki Java, które warto znać, czyli Vavr. Dlaczego warto jej używać, jakie ma fajne konstrukcje i co można dzięki niej uzyskać? Tego wszystkiego dowiesz się z niniejszego artykułu.
Co to jest Vavr?
Na początek warto rzucić okiem do dokumentacji biblioteki:
Vavr (formerly called Javaslang) is a functional library for Java 8+ that provides persistent data types and functional control structures.
Źródło: https://www.vavr.io/vavr-docs/
Widzimy od razu, że idea stojąca za Vavr to programowanie funkcyjne. Jest to oczywiście próba przeniesienia tego paradygmatu do świata Javy, co czasem bywa niełatwe.
W definicji mowa też o persistent data types. Czyli tłumacząc, o takich strukturach danych, które w przypadku próby modyfikacji zachowują starszą wersję. Efektywnie ma to oznaczać, że kolekcje te działają jak typy niezmienne (ang. immutable types).
Z kolei functional control structures to mechanizmy pozwalające na sterowanie programem w trochę bardziej świadomy sposób. Np. świadome stwierdzenie, że operacja pobierania danych może się udać, ale również może się nie udać. Struktury takie dają możliwość wyrażenia takiego podejścia w kodzie.
W każdym razie za Vavr stoi pewna obietnica. A tą obietnicą jest uporządkowanie naszego kodu tak, żeby był to kod lepszy. Pamiętajmy jednak przy tym, że programowanie funkcyjne nie jest gwarancją lepszego kodu, choć jako narzędzie jest w bardzo tym pomocne.
Czym Vavr się chwali?
Jest kilka punktów, które sprawiają, że Vavr jest godną uwagi biblioteką:
- Mamy tu kilka ciekawych struktury danych zgodnie z purely functional data structures. Mamy też kolekcje i warto też zwrócić uwagę na API, które ułatwia działanie z tradycyjnymi kolekcjami z JDK.
- Pattern matching.
- Monad Try.
- I na końcu trochę ładniejszy niż Optional z JDK typ Option.
Zacznijmy od prostej listy
Struktury funkcyjne w Vavr mają bardzo fajne API. Oto mały przykład:
import io.vavr.collection.List;
...
@Test
public void listTest() {
List<Integer> list = List.of(1, 2, 3, 4, 5, 6);
assertEquals(Integer.valueOf(1), list.head());
assertEquals(list.tail(), List.of(2, 3, 4, 5, 6));
assertEquals(list.prepend(0), List.of(0, 1, 2, 3, 4, 5, 6));
}
Jak widać mamy tutaj typowe dla programowania funkcyjnego metody head() oraz tail(). Co więcej, widzimy, że możemy również porównać listy (drugi i trzeci assertEquals()).
Tych kilka, małych metod wbrew pozorom rozwiązuje bardzo wiele problemów algorytmicznych. Wiele problemów jest właśnie definiowanych w sposób funkcyjny, gdzie naturalnym staje się posługiwanie głową i ogonem listy. Myślę, że Ci z Was, którzy w tej chwili jeszcze studiują lub uczą się algorytmów, mogą z tego skorzystać i nie pisać własnych rozwiązań. Szkoda czasu.
Kolejki i krotki
Podobnie, jeśli chcemy mieć zdefiniowaną prostą kolejkę:
@Test
public void queueTest() {
Queue<Integer> queue = Queue.of(1, 2)
.enqueue(3)
.enqueue(4);
assertEquals(queue, Queue.of(1, 2, 3, 4));
}
Bardzo łatwo ją zdefiniować. Dzięki metodzie enqueue() możemy dodawać do niej elementy. I podobnie, jak w przypadku List, możemy sobie takie kolejki porównywać. Będą takie same.
Dla osób bardziej dociekliwych zastanawiający może być fragment definicji Vavr dotyczący niezmienności struktur danych.
Wyjaśnijmy to sobie na przykładzie:
@Test
public void queueTest2() {
Queue<Integer> queue = Queue.of(1, 2);
Tuple2<Integer, Queue<Integer>> dequeue = queue.dequeue();
assertEquals(Integer.valueOf(1), dequeue._1());
assertEquals(Queue.of(2), dequeue._2());
assertEquals(Queue.of(1, 2), queue);
}
Na początku tworzymy sobie kolejkę z dwoma elementami. Następnie przy pomocy dequeue() pobieramy pierwszy z nich. I tutaj właśnie jest fragment, gdzie warto się skupić. Otóż dequeue() nie zwraca nam samego elementu. Zwraca natomiast typ Tuple2.
Co to jest Tuple2? Angielskie tuple to w tłumaczeniu na polski krotka. Czyli struktura zawierająca kilka elementów. W Vavr, jak widać, liczba tych elementów znajduje się w nazwie typu zwracanej krotki. W tym przypadku jest to dwuelementowa krotka.
Do poszczególnych elementów możemy dobrać się poprzez metody: _1() dla pierwszego elementu, _2() dla drugiego.
I tak metoda dequeue() zwraca krotkę, w której w pierwszy elementem jest wyciągnięty z kolejki obiekt, a drugim nowa kolejka, która powstała po wyciągnięciu tego obiektu. Tym samym, jak widzimy w ostatniej asercji, nasza pierwotna kolejka pozostała w niezmienionym stanie.
A może drzewo czerwono-czarne?
Doskonale pamiętam jeden z przedmiotów na studiach. Były to kolejny wykład z serii algorytmy i struktury danych. Bynajmniej nie pamiętam tego przedmiotu z powodu ciekawych wykładów, te były wówczas dla mnie bardzo poprowadzone w bardzo nudny sposób. Pamiętam natomiast egzamin końcowy, na którym na paru stronach A4 trzeba było krok po kroku przedstawić w formie graficznej operacje przeprowadzane na drzewach czerwono-czarnych.
A ponieważ Vavr w przypadku uporządkowanych zbiorów stosuje właśnie drzewo Red/Black Tree, to warto rzucić okiem na jego implementację w kodzie biblioteki.
A dla nas, na poziomie API do programowania, cała magia ukryta pod postacią prostych linii zbioru:
SortedSet<Integer> set = TreeSet.of(2, 3, 1, 2);
Option, optionnel, opzionale?
Z Optional w Javie było tak trochę dziwnie. Bo niby wszedł w JDK 8. Ale tak wszedł, ale API takie trochę lekko wybrakowane. Bo niby był ifPresent(), ale nie było ifNotPresent(). Bo niby później dodano ifPresentOrElse(), ale … niesmak pozostał 😀
A w Vavr typ Option był cały czas stabilny. Robi to samo, ale ciut lepiej. Przede wszystkim pozwala nam walczyć z uwielbianymi przez wszystkich developerów Javy wyjątkami NullPointerException. Czyli jest to takie pudełko na konkretną wartość lub null.
I taki przykład kodu:
@Test
public void optionalTest() {
Option<String> possibleNullText = Option.of(null);
assertTrue(possibleNullText.isEmpty());
assertEquals("alternative", possibleNullText.getOrElse("alternative"));
}
Możemy też korzystać z licznych dostępnych metod:
@Test
public void optionalTest2() {
String someText = Option.of("alternative text")
.map(String::toUpperCase)
.getOrElse("another solution");
assertEquals("ALTERNATIVE TEXT", someText);
}
A w tym przypadku konkretnie możemy zmapować naszą wartość w pudełku, jakim jest Option i zmienić wielkość liter. Pamiętamy przy tym, że map() się nie wykona, gdy nie będzie w tym pudełku konkretnej wartości.
Może wyjątek, a może nie, czyli Vavr i Try
Bardzo często nasz kod np. pyta zewnętrzne usługi. I jak je pyta, to i czasem może trafić się jakiś wyjątek. Bo albo dane się nie sparsowały poprawnie albo sieć padła, a czasem może żądanie nie będzie poprawnie uwierzytelnione. Nie ważne dlaczego, czasem trafi się nam wyjątek.
A skoro tak jest, to dlaczego nie mielibyśmy wyrażać tego bezpośrednio w kodzie. Oczekujemy przecież takiego efektu. Czasem zadziała, a czasem będzie problem.
Vavr przychodzi tu do nas z pomocą. Jest nim typ Try. Zobaczmy, jak on działa:
@Test
public void tryTest() {
Try<Integer> fetchedInteger = Try.of(() -> {
throw new RuntimeException("exception");
});
assertTrue(fetchedInteger.isFailure());
assertEquals(Integer.valueOf(99), fetchedInteger.getOrElse(99));
}
Powyżej widzimy, że tworzymy obiekt Try poprzez przekazanie lambdy, która przy ewaluacji rzuca zawsze wyjątek (na marginesie Try zawsze robi ewaluację, bo jest zachłanny). Sprawdzamy więc wynik. Okazuje się, że nasz Try opakował wyjątek i jest w stanie failure. Dalej widzimy, że mamy operatory pozwalające nam na wyjście z tej sytuacji, czyli getOrElse. Tych operatorów zdecydowanie więcej, tutaj tylko przedstawiłem te podstawowe.
Dzięki typowi Try możemy w jawny sposób określać efekty oczekiwane przez nas w kodzie programu. Jest to duża zaleta stosowania Vavr.
Pattern matching
Pattern matching to temat, który powoli, powoli, powoli … wkracza do JDK. Chyba najciekawiej zapowiada się JEP 360: Sealed Classes (Preview) debiutujący w JDK 15.
O JDK 15 powstał oddzielny artykuł. Java 15 – co nowego w JDK? Zachęcam do przejrzenia przykładu z sealed classes. W końcu w Javie można poczuć się jak w Scali z mechanizmem Case Classes. Kto korzystał, ten wie, jakie to fajne narzędzie.
Ale zanim nowości zagoszczą w JDK i się przyjmą, to mamy pewnie miliony projektów, które z pattern marchingu nie korzystają. A szkoda. Dzięki Vavr można to zmienić.
@Test
public void patternMatching() {
int pickANumber = 7;
String text = Match(pickANumber).of(
Case($(is(7)), "You win!"),
Case($(), "Lost")
);
assertEquals("You win!", text);
}
Dobra, ok 😀 No nie jest to szał wizualny. Ale przynajmniej działa. I dla mnie i tak dużo lepiej to wygląda niż switch lub wysublimowany blok if’ów na 100 linii.
O małych pomocach dla kolekcji
Na koniec zostawiam drobną pomoc dla kolekcji i API Stream w Javie. Otóż mając Vavr w projekcie odchodzi nam boilerplate kod związany z tworzeniem strumienia i potem zbieraniem jego wyników.
Normalnie w Javie byśmy musieli zrobić coś takiego:
@Test
public void javaStreamTest() {
java.util.List<String> wordList = java.util.List.of("one", "two", "three");
java.util.List<String> strings = wordList.stream()
.map(String::toUpperCase)
.filter(word -> word.contains("O"))
.map(word -> word.concat("_"))
.collect(Collectors.toList());
assertEquals(2, strings.size());
assertTrue(strings.contains("ONE_"));
assertTrue(strings.contains("TWO_"));
}
Czyli na wordList wywołać stream(), a następnie collect() i podać collector, żeby zebrać wyniki. Mi się to nigdy nie podobało i wolę konstrukcję z Vavr:
@Test
public void collectionTest() {
java.util.List<String> wordList = java.util.List.of("one", "two", "three");
java.util.List<String> strings = Stream.ofAll(wordList)
.map(String::toUpperCase)
.filter(word -> word.contains("O"))
.map(word -> word.concat("_"))
.toJavaList();
assertEquals(2, strings.size());
assertTrue(strings.contains("ONE_"));
assertTrue(strings.contains("TWO_"));
}
Wydaje mi się, że Stream.ofAll() jest bardziej przejrzysty, jak również toJavaList() od razu pokazuje, co chcę uzyskać.
Podsumowanie
Mam nadzieję, że tych parę przykładów pokazuje Ci, jak fajną biblioteką jest Vavr.
Zdecydowanie daj znać, co sądzisz? Może już stosowałaś/stosowałeś ją w praktyce i chcesz się podzielić spostrzeżeniami?
W ramach cyklu o bibliotekach Javy możesz przeczytać i skomentować następujące artykuły:
W kwestii Option’a trzeba zwrócić uwagę na różnicę w funkcji map w stosunku do Optional. W optionalu nie ma problemy w przemapowanie na null’a i dalsze przetwarzanie będzie działać poprawnie. Natomiast w Option’ie przy dalszym przetwarzaniu poleci NPE, gdyż będzie to some(null). Oczywiście mapowanie na null’a jest złą praktyką i wtedy lepiej użyć flatmap, ale używając zewnętrznego API można się niemile zaskoczyć. Dodatkowo warto dodać, że lista w vavrze jest listą jedno kierunkową i użycie metody prepend w odróżnieniu od append jest kosztowne. IMHO pisząc o bibliotece wspomagającej programowanie funkcyjne warto dodać dział o interfejsach funkcyjnych FunctionN oraz w skrócie… Czytaj więcej »
Cześć, dzięki za komentarz. W artykule skupiłem się na elementach Vavr’a, które mogą pomóc programistom, którzy z niego wcześniej nie korzystali. Za jakiś czas pewnie pojawi się artykuł bardziej ogólnie traktujący o FP 🙂
Jakościowy blog Bartku 🙂
Odnośnie artykułu – Match/Case z Vavr to jakieś nieporozumienie, że o @Pattern i @Unapply nie wspomnę. Do tej pory nie spotkałem się z rozwiązaniem dla pattern matching w języku, który nie posiada wbudowanego matchowania (a wtedy nie potrzeba innych rozwiązań).
Poza Match Vavr jest rzeczywiście solidny, a przynajmniej był kiedy jeszcze pisałem w Javie.
Pozdrów ciepło żonę i dzieci 😀
Dzięki. Jest szansa na lepszy pattern matching. Jeśl JEPy się zgrają, to będzie zdecydowanie to szło w kierunku case classes.