Java - lambdy i streamy, czyli o programowaniu funkcyjnym słów kilka

Rafał Pieńkowski, 17 marca 2020

Java 8 została wypuszczona już dobrych kilka lat temu, a streamy, które wprowadziła, ułatwiają programowanie java developerom prawie każdego dnia. Zatem warto, aby się dobrze z nimi zapoznać.

Stream API używa lambd, więc zacznijmy od wyjaśnienia sobie, czym są i po co służą owe „lambdy”. W uproszczeniu mówiąc, są to anonimowe metody, czyli metody nie należące do żadnej klasy, lecz których definicję piszemy od razu w miejscu ich wywołania. Zobrazujemy sobie to za chwilę na przykładzie, jednak zanim to zrobimy, utwórzmy prostą klasę Person, która będzie nam służyła także w dalszej części artykułu.

public class Person {
    
    private String name;
    
    private int age; 
    
    // constructors, getters and setters

Mamy prostą klasę Person, w której mamy pola name oraz age. Dla przejrzystości, przyjmijmy, że są zdefiniowane konstruktory, gettery oraz settery. Załóżmy, że mamy listę takich osób, a następnie, chcemy tę listę posortować według imienia:

List<Person> persons = new ArrayList<>();
persons.add(new Person("Andrzej", 30));
persons.add(new Person("Stefan", 26));
persons.add(new Person("Katarzyna", 29));

Collections.sort(persons, new Comparator<Person>() {
    @Override
    public int compare(Person p1, Person p2) {
        return p1.getName().compareTo(p2.getName());
    }
});

Metoda sort jako drugi argument przyjmuje obiekt typu Comparator, więc w tym wypadku tworzymy klasę anonimową. Przed javą 8, trzeba było pisać tak jak na przykładzie powyżej – sporo „boilerplate kodu”, czyli kodu wymaganego przez kompilator, ale nie wnoszącego nic do logiki biznesowej. Tak naprawdę tylko:

p1.getName().compareTo(p2.getName());

Jest tu logiką biznesową. Z pomocą przychodzą lambdy:

Collections.sort(persons,(p1, p2) -> p1.getName().compareTo(p2.getName()));

Omówmy poszczególne elementy: (p1, p2) to zestaw argumentów. Jak widać, nie potrzeba definiowania typów – kompilator wie o nich. -> to symbol składniowy, oddzielający argumenty od ciała naszej lambdy. Następnie jest już ciało lambdy – jeśli można zapisać je w jednej linijce, niepotrzebne są zarówno nawiasy klamrowe jak i słówko return.

Kiedy już znamy lambdy, przejdźmy do „mięsa”, czyli Stream API. Najczęściej jest ono używane, kiedy chcemy daną kolekcję przefiltrować lub przekształcić, przy używaniu łatwiejszego i bardziej czytelnego kodu niż standardowe zagnieżdżone pętle. Rozszerzmy naszą początkową klasę Person:

public class Person {

    private String name;

    private int age;

    private List<String> pets;

    // constructors, getters and setters

}

Dołożyliśmy listę nazw zwierząt danej osoby.

Załóżmy, że mając listę takich osób, chcemy otrzymać listę takich osób, które mają mniej niż 30 lat:

 List<Person> personsUnder30Age = persons.stream()
                                    .filter(person -> person.getAge() < 30)
                                    .collect(Collectors.toList());

Przeanalizujmy krok po kroku, co się dzieje:

  • .stream() powoduje powstanie obiektu typu Stream. W ten sposób budujemy stream z kolekcji.
  • .filter(…) jest metodą operującą na streamie, która filtruje jego elementy i przepuszcza dalej tylko te, które spełniają podany warunek, tzn. ciało metody zwróci true. Metoda ta przyjmuje stream i zwraca również stream. Jeśli chcielibyśmy dodać kolejną metodę operującą na streamie, moglibyśmy wywołać ją bezpośrednio po użyciu .filter(…).
  • person -> person.getAge() < 30 jest warunkiem filtrującym, zapisanym jako lambda
  • .collect(Collectors.toList()) to operacja kończąca, która zamienia wynikowy stream w daną kolekcję, w tym przypadku, w listę.
  • personsUnder30Age jest listą wynikową. Jest to zupełnie nowa lista, tzn. lista persons pozostaje bez zmian.

Załóżmy, że mając listę osób, chcemy dostać listę ich imion. Innymi słowy, chcemy w zgrabny sposób przekształcić obiekty typu Person w listę Stringów. Zrealizuje to poniższy kod:

List<String> names = persons.stream()
                        .map(person -> person.getName())
                        .collect(Collectors.toList());

Widzimy tutaj podobieństwa do przykładu z filtrowaniem, jednak różnicą jest użycie metody .map(…) zamiast .filter(…). Dokonuje ona przemapowania – w tym przykładzie, przekształca ona stream elementów typu Person w stream elementów typu String (według użycia metody getName() obiektu Person).

Kolejnym przykładem, będzie sytuacja przekształcenia listy osób na listę nazw zwierząt – np. chcemy znać wszystkie dostępne zwierzęta w danej grupie osób (czyli np „pies”, „kot” itp). Na pierwszy rzut oka, moglibyśmy zacząć z następującą lambdą:

.map(person -> person.getPets())

Jednak jak się przekonamy, zamiast streamu elementów typu String, dostaniemy stream elementów typu **List**! Nie o to nam chodziło, więc musimy jakoś „spłaszczyć” tę strukturę. Z pomocą przychodzi metoda **.flatMap(...)**:

Set<String> petNames = persons.stream()
                        .map(person -> person.getPets())
                        .flatMap(pets -> pets.stream())
                        .collect(Collectors.toSet());

A więc używamy mapowania normalnie tak jak chcemy, jednak później, jest potrzeba spłaszczenia streama – dzięki temu zbiegowi, później operujemy już na streamie Stringów. Ale uwaga – różni ludzie mogli mieć te same zwierzęta, więc aby uniknąć duplikatów, powinniśmy użyć tym razem setu zamiast listy.

Streamy w javie pozwalają na szybkie pisanie czytelnego kodu, jednak należy mieć na uwadze, że wprowadzają pewien narzut, także w przypadku bardzo rygorystycznych wymagań czasowych może się okazać, że pozostanie przy standardowych pętlach będzie bardziej efektywne. W powyższym artykule przedstawiłem 3 najczęściej używane metody operacji na streamach, tj. .map(…), .filter(…) oraz .flatMap(…). Oczywiście Stream API jest dużo bogatsze, zatem zachęcam do jego explorowania i próbowania różnych kombinacji.


Tematy: javafunctional
Napisano w: Opinie
Zapisz się przez RSS
comments powered by Disqus