Ukoronowaniem wszystkich poprzednich lekcji odnośnie Javy 8, jest właśnie biblioteka Stream API. To ona wymusiła na programistach poważne zmiany w samej semantyce języka Java, takich jak choćby lambdy, a co za nimi idzie interfejsy funkcyjne. Strumieniem nazywamy specjalną sekwencję operacji, które można wykonywać jedną po drugim (w formie tzw. pipeline’u), tak aby przekształcić go do dowolnego rezultatu.
Tworzenie strumieni
Sens używania strumieni jest tylko wtedy, gdy mamy do czynienia z więcej niż jednym elementem. W takim przypadku najczęściej używa się ich, jeśli w Twoim programie korzystać z kolekcji, tablic oraz łańcuchów znaków.
String [] array = {"Age of Empire", "Baldurs Gate", "Max Payne", "Star Craft"}; Stream<String> streamArray = Arrays.stream(array); Stream<String> streamList = Arrays.asList(array).stream(); Stream<Object> emptyStream = Stream.empty(); Stream<String> title = Stream.of("Welcome to Poland!"); IntStream numbers = IntStream.range(1, 21);
W drugiej linii, za pomocą klasy Arrays przekształciłem zwykłą tablicę na stream. Nie ma też problemu, uczynić to samo dla listy (lub też każdej innej kolekcji). W moim przypadku znów skorzystałem z klasy Arrays, która udostępnia metodę asList, konwertującą tablicę na listę. Ogólna zasada w przypadku kolekcji jest bardzo prosta. Jeśli wywołujesz metodę stream, to bądź pewny, że Twoja kolekcja jest już przekształcona do strumienia.
Nie jest to jednak koniec. Podobnie jak w przypadku Optionali, możesz stworzyć pusty strumień, korzystając z metody empty. Mogę również użyć metodę of, jeśli pracuję na ciągach znaków. Natomiast, gdy masz potrzebę pracy na elementach liczbowych, warto sięgnąć po dedykowane strumienie, takie jak IntStream, czy LongStream. Różnią się one znacząco od zwykłego streama i nie będę je tutaj omawiał*.
Ostatnim źródłem danych dla streama może być np. plik tekstowy. Tutaj z pomocą przyjdzie metoda lines z klasy Files.
Stream<String> lines = Files.lines(Paths.get("c:///textToRead.txt"))
Właściwości Stream API
Skoro umiesz już załadować elementy do strumieni, chciałbym podać Ci kilka przydatnych informacji ogólnych o strumieniach.
Pierwszą kwestią, jaką warto podkreślić, jest to, że omawiana przeze mnie technologia nie jest następcą standardowych sposobów przetwarzania struktur danych, takich jak pętla for czy warunek if. Strumienie to bardzo potężne narzędzie i czasem nie będzie potrzeby, aby z niego korzystać. Zwłaszcza, że jest ono bardziej zasobożerne niż w przypadku klasycznych rozwiązań.*
Podstawowym podziałem operacji jakie możesz wykorzystać w streamach, są metody, które wykonują swoją pracę natychmiast (nazywane operacjami terminate), oraz te, które leniwie (ang. lazy) czekają na uruchomienie, tych pierwszych (intermediate). Śmiało można powiedzieć operacje terminate są po prostu akcjami zakończenia całego procesu przetwarzania danych.
Ostatnią ważną kwestią, jest kolejność wykonywania operacji. Tak samo, jak w przypadku zwykłego przetwarzania za pomocą pętli for, tu także kolejność akcji przypisanych do strumienia (w większości przypadków) ma znaczenie.
Operacje intermediate
- filter – operacja ta przyjmuje interfejs typu predicate, po którym zostają przefiltrowane elementy, zgodnie z warunkiem zapisanym w lambdzie. Można ją skojarzyć z słowem WHERE w SQL. Z reguły wywołujesz tą akcję jako pierwszą.
- map – funkcja, która dokonuje przekształcenia poprzedniego obiektu w inny nowy. Działa na każdym elemencie strumienia oddzielnie, w taki sposób, że wszystkie będą zmapowane do nowego typu.
- sort – ustawienie kolejności elementów. Może przyjąć jako argument compareTo, także w formie lambdy.
- distinct – zwraca unikalne wartości.
- limit – ograniczenie wyników do podanego limitu.
- skip – działa podobnie jak continue w pętli for. W tym przypadku skip opuści, podane w argumencie funkcji, elementy strumienia.
Przykład użycia:
List<Integer> numbers = Arrays.asList(1,1,2,3,4,5); numbers.stream() .distinct() .filter(x -> x < 5) .map(x -> x + 1) .sorted((o1, o2) -> Integer.compare(o2, o1)) .limit(2) .forEach(System.out::println);
Co się tutaj zadziało? Wpierw odwołałem się do metody stream, która udostępnia mi całe API, następnie użyłem funkcji distinct, aby wykluczyć powtarzające się elementy (dwie jedynki). W dalszej kolejności funkcją filter, wyeliminowałem wszystkie wartości większe niż cztery. Po przefiltrowaniu danych, zmapowałem stare elementy na nowe, zwiększając każdy o jeden. Na koniec posortowałem wynik malejąco, ograniczyłem rezultat do dwóch wartości (funkcja limit) i wyświetliłem dane za pomocą forEach (tą operację omawiam w następnym akapicie).
Wynik na konsoli powinien zwrócić liczby: 5, 4.
Operacje terminate
forEach
– pętla for each ale dla streamów.toArray
– konwertuje elementy do typu tablicowego.reduce
– dokonuje operacji redukcji.collect
– pozwala zwracać kolekcję lub dokonywać grupowania (np. do Stringa).min
– zwraca minimalną wartość liczbową opakowaną w Optional. W argumencie wymaga, podobnie jak funkcja sort, aby wskazać sposób w jaki chcesz posortować elementy.max
– podobnie jak wyżej, tylko dotyczy szukania wartości maksymalnej.count
– policzy, ile elementów występuje w końcowym strumieniu.anyMatch
– zwraca wartość typu boolean, gdy jeden element spełnia warunek w argumencie.allMatch
– zwraca wartość typu boolean, gdy wszystkie elementy spełniają warunek w argumencie.noneMatch
– zwraca wartość typu boolean, gdy żaden z elementów nie spełnia warunku w argumencie.- findAny – szuka aż znajdzie jakąkolwiek wartość spełniającą warunek i zwraca ją (działa niedeterministycznie).
- findFirst – szuka aż napotka pierwszą wartość spełniającą warunek i również ją zwróci.
Najczęściej będziesz korzystać z metody collect, w celu konwersji strumienia w jedną z dostępnych w JDK kolekcji.
List<String> games = Arrays.asList("Age of Empire", "Baldurs Gate", "Max Payne", "Star Craft"); List<String> bigNames = games.stream() .map(String::toUpperCase) .collect(Collectors.toList());
Aby wyprodukować ponownie listę lub zbiór na podstawie strumienia, wystarczy skorzystać z gotowych metod klasy Collectors, takich jak toList czy toSet. Trochę bardziej skomplikowana wygląda konwersja do mapy.
List<String> movies = Arrays.asList("Matrix", "Star Wars", "Godfather"); Map<String, String> listToMap = movies.stream() .collect(Collectors.toMap(Function.identity(), x -> new StringBuilder(x).reverse().toString()));
Powyższy kod dokonuje mapowania wedle wzoru: kluczem będzie element listy, a jej wartością ten sam napis napisany wspak. Metoda toMap korzysta z dwóch argumentów: Function.identity, która po prostu kopiuje te same wartości, które są w liście oraz lambda, która zwraca nowozdefiniowany String.
Korzystanie z ParallelStream
Korzystanie z metod findAny i findFirst dla zwykłych strumieni zawsze zwraca ten sam rezultat. W takim razie po co stworzono dwie osobne metody, skoro wynik jest ten sam? Otóż powodem jest możliwość przetwarzania danych w środowisku wielowątkowym, co znacznie przyśpiesza pracę na bardzo wielu elementach. W takim przypadku korzystanie z tych metod ma już znaczenie.
Wpierw stworzę sobie listę gier, a w niej dwa tytuły zaczynające się na literę „S”.
List<String> games = Arrays.asList("Age of Empire", "Baldurs Gate", "Max Payne", "Star Craft", "Sim City");
Następnie, porównam wyniki obu metod (findFirst oraz findAny), korzystając ze zwykłych strumieni.
String findFirstResult = games.stream().filter(s -> s.startsWith("S")).findFirst().get(); String fidnAnyResult = games.stream().filter(s -> s.startsWith("S")).findAny().get(); System.out.println("No parallel stream used, findFirst result " + findFirstResult + ", findAny result " + fidnAnyResult);
Teraz zaimplementuję to samo, ale z użyciem parallelStreamu.
String findFirstParallelResult = games.parallelStream().filter(s -> s.startsWith("S")).findFirst().get(); String fidnAnyParallelResult = games.parallelStream().filter(s -> s.startsWith("S")).findAny().get(); System.out.println("Parallel stream used, findFirst result " + findFirstParallelResult + ", findAny result " + fidnAnyParallelResult); //FindFirst always print Star Craft
Konsola w pierwszym przypadku zawsze wyświetli ten sam wariant („Star Craft”). W drugim przykładzie, w przypadku findAny jest to zależne od procesora (u mnie jest to „Sim City”).
No parallel stream used, findFirst result Star Craft, findAny result Star Craft Parallel stream used, findFirst result Star Craft, findAny result Sim City
Dzieje się tak dlatego, że w zależności od liczby dostępnych procesorów, wirtualna maszyna Javy dzieli strumień na kilka równych części i stara się znaleźć szukaną wartość równolegle w każdej z nich.
Operacja reduce
Ciekawym rodzajem funkcji, są operacje typu reduce, które poniekąd łączą cech zarówno operacji terminate jak i intermediate. Operacja reduce pozwala, za pomocą wyrażenia lambda, na złączenie wszystkich elementów strumienia w jeden rezultat.
Akcja ta posiada trzy charakterystyczne pozycje:
- Identity – wartość początkowa i domyślna, na wypadek, gdyby strumień, był pusty (nieobowiązkowa).
- Accumulator – wyrażenie lambda, które będzie „przepisem”, w jaki sposób złączyć elementy.
- Combiner – w przypadku użycia strumieni równoległych (parallel stream), będzie to funkcja, która złączy wyniki poszczególnych części strumienia w całość. Jeśli skorzystasz z pojedynczego strumienia, to można pominąć to pole.
Przykład implementacji, która policzy sumę wszystkich elementów listy.
List<Integer> listNumber = Arrays.asList(1,2,3,4,5); listNumber.stream().reduce(0, (x, y) -> x + y);
Wynik powinien wynieść: 15
Ta sama funkcja, ale za pomocą równoległych strumieni będzie wyglądać tak:
listNumber.parallelStream().reduce(0, (x, y) -> x + y, Integer::sum);
*W celu pogłębienia wiedzy o streamach, zachęcam do zajrzenia do oficjalnej dokumentacji: https://docs.oracle.com/javase/8/docs/api/?java/util/stream/Stream.html
Kod do lekcji: https://github.com/developeronthego/java-jdk8/tree/master/src/main/java/java8/stream
Porównaj swoje wiadomości z tej lekcji do strumieni systemowych: Java #10: Strumienie systemowe lub wejścia/wyjścia: Java #40: strumienie wejścia/wyjścia