Witam Cię w ósmej części kursu. Będzie ona opowiadać o zagadnieniach związanych z programowaniem funkcyjnym. Dowiesz się co to są wyrażenia lambda w Javie i dlaczego warto ich używać. Kolejnym zagadnieniem w tym odcinku kursu będą streamy w Javie, 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:
Function<Integer, Integer> fun = x -> x * x;
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ą):
Function<Integer, Integer> func = new Function<Integer, Integer>() { public Integer apply(Integer x) { return x * x; } }
A jak użyć wyrażenia lambda w Javie ?
public class Main { public static void main(String[] args) { Function<Integer, Integer> fun = x -> x * x; System.out.println(fun.apply(2)); } }
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:
public class Main { public static void main(String[] args) { printResult("Liczba do potęgi 2", 2, x -> x * x); // 1 printResult("Liczba do potęgi 3", 2, x -> x * x * x); printResult("Liczba podzielona przez 2", 2, x -> x / 2); printResult("Liczba pomnożona przez 3", 2, x -> x * 3); } private static void printResult(String operation, Integer x, Function<Integer, Integer> fun) { // 2 System.out.println("Ta metoda drukuje wynik wyrażenia lambda: "); System.out.println(operation +": "+ fun.apply(x)); // 3 System.out.println("------------------------------------------"); } }
- Przekazujemy wyrażenie lambda jako parametr.
- W deklaracji metody, jako trzeci parametr widnieje funkcja
Function<Integer, Integer>
. - 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:
Ta metoda drukuje wynik wyrażenia lambda: Liczba do potęgi 2: 4 ------------------------------------------ Ta metoda drukuje wynik wyrażenia lambda: Liczba do potęgi 3: 8 ------------------------------------------ Ta metoda drukuje wynik wyrażenia lambda: Liczba podzielona przez 2: 1 ------------------------------------------ Ta metoda drukuje wynik wyrażenia lambda: Liczba pomnożona przez 3: 6 ------------------------------------------
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:
Consumer<String> cons = x -> System.out.println(x); // i później użyć w kodzie cons.accept(1);
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).
Supplier<MyObjectMapper> supplier = () -> new MyObjectMapper(); // i później użyć w kodzie MyObjectMapper mapper = supplier.get();
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.
Predicate<String> filterNonEmpty = s -> s != null && !s.isEmpty(); //i później w kodzie System.out.println("Czy string jest nie pusty? " + filterNonEmpty.test("To jest string"));
W wyniku otrzymamy:
Czy string jest nie pusty? true
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.
// lambda Consumer<String> consumer = s -> System.out.println(s); // method reference Consumer<String> consumer = System.out::println;
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)
:
// lambda Function<Integer, String> fun = i -> String.valueOf(i); // method reference Function<Integer, String> fun = String::valueOf;
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)
:
class Car { private String name; private String color; public Car(String name, String color) { this.name = name; this.color = color; } public String getName() { return name; } public String getColor() { return color; } public String changeColor(String color) { this.color = color; return "New color is " + color; } }
Tworzymy obiekt dla klasy Car
:
Car car = new Car("Ford", "Red");
i używamy go w lambdzie:
// lambda Function<String, String> func = color -> car.changeColor(color); // method reference Function<String, String> func = car::changeColor;
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:
// lambda Consumer<Car> consumer = car -> car.getName(); // method reference Consumer<Car> consumer = Car::getName;
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:
// lambda BiFunction<String, String, Integer> findIndex = (s1, s2) -> s1.indexOf(s2); // method reference BiFunction<String, String, Integer> findIndex = String::indexOf;
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:
// lambda Function<String, File> fun = path -> new File(path); // method reference Function<String, File> fun = File::new;
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:
List<String> list = Arrays.asList("a", "aa", "b", "c", "cc", "dd", "e");
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:
List<String> collected = list.stream() .filter(s -> s.length() == 1) .collect(Collectors.toList()); System.out.println(collected);
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:
[a, b, c, e]
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
:
List<Car> cars = Arrays.asList(new Car("Ford", "Black"), new Car("Toyota", "Red"), new Car("Nissan", "Green")); List<String> collect = cars.stream() .map(car -> car.getName()) .collect(Collectors.toList()); System.out.println(collect); // Nasz program wydrukuje nam [Ford, Toyota, Nissan]
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:
class Person { private String name; private List<Car> cars; public Person(String name, List<Car> cars) { this.name = name; this.cars = cars; } public String getName() { return name; } public List<Car> getCars() { return cars; } }
Lista osób wraz z samochodami, po których będziemy filtrować:
List<Person> personList = Arrays.asList( new Person("Jhon", Arrays.asList( new Car("Ford", "Black"), new Car("Toyota", "Red")) ), new Person("Paul", Arrays.asList( new Car("Kia", "Red"), new Car("Seat", "Yellow")) ), new Person("Sam", Arrays.asList( new Car("Ford", "Black")) ) );
Odpowiednio zmapowana lista samochodów wraz z zapewnioną unikalnością listy wyników przy pomocy metody distinct
:
List<String> collect = personList.stream() .flatMap(person -> person.getCars().stream()) .map(car -> car.getName()) .distinct() .collect(Collectors.toList()); System.out.println(collect); // Program wydrukuje nam [Ford, Toyota, Kia, Seat]
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
:
List<String> list = Arrays.asList("a", "aa", "b", "c", "cc", "dd", "e"); List<String> collect = list.stream() .sorted(Comparator.reverseOrder()) .limit(1) .collect(Collectors.toList()); System.out.println(collect); // Program wydrukuje nam [e]
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
:
List<Integer> list = Arrays.asList(0, 21, 2, 1, 4, 4, 5); Optional<Integer> collect = list.stream() .skip(1) .min(Comparator.naturalOrder()); System.out.println(collect.get()); // Program wydrukuje nam // 1
Użycie findFirst
Gdy potrzebujemy znaleźć pierwsze wystąpienie jakiegoś elementu ze strumienia posłużymy się metodą findFirst
:
List<Integer> list = Arrays.asList(0, 9, 2, 1, 20, 4, 5); Optional<Integer> collect = list.stream() .filter(integer -> integer > 10) .findFirst(); System.out.println(collect.get()); // Program wydrukuje nam 20
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
:
List<Integer> list = Arrays.asList(0, 9, 2, 1, 20, 4, 5); boolean collect = list.stream() .anyMatch(integer -> integer >= 0 ); System.out.println(collect); // Program wydrukuje nam true
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:
List<String> list = Arrays.asList("a", "b", "c", "d", "e"); String result = list .stream() .reduce("", (part, element) -> part + element.toUpperCase()); System.out.println(result); // Program wydrukuje nam ABCDE
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.
Kurs Java dla początkujących
Spis Treści:
- Wprowadzenie
- Klasy i Obiekty
- Tablice
- Kolekcje
- Instrukcje warunkowe i pętle
- Operacje wejścia i wyjścia
- Dziedziczenie, Polimorfizm, Interfejsy
- Stream’y i lambdy
Ź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
Do źródeł wiedzy w ramach tego tematu dołączyłbym książkę: Modern Java Recipes
p.s.
Proponowany następny temat: Clean Code 😉
Dzięki za komentarz. Clean Code to dobry temat i bardzo mi bliski, więc na pewno coś w tym temacie powstanie.
Zrobisz kurs do Javy 14 od podstaw?
Pomiędzy Javą 8 a 14 nie ma aż takich dużych różnic, więc nie wiem czy jest sens robić taki kurs dla Javy 14. Ale mam w planach kilka artykułów, w których opiszę nowe funkcje Javy 14.
Polecam także artykuł Zmiany w Javie od wersji 8 do Java 11
A co byś proponował ruszyć dalej po opanowaniu podstaw? Od razu iść w Springa, czy najpierw najpopularniejsze biblioteki, testy, wzorce, Hyperthreading?
Jak znasz już trochę podstawy, to zacznij po prostu pisać jakieś aplikacje. Na początek mniejsze (mogą być nawet w command line), a później większe. Możesz też, spróbować swoich sił ze springiem i zrobić jakąś prostą aplikację, typu TODO list (nie wiem, na jakim poziomie teraz jesteś, więc trudno coś doradzić). Programowanie to pisanie aplikacji, więc idź w tym kierunku.
To, że będziesz uczył się przez pół roku Javy, wielowątkowości, wzorców itd. to nie wiele Ci da, bo ciężko będzie Ci to zastosować w praktyce. Ucz się pisać aplikacje, a po drodze ucz się dodatkowych rzeczy takich jak wzorce, algorytmy, struktury danych itd.
Taka mała uwaga. Napisałeś, że główną zaletą lambdy jest to, że może być przypisana jako parametr podczas wywoływania funkcji. Pewnie chodziło Ci o to, że za pomocą lambdy można to zrobić tak, że kod jest krótszy i bardziej czytelny. Bo zanim powstały lambdy (i w dalszym ciągu) jako parametr możemy przekazać obiekt klasy anonimowej implementującej interfejs funkcyjny, z tym że oczywiście jest to mniej czytelne 🙂
Tak, dokładnie o to mi chodziło 😉 Z lambdami jest czytelniej.
Pytanie do Streamów.
Nie raz słyszałem żeby ich nie używać bo co prawda są one wygodne, ale są drogie i koszt ich wywołania jest wiekszy niż zrobienie tego samego bez nich.
Jak to więc wygląda używanie streamów pod względem wydajnościowym, ma to faktycznie jakiś wpływ, a jak tak to jaki? Kiedy używać, a kiedy lepiej sie wstrzymać?
Możesz używać streamów bez ograniczeń w zasadzie wszędzie. W większości przypadków różnica w wydajności nie ma znaczenia. Jeśli mówimy o różnicy wydajności pomiędzy streamem i zwykłą pętlą, jest to różnica rzędu mikro. Więc używanie pętli, bo są szybsze to w większości wypadków mikrooptymalizacja(czego należy unikać w większości przypadków).
To, co dają streamy to większa czytelność kodu, większe skupienie się na tym co ma być zrobione, a nie jak ma to być zrobione. W większości przypadków (99.99% 😉) powinieneś preferować czytelność kodu ponad wydajność(tą w skali mikro oczywiście).
Dla lepszego zobrazowania podam taki przykład: nie ma znaczenia, czy użyjesz pętlę, czy stream, jeśli zapytanie do bazy pobierające dane, które iterujesz wykonuje się w 200 ms.
Oczywiście jest niewielki promil przypadków gdzie to będzie miało znaczenia, wszystko trzeba rozpatrywać w kontekście danego przypadku.
Cześć,
dzięki, bardzo fajny artykuł, super się czyta, jest bardzo przejrzysty i konkretny 🙂
Pytanie:
Czy w przykładzie REFERENCJI DO METODY STATYCZNEJ nie jest przypadkiem błąd?
jest:
// method reference
Function fun = String::valueOf;
a nie powinno przypadkiem być:
// method reference
Function fun = String::valueOf;
???
Coś chyba wycięło kod. Chodzi Ci o to, że powinno być
Function<Integer, String> fun = String::valueOf; – tak już poprawiłem, dzięki 😉
Tak, już właśnie jak dałem publikuj komentarz, to zauważyłem, że akurat nie wiem dlaczego ucięło najważniejszą cześć 😉 Dzięki za odpowiedź!