Java 8 Streams – przetwarzanie kolekcji

TL;DR: Java 8 Streams to nowy sposób przetwarzania kolekcji używając podejścia funkcjonalnego. Zamiast pisać pętle for-each, używasz metod jak filter(), map(), reduce(). Kod staje się krótszy, czytelniejszy i łatwiej można go zrównoleglić.

Java 8 wprowadziła jedną z największych rewolucji w historii języka – Streams API. Jeśli dotychczas przetwarzałeś kolekcje używając tradycyjnych pętli, przygotuj się na zmianę myślenia. Streams pozwalają pisać kod w stylu funkcjonalnym, który jest bardziej ekspresyjny i często szybszy niż imperatywne podejście.

Dlaczego Streams są ważne

Java 8 to przełomowa wersja wydana w 2014 roku, która wprowadza programowanie funkcjonalne do ekosystemu Java. Streams API to odpowiedź na potrzeby nowoczesnego programowania – przetwarzanie dużych zbiorów danych, wykorzystanie wielordzeniowych procesorów i pisanie bardziej czytelnego kodu.

W 2016 roku firmy zaczynają masowo migrować na Java 8. Znajomość Streams API staje się wymaganiem w ofertach pracy. Kod napisany w stylu funkcjonalnym jest łatwiejszy do zrozumienia i testowania, co przekłada się na mniejsze koszty utrzymania aplikacji.

Co się nauczysz:

  • Jak utworzyć i używać Stream z różnych źródeł danych
  • Jakie są podstawowe operacje: filter, map, reduce, collect
  • Kiedy używać Streams zamiast tradycyjnych pętli
  • Jak zrównoleglić przetwarzanie z Parallel Streams
  • Najlepsze praktyki i typowe pułapki w Streams API

Wymagania wstępne:

  • Dobra znajomość kolekcji Java (List, Set, Map)
  • Podstawy lambda expressions w Java 8
  • Rozumienie pojęć: immutability, side effects
  • Znajomość pętli for-each i iteratorów

Problem z tradycyjnym podejściem

Analogia: Tradycyjne pętle to jak gotowanie krok po kroku – weź garnek, nalej wodę, postaw na gazie, czekaj aż zagotuje. Streams to jak przepis kulinarny – „ugotuj makaron al dente” – mówisz CO chcesz osiągnąć, nie JAK to zrobić.

Zobaczmy typowy kod do przetwarzania listy przed Java 8:

// Tradycyjne podejście - imperatywne
List<String> names = Arrays.asList("Jan", "Anna", "Piotr", "Aleksandra", "Tomasz");

// Znajdź wszystkie imiona dłuższe niż 4 znaki, 
// przekonwertuj na wielkie litery i posortuj
List<String> result = new ArrayList<>();

for (String name : names) {
    if (name.length() > 4) {  // Filtrowanie
        result.add(name.toUpperCase());  // Transformacja
    }
}

Collections.sort(result);  // Sortowanie

System.out.println(result); // [ALEKSANDRA, ANNA, PIOTR, TOMASZ]

Ten sam kod z Java 8 Streams:

// Podejście funkcjonalne z Streams
List<String> names = Arrays.asList("Jan", "Anna", "Piotr", "Aleksandra", "Tomasz");

List<String> result = names.stream()
    .filter(name -> name.length() > 4)    // Filtrowanie
    .map(String::toUpperCase)             // Transformacja  
    .sorted()                             // Sortowanie
    .collect(Collectors.toList());        // Zbieranie wyniku

System.out.println(result); // [ALEKSANDRA, PIOTR, TOMASZ]
Pro tip: Streams używają „fluent interface” – możesz łączyć operacje w łańcuch. Każda operacja zwraca nowy Stream, więc nie modyfikujesz oryginalnej kolekcji.

Tworzenie Streams z różnych źródeł

Stream możesz utworzyć na różne sposoby:

// Z kolekcji
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> streamFromList = list.stream();

// Z tablicy
String[] array = {"x", "y", "z"};
Stream<String> streamFromArray = Arrays.stream(array);

// Z pojedynczych wartości
Stream<String> streamFromValues = Stream.of("jeden", "dwa", "trzy");

// Nieskończony stream liczb
Stream<Integer> infiniteNumbers = Stream.iterate(0, n -> n + 2);

// Z zakresu liczb
IntStream range = IntStream.range(1, 10); // 1,2,3...9

// Z pliku (każda linia to element)
Stream<String> lines = Files.lines(Paths.get("plik.txt"));
Uwaga: Stream można „skonsumować” tylko raz. Po wywołaniu operacji terminalnej (jak collect()) Stream zostaje zamknięty i nie można go już używać.

Operacje pośrednie (Intermediate Operations)

Operacje pośrednie przekształcają Stream ale nie wykonują przetwarzania do momentu wywołania operacji terminalnej. To nazywa się „lazy evaluation”.

filter() – filtrowanie elementów

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

// Tylko liczby parzyste
List<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());
// [2, 4, 6, 8, 10]

// Można łączyć warunki
List<Integer> filtered = numbers.stream()
    .filter(n -> n > 3)
    .filter(n -> n < 8)
    .collect(Collectors.toList());
// [4, 5, 6, 7]

map() – transformacja elementów

List<String> words = Arrays.asList("hello", "world", "java", "streams");

// Konwersja na wielkie litery
List<String> upperCase = words.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());
// [HELLO, WORLD, JAVA, STREAMS]

// Długość każdego słowa
List<Integer> lengths = words.stream()
    .map(String::length)
    .collect(Collectors.toList());
// [5, 5, 4, 7]

// Transformacja obiektów
List<User> users = getUsers();
List<String> userNames = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());

sorted() – sortowanie

List<String> names = Arrays.asList("Zofia", "Adam", "Barbara", "Czesław");

// Sortowanie naturalne
List<String> sortedNames = names.stream()
    .sorted()
    .collect(Collectors.toList());
// [Adam, Barbara, Czesław, Zofia]

// Sortowanie z własnym Comparatorem
List<String> sortedByLength = names.stream()
    .sorted(Comparator.comparing(String::length))
    .collect(Collectors.toList());
// [Adam, Zofia, Barbara, Czesław]

// Sortowanie odwrotne
List<String> reversed = names.stream()
    .sorted(Comparator.reverseOrder())
    .collect(Collectors.toList());

distinct() i limit()

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

// Usunięcie duplikatów
List<Integer> unique = numbersWithDuplicates.stream()
    .distinct()
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5]

// Pierwsze 3 elementy
List<Integer> firstThree = numbersWithDuplicates.stream()
    .distinct()
    .limit(3)
    .collect(Collectors.toList());
// [1, 2, 3]

// Pomiń pierwsze 2, weź kolejne 3
List<Integer> skipAndLimit = numbersWithDuplicates.stream()
    .distinct()
    .skip(2)
    .limit(3)
    .collect(Collectors.toList());
// [3, 4, 5]

Operacje terminalne (Terminal Operations)

Operacje terminalne wykonują przetwarzanie i „konsumują” Stream:

collect() – zbieranie wyników

List<String> words = Arrays.asList("java", "python", "javascript", "go");

// Do listy
List<String> list = words.stream()
    .filter(w -> w.length() > 4)
    .collect(Collectors.toList());

// Do setu (unikalne wartości)
Set<String> set = words.stream()
    .collect(Collectors.toSet());

// Do mapy (słowo -> długość)
Map<String, Integer> wordLengths = words.stream()
    .collect(Collectors.toMap(
        word -> word,           // klucz
        String::length          // wartość
    ));

// Łączenie w string
String joined = words.stream()
    .collect(Collectors.joining(", "));
// "java,python,javascript,go"

forEach() – wykonanie akcji dla każdego elementu

List<String> names = Arrays.asList("Jan", "Anna", "Piotr");

// Wydrukuj każde imię
names.stream()
    .map(String::toUpperCase)
    .forEach(System.out::println);

// Nie rób tego! forEach tylko do side effects
names.stream()
    .forEach(name -> {
        // Zła praktyka - modyfikowanie zewnętrznych obiektów
        // someExternalList.add(name.toUpperCase()); 
    });

reduce() – redukcja do jednej wartości

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

// Suma wszystkich liczb
int sum = numbers.stream()
    .reduce(0, Integer::sum);
// 15

// Iloczyn wszystkich liczb
int product = numbers.stream()
    .reduce(1, (a, b) -> a * b);
// 120

// Najdłuższe słowo
List<String> words = Arrays.asList("kot", "pies", "słoń", "mysz");
Optional<String> longest = words.stream()
    .reduce((w1, w2) -> w1.length() > w2.length() ? w1 : w2);
// Optional[słoń]

Operacje sprawdzające

List<Integer> numbers = Arrays.asList(2, 4, 6, 8, 10);

// Czy wszystkie są parzyste?
boolean allEven = numbers.stream()
    .allMatch(n -> n % 2 == 0);
// true

// Czy jakieś są większe niż 5?
boolean anyGreaterThan5 = numbers.stream()
    .anyMatch(n -> n > 5);
// true

// Czy żadne nie są ujemne?
boolean noneNegative = numbers.stream()
    .noneMatch(n -> n < 0);
// true

// Pierwszy element większy niż 5
Optional<Integer> firstGreater = numbers.stream()
    .filter(n -> n > 5)
    .findFirst();
// Optional[6]

Parallel Streams – wykorzystanie wielordzeniowości

Jedną z największych zalet Streams jest łatwość równoległego przetwarzania:

List<Integer> largeList = IntStream.range(0, 1_000_000)
    .boxed()
    .collect(Collectors.toList());

// Sekwencyjne przetwarzanie
long sequentialSum = largeList.stream()
    .mapToLong(i -> expensiveOperation(i))
    .sum();

// Równoległe przetwarzanie - często szybsze!
long parallelSum = largeList.parallelStream()
    .mapToLong(i -> expensiveOperation(i))
    .sum();

// Lub zamiana zwykłego Stream na parallel
long parallelSum2 = largeList.stream()
    .parallel()
    .mapToLong(i -> expensiveOperation(i))
    .sum();
Uwaga: Parallel Streams nie zawsze są szybsze! Dla małych kolekcji lub prostych operacji overhead może być większy niż korzyść. Testuj wydajność w swoim przypadku.

Praktyczny przykład – analiza danych

Zobaczmy praktyczny przykład analizy listy pracowników:

public class Employee {
    private String name;
    private String department;
    private int salary;
    private int age;
    
    // konstruktor, gettery, settery...
}

List<Employee> employees = getEmployees();

// Średnia pensja w dziale IT
OptionalDouble avgItSalary = employees.stream()
    .filter(emp -> "IT".equals(emp.getDepartment()))
    .mapToInt(Employee::getSalary)
    .average();

// Najstarszy pracownik w każdym dziale
Map<String, Optional<Employee>> oldestByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.maxBy(Comparator.comparing(Employee::getAge))
    ));

// Top 5 najlepiej zarabiających
List<String> topEarners = employees.stream()
    .sorted(Comparator.comparing(Employee::getSalary).reversed())
    .limit(5)
    .map(Employee::getName)
    .collect(Collectors.toList());

// Statystyki pensji
IntSummaryStatistics salaryStats = employees.stream()
    .mapToInt(Employee::getSalary)
    .summaryStatistics();

System.out.println("Średnia: " + salaryStats.getAverage());
System.out.println("Min: " + salaryStats.getMin());
System.out.println("Max: " + salaryStats.getMax());

Best practices i typowe błędy

Best practices:

  • Używaj method references gdy to możliwe: String::length zamiast s -> s.length()
  • Unikaj side effects w operacjach pośrednich
  • Preferuj collect() zamiast forEach() do budowania wyników
  • Używaj specialized streams (IntStream, LongStream) dla primitives
Błąd #1: Próba wielokrotnego użycia tego samego Stream. Stream można „skonsumować” tylko raz.
Błąd #2: Modyfikowanie oryginalnej kolekcji podczas przetwarzania Stream. To może prowadzić do ConcurrentModificationException.
Błąd #3: Używanie Parallel Streams bez testowania wydajności. Nie zawsze są szybsze od sekwencyjnych.
Błąd #4: Zbyt długie łańcuchy operacji. Jeśli pipeline ma więcej niż 5-6 operacji, rozważ podział na mniejsze części.
Kiedy używać Streams zamiast tradycyjnych pętli?

Streams są lepsze gdy przetwarzasz kolekcje funkcjonalnie (filter, map, reduce). Tradycyjne pętle nadal są dobre do prostych iteracji i gdy potrzebujesz złożonej logiki sterującej.

Czy Streams są zawsze szybsze?

Nie. Dla małych kolekcji tradycyjne pętle mogą być szybsze. Streams mają overhead, ale oferują lepszą czytelność kodu i możliwość łatwego równoległego przetwarzania.

Co to jest lazy evaluation w Streams?

Operacje pośrednie nie są wykonywane od razu, tylko zapisywane. Faktyczne przetwarzanie rozpoczyna się dopiero przy operacji terminalnej. To pozwala na optymalizacje.

Jak debugować Streams?

Użyj .peek() do podglądania elementów w pipeline: .peek(System.out::println). Lub przerwij łańcuch i sprawdź wyniki pośrednie.

Czy mogę modyfikować obiekty w Stream?

Technически tak, ale nie powinieneś. Streams promują functional programming – twórz nowe obiekty zamiast modyfikować istniejące.

Co to są Collectors?

Collectors to gotowe implementacje operacji collect(). Pozwalają zbierać wyniki do list, map, grupować dane, robić statystyki itp.

Kiedy używać Optional z Streams?

Operacje jak findFirst(), reduce(), min(), max() zwracają Optional bo mogą nie znaleźć wyniku (np. dla pustego Stream).

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Masz listę transakcji bankowych:

class Transaction {
    String type; // "CREDIT" lub "DEBIT"
    double amount;
    String category; // "FOOD", "TRANSPORT", "ENTERTAINMENT"
    LocalDate date;
}

Używając Streams napisz kod który:

  • Znajdzie sumę wszystkich wydatków (DEBIT) w kategorii FOOD
  • Zgrupuje transakcje po kategorii i policzy średnią kwotę
  • Znajdzie 3 największe single transakcje z ostatniego miesiąca
  • Sprawdzi czy są jakieś transakcje większe niż 1000

Masz pytania o Java 8 Streams? Podziel się swoimi doświadczeniami w komentarzach – Streams na początku mogą wydawać się skomplikowane, ale szybko stają się naturalną częścią kodu!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry