Wyrażenia lambda są bardzo przydatną zmianą jaka weszła do Javy 8. W przypadku dat mieliśmy do czynienia z nowym standardem, który zastępuje wcześniej stosowane rozwiązania (np. bibliotekę Joda). Tym razem, nadal możemy programować “po staremu”, jednak czystość kodu wynikająca z prawidłowego stosowania lambd jest naprawdę fajnym zyskiem. Dlatego zachęcamy do zapoznania się z tym elementem języka i wcieleniem go do swojego codziennego programowania.
Czym są lambdy?
Jak wiecie, Java to obiektowy język programowania, jednak lambdy pozwalają na pisanie kodu w sposób funkcyjny. Oznacza to, że zamiast operować na stanach obiektów możemy bezpośrednio deklarować, co chcemy zrobić. Odnosząc lambdy do programowania obiektowego, możemy o nich myśleć jak o klasach tymczasowych zawierających jedną metodę. Lambdy to obiekty, zawierające fragment kodu: funkcję, a także specyficzne atrybuty i parametry ważne dla nich (środowisko, w ramach którego operuje funkcja). Może brzmi to skomplikowanie, ale mam nadzieję, że poniższe przykłady ułatwią Ci zrozumienie lambd.
Przykład
Zaczniemy od przykładu bez użycia lambd, żebyś miała porównanie i łatwo mogła zrozumieć, po co i jak to robimy. W kodzie pomijamy importy i package, bo nie są one istotne, chcemy byś przede wszystkim skupiła się na sednie ;)
W naszym zadaniu chcemy wypisać listę celebrytów, na podstawie ich umiejętności (w naszym programie celebryci je mają, choć nie zawsze pokrywa się to z rzeczywistością ;)). Mamy więc klasę Celebrity:
public class Celebrity {
private String name;
private boolean canSing;
private boolean canAct;
private boolean canDance;
public Celebrity(String starName, boolean singer, boolean actor, boolean dancer) {
this.name = starName;
this.canSing = singer;
this.canAct = actor;
this.canDance = dancer;
}
public boolean canSing() {
return canSing;
}
public boolean canDance() {
return canDance;
}
public boolean canAct() {
return canAct;
}
public String getName() {
return name;
}
public String toString() {
return getName();
}
}
Obiekt Celebrity ma cztery pola, które są też w jego konstruktorze. Ma też trzy metody, które zwracają informację (stan) na temat konkretnych umiejętności celebryty. Ma także metodę to String(), po to byśmy mogli łatwo zidentyfikować Celebrytę w programie.
Jako, że będziemy wykonywać wiele różnych sprawdzeń, tworzymy interfejs:
public interface CheckTalent{
boolean test (Celebrity celebrity);
}
Oraz implementacje do niego, chcemy sprawdzić, czy Celebryta umie śpiewać:
public class CheckIfSinger implements CheckTalent {
boolean test (Celebrity celebrity) {
return celebrity.canSing();
}
}
Mamy już wszystko, by sprawdzić, czy Celebryci potrafią śpiewać.
public class PudelekReading {
public static void main (String[] args){
List<Celebrity> celebrities = new ArrayList<Celebrity>(); //lista celebrytów
celebrities.add(new Celebrity("Justin Bieber", true, false, true));
celebrities.add(new Celebrity("Kim Kardashian", false, false, false));
celebrities.add(new Celebrity("Joanna Krupa", true, true, false));
print(celebrities, new CheckIfSinger());//przekazujemy klasę, która sprawdza
}
private static void print(List<Celebrity> celebrities, CheckTalent checker){
for (Celebrity celebrity:celebrities){
if(checker.test(celebrity)){
System.out.println(celebrity + " ");
}
System.out.println();
}
}
}
Metoda print jest dość generyczna — może sprawdzić każdy talent, co jest dobrą praktyką. Co musimy więc zrobić, by sprawdzić, czy Celebryta tańczy? Musimy napisać nową klasę CheckIfDancer, a następnie dodać linijkę w PudelekReading print(celebrities, new CheckIfDancer()) ...
No dobra, pora na lambdy. Naszą linijkę print(celebrities, new CheckIfSinger()) w kodzie możemy zamienić takim oto wyrażeniem (na razie nie przejmuj się tym, że dziwnie wygląda):
print(celebrities,c -> c.canSing());
Pokazujemy Javie, że skupiamy się na celebrytach, którzy potrafią śpiewać. Jak wyglądało by sprawdzenie, czy potrafią tańczyć?
print(celebrities,c -> c.canDance());
OK, jak może zauważyłaś, by to zrobić nie potrzebujemy już klasy implementującej nasz interfejs, po prostu dodajemy lambdę.
Sama potem zobaczysz, jak łatwo jest pisać lambdy i jak bardzo zwiększają one czytelność Twojego kodu. Wyrażenia lambda wykorzystują koncept, który nazywa się deferred execution, czyli takie, które są wykonywanie z odroczeniem. W naszym przypadku, to odroczenie to moment, gdy metoda print() jest wywołana.
Pisanie prostych wyrażeń lambda
Wróćmy do naszego przykładu:
c -> c.canSing();
Taki zapis mówi Javie, że ma wywołać metodę z Celebrity jako parametrem i zwrócić booleana jako wynik c.canSing().Poniżej znajdziesz grafikę z dwoma dozwolonymi zapisami wyrażeń lambda. Oba robią dokładnie to samo.
Pierwszy składa się z 3 części: parametru c, strzałki która oddziela parametr od ciała i ciała, które wywołuje pojedynczą metodę i zwraca jej wynik.
Drugi zapis również składa się z trzech części: parametru c typu Celebrity zawartego w nawiasach, strzałki, która oddziela parametr od ciała i ciała, które składa się z jednej lub kilku linijek kodu, i które zawiera w sobie return i średnik.
Nawiasy możemy opuścić tylko dla jednego parametru, któremu nie określamy wyraźnie typu. Klamerki mogą być pominięte tylko jeśli mamy pojedynczą linię kodu. Lambda może mieć też zero argumentów, co zapisujemy po prostu w postaci ().
To co jeszcze warto wiedzieć o lambdach, to to, że mają one dostęp do zmiennych i np.:
boolean wantSing = true;
print(celebrities, c ->c.canSing()==wantSing);
to całkowicie poprawny kod. Warto pamiętać, że wyrażenia lambda mają dostęp do pól klasy oraz statycznych zmiennych. Jeśli chodzi o parametry metody i zmienne lokalne, to ten dostęp jest możliwy tylko wtedy, gdy nie przypisujemy do nich nowych wartości. Czyli:
(a,b) -> {int a=0;return 5;} // kod się nie skompiluje
(a,b) -> {int c=0;return 5;} // to jest ok
Interfejs Predictate<T>
Wróćmy jeszcze na chwilę do naszego przykładu, pamiętasz interfejs checkTalent z metodą test? Java zapewnia nam taki interfejs w pakiecie java.util.function:
public interface Predicate{
boolean test (T t);
}
Jak widzisz, wygląda on tak jak nasz interfejs z przykładu z małą różnicą, mamy w nim typ T zamiast Celebrity. W taki sposób oznaczamy generyki (które działają jak szablony klas/interfejsów, bo konkretny parametr podajemy im dopiero w momencie użycia). Jeśli więc wrócimy do naszego kodu to korzystając z tego interfejsu, klasa PudelekReading wygląda następująco:
public class PudelekReading {
public static void main (String[] args){
List celebrities = new ArrayList(); //lista celebrytów
celebrities.add(new Celebrity("Justin Bieber", true, false, true);
celebrities.add(new Celebrity("Kim Kardashian", false, false, false);
celebrities.add(new Celebrity("Joanna Krupa", true, true, false);
print(celebrities,c -> c.canSing());//wyrażenie lambda
}
private static void print(List celebrities, Predicate<Celebrity> checker){ // interfejs Predicate
for (Celebrity celebrity : celebrities){
if(checker.test(celebrity)){
System.out.println(celebrity + " ");
}
System.out.println();
}
}
}
Doszliśmy więc do tego momentu — z czterech klas, zrobiły się dwie, przy czym sam zapis z użyciem lambdy, gdy już go rozumiemy, jest nawet czytelniejszy. Koniec z teorią, pora na kilka przykładów zastosowania lambd.
Lambda zamiast pętli
String[] a = {"cat", "dog", "mouse", "rat", "pig", "rabbit", "hamster", "parrot"};
List animals = Arrays.asList(a);
// Tradycyjna pętla
for (String animal : animals) {
System.out.print(animal + "; ");
}
// Wyrażenie lambda
animals.forEach((animal) -> System.out.print(animal + "; "));
// Z użyciem podwójnego dwukropka
animals.forEach(System.out::println);
W powyższym przykładzie pojawił się nowy operator (::), który przekształca metodę w wyrażenie lambda, więcej o takim zapisie przeczytasz tutaj.
Tworzenie nowego wątku
//podejście klasyczne
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello world!");
}
}).start();
//wyrażenie lambda
new Thread(() -> System.out.println("Hello world!")).start();
Sortowanie kolekcji
String[] animals = {"cat", "dog", "mouse", "rat", "pig", "rabbit", "hamster", "parrot"};
// sortowanie zwierząt z kolekcji z użyciem anonimowej klasy wewnętrznej
Arrays.sort(animals, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return (s1.compareTo(s2));
}
});
//sortowanie zwierząt z kolekcji z użyciem lambd
Comparator<String> sortByName = (String s1, String s2) -> (s1.compareTo(s2));
Arrays.sort(animals, sortByName);
// albo
Arrays.sort(animals, (String s1, String s2) -> (s1.compareTo(s2)));
Praca ze strumieniami
Tutaj pora na naprawdę super użyteczny przykład stosowania lambd, ale zanim go przedstawimy wytłumaczymy czym są strumienie w Javie 8.
Strumień (Steam) reprezentuje sekwencje elementów i pozwala na różne operacje na tych elementach. Operacje te mogą być pośrednie i takie możemy układać w łańcuchy metod, oraz końcowe, zwracające wynik, lub nie.
Poniżej przykład operacji na strumieniu:
List fruits =
Arrays.asList("apple", "banana", "cherry", "plum", "pear", "pinapple");
fruits.stream().filter(s -> s.toString().startsWith("p")).map(s -> s.toString().toUpperCase()).sorted().forEach(System.out::println);
Jaki będzie wynik tego łańcuchu operacji?
PEAR
PINAPPLE
PLUM
Przeanalizujmy więc, co się stało.
stream() — zamienia listę na strumień, filter(…) — w naszym przypadku filtruje te owoce, które zaczynają się na p, map(…) — zamienia każdy string na wielkie litery, sorted() — sortuje AZ, a forEach(…) — wypisuje je w nowych liniach. filter(),map(),sorted() są pośrednie, natomiast forEach() końcowa i zwraca nam ona wynik. Po pełną listę operacji jakie możemy wykonywać na strumieniach odsyłamy do dokumentacji, warto zwrócić uwagę na operacje collect, która pozwala na zamienienie strumienia elementów w kolekcje np. listę, stos czy mapę.
Większość operacji na strumieniach może przyjmować jako swoje parametry pewne wyrażenia lambda, ale też takie operacje muszą spełniać pewne warunki: muszą być nieinterferujce (non-interfering) i bezstanowe (stateless).
Funkcja jest nieinterferujaca, gdy nie modyfikuje podstawowego źródła danych do strumienia (nie usuwa, nie edytuje, nie zmienia elementów ze strumieniowanej kolekcji).
Funkcja jest bezstanowa, gdy wynik operacji jest deterministyczny, czyli nie zależy od żadnej zmiennej, którą można zmieniać (która jest mutable) albo stanu z zewnętrznego źródła, który może się zmieniać w trakcie jej wykonywania.
Aby korzystać ze strumienia nie musimy wcale tworzyć najpierw kolekcji, wystarczy wtedy wykorzystać Stream.of(). Oprócz strumieni, które przyjmują obiekty, istnieją też IntStream, LongStream, DoubleStream, które przyjmują kolejne prymitywy: int, long, double. Dzięki mapowaniu możemy zamieniać strumienie obiektowe na te z prymitywów i odwrotnie.
Przejdźmy do bardziej złożonego przykładu, w którym pokażemy Ci zastosowanie wyrażeń lambda.
public class Beverage{
String name;
int price;
Beverage
(String name, int price){ this.name = name; this.age = price; } @Override public String toString(){ return name; } }
Tworzymy też listę napojów
List beverage = Arrays.asList(
new Beverage("Cola", 2),
new Beverage("HipsterCola", 5),
new Beverage("SuperHipsterCola", 5),
new Beverage("UltraSuperHipsterCola", 10),
new Beverage("CheapCola", 2));
Zacznijmy od prostego filtru:
List<Beverage> filtered = beverages
.stream()
.filter(b -> b.name.startsWith("C"))
.collect(Collectors.toList());
System.out.println(filtered); // [Cola, CheapCola]
Za pomocą wyrażenia lambda stworzyliśmy filtr, filtrujący napoje. których nazwa zaczyna się na C, a następnie zapisaliśmy wynik tej operacji jako listę, za pomocą operacji collect.
Teraz spróbujemy pogrupować nasze napoje według ceny:
Map<Integer, List> colaByPrice =
beverages
.stream()
.collect(Collectors.groupingBy(b -> b.price));
colaByPrice
.forEach((price, p) -> System.out.format("price %s: %s\n",price, p));
//price 2: [Cola, CheapCola]
//price 5: [HipsterCola, SuperHipsterCola]
//price 10: [UltraSuperHipsterCola]
Tym razem, stworzyliśmy mapę, której kluczem jest cena, a wartością lista napojów, które tyle kosztują. Nie przejmuj się składnią argumentu System.out.format(), użyliśmy go tutaj tylko po to by pokazać Ci to pogrupowanie — dociekliwi znajdą więcej info tutaj.
Kolejnym zadaniem będzie obliczenie średniej ceny za napój:
Double averagePrice =
beverages
.stream()
.collect(Collectors.averagingInt(b -> b.price));
System.out.println(averagePrice); //4.8
A teraz, wypiszemy wszystkie napoje, na które jest dodatkowa promocja, ustalmy, że to te kosztujące >2.
String specialOffer =
beverages
.stream()
.filter(b -> b.price >2)
.map(b -> b.name)
.collect(Collectors.joining(" and ", "Special offer:", "are -20%.")); //
System.out.println(specialOffer); //Special Offer: HipsterCola and SuperHipsterCola and UltraSuperHipsterCola are -20%.
Składnia joining() to 3 opcjonalne elementy: element oddzielający, prefix i sufix.
To solidne podstawy do pracy ze strumieniami i wyrażeniami lambda. Po więcej odsyłamy do literatury dodatkowej. Mamy nadzieję, że dzięki tej lekcji zaczniesz używać lambd, które naprawdę ułatwiają pracę programisty.