CodeGym /Kursy /JAVA 25 SELF /Leniwe przetwarzanie (lazy evaluation) w Stream API

Leniwe przetwarzanie (lazy evaluation) w Stream API

JAVA 25 SELF
Poziom 33 , Lekcja 1
Dostępny

1. Czym jest leniwe przetwarzanie?

Leniwe przetwarzanie, czyli „leniwe obliczenia” (lazy evaluation), — to zasada, według której operacje na danych są odkładane do chwili, gdy wynik faktycznie będzie potrzebny. W kontekście Stream API oznacza to: jeśli napisałeś łańcuch przekształceń nad kolekcją, Java nie wykona ich od razu — czeka, aż zostanie wywołana operacja terminalna. I dopiero wtedy jest obliczany cały łańcuch.

Po co to potrzebne? Po pierwsze, oszczędza zasoby: elementy, które finalnie nie będą potrzebne, po prostu nie są przetwarzane. Po drugie, zwiększa wydajność — można budować długie łańcuchy bez tworzenia wielu kolekcji pośrednich. Wreszcie, dostępne są „krótkie obliczenia”: gdy tylko znajdzie się pierwszy pasujący element, dalsze przetwarzanie zostaje przerwane.

Analogią może być leniwy kelner. Mówisz: „Przynieś menu, potem kawę, a potem ciastko”. Kiwa głową, ale nic nie robi… dopóki nie dodasz: „A teraz naprawdę przynieś”. Wtedy idzie zrealizować zamówienie — i może przynieść tylko kawę, jeśli ciastek już nie ma. Mniej więcej tak działa też leniwe przetwarzanie w strumieniach.

2. Operacje pośrednie i terminalne

Pośrednie (intermediate):

  • filter
  • map
  • sorted
  • distinct
  • peek (do debugowania)
  • i inne

Operacje pośrednie zwracają nowy Stream, ale nie uruchamiają obliczeń. One tylko „budują plan” przetwarzania.

Terminalne (terminal):

  • collect
  • forEach
  • reduce
  • count
  • findFirst, findAny
  • anyMatch, allMatch, noneMatch
  • i inne

Tylko operacja terminalna uruchamia wykonanie całego łańcucha.

Przykład: nic się nie dzieje bez operacji terminalnej

List<String> names = List.of("Alicja", "Bob", "Bill");

names.stream()
     .filter(name -> {
         System.out.println("Filtruję " + name);
         return name.startsWith("A");
     });
// Nie będzie żadnych komunikatów! Powyższy kod tylko „buduje” łańcuch.

Teraz dodajmy operację terminalną:

names.stream()
     .filter(name -> {
         System.out.println("Filtruję " + name);
         return name.startsWith("A");
     })
     .forEach(System.out::println);
// Teraz zobaczymy wynik na konsoli!

Rezultat:

Filtruję Alicja
Filtruję Bob
Filtruję Bill
Alicja

3. Zalety leniwego przetwarzania

Oszczędność zasobów

Leniwe przetwarzanie pozwala nie tracić czasu i pamięci na elementy, które nie są potrzebne. Na przykład, jeśli szukasz pierwszego pasującego obiektu, przetwarzanie zatrzyma się na pierwszym trafieniu.

List<String> names = List.of("Alicja", "Bob", "Bill", "Anna");

String firstA = names.stream()
    .filter(name -> {
        System.out.println("Sprawdzam: " + name);
        return name.startsWith("A");
    })
    .findFirst()
    .orElse("Nie znaleziono");

System.out.println("Wynik: " + firstA);

Wyjście:

Sprawdzam: Alicja
Wynik: Alicja

Zwróć uwagę: pozostałe elementy nie są nawet sprawdzane!

Długie łańcuchy bez kolekcji pośrednich

Można łączyć wiele operacji (filter, map, sorted itd.) bez tworzenia kolekcji na każdym kroku.

List<String> names = List.of("Alicja", "Bob", "Bill", "Anna");

List<String> result = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList(); // Java 16+, wcześniej — .collect(Collectors.toList())

Krótkie obliczenia

Jeśli wystarczy wiedzieć, „czy jest wśród elementów pasujący”, pozostałe nie będą sprawdzane:

boolean hasLongName = names.stream()
    .anyMatch(name -> {
        System.out.println("Sprawdzam: " + name);
        return name.length() > 10;
    });

// Jeśli pierwszy element jest długi — reszta nie zostanie sprawdzona!

4. Przykłady: jak działa leniwe przetwarzanie

Przykład 1: nic się nie dzieje bez operacji terminalnej

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

numbers.stream()
    .filter(n -> {
        System.out.println("Filtruję " + n);
        return n % 2 == 0;
    });
// Brak wyjścia!

Przykład 2: łańcuch z operacją terminalną

numbers.stream()
    .filter(n -> {
        System.out.println("Filtruję " + n);
        return n % 2 == 0;
    })
    .map(n -> {
        System.out.println("Mnożę " + n);
        return n * 10;
    })
    .forEach(System.out::println);

Wyjście:

Filtruję 1
Filtruję 2
Mnożę 2
20
Filtruję 3
Filtruję 4
Mnożę 4
40
Filtruję 5

Ważna uwaga: operacje wykonywane są poelementowo: najpierw filter, potem map, następnie forEach — dla każdego elementu po kolei. To nie są dwa oddzielne przebiegi „najpierw odfiltrować wszystko, potem przekształcić wszystko”.

Przykład 3: użycie peek do debugowania

numbers.stream()
    .filter(n -> n % 2 == 0)
    .peek(n -> System.out.println("Przeszedł filtr: " + n))
    .map(n -> n * 10)
    .peek(n -> System.out.println("Po map: " + n))
    .forEach(System.out::println);

5. Przydatne niuanse

Nie używaj strumieni z efektami ubocznymi

Leniwość może spłatać figla, jeśli liczysz na natychmiastowe wykonanie. Działania uboczne wewnątrz map, filter lub peek (zapis do pliku, zmiana stanu zewnętrznego) mogą być wykonywane w innej kolejności, nie dla wszystkich elementów albo w ogóle nie zostaną wykonane bez operacji terminalnej.

Filtruj jak najwcześniej

Umieszczaj filter bliżej początku łańcucha, aby wcześniej odcinać zbędne elementy i zmniejszać zakres dalszej pracy.

Potrzebujesz tylko pierwszego wyniku? Używaj odpowiednich operacji terminalnych

Jeśli potrzebny jest pierwszy pasujący element — wywołaj findFirst lub findAny. To pozwoli strumieniowi zatrzymać się zaraz po znalezieniu wyniku.

Strumienie nie służą do modyfikowania kolekcji źródłowej

Strumienie nie są przeznaczone do dodawania/usuwania elementów z kolekcji źródłowej. Do modyfikacji struktury kolekcji używaj innych mechanizmów.

Wizualizacja działania leniwych strumieni

List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");

words.stream()
    .filter(w -> w.length() > 3)
    .map(String::toUpperCase)
    .forEach(System.out::println);

Jak to przebiega:

Etap cat dog elephant fox giraffe
filter
map
ELEPHANT GIRAFFE
forEach
wydruk wydruk

Tabela: porównanie podejść eager i lazy

Podejście Kiedy wykonywane jest przetwarzanie? Zużycie pamięci Wydajność
Eager (zachłanny) Natychmiast po wywołaniu Może być duże Czasem wolno
Lazy (leniwy) Tylko w razie potrzeby Minimalne Zazwyczaj szybciej

Podejście zachłanne — to na przykład sytuacja, gdy ręcznie organizujesz kilka przebiegów po kolekcji, tworząc kolekcje pośrednie.
Podejście leniwe — to strumienie: nic się nie dzieje, dopóki nie potrzebny jest wynik.

6. Typowe błędy przy pracy z leniwymi strumieniami

Błąd nr 1: oczekiwanie natychmiastowego wyniku. Nowicjusze sądzą, że wywołania filter lub map wykonują się od razu. Jednak bez operacji terminalnej (np. collect, forEach) nic się nie wydarzy — stąd „nie działa debug”, „nic się nie wypisuje”.

Błąd nr 2: efekty uboczne w operacjach pośrednich. Zapis do pliku, zmiana zmiennych zewnętrznych wewnątrz map/filter/peek — to zła praktyka. Z powodu leniwości i optymalizacji takie działania mogą być wykonywane nie w pełni, nie w oczekiwanej kolejności albo w ogóle.

Błąd nr 3: zapomniano wywołać operację terminalną. Napisano łańcuch strumieni, ale brakuje zakończenia przez collect, forEach itp. Efekt — „cisza”.

Błąd nr 4: oczekiwanie, że wszystkie elementy zostaną przetworzone. Operacje takie jak findFirst lub anyMatch przerywają potok przy pierwszym wyniku. Pozostałe elementy nie są przetwarzane — stąd zdziwienie „dlaczego mój println nie zadziałał dla wszystkich?”.

Błąd nr 5: używanie strumieni do modyfikowania kolekcji źródłowej. Strumienie nie są przeznaczone do modyfikowania kolekcji źródłowych (dodawanie/usuwanie elementów). Używaj wyspecjalizowanych metod kolekcji lub iteratorów.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION