Lambda - Java
W tym przykładzie wprowadzimy bardzo popularną operację pośrednią nazywaną odwzorowaniem (lub mapowaniem). Odpowiada ona za przekształcanie elementów strumienia na nowe wartości. Wynikiem jest nowy strumień z elementami zawierającymi wartości po przekształceniu. Czasem odwzorowane elementy są innego typu niż elementy oryginalnego strumienia.
Aby zilustrować odwzorowanie, przypomnijmy program z rysunku 5.5, którym liczyliśmy sumę liczb parzystych od 2 do 20, wykorzystując iterację zewnętrzną:
for (int number = 2; number <= 20; number += 2) {
total += number;
}
Rysunek 17.4 przedstawia wykonanie tego samego zadania, ale z użyciem strumienia i iteracji wewnętrznej. Potok strumienia w wierszach od 9. do 11. wykonuje trzy wywołania metod ułożone w łańcuch:
2 // Obliczanie sumy parzystych liczb całkowitych od 2 do 20 za pomocą IntStream
3 import java.util.stream.IntStream;
4
5 public class StreamMapReduce {
6 public static void main(String[] args) {
7 // Suma liczb parzystych od 2 do 20
8 System.out.printf("Suma liczb parzystych od 2 do 20 wynosi: %d%n",
9 IntStream.rangeClosed(1, 10) // 1…10
10 .map((int x) -> {return x * 2;}) // Mnożenie przez 2
11 .sum()); // Suma
12 }
13 }
Suma liczb parzystych od 2 do 20 wynosi: 110
Rysunek 17.4. Obliczanie sumy parzystych liczb całkowitych od 2 do 20 za pomocą IntStream
- Wiersz 9. tworzy źródło danych — obiekt IntStream z elementami 1, 2, 3, 4, 5, 6, 7, 8, 9 i 10.
- Wiersz 10., który wkrótce opiszemy bardziej szczegółowo, wykonuje krok przetwarzania, który odwzorowuje każdy element strumienia (x) na element pomnożony przez 2. Wynikiem jest strumień parzystych liczb całkowitych 2, 4, 6, 8, 10, 12, 14, 16, 18 i 20.
- Wiersz 11. redukuje strumień do pojedynczej wartości — sumy elementów. To operacja kończąca, która inicjuje przetwarzanie strumienia i zwraca sumę elementów.
Nowością w kodzie jest operacja odwzorowania z wiersza 10., która w tym konkretnym przypadku mnoży każdy element strumienia przez 2. Metoda map z IntStream otrzymuje jako swój argument (wiersz 10.):
co jak się przekonasz za chwilę, jest równoznaczne z zapisem „metoda, która otrzymuje parametr x typu int i zwraca wartość pomnożoną przez 2”. Dla każdego elementu w strumieniu map wywołuje tę metodę, przekazując jej aktualny element strumienia. Wartość zwrócona przez metodę staje się częścią nowego strumienia wygenerowanego przez map.
Wyrażenia lambda
Jak się przekonasz w dalszej części rozdziału, wiele operacji pośrednich i operacji końcowych strumieni otrzymuje metody jako argumenty. Argument map metody w wierszu 10.:
nazywamy wyrażeniem lambda (lub po prostu lambdą). Tak naprawdę reprezentuje ono metodę anonimową, czyli metodę bez nazwy. Choć składnia wyrażenia lambda nie wygląda jak metody używane do tej pory, część po lewej wygląda jak lista parametrów metody, a część po prawej jak treść metody. Wkrótce wyjaśnimy szczegóły składni.
Wyrażenia lambda umożliwiają tworzenie metod, które są traktowane jak dane. Możesz:
- przekazać lambdę jako argument do innej metody (np. do map, a nawet do innej lambdy);
- przypisać lambdę do zmiennej w celu późniejszego użycia;
- zwrócić wyrażenie lambda z metody.
Przekonasz się, że właściwości te dają naprawdę duże możliwości.
Obserwacja z poziomu inżynierii oprogramowania
Lambdy i strumienie pozwalają połączyć wiele zalet programowania funkcyjnego z zaletami programowania obiektowego.
Składnia lambd
Lambda składa się z listy parametrów, po której znajdują się znacznik strzałki (->) i treść lambdy:
Lambda z wiersza 10.:
otrzymuje parametr typu int, mnoży go przez 2 i zwraca wynik. W tym przypadku treścią jest blok instrukcji zawarty w nawiasach klamrowych. Kompilator odgaduje na podstawie kodu, że zwracanym typem jest int, ponieważ parametr x jest typu int, więc mnożenie literału 2 przez int również da typ int. Podobnie jak w przypadku deklaracji metody, lambda ma listę parametrów oddzielonych przecinkami. Powyższa lambda jest równoważna metodzie:
return x * 2;
}
ale lambda nie ma nazwy, a kompilator odgaduje zwracany przez nią typ. Istnieje kilka odmian składni lambd.
Pominięcie typu parametru
Parametr typu w lambdzie najczęściej można pominąć:
W tej sytuacji kompilator odgaduje typ parametru i typ zwracany na podstawie kontekstu lambdy, ale o tym nieco później. Jeśli z jakiegoś powodu kompilator nie jest w stanie określić typu parametru lub zwracanej wartości (bo jest kilka możliwości), zgłosi błąd.
Uproszczenie treści lambdy
Jeśli treść lambdy składa się tylko z jednego wyrażenia, słowo kluczowe return, nawiasy klamrowe i przecinek można pominąć:
W tym przypadku lambda niejawnie zwraca wartość wyrażenia.
Uproszczenie listy parametrów lambdy
Jeśli lista parametrów lambdy zawiera tylko jeden parametr, nawiasy można pominąć:
Lambda z pustą listą parametrów
Aby zdefiniować lambdę z pustą listą parametrów, należy użyć pustych nawiasów na lewo od znacznika strzałki (->):
Referencje do metod
Poza przedstawionymi powyżej odmianami składni lambd istnieje również specjalny skrót nazywany referencją do metody.
Operacje pośrednie i operacje kończące
W potoku strumienia przedstawionym w wierszach od 9. do 11. map to operacja pośrednia, a sum to operacja kończąca. Metoda map to jedna z wielu operacji pośrednich przyjmujących zadania do wykonania na każdym elemencie strumienia.
Operacje leniwe i operacje gorliwe
Operacje pośrednie korzystają z tak zwanej ewaluacji leniwej — każda operacja pośrednia tworzy nowy obiekt strumienia, ale nie wykonuje żadnych operacji na elementach strumienia do momentu wywołania operacji kończącej wymagającej wyniku. W ten sposób programiści bibliotek mogą zoptymalizować wydajność przetwarzania strumieni. Jeśli na przykład mamy milion obiektów Person i szukamy pierwszego z nazwiskiem "Nowak", to zamiast przetwarzać milion elementów możemy przerwać przetwarzanie strumienia, gdy tylko znajdziemy poszukiwany obiekt Person.
Wskazówka poprawiająca wydajność
Leniwa ewaluacja (obliczanie) poprawia wydajność, bo operacje są wykonywane tylko wtedy, gdy jest to faktycznie niezbędne.
Operacje kończące są gorliwe (natychmiastowe) — wykonują żądaną operację od razu po wywołaniu. Rysunki 17.5 i 17.6 zawierają opisy popularnych operacji pośrednich i operacji kończących.
Operacja | Opis |
filter | Zwraca strumień zawierający tylko elementy spełniający określony warunek (nazywany predykatem). Nowy strumień ma najczęściej mniej elementów niż pierwotny. |
distinct | Zwraca strumień zawierający tylko elementy unikatowe — duplikaty są usuwane. |
limit | Zwraca strumień o określonej liczbie elementów od początku pierwotnego strumienia. |
map | Zwraca strumień, w którym każda pierwotna wartość została odwzorowana na nową wartość (być może innego typu) — przykładami mogą być zamiana liczb całkowitych na kwadraty liczb całkowitych lub zamiana ocen liczbowych na literowe. Nowy strumień ma taką samą liczbę elementów jak pierwotny. |
sorted | Zwraca strumień, którego elementy są ułożone w określonym porządku. Nowy strumień ma taką samą liczbę elementów jak pierwotny. Pokażemy, jak określić, czy sortowanie ma się odbywać rosnąco czy malejąco. |
forEach | Wykonuje operacje na każdym elemencie strumienia (np. wyświetla każdy element). |
Operacje redukcji — pobierają wszystkie wartości strumienia i zwracają pojedynczą wartość | |
average | Zwraca średnią elementów w strumieniu liczb. |
count | Zwraca liczbę elementów w strumieniu. |
max | Zwraca maksymalną wartość w strumieniu. |
min | Zwraca minimalną wartość w strumieniu. |
reduce | Redukuje elementy kolekcji do pojedynczej wartości za pomocą funkcji akumulującej (np. lambdy dodającej dwa elementy i zwracającej ich sumę). |
Rysunek 17.6. Typowe operacje kończące na strumieniu
Filtrowanie
Bardzo popularną operacją pośrednią dla strumieni jest filtrowanie elementów, czyli wybieranie elementów na podstawie określonego warunku nazywanego predykatem. Poniższy kod wybiera tylko parzyste liczby całkowite z przedziału
od 1 do 10, a następnie mnoży je przez 3 i zwraca ich sumę:
for (int x = 1; x <= 10; x++) {
if (x % 2 == 0) { // Jeśli x jest parzyste…
total += x * 3;
}
}
Rysunek 17.7 pokazuje implementację tej pętli za pomocą strumieni.
2 // Program, który potraja parzyste liczby całkowite od 2 do 10, a następnie zwraca ich sumę dzięki IntStream
3 import java.util.stream.IntStream;
4
5 public class StreamFilterMapReduce {
6 public static void main(String[] args) {
7 // Zsumuj trzykrotności liczb parzystych od 2 do 10
8 System.out.printf(
9 "Suma trzykrotności liczb parzystych od 2 do 10 wynosi: %d%n",
10 IntStream.rangeClosed(1, 10)
11 .filter(x -> x % 2 == 0)
12 .map(x -> x * 3)
13 .sum());
14 }
15 }
Suma trzykrotności liczb parzystych od 2 do 10 wynosi: 90
Rysunek 17.7. Program, który potraja parzyste liczby całkowite od 2 do 10, a następnie zwraca ich sumę dzięki IntStream
Potok strumienia z wierszy od 10. do 13. wykonuje łańcuchowo cztery wywołania metod:
- Wiersz 10. tworzy źródło danych — IntStream dla zamkniętego zakresu od 1 do 10.
- Wiersz 11., który bardziej szczegółowo omówimy za chwilę, filtruje elementy strumienia, wybierając tylko te, które są podzielne przez 2 (czyli liczby parzyste). Wynikowy strumień zawiera wartości 2, 4, 6, 8 i 10.
- Wiersz 12. odwzorowuje każdy element (x) w strumieniu na element trzykrotnie większy, tworząc strumień z wartościami 6, 12, 18, 24 i 30.
- Wiersz 13. redukuje strumień do sumy jego elementów (90).
Nową funkcjonalnością jest operacja filtrująca z wiersza 11. Metoda filter z Int Stream otrzymuje jako swój argument metodę, która przyjmuje jeden parametr i zwraca jako wynik wartość logiczną. Jeśli wynikiem jest true, element ten pozostaje w wynikowym strumieniu.
Lambda w wierszu 11. ma postać:
i określa, czy argument otrzymany jako typ int jest podzielny przez 2 (czyli reszta z dzielenia przez 2 wynosi 0). Jeśli tak, zwraca true; w przeciwnym razie lambda zwraca false. Dla każdego elementu w strumieniu filter wywołuje metodę i przekazuje jej aktualny element. Jeśli wartością zwróconą przez metodę jest true, element pozostaje częścią strumienia zwracanego przez filter.
Wiersz 11. tworzy strumień pośredni, który reprezentuje tylko elementy podzielne przez 2. Wiersz 12. tworzy kolejny strumień pośredni metodą map, który powoduje potrojenie wszystkich wartości strumienia (z 2, 4, 6, 8 i 10 na 6, 12, 18, 24 i 30). Wiersz 13. rozpoczyna faktyczne przetwarzanie strumienia, bo wywołuje operację kończącą sum. W tym momencie połączone kroki przetwarzania są stosowane dla każdego elementu, a sum zwraca sumę elementów, które pozostały w strumieniu. Opiszemy to dokładniej w następnym podrozdziale.
Wskazówka zapobiegająca błędom
Kolejność wykonywania operacji w strumieniu ma znaczenie. Na przykład odfiltrowanie liczb nieparzystych z zakresu od 1 do 10 daje wynik 2, 4, 6, 8, 10, a następnie odwzorowanie ich na wartości dwa razy większe daje wynik 4, 8, 12, 16 i 20. Gdybyśmy jednak najpierw odwzorowali liczby od 1 do 10 na wartości dwa razy większe, uzyskalibyśmy 2, 4, 6, 8, 10, 12, 14, 16, 18 i 20, a późniejsze filtrowanie pozostawiłoby wszystkie liczby, bo wszystkie są parzyste.
Potok strumienia przedstawiony w tym przykładzie można zrealizować tylko za pomocą map i sum. Ćwiczenie 17.18 zawiera polecenie wyeliminowania operacji filter.
Jak elementy poruszają się po potoku strumienia?
W podrozdziale wyżej wspomnieliśmy, że każda operacja pośrednia powoduje powstanie nowego strumienia. Każdy nowy strumień to po prostu obiekt reprezentujący wszystkie kroki przetwarzania zdefiniowane do tej pory w potoku. Łańcuch wywołań metod operacji pośrednich dodaje do zbioru kolejny element obróbki każdego elementu strumienia. Ostatni obiekt potoku strumienia zawiera wszystkie kroki przetwarzania zdefiniowane dla elementów strumienia. Gdy zainicjalizujemy potok strumienia operacją końcową, wszystkie operacje pośrednie będą stosowane dla elementu strumienia przed przejściem do następnego elementu strumienia. Oznacza to, że strumień końcowy z rysunku 17.7 działa w następujący sposób:
Jeśli element jest liczbą parzystą
Pomnóż element przez 3 i dodaj wynik do sumy
Aby to udowodnić, przyjrzyjmy się zmodyfikowanej wersji rysunku 17.7, która dla każdego kroku pośredniego wyświetla nazwę operacji i wartość bieżącego elementu:
.filter(
x -> {
System.out.printf("%nfilter: %d%n", x);
return x % 2 == 0;
})
.map(
x -> {
System.out.println("map: " + x);
return x * 3;
})
.sum()
Zmodyfikowana wersja wyświetla poniższe informacje (poza dodanymi komentarzami), co wyraźnie wskazuje, że metoda map dla liczby parzystej zostaje wywołana przed krokiem filter następnego elementu strumienia:
filter: 2 // Liczba parzysta, więc dochodzi do odwzorowania
map: 2
filter: 3 // Liczba nieparzysta, więc operacja odwzorowania nie jest realizowana
filter: 4 // Liczba parzysta, więc dochodzi do odwzorowania
map: 4
filter: 5 // Liczba nieparzysta, więc operacja odwzorowania nie jest realizowana
filter: 6 // Liczba parzysta, więc dochodzi do odwzorowania
map: 6
filter: 7 // Liczba nieparzysta, więc operacja odwzorowania nie jest realizowana
filter: 8 // Liczba parzysta, więc dochodzi do odwzorowania
map: 8
filter: 9 // Liczba nieparzysta, więc operacja odwzorowania nie jest realizowana
filter: 10 // Liczba parzysta, więc dochodzi do odwzorowania
map: 10
Dla elementów nieparzystych operacja map nie była wykonywana. Gdy krok filter zwróci false, następne kroki przetwarzania są ignorowane, ponieważ element nie stanowi części wyników.
Programowanie w Javie. Solidna wiedza w praktyce. Wydanie XI, Autorzy: Paul Deitel, Harvey Deitel, Wydawnictwo: Helion