To jest jeden z artykułów w ramach darmowego kursu programowania w Javie. Proszę zapoznaj się z pozostałymi częściami, mogą one być pomocne w zrozumieniu materiału z tego artykułu.
W szczególności potrzebna będzie wiedza na temat kolekcji, typów generycznych i wyrażeń lambda.
Czym są strumienie
Strumienie służą do przetwarzania danych. Zawierają1 dane i pozwalają na opisanie co chcesz zrobić tymi danymi.
Dane mogą być przechowywane w kolekcji, mogą być wynikiem pracy z wyrażeniami regularnymi. W strumień możesz opakować praktycznie dowolny zestaw danych. Strumienie pozwalają w łatwy sposób zrównoleglić pracę na danych. Dzięki temu przetwarzanie dużych zbiorów danych może być dużo szybsze. Strumienie kładą nacisk na operacje jakie należy przeprowadzić na danych.
Niestety pojęcie strumienia jest dość szerokie. Możesz się z nim także spotkać w przypadku pracy z plikami. W tym artykule mówiąc o strumieniach mam na myśli klasy implementujące interfejs Stream
.
Strumień na przykładzie
Proszę spójrz na przykład poniżej. Postaram się pokazać Ci dwa różne sposoby na zrealizowanie wymagań. Pierwszy ze sposobów będzie opierał się na pętli, drugi na strumieniach.
public class BoardGame {
public final String name;
public final double rating;
public final BigDecimal price;
public final int minPlayers;
public final int maxPlayers;
public BoardGame(String name, double rating, BigDecimal price, int minPlayers, int maxPlayers) {
this.name = name;
this.rating = rating;
this.price = price;
this.minPlayers = minPlayers;
this.maxPlayers = maxPlayers;
}
}
Klasa BoardGame
opisuje grę planszową. Przy jej pomocy możesz utworzyć listę gier:
List<BoardGame> games = Arrays.asList(
new BoardGame("Terraforming Mars", 8.38, new BigDecimal("123.49"), 1, 5),
new BoardGame("Codenames", 7.82, new BigDecimal("64.95"), 2, 8),
new BoardGame("Puerto Rico", 8.07, new BigDecimal("149.99"), 2, 5),
new BoardGame("Terra Mystica", 8.26, new BigDecimal("252.99"), 2, 5),
new BoardGame("Scythe", 8.3, new BigDecimal("314.95"), 1, 5),
new BoardGame("Power Grid", 7.92, new BigDecimal("145"), 2, 6),
new BoardGame("7 Wonders Duel", 8.15, new BigDecimal("109.95"), 2, 2),
new BoardGame("Dominion: Intrigue", 7.77, new BigDecimal("159.95"), 2, 4),
new BoardGame("Patchwork", 7.77, new BigDecimal("75"), 2, 2),
new BoardGame("The Castles of Burgundy", 8.12, new BigDecimal("129.95"), 2, 4)
);
Lista games
zawiera 10 tytułów gier planszowych. Pochodzą one z listy najbardziej popularnych gier według portalu BGG2. Załóżmy, że chciałbyś zrobić znajomemu prezent. Chcesz kupić grę, gra powinna spełniać następujące warunki:
- powinna pozwolić na grę w więcej niż 4 osoby,
- powinna mieć ocenę wyższą niż 8,
- powinna kosztować mniej niż 150 zł.
Następnie chcesz wyświetlić nazwy gier spełniających takie wytyczne wielkimi literami. Warunki te możesz spełnić przy pomocy poniższego fragmentu kodu:
for (BoardGame game : games) {
if (game.maxPlayers > 4) {
if (game.rating > 8) {
if (new BigDecimal(150).compareTo(game.price) > 0) {
System.out.println(game.name.toUpperCase());
}
}
}
}
Prawda, że kod układa się w piękną strzałkę ;)? Taka struktura ma swoją nazwę: Arrow Anti-Pattern. Dobrze jest unikać tego typu zagnieżdżonych warunków. Jednym ze sposobów uniknięcia tego antywzorca może być użycie strumieni:
games.stream()
.filter(g -> g.maxPlayers > 4)
.filter(g -> g.rating > 8)
.filter(g -> new BigDecimal(150).compareTo(g.price) > 0)
.map(g -> g.name.toUpperCase())
.forEach(System.out::println);
Oba sposoby pozwalają na uzyskanie tych samych wyników. Drugi sposób wykorzystuje strumienie i wyrażenia lambda. Operacje na strumieniach wykorzystując wzorzec łączenia metod (ang. method chaining), zwany także płynnym interfejsem (ang. fluent interface).
Rozłożę teraz ten strumień na części pierwsze.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Analiza przykładowego strumienia
Aby w ogóle mówić o operacjach na strumieniu należy go na początku utworzyć. W poprzednim przykładzie użyłem metody stream
. Metoda ta jest metodą domyślną zaimplementowaną w interfejsie Collection
. Pozwala ona na utworzenie strumienia na podstawie danych znajdujących się w danej kolekcji.
Stream<BoardGame> gamesStream = games.stream();
Strumienie zostały wprowadzone w Java 8. W tej wersji także dodano możliwość dodawania metod domyślnych do interfejsów. Te domyślne implementacje metod pozwoliły na dodanie nowych funkcjonalności nie psując kompatybilności wstecz.
Interfejs Stream
jest interfejsem generycznym. Przechowuje on informację o typie, który aktualnie znajduje się w danym strumieniu. W przykładzie powyżej utworzyłem strumień gamesStream
zawierający instancje klasy BoardGame
. Strumień ten utworzyłem na podstawie listy.
Następnie filtruję strumień używając wyrażeń lambda. Zwróć uwagę na to, że każde wywołanie metody filter
tworzy nową instancję klasy Stream
. Każda linijka odpowiedzialna jest za filtr innego rodzaju. Pierwszy wybiera wyłącznie te gry, w które może grać więcej niż 4 graczy. Wśród tak odfiltrowanych gier następnie wybieram te, których ocena jest wyższa niż 8. Ostatnim zawężeniem jest wybranie gier, które kosztują mniej niż 150zł:
Stream<BoardGame> filteredStream = gamesStream
.filter(g -> g.maxPlayers > 4)
.filter(g -> g.rating > 8)
.filter(g -> new BigDecimal(150).compareTo(g.price) > 0);
W tym przypadku nie zapisywałem pośrednich strumieni do zmiennych. Zapisałem wyłącznie wynik, który otrzymam po użyciu wszystkich trzech filtrów. Następnie z każdej gry pobieram jej nazwę i zmieniam ją na pisaną wielkimi literami:
Stream<String> namesStream = filteredStream
.map(g -> g.name.toUpperCase());
Strumień filteredStream
zawiera instancje klasy BoardGame
, z każdej z tych instancji pobieram nazwę. Nazwa ta jest następnie zwracana. Dzięki temu powstaje nowy strumień. Tym razem strumień zawiera zmienne typu String
.
Ostatnią fazą jest wyświetlenie tak wybranych danych. Używam do tego odwołania do metody println
:
namesStream.forEach(System.out::println);
Operacje na strumieniu
Operacje związane ze strumieniami można podzielić na trzy rozłączne grupy:
- tworzenie strumienia,
- przetwarzanie danych wewnątrz strumienia,
- zakończenie strumienia.
Każdy strumień ma dokładnie jedną metodę, która go tworzy na podstawie danych źródłowych3. Następnie dane te są przetwarzane przez dowolną liczbę operacji. Każda z tych operacji tworzy nowy strumień danych wywodzący się z poprzedniego. Na samym końcu strumień może mieć dokładnie jedną metodę kończącą pracę ze strumieniem.
Wymagania dla operacji
Każda z operacji wykonywanych na strumieniu musi spełniać jasno określone wymagania.
Nie posiada stanu
Operacja nie może posiadać stanu. Przykładem operacji, która taki stan posiada jest metoda modify
:
public class StatefullOperation {
private final Set<Integer> seen = new HashSet<>();
private int modify(int number) {
if (seen.contains(number)) {
return number;
}
seen.add(number);
return 0;
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
Stream<Integer> numbers = Stream.of(1, 2, 3, 1, 2, 3, 1, 2, 3);
StatefullOperation requriements = new StatefullOperation();
int sum = numbers.parallel()
.map(requriements::modify)
.mapToInt(n -> n.intValue()).sum();
System.out.println(sum);
}
}
}
Jeśli nie spełnisz tego wymagania może to prowadzić do dziwnych, niedeterministycznych wyników w trakcie równoległego przetwarzania strumienia danych (o przetwarzaniu równoległym przeczytasz w jednym z poniższych akapitów). Spróbuj uruchomić ten fragment wiele razy. Czy dostajesz takie same wyniki za każdym razem :)? Uwierz mi, nie chcesz szukać takich błędów w programach uruchomionych na środowisku produkcyjnym. Znam to, byłem tam, nie rób tego.
Nie modyfikuje źródła danych
Operacja nie może modyfikować źródła danych. Taka modyfikacja jest automatycznie wykryta w trakcie pracy ze strumieniem. Pokazuje ją poniższy fragment kodu:
List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.stream()
.map(v -> numbers.add(v) ? 1 : 0)
.forEach(System.out::println);
Uruchomienie tego kodu kończy się rzuceniem wyjątku:
1
Exception in thread "main" java.util.ConcurrentModificationException
1
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1380)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
at pl.samouczekprogramisty.kursjava.streams.requirements.InterferingOperation.main(InterferingOperation.java:15)
Rodzaje operacji na strumieniach
Tworzenie strumieni
Strumienie można tworzyć na wiele sposobów poniżej pokażę Ci kilka przykładów.
- Strumień na podstawie kolekcji:
Stream<Integer> stream1 = new LinkedList<Integer>().stream();
- Strumień na podstawie tablicy:
Stream<Integer> stream2 = Arrays.stream(new Integer[]{});
- Strumień na podstawie łańcucha znaków rozdzielanego przez wyrażenie regularne:
Stream<String> stream3 = Pattern.compile(".").splitAsStream("some longer sentence");
- Strumień typów prostych:
DoubleStream doubles = DoubleStream.of(1, 2, 3);
IntStream ints = IntStream.range(0, 123);
LongStream longs = LongStream.generate(() -> 1L);
- Strumień danych losowych:
DoubleStream randomDoubles = new Random().doubles();
IntStream randomInts = new Random().ints();
LongStream randomLongs = new Random().longs();
- Pusty strumień:
Stream.empty();
- Strumień danych z pliku:
try (Stream<String> lines = new BufferedReader(new FileReader("file.txt")).lines()) {
// do something
}
Strumień danych z pliku musi być zamknięty. W przykładzie powyżej użyłem do tego konstrukcji try-with-resources. Strumień możesz także zamknąć wywołując na nim metodę close
.
Operacje na strumieniach
Nie opiszę tutaj wszystkich metod dostępnych na strumieniach. Jeśli chcesz poznać ich więcej zachęcam do zapoznania się z dokumentacją interfejsu Stream
.
filter
– zwraca strumień zawierający tylko te elementy dla których filtr zwrócił wartośćtrue
,map
– każdy z elementów może zostać zmieniony do innego typu, nowy obiekt zawarty jest w nowym strumieniu,peek
– pozwala przeprowadzić operację na każdym elemencie w strumieniu, zwraca strumień z tymi samymi elementami,limit
– zwraca strumień ograniczony do zadanej liczby elementów, pozostałe są ignorowane.
Kończenie strumienia
Operacjami kończącymi są wszystkie, które zwracają typ inny niż Stream
. Metody tego typu mogą także nie zwracać żadnych wartości.
forEach
– wykonuje zadaną operację dla każdego elementu,count
– zwraca liczbę elementów w strumieniu,allMatch
– zwraca flagę informującą czy wszystkie elementy spełniają warunek. Przestaje sprawdzać na pierwszym elemencie, który tego warunku nie spełnia,collect
– pozwala na utworzenie nowego typu na podstawie elementów strumienia. Przy pomocy tej metody można na przykład utworzyć listę. KlasaCollectors
zawiera sporo gotowych implementacji.
Właściwości strumieni
Leniwe rozstrzyganie
Strumienie są leniwe :). Oznacza to, że przetwarzają elementy dopiero po wykonaniu metody kończącej. Dodatkowo niektóre operacje powodują wcześniejsze zakończenie czytania danych ze strumienia. Przykładem takiej operacji jest limit
. Poniższy przykład pokaże Ci dokładnie te właściwości:
IntStream numbersStream = IntStream.range(0, 8);
System.out.println("Przed");
numbersStream = numbersStream.filter(n -> n % 2 == 0);
System.out.println("W trakcie 1");
numbersStream = numbersStream.map(n -> {
System.out.println("> " + n);
return n;
});
System.out.println("W trakcie 2");
numbersStream = numbersStream.limit(2);
System.out.println("W trakcie 3");
numbersStream.forEach(System.out::println);
System.out.println("Po");
Po uruchomieniu tego kodu na konsoli będziesz mógł zobaczyć:
Przed
W trakcie 1
W trakcie 2
W trakcie 3
> 0
0
> 2
2
Po
Zauważ, że komunikaty “W trakcie X” zostały wyświetlone przed operacją map
. Zwróć także uwagę na to, że przetwarzanie skończyło się po dwóch elementach. To sprawka metody limit
.
Przetwarzanie sekwencyjne i równoległe
Strumienie mogą być przetwarzane sekwencyjnie bądź równolegle. Metoda stream
tworzy sekwencyjny strumień danych. Metoda parallelStream
tworzy strumień, który jest uruchamiany jednocześnie na kilku wątkach. To ile wątków zostanie uruchomionych zależy od procesora.
Strumień sekwencyjny można przełączyć na równoległy wywołując na nim metodę parallel
. Odwrotna operacja także jest możliwa dzięki metodzie sequential
.
Dobre praktyki
W tym paragrafie postaram się zebrać dobre praktyki ułatwiające pracę ze strumieniami danych.
Filtrowanie na początku
W związku z tym, że operacje na strumieniach wykonywane są tylko wtedy gdy jest to konieczne warto ograniczyć liczbę elementów najwcześniej jak to możliwe. Dzięki takiej prostej operacji możemy znacząco ograniczyć liczbę elementów, na których wykonana będzie czasochłonna metoda. W przykładzie poniżej symuluję czasochłonne wykonanie przez Thread.sleep(100)
. Wywołanie to “usypia” wątek na 100 milisekund 4:
public static int timeConsumingTransformation(int number) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return number;
}
W pierwszym przykładzie czasochłonna metoda wykonana jest na każdej z liczb:
int slowNumber = IntStream.range(1950, 2150)
.map(StreamsGoodPractices::timeConsumingTransformation)
.filter(n -> n == 2000)
.sum();
Lepszym rozwiązaniem, może być odwrócenie kolejności tych operacji. W tym przypadku czasochłonna metoda zostanie wywołana wyłącznie na przefiltrowanych elementach:
int fastNumber = IntStream.range(1950, 2150)
.filter(n -> n == 2000)
.map(StreamsGoodPractices::timeConsumingTransformation)
.sum();
Unikaj skomplikowanych wyrażeń lambda
Skomplikowane, wieloliniowe wyrażenie lambda może nie być czytelne. W takim przypadku, moim zdaniem, lepiej opakować kod w metodę i użyć odnośnika do metody wewnątrz strumienia. Proszę porównaj dwa poniższe przykłady
IntStream.range(1950, 2150)
.filter(y -> (y % 4 == 0 && y % 100 != 0) || y % 400 == 0)
.forEach(System.out::println);
IntStream.range(1950, 2150)
.filter(StreamsGoodPractices::isLeapYear)
.forEach(System.out::println);
public static boolean isLeapYear(int year) {
boolean every4Years = year % 4 == 0;
boolean notEvery100Years = year % 100 != 0;
boolean every400Years = year % 400 == 0;
return (every4Years && notEvery100Years) || every400Years;
}
Chociaż drugi przykład jest zdecydowanie dłuższy wydaje mi się, że jest tez bardziej czytelny. A czytelność kodu ma znaczenie :).
Nie nadużywaj strumieni
Jak ktoś umie obsługiwać młotek to każdy problem wygląda jak gwóźdź. Strumienie są jednym ze sposobów rozwiązania problemu. To nie jest prawda, że znając strumienie powinieneś zapomnieć o pętlach. Dobrze jest znać oba mechanizmy. Poza tym, niektórych konstrukcji nie da się uzyskać przy pomocy strumieni. Przykładem mogą być tu niektóre pętle ze słówkiem kluczowym break
.
Strumienie to nie struktury danych
W poprzednich artykułach opisałem kilka struktur danych. Przykładem struktur danych może być lista wiązana czy mapa. Strumienie nie są strukturą danych. W odróżnieniu od struktur nie służą do przechowywania danych. Strumienie jedynie pomagają określić operacje, które na tych danych chcesz wykonać.
Mówi się, że strumienie pozwalają w deklaratywny sposób opisać operacje na danych. Można to uprościć do stwierdzenia, że struktury służą do przechowywania danych a strumienie służą do opisywania algorytmów, operacji na danych.
Zadania
Na koniec przygotowałem dla Ciebie kilka zadań do rozwiązania, które pomogą Ci utrwalić wiedzę zdobytą w tym artykule:
- Przerób poniższy fragment kodu tak żeby używał strumieni:
double highestRanking = 0; BoardGame bestGame = null; for (BoardGame game : BoardGame.GAMES) { if (game.name.contains("a")) { if (game.rating > highestRanking) { highestRanking = game.rating; bestGame = game; } } } System.out.println(bestGame.name);
- Znajdź minimalny element w kolekcji używając strumieni i funkcji
reduce
. Twoja funkcja powinna działać jak istniejąca funkcjamin
. - Używając metody
flatMap
napisz strumień, który “spłaszczy” listę list.
Jak zwykle zachęcam Cię do samodzielnego rozwiązania zadań, wtedy nauczysz się najwięcej. Jeśli jednak będziesz miał z czymś kłopot możesz rzucić okiem do przykładowych rozwiązań, które przygotowałem.
Dodatkowe materiały do nauki
Poniżej zebrałem dla Ciebie kilka dodatkowych źródeł, które pozwolą spojrzeć Ci na temat strumieni z innej strony.
- Bardzo dobra dokumentacja pakietu
java.util.stream
, - Część I tutoriala dotyczącego strumieni na stronie Oracle,
- Część II tutoriala dotyczącego strumieni na stronie Oracle,
- Szczegółowy opis strumieni – Baeldung,
- Kod źródłowy użyty w tym artykule.
Podsumowanie
Strumienie wraz z wyrażeniami lambda to bardzo użyteczne narzędzie. Po lekturze artykułu wiesz już czym są strumienie i jak z nimi pracować. Potrafisz utworzyć strumień i zaaplikować do niego zestaw operacji. Znasz dobre praktyki pracy ze strumieniami. Rozwiązując ćwiczenia utrwaliłeś wiedzę z artykułu w praktyce.
Na koniec mam do Ciebie prośbę. Podziel się linkiem do artykułu ze swoimi znajomymi jeśli ten artykuł był dla Ciebie wartościowy. Jeśli nie chcesz pominąć kolejnych artykułów na blogu dopisz się do samouczkowego newslettera i polub profil Samouczka Programisty na Facebooku. Do następnego razu!
-
To jest pewne uproszczenie. Strumienie nie muszą zawierać danych, które zwracają. Na przykład strumień generujący kolejne liczby pseudolosowe nie zawiera tych liczb, jedynie je generuje. ↩
-
Sam bardzo często gram w planszówki ;). Grałem w większość wymienionych tu gier – mogę je z czystym sumieniem polecić. ↩
-
Dane źródłowe mogą także pochodzić z innego strumienia. ↩
-
To tylko przykładowa metoda, w praktyce taka czasochłonna operacja może polegać na przykład na pobraniu danych z bazy danych czy z pliku na dysku. ↩
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Zostaw komentarz