Home > Fragmenty szkoleń > Przetwarzanie danych za pomocą strumieni Java SE 8+

Przetwarzanie danych za pomocą strumieni Java SE 8+

Szkolenia IT Przetwarzanie danych za pomocą strumieni Java SE 8+

Prawie każda aplikacja Java tworzy i przetwarza kolekcje. Kolekcje są podstawą wielu zagadnień programistycznych: pozwalają grupować i przetwarzać dane. Na przykład możesz chcieć utworzyć zbiór zamówień złożonych przez klientów. Następnie możesz przetworzyć całą kolekcję, aby dowiedzieć się, ile zamówień złożył konkretny klient.

Pomimo ogromnego ich znaczenia przetwarzanie kolekcji w Javie było dotychczas dalekie od doskonałości.

Podczas przetwarzania kolekcji spotykamy się z zagadnieniami podobnymi do operacji w języku SQL, takimi jak wyszukiwanie (np. znajdowanie zamówień danego klienta) lub grupowanie (np. grupowanie zamówień wg. ich typu). Większość baz danych dostarcza prostych możliwości deklaratywnego definiowania tego typu operacji udostępniając interfejs w postaci języka SQL.

Np. kwerenda

SELECT id, count(1) as order_count from orders group by client_id

pozwala zgrupować kolekcję wg. identyfikatora klienta i pobrać oraz wyświetlić id klienta oraz liczbę zamówień dla każdego z klientów.

Jak widać korzystając z języka SQL nie musimy implementować mechanizmu grupowania kolekcji po wartości wybranego pola jak również mechanizmu zliczania elementów każdej grupy. Wydaje się oczywistym , iż podobne mechanizmy ułatwiające obsługę kolekcji powinny znaleźć się w każdym języku programowania, w tym w Javie.

Od wersji 8 Java SE w celu ułatwienia obsługi kolekcji wprowadzono nowe rozwiązanie – Strumienie (Streams). Założeniem było zapewne, aby dostarczony mechanizm pozwalał na przetwarzanie danych w sposób deklaratywny. Dodatkowo strumienie mogą wykorzystywać przetwarzanie wielordzeniowe bez konieczności pisania kodu wielowątkowego – co oznacza iż powinny być bardziej efektywne niż dotychczasowe konstrukty.

Wróćmy do „tradycyjnej Javy i spróbujmy wykonać prostą operację na kolekcji

// Wczytaj plik i zapisz do zmiennej typu String (ciąg znaków)

String tresc = new String(Files.readAllBytes(

                Paths.get(„zemsta.txt”)), StandardCharsets.UTF_8);

 

// Podziel treść na słowa; znaki inne niż litery są ogranicznikami

List<String> slowa = Arrays.asList(tresc.split(„\\PL+”));

W tym miejscu mamy gotową kolekcję, którą możemy na różne sposoby przetwarzać. Załóżmy, że chcemy zliczyć wszystkie wyrazy dłuższe niż 12 znaków. W starym podejściu zrobimy to na przykład w ten sposób:

int licznik = 0;

for (String s : słowa) {

                if (s.length() > 12) licznik++;

}

 

Korzystając ze strumieni, zrobimy to tak:

 

long licznik = slowa.stream()

                .filter(s -> s.length() > 12)

                .count();

Jak widać w powyższym kodzie nie mamy śladu instrukcji pętli lub zliczania elementów. Same nazwy metod tłumaczą nam, co zostanie wykonane w tym kodzie. Ponadto, gdy w pętli szczegółowo opisane są wszystkie wykonywane operacje, strumień zaś może zaplanować działania w dowolny sposób gwarantujący uzyskanie poprawnego wyniku.

Myśląc o strumieniach rozważamy „co”, a nie „jak” ma być wykonane. W naszym przykładzie definiujemy, co chcemy wykonać: wybrać długie wyrazy i je policzyć. Nie określamy, w jakiej kolejności ani w jakim wątku ma to być wykonane — inaczej niż w pętli zaprezentowanej wcześniej, gdzie opisano, jakie dokładnie obliczenia mają zostać wykonane.

