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. 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 BGG1. 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.

Jeśli chcesz, mogę powiadamiać Cię o nowych treściach na blogu. Dołącz do grupy Samouków, którzy jako pierwsi dowiadują się o nowościach.

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łowych2. 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");
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ę. Klasa Collectors 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 3:

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:

  1. 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);
    
  2. Znajdź minimalny element w kolekcji używając strumieni i funkcji reduce. Twoja funkcja powinna działać jak istniejąca funkcja min.
  3. 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.

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!

  1. Sam bardzo często gram w planszówki ;). Grałem w większość wymienionych tu gier - mogę je z czystym sumieniem polecić. 

  2. Dane źródłowe mogą także pochodzić z innego strumienia. 

  3. 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. 

Zostaw komentarz