Java Stream API: základy práce so streamami

Java Stream API prináša do Javy deklaratívny spôsob práce s dátami: namiesto opisu jednotlivých krokov spracovania jednoducho povieš, aký výsledok chceš získať. Tento tutoriál ti na príkladoch kódu ukáže, ako stream vzniká, aké operácie ponúka a ako z neho získať výsledok.

Vizualizácia Java Stream API ako výrobnej linky, na ktorej operácia filter vyberá prvky, map ich transformuje a collect zhromažďuje výsledky.
Java Stream API umožňuje filtrovať, transformovať a zhromažďovať dáta pomocou operácií filter, map a collect.

V článku sa dozvieš:

    Recommend

    Odporúčame ti…

    Práve čítaš prvý článok zo série Java Stream API, ktorý sa venuje základom. Druhú časť s pokročilými technikami nájdeš v článku Pokročilé techniky Java Streamov.

    Úvod: Prečo sme prestali milovať for cykly?

    Príchod Javy 8 v roku 2014 priniesol Java Stream API a s ním najväčšiu zmenu v jazyku od zavedenia generík v Jave 5. Dovtedy boli vývojári vedení k imperatívnemu spôsobu uvažovania, to znamená, že riešenie problému sa zapisovalo ako sled presných krokov, ktoré sa vykonávajú jeden po druhom, podobne ako ich spracúva samotný procesor.

    Java Stream API tento prístup zásadne mení. Nejde len o skrátený zápis existujúcich konštrukcií ani o kozmetické vylepšenie jazyka. Predstavuje prechod k deklaratívnemu spracovaniu dát, pri ktorom sa sústreďuješ na výsledok, ktorý chceš dosiahnuť, a nie na detailný postup, akým má program k tomuto výsledku dospieť.

    Predstavme si, že riešime bežnú úlohu: máme zoznam používateľov a potrebujeme získať mená všetkých aktívnych používateľov, zoradené v abecednom poradí.

    Imperatívny štýl (Java 7)

    Riešenie starým spôsobom by mohlo vyzerať nasledovne:

    List<String> activeUserNames = new ArrayList<>();
    for (User user : users) {
        if (user.isActive()) {
            activeUserNames.add(user.getName());
        }
    }
    Collections.sort(activeUserNames);

    Na prvý pohľad ide o úplne korektný a funkčný kód. Pri podrobnejšom pohľade sa však objaví niekoľko nedostatkov:

    • Zbytočná rozvláčnosť – jednoduchá myšlienka je rozdelená do viacerých riadkov technického kódu.
    • Zmiešanie zámeru a implementácie – v jednom bloku sa rieši iterácia, podmienka, transformácia dát aj ukladanie výsledku.
    • Práca so zdieľaným stavom – zoznam activeUserNames sa postupne mení, čo zvyšuje riziko chýb a komplikuje paralelné spracovanie.

    Deklaratívny štýl (Java Stream API)

    Ekvivalentné riešenie moderným prístupom by sme prepísali takto:

    List<String> activeUserNames = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .sorted()
        .collect(Collectors.toList());

    Rozdiel je okamžite viditeľný. Tento kód sa číta takmer ako prirodzený opis problému:

    „Vezmi používateľov, vyfiltruj aktívnych, transformuj ich na mená, zoraď a ulož do zoznamu.“

    Jednotlivé kroky sú jasne oddelené, logika je prehľadná a výsledok je čitateľný aj bez detailnej znalosti implementačných mechanizmov.

    6 min.ambda výrazy v Java

    Java Lambda výrazy: Čo sú a ako ich používať?

    Robia ti lambda výrazy problémy alebo nevieš, čo znamenajú? Tu si na správnom mieste.

    Imperatívny vs. deklaratívny prístup

    Hlavným dôvodom vzniku Java Stream API nie je skrátenie kódu, ale zmena spôsobu uvažovania o spracovaní dát.

    Imperatívny prístup (for cykly) – pozornosť sa sústreďuje na riadenie toku programu. Vývojár presne určuje, ako má byť úloha vykonaná: kedy iterovať, kedy kontrolovať podmienku a kam ukladať výsledky. Ide o detailné riadenie každého kroku spracovania.

    Deklaratívny prístup (streamy) – dôraz sa kladie na výsledok, nie na postup. Vývojár popisuje, čo chce dosiahnuť, zatiaľ čo detaily iterácie, optimalizácie a poradia spracovania sú ponechané na implementácii Java Stream API.

    Tento rozdiel tvorí základ všetkého, čo v ďalších kapitolách o Java Stream API uvidíme.

    Čistota kódu a imutabilita

    V imperatívnom príklade je potrebné vytvoriť pomocnú premennú activeUserNames, ktorú následne v priebehu cyklu opakovane meníme. Takýto prístup môže prirodzene viesť k vedľajším, neželaným efektom.

    Streamy podporujú funkcionálny štýl programovania:

    • pôvodná kolekcia users zostáva nezmenená,
    • každá operácia (filter, map, sorted) vytvára nový prúd dát,
    • spracovateľský reťazec je bezstavový a ľahko testovateľný.

    Výsledkom je čistejší kód, ktorý sa jednoduchšie udržiava a je výrazne bezpečnejší pri paralelnom spracovaní.

    Kľúčový princíp: Lazy Evaluation

    Ide o koncept, ktorý začiatočníci často prehliadajú, no práve on má zásadný vplyv na výkon streamov.

    Pri zápise users.stream().filter(User::isActive); nespracuje program ani jeden prvok. Stream si iba zostavuje akýsi spracovateľský plán pre postupnosť operácií, ktoré sa môžu vykonať. Skutočné spracovanie dát sa spustí až pri zavolaní terminálnej operácie, napríklad collect, count, findFirst alebo forEach.

    Možno sa teraz pýtaš, prečo je to dôležité? Z dôvodu efektivity. Ak máš napríklad milión používateľov, ale hľadáš len prvého aktívneho:

    Optional<User> firstActive = users.stream()
        .filter(User::isActive)
        .findFirst();

    Stream spracuje iba minimum prvkov potrebných na nájdenie výsledku a následne okamžite skončí. Vďaka tzv. short-circuiting správaniu nemusí nikdy prejsť celú kolekciu. Naopak, nevhodne navrhnutý imperatívny kód by mohol zbytočne iterovať cez všetky prvky.

    Teraz už vieš, prečo Java Stream API nepredstavuje len modernejší zápis cyklov, ale úplne nový spôsob práce s dátami. Ďalším krokom je pochopenie anatómie streamu: zdroj, intermediárne operácie a terminálne operácie, aby bolo jasné, čo presne sa deje medzi vstupom a výsledkom.

    Anatómia streamu (Stream Pipeline)

    Ak by sme mali Java Stream API prirovnať k niečomu z reálneho sveta, veľmi výstižná je analógia výrobnej linky. Dáta vstupujú na jednom konci, postupne prechádzajú sériou spracovateľských krokov a na konci vzniká výsledok.

    Aby bolo možné streamy používať efektívne a predvídateľne, je nevyhnutné pochopiť tri základné fázy tejto spracovateľskej pipeline:

    • Zdroj (Source)
    • Intermediárne operácie (Intermediate Operations)
    • Terminálna operácia (Terminal Operation)

    Tieto fázy si ukážeme na jednoduchom, no výstižnom príklade:

    List<String> names = Arrays.asList("Anna", "Bill", "Charlie", "Diana");
    long count = names.stream()            // 1. Source
        .filter(s -> s.startsWith("A"))    // 2. Intermediate operation
        .map(String::toUpperCase)          // 2. Intermediate operation
        .count();                          // 3. Terminal operation
    

    1. Zdroj (Source)

    Všetko sa začína dátami. Zásadná myšlienka, ktorú si treba uvedomiť, je, že stream nie je dátová štruktúra. Stream dáta neukladá, nekopíruje ani nevlastní. Slúži len ako abstrakcia, ktorá umožňuje dáta zo zdroja postupne získavať a spracúvať v momente, keď sú skutočne potrebné.

    Zdrojom streamu môže byť napríklad:

    • kolekcia – List, Set (najčastejší prípad),
    • pole – napr. Arrays.stream(new int[] {1, 2, 3}),
    • I/O zdroj – napríklad riadky súboru pomocou Files.lines(path),
    • generátor – funkcia produkujúca (potenciálne nekonečný) prúd dát.

    Oddelenie dát od spôsobu ich spracovania patrí medzi základné dizajnové princípy Java Stream API.

    2. Intermediárne operácie (Spracovanie)

    Intermediárne operácie predstavujú jednotlivé spracovateľské kroky v pipeline. Patria sem napríklad filter, map, sorted, distinct a mnoho ďalších.

    Majú dve spoločné a kľúčové vlastnosti: vždy vracajú nový stream a nespúšťajú spracovanie dát. Vďaka tomu je možné operácie reťaziť (method chaining) a vytvárať prehľadné a plynulé spracovateľské reťazce.

    Z hľadiska výkonu rozlišujeme dva typy intermediárnych operácií.

    • Bezstavové operácie (Stateless): spracúvajú každý prvok nezávisle od ostatných. Nepotrebujú žiadny kontext ani informácie o iných prvkoch v streame. Typické príklady: filter a map. Napríklad transformácia reťazca „Anna“ na „ANNA“ nijako nezávisí od toho, či sa v kolekcii nachádza aj „Bill“. Práve preto sú tieto operácie veľmi rýchle a ideálne pre paralelné spracovanie.
    • Stavové operácie (Stateful): potrebujú poznať alebo si pamätať aj iné prvky v streame, aby mohli rozhodnúť o výsledku. Príklady: sorted – triedenie vyžaduje znalosť všetkých prvkov, distinct – je potrebné evidovať, ktoré hodnoty už boli spracované.

    Takéto operácie môžu obmedzovať výkon, najmä pri spracovaní veľkých dátových množín.

    3. Terminálna operácia (Spúšťač spracovania)

    Terminálna operácia predstavuje moment, v ktorom sa spracovanie skutočne vykoná. Dovtedy je stream len pasívnym popisom toho, čo by sa malo stať.

    Terminálna operácia:

    • spustí celé spracovanie pipeline,
    • vráti konkrétny výsledok (nie stream) alebo void,
    • spotrebuje stream.

    Medzi najčastejšie terminálne operácie patria collect, count, reduce, findFirst a forEach.

    Recommend

    Odporúčame ti…

    Po vykonaní terminálnej operácie je stream považovaný za uzavretý. Akýkoľvek pokus o jeho opätovné použitie skončí výnimkou IllegalStateException.

    Ako to funguje vo vnútri – Loop Fusion

    Častou mylnou predstavou je, že jednotlivé operácie v streame sa vykonávajú po celých kolekciách, najprv filter, potom map, následne sorted a podobne. V skutočnosti streamy fungujú vertikálne, nie vo vrstvách.

    Najprv sa vezme prvý prvok („Anna“), ktorý prejde cez filter. Ak vyhovuje podmienke, okamžite pokračuje do map a následne do terminálnej operácie. Až potom sa spracúva ďalší prvok („Bill“).

    Tento mechanizmus sa nazýva loop fusion a prináša dve zásadné optimalizácie:

    • short-circuiting – operácie ako findFirst alebo anyMatch môžu spracovanie ukončiť okamžite po nájdení výsledku,
    • minimálny počet prístupov do pamäte – dáta sa spracúvajú v jednom priechode bez vytvárania zbytočných medzikolekcií.

    Stream sa teda skladá zo zdroja dát, prechádza sériou intermediárnych operácií a končí terminálnou operáciou, ktorá až spustí samotné spracovanie. Práve to robí jeho pipeline takou efektívnou. Spôsobov, ako taký stream vytvoriť, je pritom oveľa viac než jediné volanie .stream() na kolekcii, a niektoré z nich bývajú prekvapivo výkonnejšie aj flexibilnejšie.

    Vytváranie streamov: nie je to len .stream()

    Väčšina vývojárov sa so Java Stream API prvýkrát stretne pri volaní list.stream(). Ide však len o základný a najčastejší prípad. Java 8 a novšie verzie poskytujú viacero spôsobov, ako vytvárať streamy z rôznych typov dátových zdrojov, a niektoré z nich sú výrazne výkonnejšie a flexibilnejšie, než sa na prvý pohľad zdá. Ak chceš Java Stream API používať efektívne, je dôležité všetky tieto možnosti poznať.

    1. Z kolekcií

    Najbežnejším zdrojom streamu sú kolekcie. Každá trieda implementujúca rozhranie Collection (List, Set, Queue) poskytuje metódu stream():

    List<String> letters = List.of("A", "B", "C");
    Stream<String> stream = letters.stream();

    Pozor na mapy

    Častou chybou začiatočníkov je pokus o použitie map.stream(). Trieda Map neimplementuje rozhranie Collection, a preto túto metódu nemá. Namiesto toho je potrebné vytvoriť stream z jednej z jej kolekčných projekcií:

    // Stream of keys
    scores.keySet().stream();
    
    // Stream of values
    scores.values().stream();
    
    // Stream of key-value pairs (most common)
    scores.entrySet().stream();

    V praxi sa najčastejšie pracuje so streamom nad entrySet(), pretože poskytuje prístup ku kľúčom aj hodnotám zároveň.

    2. Z polí (Arrays)

    Pri práci s poľami (najmä v staršom alebo existujúcom kóde) sa na vytváranie streamov používa pomocná trieda Arrays:

    String[] languages = {"Java", "Python", "C#"};
    Stream<String> stream = Arrays.stream(languages);
    

    Pri primitívnych poliach je tento prístup obzvlášť dôležitý:

    int[] numbers = {1, 2, 3};
    IntStream intStream = Arrays.stream(numbers);

    Namiesto Stream<Integer> sa použije IntStream, čím sa eliminuje zbytočný autoboxing a dosiahne sa lepší výkon.

    3. Z konkrétnych hodnôt (Stream.of)

    V niektorých situáciách nie je potrebná kolekcia, cieľom je len rýchlo spracovať niekoľko objektov. Typickým príkladom sú testy alebo ukážkový kód:

    Stream<String> stream = Stream.of("Spring", "Hibernate", "JPA");
    Recommend

    Odporúčame ti…

    Metóda Stream.of(…) interne deleguje na Arrays.stream(…). Rozdiel spočíva najmä v čitateľnosti a expresivite zápisu.

    4. Streamovanie súborov (I/O operácie)

    Jednou z najsilnejších, no často prehliadaných vlastností Java Stream API je práca so vstupno-výstupnými zdrojmi. Metóda Files.lines(Path) vracia stream reťazcov, kde každý prvok reprezentuje jeden riadok súboru.

    Rozdiel oproti tradičným prístupom je zásadný:

    • Files.readAllLines() načíta celý súbor naraz do pamäte,
    • Files.lines() číta súbor riadok po riadku, presne podľa potreby streamu.

    Pri veľkých súboroch (stovky MB alebo GB) má tento prístup výrazný vplyv na spotrebu pamäte a výkon:

    try (Stream<String> lines = Files.lines(Paths.get("server.log"))) {
        lines.filter(line -> line.contains("ERROR"))
             .forEach(System.out::println);
    } catch (IOException e) {
        // handle exception
    }
    Recommend

    Odporúčame ti…

    Streamy vytvorené z I/O zdrojov implementujú rozhranie AutoCloseable. Vždy ich používaj v bloku try-with-resources, inak hrozí únik systémových zdrojov, napríklad nezatvorené súbory.

    5. Nekonečné streamy (Infinite Streams)

    Streamy nemusia mať konečný počet prvkov. Java Stream API umožňuje generovať hodnoty za behu, čím vznikajú potenciálne nekonečné dátové toky. Takýto stream je potrebné obmedziť, napríklad operáciou limit(), inak terminálna operácia nikdy neskončí.

    Stream.iterate(seed, function)

    Vhodné vtedy, keď nasledujúci prvok závisí od predchádzajúceho, napríklad pri číselných postupnostiach alebo sekvenciách dátumov:

    // Generates: 0, 2, 4, 6, 8
    Stream.iterate(0, n -> n + 2)
        .limit(5)
        .forEach(System.out::println);

    Stream.generate(supplier)

    Používa sa na generovanie nezávislých hodnôt, napríklad náhodných čísel alebo nových inštancií objektov:

    // Generates 5 random UUIDs
    Stream.generate(UUID::randomUUID)
        .limit(5)
        .forEach(System.out::println);

    Java Stream API nie je obmedzené len na kolekcie. Možno ho efektívne využiť na spracovanie polí, mapových štruktúr, veľkých súborov aj nekonečných dátových tokov. V nasledujúcej kapitole sa dostaneme k samotnému jadru práce so streamami – filtrovaniu a transformácii dát. Ukážeme si, kedy použiť flatMap a prečo patrí medzi najčastejšie nepochopené koncepty v Java Stream API.

    Transformácia a filtrovanie dát (Intermediárne operácie)

    Ak by sme si stream predstavili ako výrobnú linku, práve v tejto fáze prebieha samotné spracovanie dát. Surové vstupy sa filtrujú, transformujú a preklápajú do podoby, ktorá je vhodná pre ďalšie spracovanie alebo výslednú agregáciu.

    Všetky operácie, ktoré si v tejto kapitole ukážeme, majú dve spoločné vlastnosti:

    • sú intermediárne, to znamená, že vždy vracajú nový stream,
    • sú lenivé (lazy), a teda nevykonajú sa okamžite, ale až po zavolaní terminálnej operácie.

    Filtrovanie alebo oddelenie zrna od pliev

    Základnou operáciou je metóda filter(Predicate<T>). Prijíma podmienku a do ďalšej fázy pipeline pustí len tie prvky, pre ktoré podmienka vráti hodnotu true:

    // Classic filter: keep only even numbers
    stream.filter(n -> n % 2 == 0);

    Java 9+: Metódy takeWhile a dropWhile

    Od Javy 9 pribudli dve metódy, ktoré sú mimoriadne užitočné pri zoradených streamoch.

    Predstav si zoradený zoznam cien: [10, 20, 30, 40, 50, 60]. Cieľom je získať všetky hodnoty menšie ako 40: stream.filter(n -> n < 40);. Táto verzia prejde všetky prvky, aj keď už narazí na hodnoty 50 a 60.

    Naproti tomu verzia s metódou takeWhile prvky spracúva len dovtedy, kým podmienka platí. Pri jej prvom porušení okamžite ukončí spracovanie celého streamu: stream.takeWhile(n -> n < 40);
    Pri veľkých dátových množinách môže ísť o zásadný výkonnostný rozdiel.

    Metóda dropWhile funguje presne opačne – zahadzuje prvky, kým podmienka platí, zvyšok streamu pustí ďalej bez ďalšieho testovania: stream.dropWhile(n -> n >= 40);

    Mapovanie (ako transformácia 1 k 1)

    Metóda map(Function<T, R>) slúži na transformáciu jedného objektu na iný. Pre každý vstupný prvok vznikne presne jeden výstupný prvok. Počet prvkov v streame sa nemení, mení sa len ich typ alebo reprezentácia:

    List<String> emails = users.stream()
        .map(User::getEmail)          // User -> String
        .map(String::toLowerCase)     // String -> String
        .collect(Collectors.toList());

    Metódu map používaj vždy vtedy, keď má transformácia jednoznačný výsledok pre každý vstupný prvok.

    Splošťovanie dát pomocou flatMap

    Metóda flatMap patrí medzi najčastejšie nepochopené operácie v Java Stream API.

    Základné pravidlo je jednoduché:

    • pri volaní map vznikne z jednej hodnoty iná hodnota,
    • pri volaní flatMap vznikne z jednej hodnoty viac hodnôt, ktoré chceš zlúčiť do jedného streamu.

    Príklad z praxe:

    Máme zoznam vývojárov (Developer) a každý ovláda viacero jazykov (List<String> languages). Chceme získať zoznam všetkých jazykov, ktoré tvoj tím ovláda.

     

    • Riešenie s map (Nesprávne)
    Stream<List<String>> result = developers.stream()
        .map(Developer::getLanguages);

    Výsledkom je Stream<List<String>> – stream zoznamov! Výstup môže byť napr. [[„Java“, „Go“], [„Python“], [„Java“, „C++“]], takýto tvar je vo väčšine prípadov nevhodný na ďalšie spracovanie.

     

    • Riešenie s flatMap (Správne)

    Aplikovanie flatMap na stream robí dve veci naraz: aplikuje mapovaciu funkciu a sploští vnorené štruktúry do jedného plynulého streamu:

    List<String> allLanguages = developers.stream()
        .flatMap(dev -> dev.getLanguages().stream()) // must return a Stream
        .distinct()
        .collect(Collectors.toList());
    
    

    Výsledok je [„Java“, „Go“, „Python“, „C++“].

    Ladenie streamov pomocou peek()

    Streamy sú často dlhé reťazce operácií. Čo ak to na konci vráti zlý výsledok? Ako zistíme, čo sa stalo po filter a pred map? Nemôžeme jednoducho do stredu zápisu vložiť System.out.println, pretože to by rozbilo syntax.

    Na tento účel slúži peek(Consumer<T>). Umožňuje nakuknúť do streamu bez toho, aby ho modifikoval:

    List<Integer> result = Stream.of(1, 2, 3, 4)
        .filter(n -> n % 2 == 0)
        .peek(n -> System.out.println("After filter: " + n)) // Debug log
        .map(n -> n * n)
        .peek(n -> System.out.println("After map: " + n))    // Debug log
        .collect(Collectors.toList());
    Recommend

    Odporúčame ti…

    Metóda peek by sa mala používať len na ladenie. Nikdy ju nepoužívaj na zmenu stavu objektov, pretože pri paralelných streamoch môže byť správanie nepredvídateľné a pri niektorých JVM optimalizáciách môže byť peek dokonca preskočený, ak Java zistí, že neovplyvňuje výsledok.

    Dáta máme vyfiltrované a transformované. Sú pripravené v pamäti, ale stále sme ich nespracovali do konečnej podoby. Ďalším krokom sú terminálne operácie, ktorými celý tento proces spustíme a získame výsledok.

    Terminálne operácie: Ako získať výsledok

    Doteraz sme len definovali plán. Vytvorili sme akési potrubie (stream), nastavili filtre a transformácie, ale voda ním ešte netečie. Terminálna operácia je ten moment, keď otočíš kohútikom. Bez terminálnej operácie sa stream nespustí. Akonáhle ju zavoláš, celý stream sa spracuje a následne sa považuje za spotrebovaný a nedá sa použiť znova.

    Matching – rýchle overovanie

    Nepotrebuješ vždy spracovať všetky dáta. Stačí ti odpoveď typu áno / nie. Presne na to slúžia tzv. skratové (short-circuiting) operácie, podobne ako operátory && alebo || v podmienkach. Akonáhle poznajú výsledok, okamžite končia.

    • anyMatch(Predicate) vráti true, ak aspoň jeden prvok spĺňa podmienku.
    • allMatch(Predicate) vráti true, ak všetky prvky spĺňajú podmienku.
    • noneMatch(Predicate) vráti true, ak žiaden prvok podmienku nespĺňa.
    boolean hasAdmin = users.stream()
        .anyMatch(User::isAdmin); // Stops as soon as the first admin is found

    Tieto operácie sú extrémne efektívne, najmä pri veľkých datasetoch.

    Hľadanie prvku – findFirst vs. findAny

    Obe metódy vracajú Optional<T>, pretože stream môže byť prázdny, napríklad ak filter zahodí všetky prvky.

    • findFirst() vráti prvý prvok podľa poradia streamu (rešpektuje poradie kolekcie).
    • findAny() vráti ľubovoľný prvok.

    V sekvenčnom streame je rozdiel medzi nimi minimálny, zvyčajne sa vráti prvý prvok. V paralelnom streame je findAny() výrazne rýchlejší, pretože nemusí synchronizovať vlákna, aby zistil, ktorý prvok je prvý. Ak ti nezáleží na poradí, preferuj findAny(), dáva JVM viac priestoru na optimalizáciu.

    Jednoduché redukcie – count, min, max

    Tieto operácie zredukujú celý stream na jednu hodnotu:

    • count() vráti počet prvkov (long).
    • min(Comparator) nájde najmenší prvok podľa komparátora (porovnávača).
    • max(Comparator) nájde najväčší prvok podľa komparátora.

    Pri min a max opäť dostávaš Optional, pretože prázdny stream nemá minimum ani maximum:

    Optional<User> oldestUser = users.stream()
        .max(Comparator.comparingInt(User::getAge));
    
    oldestUser.ifPresent(u ->
        System.out.println("Oldest user: " + u.getName()));

    Iterácia pomocou forEach a jeho nástrahy

    Zavolanie metódy forEach(Consumer) je terminálna operácia, ktorá spracuje každý prvok streamu. Práve tu však vývojári najčastejšie porušujú princípy Java Stream API.

    Zlaté pravidlo hovorí o tom, že forEach používaj iba pri operáciách, ktoré nemenia výsledok výpočtu. Napríklad za vhodné použitie sa považuje výpis do konzoly, logovanie alebo uloženie objektu do databázy.

    • Príklad zlého použitia (code smell): pridávanie prvkov do externého zoznamu, inkrementovanie zdieľaných premenných.
    List<String> result = new ArrayList<>();
    stream.forEach(s -> result.add(s)); // DO NOT DO THIS!
    // Use collect(Collectors.toList()) instead
    

    Pri paralelných streamoch toto správanie vedie k chybám, napríklad výnimke ConcurrentModificationException, alebo strate dát.

    Porovnanie forEach vs. forEachOrdered

    Pri paralelnom spracovaní forEach negarantuje poradie vykonávania (vhodné pre rýchlosť). Ak potrebuješ zachovať poradie aj pri paralelnom streame, napríklad pri výpise do konzoly, použi forEachOrdered. Spomalí to však proces, pretože vlákna sa musia synchronizovať.

    Všeobecná redukcia – reduce

    Matkou všetkých agregácií je metóda reduce. Prijíma počiatočnú hodnotu a funkciu, ktorá spája dva prvky do jedného. Pomocou nej vieš implementovať sumu, súčin alebo zreťazenie reťazcov:

    int sum = numbers.stream()
        .reduce(0, (a, b) -> a + b);
    
    // or even better:
    // .reduce(0, Integer::sum)

    Pomocou reduce vieš implementovať sumu, súčin, zreťazenie textov alebo vlastné agregácie.
    V praxi však častejšie siahneš po Collectors, ktoré sú čitateľnejšie a optimalizované pre bežné scenáre, ako zbieranie do kolekcií, zoskupovanie či sumarizácia.

    FAQ: Často kladené otázky o Java Stream API

    Aký je rozdiel medzi map a flatMap?

    Metóda map transformuje každý prvok na presne jeden nový prvok, takže počet prvkov v streame ostáva rovnaký. Metóda flatMap naproti tomu spája viacero hodnôt z jedného prvku, napríklad zoznamy, do jedného plochého streamu – využiješ ju najmä pri práci s vnorenými štruktúrami.

    Dá sa stream použiť viackrát?

    Nie. Po zavolaní terminálnej operácie sa stream považuje za spotrebovaný a akýkoľvek ďalší pokus o jeho použitie skončí výnimkou IllegalStateException. Ak potrebuješ rovnaké dáta spracovať viackrát, musíš z pôvodného zdroja vytvoriť nový stream.

    Kedy použiť stream namiesto for cyklu?

    Stream sa oplatí vtedy, keď chceš dáta filtrovať, transformovať alebo agregovať deklaratívnym spôsobom a kód má ostať čitateľný a bezstavový. Pri jednoduchej iterácii bez transformácie, alebo keď potrebuješ meniť premenné mimo cyklu, môže byť naopak prehľadnejší klasický for cyklus.

    Java Stream API: základy zvládnuté, čo ďalej?

    Gratulujeme, už ovládaš základy Java Stream API. Hoci sme sa dotkli pomerne veľkého množstva nových pojmov, v skutočnosti už vieš všetko podstatné, čo Java vývojár potrebuje na každodennú prácu so streamami:

    • vieš, prečo je deklaratívny štýl čitateľnejší a bezpečnejší než klasické for cykly,
    • rozumieš anatómii streamu – od zdroja, cez intermediárne operácie, až po terminálnu operáciu,
    • vieš, že streamy možno vytvárať nielen z kolekcií, ale aj zo súborov alebo generátorov dát,
    • ovládaš rozdiel medzi map a flatMap, ktorý je kľúčový pri práci s vnorenými štruktúrami,
    • poznáš základné terminálne operácie a vieš, ako získať výsledok spracovania,
    • a vieš, na čo si dať pozor pri práci so streamami.

    Tieto znalosti tvoria pevný základ, na ktorom stojí moderné používanie Java Stream API. Odporúčam ti ich najskôr prakticky precvičiť, napríklad tak, že v existujúcich projektoch nahradíš vybrané imperatívne cykly streamami a porovnáš si čitateľnosť výsledného kódu.

    V ďalšom článku Java Stream API – pokročilé techniky nadviažeme priamo na základné poznatky o Java Stream API. Pozrieme sa na výkonnostné aspekty, paralelné streamy, časté chyby a anti-patterny vrátane refaktoringu reálneho kódu.

    O autorovi

    Jozef Wagner

    Java Developer Senior

    Viac ako 10 rokov programujem v Jave, momentálne pracujem v msg life Slovakia ako Java programátor senior a pomáham zákazníkom implementovať ich požiadavky do poistného softvéru Life Factory. Vo voľnom čase si rád oddýchnem v lese, prípadne si zahrám nejakú dobrú počítačovú hru.

    Daj nám o sebe vedieť