Kurs java dla początkujących - #8 Stream'y i lambdy

Kurs Java dla początkujących – #8 Streamy i lambdy

Witam Cię w ósmej części kursu. Będzie ona opowiadać o zagadnieniach związanych z programowaniem funkcyjnym w Javie. Dowiesz się co to są wyrażenia lambda i dlaczego warto ich używać. Kolejnym zagadnieniem w tym odcinku kursu będą stream’y, które bezpośrednio łączą się z lambdami. Na koniec przedstawię kilka praktycznych przykładów, jak lambdy mogą pomóc Ci w codziennym programowaniu.

Co to jest wyrażenie lambda ?

Wyrażenia lambda to inaczej anonimowa funkcja. W Javie, lambda to implementacja interfejsu funkcyjnego, który może posiadać tylko jedną metodę abstrakcyjną. Dla jasności dodam, że takie interfejsy są (powinny być) oznaczane adnotacją @FunctionalInterface. Lambdy w Javie można też traktować jako zastępstwo dla klas anonimowych (jeśli mają tylko jedną metodę w interfejsie, który implementują).

Wyrażenia lambda zostały wprowadzone w Javie 8. I jest to o tyle istotne, że jeszcze wiele firm używa niższych wersji Javy.

Jednym z podstawowych interfejsów funkcyjnych w Javie jest java.util.function.Function<T, R>. Abstrakcyjna metoda, którą definiuje ten interfejs to R apply(T t). R i T to typy generyczne, o których pisałem w  4 części kursu. R to typ zwracany. T to typ jedynego parametru, który przyjmuje metoda apply.

A lambda dla takiego interfejsu wygląda np. tak:

Można także przedstawić to wyrażenie jako klasę anonimową, w taki sposób (poniższy przykład został przedstawiony dla lepszego zrozumienia jak działają lambdy – nie należy stosować tych konstrukcji wymiennie – lambda jest zawsze preferowaną konstrukcją):

A jak użyć wyrażenia lambda ?

Oczywiście to bardzo prosty przykład, nie odzwierciedlający prawdziwej mocy, która drzemie w tym wyrażeniu. Główną zaletą lambdy jest to, że można ją przekazać jako parametr do jakiejś metody. Dzięki czemu, możemy w zależności od potrzeb „wstrzyknąć” różne fragmenty wyrażeń, tak jak zaprezentowałem to na poniższym przykładzie:

  1. Przekazujemy wyrażenie lambda jako parametr.
  2. W deklaracji metody, jako trzeci parametr widnieje funkcja Function<Integer, Integer>.
  3. Używamy metody apply, gdzie wyrażenie lambda jest wykonywane.

Dzięki zastosowaniu funkcji, napisaliśmy jedną małą metodę printResult(...), która może wydrukować dowolną nazwę i wynik operacji. Uniknęliśmy duplikacji kodu i jest on przez to o wiele bardziej zwięzły.

A powyższy program wyświetli:

Consumer, Supplier, Predicate

Poza zwykłymi funkcjami (Function<T, R>), mamy w Javie dostępne jeszcze bardziej wyspecjalizowane interfejsy funkcyjne takie jak: Consumer, Supplier, Predicate. Każda z nich pełni inną rolę.

Omówię teraz pokrótce każdy z wymienionych interfejsów.

Consumer

Interfejs Consumer (konsument) zawiera metodę void accept(T t), która przyjmuje jeden parametr i niczego nie zwraca, konsumuje ona parametr, który przyjmuje. Oznacza to, że możemy go np. wydrukować, zapisać do pliku lub wykonać jakieś inne operacje nazywane także efektem ubocznym (side effect). W kodzie tę funkcję możemy przedstawić w taki oto sposób:

Supplier

Interfejs Supplier (dostawca) zawiera metodę T get(), która zwraca jakiś typ i nie przyjmuje żadnych parametrów. Możemy go użyć do dostarczania różnych instancji obiektów. W takiej sytuacji, dostarczany obiekt jest tworzony dopiero wtedy, gdy użyjemy metoda get() naszego dostawcy (jest to tak zwany mechanizm Lazy Loading).

Predicate

Interfejs Predicate zawiera metodę boolean test(T t), która przyjmuje jako parametr obiekt i zwraca boolean. Predicate jest wykorzystywany jako dostarczyciel wszelkiego rodzaju warunków. Można go wykorzystywać np. przy filtrowaniu w Stream Api.

W wyniku otrzymamy:

 

Referencje metod

W Javie poza standardowym użyciem lambd, możemy korzystać także z Metod Reference. Jest to uproszczony zapis niektórych lambd. Jeśli wyrażenie lambda nie robi nic poza wywołaniem jakiejś metody, i ta metoda jest wywoływana z taką samą listą parametrów, jak wyrażenie lambda, to możemy wtedy użyć referencji do metody. Co sprowadza się do uproszczonego zapisu wywołania metody (z operatorem ::) bez parametrów.

Są dostępne 4 rodzaje referencji do metod. Referencja do metody:

  • statycznej: ContainingClass::staticMethodName
  • konkretnego obiektu: containingObject::instanceMethodName
  • obiektu konkretnego typu: ContainingType::methodName
  • konstruktora: ClassName::new

Referencja do metody statycznej

Jeśli wywołujemy w lambdzie metodę statyczną z takimi samymi parametrami jak lambda, możemy skorzystać wtedy z referencji do metody statycznej. Powyżej przestawiłem już jeden przykład z System.out::println, a poniżej prezentuję kolejny z wywołaniem metody String.valueOf(Object obj):

Referencja do metody konkretnego obiektu

Możemy także używać referencji do metod konkretnych obiektów. Podobnie jak w poprzednim przypadku wystarczy, że metoda danego obiektu ma taką samą listę parametrów jak lambda.

Mamy przykładową klasę Car, która posiada metodę changeColor(String color):

Tworzymy obiekt dla klasy Car:

i używamy go w lambdzie:

Referencja do metody obiektu konkretnego typu

Jeśli wywołujemy jakąś metodę na parametrze lambdy, mamy możliwość utworzenia referencji do metod obiektów konkretnego typu. Wygląda to bardzo podobnie jak w przypadku referencji do metody statycznej:

W przypadku, gdy lambda ma dwa parametry (i więcej), możemy użyć konstrukcji, w której wywołujemy jakąś metodę na pierwszym parametrze lambdy. Kolejne parametry służą jako parametry do wywoływanej metody:

Referencja do konstruktora

Jeśli w lambdzie wywołujemy konstruktor jakiejś klasy z takimi samymi parametrami jak parametry lambdy, to też możemy użyć zapisu uproszczonego:

Co to są stream’y ?

Powyżej przedstawiłem najpopularniejsze interfejsy funkcyjne w Javie 8+ oraz referencje do metod. Są one bardzo często używane w api wbudowanym w Javę. A najczęściej używamy ich w strumieniach (Stream).

Strumienie to sekwencje elementów, które mogą być przetwarzane sekwencyjnie, ale także równolegle (parallel), przez różnego rodzaju operacje agregujące. W Javie wiele elementów takich jak kolekcje, czy tablice, mogą być przetwarzane jako strumienie (lub mówiąc bardziej precyzyjnie mogą być konwertowane na strumienie).

Najprostszym przykładem przetwarzania strumieni jest filtrowanie elementów z listy:

załóżmy że chcemy odfiltrować z naszej listy tylko elementy, które mają tylko jeden znak length() == 1. Możemy skorzystać z metody filter (w klasie Stream), która jako parametr przyjmuje predykat:

Po przefiltrowaniu strumienia używamy metody collect(...) i odpowiedniego kolektora (w tym wypadkuCollectors.toList()), żeby zebrać elementy w nową listę.

Po wydrukowaniu otrzymamy listę zawierającą elementy:

Na podobnej zasadzie możemy filtrować inne kolekcje (np. Set, Map).

W przetwarzaniu strumieni w Javie mamy dostępnych wiele operacji, które możemy łączyć ze sobą, często w różnej kolejności. Operacje te dzielą się na pośrednie i terminalne. I to te drugie uruchamiają całe przetwarzanie streamu. W powyższym przykładzie, operacją pośrednią jest metoda filter, a terminalną collect. Łatwo możemy rozróżnić te metody, sprawdzając jaki typ zwracają. Operacje pośrednie zwracają obiekt Stream, a operacje terminalne zwracają jakiś konkretny obiekt (może to także być typ prymitywny), kolekcję lub Optional.

Operacje dostępne dla strumieni

Poza filtrowaniem mamy całą gamę operacji pośrednich, które możemy łączyć ze sobą:

  • map – mapuje jeden obiekt na inny
  • flatMap – zwraca „spłaszczony” strumień elementów (np. strumień, który powstał z kolekcji)
  • distinct – zwraca strumień unikalnych elementów
  • sorted – zwraca strumień naturalnie posortowanych elementów
  • peek – zwraca element strumienia (można jej użyć jako pomoc przy debugowaniu)
  • limit – ogranicza elementy strumienia
  • skip – pomija określoną ilość elementów strumienia

Mamy także dostępnych kilka operacji terminalnych:

  • collect – grupuje elementy strumienia do odpowiedniego obiektu
  • forEach – wykonuje zadaną akcję dla każdego elementu strumienia
  • reduce – redukuje strumień do jakiejś wartości (np. obiektu)
  • min – wyciąga minimalną wartość ze strumienia
  • max – wyciąga maksymalną wartość ze strumienia
  • count – zlicza elementy strumienia
  • anyMatch – sprawdza, czy którykolwiek z elementów strumienia odpowiada zadanemu predykatowi
  • allMatch – sprawdza, czy wszystkie elementy strumienia odpowiadają zadanemu predykatowi
  • noneMatch -sprawdza, czy wszystkie elementy nie odpowiadają zadanemu predykatowi
  • findFirst – zwraca pierwszy element z przetworzonego strumienia
  • findAny – zwraca jakiś element strumienia (w przypadku strumieni przetwarzanych równolegle w wielu wątkach, poszukiwany element może być dowolnym elementem strumienia)

Przykłady użycia

Użycie map()

Załóżmy, że  z listy samochodów chcemy odfiltrować tylko listę marek tychże samochodów. Najlepiej nadaje się do tego metoda map, która zmapuje nam obiekt samochodu na String (zawierający nazwę). Metoda map przyjmuje jako parametr funkcję, która ma wejściu przyjmuje obiekt Car a zwraca obiekt String:

Użycie flatMap()

Załóżmy, że mamy klasę Person i każdy obiekt tej klasy może mieć przyporządkowaną listę samochodów. Jeśli chcemy pobrać unikalną listę samochodów, pomocna będzie metoda flatMap, która spłaszczy nam listę osób do listy samochodów:

Klasa Person:

Lista osób wraz z samochodami, po których będziemy filtrować:

Odpowiednio zmapowana lista samochodów wraz z zapewnioną unikalnością listy wyników przy pomocy metody distinct:

Użycie sorted i limit

Jeśli jest potrzeba przetwarzania strumienia posortowanego, mamy do dyspozycji metodę sorted. Przyjmuje ona jako parametr Comparator (tutaj skorzystałem z dostępnego comparator Comparator.reverseOrder()). Dodatkowo ograniczyłem wyniki operacją limit:

Użycie min i skip

Do pobrania minimalnej czy maksymalnej wartości ze strumienia, możemy się posłużyć operacjami terminalnymi (min i max). Obie te operacje przyjmują jako parametr comparator (w tym wypadku użyłem Comparator.naturalOrder()), który sortuje liczby rosnąco. Dodatkowo by pozbyć się pierwszego elementu tablicy użyłem operacji skip:

Użycie findFirst

Gdy potrzebujemy znaleźć pierwsze wystąpienie jakiegoś elementu ze strumienia posłużymy się metodą findFirst:

Użycie anyMatch

W sytuacji gdy potrzebujemy tylko przetestować elementy z naszego strumienia (czy jakikolwiek spełnia dany predykat), możemy posłużyć się metodą anyMatch, która jako parametr przyjmuje predykat. Na podobnej zasadzie działają metody allMatch i noneMatch:

Użycie reduce

Metoda reduce służy do redukowania elementów strumienia do jakiejś konkretnej wartości. W zależności od elementów strumienia, możemy je redukować do różnych wartości. Np. strumień liczb możemy zredukować do sumy tych liczb lub do iloczynu lub do jakiejś innego wyniku matematycznego działania.

Poniżej przykład redukcji strumienia zawierającego litery, do pojedynczego obiektu typu String, gdzie wszystkie litery zostały zamienione na ich duże odpowiedniki:

Parametry jakie przyjmuje metoda  T reduce(T identity, BinaryOperator<T> accumulator) to kolejno:

  • identity – jest to obiekt tego samego typu, co element wynikowy metody (jest to obiekt inicjalny, w tym wypadku pusty String),
  • accumulator – jest to funkcja, która pozwala połączyć ze sobą dwie wartości.

Podsumowanie

Elementy funkcyjne w języku Java dają bardzo wiele możliwości. W wielu miejscach upraszczają kod, zamieniając go z imperatywnego na kod deklaratywny (Stream api). Taki kod jest bardziej nakierowany na to, co trzeba wykonać, a nie na to w jaki sposób to trzeba zrobić. Jest to trochę inny styl pisania kodu.

Takie elementy języka jak lambdy i referencje do metod stają się bardzo przydatnym narzędziem w rękach każdego programisty. Bardziej zwięzły kod sprawia, że rozwijanie i utrzymanie programów staje się łatwiejsze.

Zachęcam Cię także do poćwiczenia ze Stream Api we własnym zakresie. Nic nie ugruntowuje tak dobrze wiedzy, jak regularne ćwiczenia z nowo poznanymi elementami programowania.

 

Źródła:

https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html
https://docs.oracle.com/javase/tutorial/java/javaOO/methodreferences.html
https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

 

Kurs Java dla początkujących

Spis Treści:

  1. Wprowadzenie
  2. Klasy i Obiekty
  3. Tablice
  4. Kolekcje
  5. Instrukcje warunkowe i pętle
  6. Operacje wejścia i wyjścia
  7. Dziedziczenie, Polimorfizm, Interfejsy
  8. Stream’y i lambdy

 

Zapisz się na newsletter

Mateusz Dąbrowski

Cześć jestem Mateusz, zajmuję się programowaniem już ponad 12 lat i zachęcam Cię do lektury mojego bloga

2 thoughts to “Kurs Java dla początkujących – #8 Streamy i lambdy”

  1. Do źródeł wiedzy w ramach tego tematu dołączyłbym książkę: Modern Java Recipes

    p.s.
    Proponowany następny temat: Clean Code 😉

    1. Dzięki za komentarz. Clean Code to dobry temat i bardzo mi bliski, więc na pewno coś w tym temacie powstanie.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *