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 |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | 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.
GO TO FULL VERSION