Dodatkowo mamy do dyspozycji innego typu ułatwienia. Prosta zamiana stream na parallelStream pozwala bibliotece obsługującej strumienie zrównoleglić filtrowanie i zliczanie:

long licznik = slowa.parallelStream()

                .filter(s -> s.length() > 12)

                .count();

Innym, acz równie obrazowym przykładem niech będzie operacja sortowania listy zamówień względem ich wartości. Dodatkowo sortujemy jedynie duże zamówienia o wartości powyżej 500).

W podejściu tradycyjnym nasz kod mógłby wyglądać następująco:

List<Zamowienie> zamowieniaDuze = new Arraylist<>();

for(Zamowienie t: Zamowienies){

  if(t.getValue() > 500){

    zamowieniaDuze.add(t);

  }

}

Collections.sort(zamowieniaDuze, new Comparator(){

  public int compare(Zamowienie t1, Zamowienie t2){

    return t2.getValue().compareTo(t1.getValue());

  }

});

List<Integer> ZamowienieIds = new ArrayList<>();

for(Zamowienie t: zamowieniaDuze){

  ZamowieniesIds.add(t.getId());

}

W nowym podejściu, z wykorzystaniem strumieni kod wygląda następująco:

List<Integer> zamowieniaIds =

    zamowienia.stream()

                .filter(t -> t.getValue() > 500)

                .sorted(comparing(Zamowienie::getValue).reversed())

                .map(Zamowienie::getId)

                .collect(toList());

Ponownie, jak poprzednio, nie definiujemy w „jaki” sposób (wewnętrznie) ma przebiegać proces sortowania, a jedynie wskazujemy, „co” chcemy uzyskać

Czym różni się strumień od typowej kolekcji w Javie:

  • Strumień nie przechowuje swoich elementów. Mogą one być przechowywane w wykorzystywanej przez strumień kolekcji lub generowane na żądanie.
  • Pipelining: wiele operacji na strumieniu również zwraca strumień. Pozwala to na powiązanie operacji w celu utworzenia większego łańcucha. Umożliwia to pewne optymalizacje, takie jak np. lazy loading. Dodatkowo operacje strumienia nie modyfikują danych źródłowych. Na przykład metoda filter nie usuwa elementów ze strumienia, ale zwraca nowy strumień, w którym pewne elementy nie są umieszczane.
    • Operacje wykonywane przez strumień są leniwe, jeśli tylko jest to możliwe. Oznacza to, że ich wykonanie jest opóźniane do chwili, gdy potrzebny jest wynik działania. Na przykład jeśli zażądasz pierwszych pięciu długich słów, a nie wszystkich, metoda filter zatrzyma filtrowanie po odnalezieniu piątego słowa. Dzięki temu możesz korzystać nawet ze strumieni o nieskończonej długości!
  • Iteracja wewnętrzna: w przeciwieństwie do kolekcji, które są iterowane jawnie (iteracja zewnętrzna), operacje strumieniowe wykonują iterację „za kulisami”.

Wracając do naszego poprzedniego przykładu:

List<Integer> zamowieniaIds =

    zamowienia.stream()

                .filter(t -> t.getValue() > 500)

                .sorted(comparing(Zamowienie::getValue).reversed())

                .map(Zamowienie::getId)

                .collect(toList());

Widzimy, iż strumień możemy uzyskać z obiektu listy (List) za pomocą metody stream(). Jak wspomnieliśmy wyżej, można również użyć metody parallelStream() jeśli zależy nam na zrównolegleniu przetwarzania.

Kolejne operacje wywoływane są kolejno tworząc łańcuch, w ramach którego przetwarzany jest potok danych. Możemy tutaj dostrzec analogie do przetwarzania danych w języku SQL.

Analizując zaprezentowany kod możemy zwrócić uwagę, iż jego struktura jest w pewien sposób powtarzalna. Można powiedzieć, iż praca ze strumieniami polega na zdefiniowaniu następujących jej etapów:

  1. Tworzymy strumień.
  2. Określamy pośrednie operacje przekształcające początkowy strumień do innej postaci; może to wymagać wykonania kilku kroków. Operacje takie nazywamy agregującymi (aggregate operations). Strumienie obsługują operacje podobne do SQL i typowe operacje z języków programowania funkcjonalnego, takie jak filtrowanie, mapowanie, redukcja, wyszukiwanie, dopasowywanie, sortowanie i tak dalej.
  3. Wykonujemy kończącą operację generującą wynik. Ta operacja wymusza wykonanie leniwych operacji niezbędnych do jej zakończenia. Po tym kroku strumień nie może być dalej wykorzystywany.

Operacje pośrednie i końcowe

Jak wspomnieliśmy poprzednio interfejs Stream w java.util .stream.Stream definiuje wiele operacji, które można podzielić na dwie kategorie: operacje pośrednie (agregujące) i kończące (terminujące).

W wypadku operacji pośrednich wynikiem wywołania każdej metody implementującej tego typu operację jest Stream. Z kolei operacje kończące zamykają potok strumienia zwracając go w postaci listy (java.util.List) czy innego typu nie będącego strumieniem (nawet void !!!)

Dlaczego to rozróżnienie jest ważne. Otóż operacje pośrednie nie wykonują żadnego przetwarzania, dopóki operacja w potoku strumienia nie zostanie wywołana operacja kończąca. Operacje pośrednie są wykonywanie „leniwie”. Dzieje się tak, ponieważ operacje pośrednie mogą być z „scalane” i przetwarzane w jednym przebiegu przez operację terminującą.

Weźmy pod uwagę poniższy kod, którego celem jest zapisanie w postaci listy ilości samogłosek w liczących więcej niż 5 znaków wyrazach pochodzących z początkowej frazy Sceny 1 Aktu 1 Zemsty Aleksandra Fredry:

List<String> slowa = Arrays.asList(„Piękne”,„dobra”,„w”,„każdym”,„względzie”,„Lasy”,„gleba”,„wyśmienita”,„Dobrą”,„żoną”,„pewnie”,„będzie”,„Co”,„za”,„czynsze”,„To”,„kobiéta”);

 

 List<Long> samogloski_w_slowach =

    slowa.stream()

           .filter(s -> {

                    System.out.println(„filtering „ + s);

                    return s.length()>5;

                  })

           .map(s -> {

                    System.out.println(„mapping „ + s);

                    System.out.println(„vowels count „ + policzSamogloski(s));

                    return policzSamogloski(s);

                  })

           .limit(2)

           .collect(Collectors.toList());

    }

Poniżej definicja metody policz Samogłoski;

  long policzSamogloski(String slowo){

        slowo = slowo.toLowerCase();

        long result = slowo.chars().mapToObj(c -> (char) c)

            .filter(ch -> „aeiouyąę”.indexOf(ch)>0).count();

        return result;

    }

W efekcie wykonania powyższego kodu otrzymamy następujący listng:

//run:

filtering Piękne

mapping Piękne

vowels count 3

filtering dobra

filtering w

filtering każdym

mapping każdym

vowels count 1

Jak widać wszystkie operacje pośrednie wywoływane są w jednym cyklu iteracji. Co więcej iteracja kończy się w momencie zmapowania dwóch elementów strumienia wyjściowego, co określono parametrem metody limit (limit(2)). Metoda limit pozwala nam otrzymać wynik używając tzw. short-circuiting’u powodując, że musimy przetworzyć tylko część strumienia, a nie jego całość.

Operacje filter i map zostały z kolei połączone (zmerge’owane) w tym samym przebiegu iteracji.

Omówimy teraz najważniejsze metody pośrednie:

  • map: metoda służy do przekształcania danych strumienia. Standardowo jako jej parametr przekazujemy funkcję (java.util.function.Function), która odpowiada za wykonanie takiego przekształceni, np.:

Stream<String> małeSłowa = słowa.stream().map(String::toLowerCase);

  • filter: metoda służy do eliminacji elementów na podstawie zadanych kryteriów. Generalnie metoda filter przyjmuje parametr typu java.util.function.Predicate i zwraca strumień zawierający wszystkie elementy, które pasują do danego predykatu
  • distinct: Zwraca strumień z unikalnymi elementami (zgodnie z implementacją equals dla elementu stream)
  • limit (n): zwraca strumień, który nie jest dłuższy niż podany rozmiar n
  • skip (n): Zwraca strumień odrzucając n pierwszych elementów
  • sorted: metoda używana do sortowania strumienia. Poniższy segment kodu pokazuje, jak wylistować 10 liczb losowych w posortowanej kolejności:

Random random = new Random();

random.ints().limit(10).sorted().forEach(System.out::println);

 

Metody kończące:
  • kolekcjonujące: Kolektory służą do łączenia wyniku przetwarzania na elementach strumienia w celu zwrócenia wyniku zgodnego z zadanym typem.
    • collect(Collector c): operacja kolekcjonująca, wykonuje mutowalną operację redukcji na elementach strumienia korzystając z kolektora (java.util.stream.Collector) przekazanego jako parametr
  • redukujące: służą redukcji strumienia do niemutowalnego obiektu. Podobnie jak w wypadku metody collect łączenie następuje w efekcie przetworzenia elementów strumienia, jednak w tym wypadku efektem przetwarzania jest pojedyncza wartość wynikowa
    • reduce(U identity, BiFunction accumulator, BinaryOperator combiner): najbogatsza wersja metody reduce przyjmuje trzy parametry:
      • identity – element będący początkową wartością operacji redukcji i domyślnym wynikiem, jeśli strumień jest pusty
      • accumulator – funkcja przyjmująca dwa parametry: częściowy wynik operacji redukcji oraz kolejny element strumienia
      • combiner – funkcja używana do łączenia częściowego wyniku operacji redukcji, gdy redukcja jest równoległa lub gdy występuje niezgodność między typami argumentów akumulatora a typami implementacji akumulatora

Najlepszym sposobem zilustrowania działania operacji redukcji niech będzie poniższy przykład:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

int result = numbers

  .stream()

  .reduce(0, (subtotal, element) -> subtotal + element);

System.out.println(result);

  • forEach: w jednym z przykładów powyżej wykorzystano metodę forEach, która również jest rodzajem operacji terminującej. Metoda pozwala wykonać jawną iterację (a przy tym dowolną kastomową funkcję) każdym elemencie strumienia. Metoda nic nie zwraca (void).
  • proste redukcje: interfejs Stream dostarcza nam dodatkowo wielu prostych operacji redukcji:
    • dopasowania: podobnie jak filtry służą eliminacji wskazanych elementów, z tym że stanowią równocześnie rodzaj operacji terminującej która po wykonaniu zwraca wynik w postaci wartości logicznej (boolean)
      • anyMatch: przyjmuje parametr typu java.util.function.Predicate i zwraca wartość logiczną w zależności od tego, czy w strumieniu znaleziono (czy też nie) elementy pasujące do wzorca.
      • allMatch: jak wyżej, z tym ze wszystkie elementy strumienia muszą spełniać zadany warunek, aby metoda mogła zwrócić true.
      • noneMatch: zwraca true jeśli żaden z elementów strumienia nie psuje do wzorca.
    • findAny, findFirst: operacje o nieco mylącej nazwie, nie służące wbrew pozorom do przeszukiwania strumienia na bazie zadanego wzorca, ale zwracające dowolny lub pierwszy element strumienia „opakowany” w wartość typu Optional
    • count: zwraca liczbę elementów strumienia.
    • max i min: zwracają największą i najmniejszą wartość spośród elementów strumienia. Metody te zwracają wartość Optional, która opakowuje wartość zwracaną lub wskazuje, że nie ma żadnej wartości (ponieważ strumień był pusty). Oto przykład wykorzystania redukcji max:

//Maksymalną wartość ze strumienia możesz pobrać w taki sposób:

Optional<String> najdłuższe = slowa.max(String::compareToIgnoreCase);

System.out.println(„najdłuższe: „ + largest.getOrElse(„”));

Tworzenie strumieni

Strumienie można tworzyć z różnych źródeł elementów takich jak np. kolekcja lub tablica za pomocą metod stream() i of().

Każdą kolekcję można zamienić w strumień za pomocą metody stream interfejsu Collection. W przypadku tablicy można do tego wykorzystać statyczną metodę Stream.of:

Stream<String> slowa = Stream.of(tresc.split(„\\PL+”));

// split zwraca tablicę String[]

Metoda of ma parametr o zmiennej liczbie elementów, dzięki czemu możesz utworzyć strumień z dowolnej liczby argumentów

Stream<String> piosenka = Stream.of(„gdzie”, „strumyk”, „płynie”, „z”, „wolna”);

Można również użyć Arrays.stream(tablica, od, do), by utworzyć strumień z części tablicy.

Z kolei aby utworzyć pusty strumień, wykorzystaj statyczną metodę Stream.empty:

Stream<String> silence = Stream.empty();

// Typ uogólniony <String> jest ustalany; jednoznaczne z Stream.<String>empty()

 

Interfejs Stream posiada dwie statyczne metody do tworzenia nieskończonych strumieni.

  • Metoda generate pobiera bezargumentową funkcję (lub, dokładniej, obiekt implementujący interfejs Supplier<T>). Gdy jest potrzebna wartość ze strumienia, funkcja ta jest wywoływana, by utworzyć kolejną wartość. Można otrzymać strumień jednakowych wartości za pomocą wyrażenia

Stream<String> powtórki = Stream.generate(() -> „Echo”);

 

lub strumień liczb losowych, wywołując

Stream<Double> losowe = Stream.generate(Math::random);

 

  • Metody iterate służy do tworzenia nieskończonych ciągów takich jak 0 1 2 3 …, Metoda ta przyjmuje wartość początkową oraz funkcję (konkretnie UnaryOperator<T>) i w pętli wywołuje funkcję, podając jako parametr poprzedni wynik.

 

Stream<BigInteger> integers

= Stream.iterate(BigInteger.ZERO, n -> n.add(BigInteger.ONE));

 

//Pierwszym elementem ciągu jest wartość początkowa (ang. seed) równa BigInteger.ZERO.

 

//Kolejnym elementem jest f(seed), czyli 1 (wartość typu BigInteger). Kolejny element to f(f(seed)), czyli 2 itd.

 

 

Typ Optional

W zaprezentowanym niżej przykładzie wykorzystano po raz kolejny typ Optional. Niektóre metody zwracają wartość Optional<T>, która opakowuje wartość faktycznie zwracaną lub wskazuje, że nie ma żadnej wartości (ponieważ strumień był pusty).

//Maksymalną wartość ze strumienia możesz pobrać w taki sposób:

Optional<String> najdłuższe = slowa.max(String::compareToIgnoreCase);

System.out.println(„najdłuższe: „ + largest.getOrElse(„”));

Dawniej często w takiej sytuacji zwracano wartość null. Zwracanie null może jednak doprowadzić do wyjątków typu Null Pointer Exception. Typ Optional jest lepszym sposobem informowania o braku zwracanej wartości.

…..

Po więcej informacji zapraszamy na szkolenie.

Artykuły na Dev-Academy.pl

Może Cię również zainteresować: