Java folyamok

Kategória: Java standard könyvtárak.

Áttekintés

A folyam (stream) a Java 8-ban jelent meg, a nyelv funkcionális kiegészítőivel együtt. Nem összetévesztendő a Java IO stream fogalmával, pl. a FileInputStream-mel, bár rokon fogalmak. Ez a stream leginkább a Java Collections API kiterjesztéseként is felfogható: az ott definiált adatszerkezetekben található adatok átalakíthatóak folyammá, melyen egyesével hajtunk végre műveleteket. E megközelítéssel fel tudunk dolgozni akkora adatmennyiséget is, ami egyszerre nem fér be a memóriába. A folyamon végrehajtható műveleteket két fő csoportba osztjuk, melyek megértése nélkülözhetetlen a stream-ek megértéséhez:

  • Nem végműveletek (nonterminal operations): egy-egy ilyen művelettel azt mondjuk meg, hogy amikor a feldolgozás egy adott elemhez ér, akkor mi történjen vele. Tipikus ilyen művelet a map(), ami átalakítja egy másik értékké (pl. a már megismert szintaxissal: map(x -> 2*x) azt jelenti, hogy a szám kétszeresét veszi; persze ez csak akkor működik, ha a folyam elemei számok), vagy a filter(), ami kiszűr bizonyos elemeket (pl. a filter(x -> x % 2 != 1) kiszűri a páratlan számokat). Az egyes műveletekkel a későbbiekben ismerkedünk meg. Most viszont fontos megjegyeznünk, hogy a művelet (azaz az átalakítás vagy a szűrés) egyelőre ténylegesen nem hajtódik végre. Valamint azt is fontos tudatosítani, hogy egyszerre egy elemet látunk; nem hajthatunk végre pl. rendezést, mert nem tudunk olyat mondani, hogy vedd az ilyen és ilyen indexű elemet, hasonlítsd őket össze stb.
  • Végműveletek (terminal operations): ennek hatására hajtódnak végre a nem végműveletek. Végművelet pl. a forEach(), ami végiglépked mindegyik elemen, és végrehajt bizonyos műveletet (pl. a forEach(System.out::println) kiírja; a szintaxis megértéséhez ld. a magyarázatot fent a metódus referencia szakaszban), vagy pl. a count(), ami megszámolja, hogy hány elem található a folyamban. Egy stream-en egy végműveletet lehet csak végrehajtani. Ráadásul ha egy stram-et egyszer elindítottuk, és befejeződött, azt újraindítani nem lehet. Lehet viszont másolatot készíteni, és a másolatot elindítani, vagy tudunk készíteni saját ún. Collector-t, melyet át tudunk adni a collect() végműveletnek, és az tetszőleges műveletet végrehajthat.

A stream-ekre jellemző még az ún. folyékony API (fluent API), ami azt jelenti, hogy a műveletek kimeneti típusa is ugyanaz mint a bemeneti (jelen esetben folyam, azaz stream), és az egyes műveletek (függvényhívások) ponttal egymás után írhatóak, azaz nem kell az egyes részeredményeket ideiglenes változókba helyezni. Egy egyúttal a funkcionális programozással is egybecseng, ahol drasztikusan lecsökken a változók száma.

Példa

Lássunk egy példát!

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
 
public class StreamExample {
    public static void main(String[] args) {
        List<String> fruits = Arrays.asList("apple", "banana", "orange", "banana", "apple");
        Stream<String> fruitsStream = fruits.stream();
        fruitsStream
            .filter(s -> s.contains("n"))
            .distinct()
            .map(s -> s.toUpperCase())
            .forEach(s -> System.out.println(s));
    }
}

A példában létrehoztunk egy 5 elemű, szavakat tartalmazó listát, hagyományos Java Collections API módszerrel, majd a stream() eljárással folyamot készítettünk belőle. Első műveletünk egy szűrés: csak azok a szavak maradnak meg, melyek tartalmaznak n karaktert. Figyeljük meg, hogy ez úgy eldönthető, hogy csak egy elemet látunk.

Ebben a pillanatban mi is történik? Ha hagyományos gyűjteményről lenne szó, akkor azt mondanánk, hogy már csak 3 elemű a lista: banana, orange és banana, mert csak ezekben van n karakter, a kétszer szereplő apple szóban nincs. Viszont mivel itt folyamokról van szó, fontos megértenünk, hogy ez nem így van: valójában a folyam továbbra is 5 elemet tartalmaz, és annyit mondtunk neki, hogy majd ha valaki elindítja a folyamot, akkor szűrd ki azokat a szavakat, melyekben van n betű. Az eredmény ez a módosított folyam.

A következő művelet a distinct(). Láthatjuk, hogy magán az eredményen hajtjuk végre; megvalósíthatnánk úgy is, hogy változóba mentjük a filter() által visszaadott Stream-et, majd azon végrehajtjuk a következő műveletet, és így tovább, a stream programozásakor viszont általában a láncolt (chained) megoldást szoktuk alkalmazni. Visszatérve a distinct()-re: ez is egy nem végművelet, ami kiszűri a duplikátumokat. Itt jó kérdés, hogy ezt hogyan valósítja meg, mert továbbra is abból kell kiindulnunk, hogy egyszerre egy elemet látunk. Feltehetőleg létre hoz egy globális szótárat, melybe beleírja, hogy mely elemek vannak már benne, majd ehhez a szótárhoz hasonlít akkor, amikor egy adott elemről kell eldönteni, hogy benne maradjon-e. Így feltehetőleg mindig az elsőként megtalált elem marad benne a folyamban. De ismét igaz a fent megfogalmazott alapelv: mivel nem végműveletről van szó, a valóságban ezen a ponton még nem történik semmi.

A következő művelet a map(), ami nagybetűssé konvertálja a szót. Azt gondolom, hogy az s -> s.toUpperCase() tömör és jól érthető kódrészlet; itt látható a korábban bemutatott funkcionális szintaxis nagy előnye.

Végül végrehajtunk egy végműveletet, ami jelen példában a forEach(): ez végigiterál mindegyik elemen, és kiírja az eredményt. És ez fogja kiváltani a nem végműveletek végrehajtását is. Valahogy a következőképpen történhet mindez:

  • apple → nincs benne n, tehát töröld ki
  • banana → van benne n, tehát ne töröld ki → kerüljön be a szótárba, de maradjon meg → változtasd nagybetűssé: BANANA → írd ezt ki
  • orange → van benne n, tehát ne töröld ki → kerüljön be a szótárba, de maradjon meg → változtasd nagybetűssé: ORANGE → írd ezt ki
  • banana → van benne n, tehát ne töröld ki → már benne van a szótárban, tehát töröld ki
  • apple → nincs benne n, tehát töröld ki

Az eredmény tehát az ORANGE és a BANANA lesz, külön sorban. A fenti példában az is látható, hogy a sorrend nagyon nem mindegy. Pl. ha megcserélnénk a distinct() és a filter() függvényeket, akkor megspóroltuk volna a második banana filter() függvényét, de írhattuk volna rosszabbul is: pl. ha előbb alakítjuk nagybetűssé, akkor 3 felesleges, és valójában drága műveletet hajtottunk volna végre.

Most nézzük meg a lehetőségeket!

Nem végműveletek

Számos nem végművelet létezik, melyek közül most bemutatok párat:

  • map(): átalakítás, erről már volt szó.
  • filter(): szűrés; erről is volt szó.
  • distinct(): a többszörös elemeket szűri ki; erről is volt már szó.
  • flatMap(): ha a folyam összetett, pl. listák listája, vagy tömbök tömbje, és mi az elemeken szeretnénk végigiterálni, akkor ezt a függvényt használhatjuk a struktúra "kivasalására". Pl. ha az arrayOfArrays egy string tömbökből álló tömb (pl. String[][] arrayOfArrays = new String[][]{{"one", "two"}, {"three", "four", "five"}, {"six"}, {"seven";}}), akkor az Arrays.stream(arrayOfArrays).flatMap(x -> Arrays.stream(x)) egy olyan folyamot hoz létre, amely az egyes elemeken halad végig (a példában "one", "two", "three", "four", "five", "six", "seven").
  • limit(): a folyam hosszát lehet vele behatárolni. Példát lejjebb látunk majd.
  • peek(): egy műveletet lehet segítségével végrehajtani, pl. a peek(s -> System.out.println(s)) kiírja az elemeket. Mivel ez nem végművelet, ténylegesen csak akkor fogja kiírni, ha egy végművelet aktiválódik.

Végműveletek

A végműveleteket is a teljesség igénye nélkül mutatom be:

  • forEach(): mindegyik elemen végrehajt egy műveletet. Erre láttunk már fent példát. Vegyük észre a különbséget a peek() és a forEach() között: az előbbi csak akkor hajtódik végre, ha egy végművelet azt kiváltja, míg ez utóbbi önmagában kiváltja azt. A peek() tehát leginkább hibakereséshez használható, pl. egy filter() művelet előtt.
  • count(): megszámolja a folyamban levő elemeket.
  • collect(): paraméterként ún. Collector-t kap, amit végrehajt. Ennek segítségével tudjuk hagyományos adatszerkezetekké alakítani a folyamot, pl. a collect(Collectors.toList()) listává alakítja.
  • toArray(): tömbbé alakítja a folyam elemeit.
  • anyMatch(), allMatch() és noneMatch(): igaz értékkel tér vissza, ha a paraméterül átadott feltételnek legalább egy elem, az összes elem megfelel, ill. egyik elem sem felel meg.
  • min() és max(): visszaadja a folyam legkisebb ill. legnagyobb elemét.
  • findFirst() és findAny(): az első ill. egy tetszőleges elemmel tér vissza.
  • reduce(): összekombinálja a folyam elemeit. Bináris operátort vár paraméterül, melynek a paraméterei és a visszatérési értéke is megfelel a folyam elemeinek típusával. Működése a követező: veszi a folyam első két elemét, végrehajtja rajtuk a műveletet, majd az eredményen és a következő elemen szintén, egészen addig, amíg el nem fogynak az elemek. Opcionálisan átadhatunk egység elemet is; ez esetben azzal és az első elemmel kezdi a műveletet. Lássunk egy példát, mely összeszorozza a folyamban található számokat!
Stream<Integer> ints = Stream.of(5, 3, 7, 8, 2);
Optional<Integer> result = ints.reduce((a, b) -> a * b);
System.out.println(result.get());

Az eredmény típusa Optional<Integer>, ami szintén Java 8 újítás. Segítségével - ahogy a nevéből következtethetünk - opcionális értékeket tudunk létrehozni. Sokkal elegánsabb, mint a null érték ellenőrzése. Néhány fontosabb eljárás:

  • get(): visszaadja a tárolt értéket; ezt már láttuk,
  • boolean isPresent(): lekérdezi, hogy van-e benne tárolt érték.
  • orElse(other): ha jelen van az érték, akkor azt adja vissza, egyébként a paraméterül átadottat.

Folyamok létrehozásának a módjai

Folyamot többféleképpen létrehozhatunk, melyek közül néhányat megnézünk:

  • A Collection interfész tetszőleges megvalósításán a stream() eljárást meghívva, melyre fent láthattunk példát.
  • Tömbből, a következőképpen: Stream.of("apple", "banana", "orange", "banana", "apple") vagy így: Arrays.stream(array).
  • Generálással: pl. a Stream.generate(() -> Math.random()).limit(10) létrehoz 10 véletlen lebegőpontos számot 0 és 1 között.
  • Iterálással: egy képlet alapján generálja a következőt az előzőből. Pl. a Stream.iterate(2, (Integer n) -> n * n).limit(5) a 2 négyzeteinek a négyzeteit hozza létre (2, 4, 16, 256, 65536).

Saját Collector

Amint arról már szó volt, egy folyamban összesen egy végművelet lehet. Most tegyük fel, hogy milliárdnyi elemet tartalmaz a folyam; a lényeg, hogy nem lehetséges (vagy legalábbis nem gazdaságos) memóriában tárolni, valamint hosszú ideig tart feldolgozni. Tegyük fel, hogy a minimum és maximum elemet szeretnénk meghatározni egyszerre. Külön-külön ezt lehet beépített eljárással, de ezt alapból csak úgy tudjuk megtenni, hogy készítünk másolatot a folyamról, és így kétszer végigfutunk rajta. (Ugyanazt a folyamot kétszer nem lehet elindítani.) Másik, sokkal hatékonyabb megoldás az, ha saját Collector megvalósítást hozunk létre, és azt átadjuk a collect() eljárásnak.

Saját Collector írása nem egyszerű; álljon itt példaként a fenti probléma általános megvalósítása:

import java.util.Comparator;
import java.util.stream.Collector;
import java.util.stream.Stream;
 
class Stats<T> {
    private int count;
    private Comparator<? super T> comparator;
    private T min;
    private T max;
 
    public Stats(Comparator<? super T> comparator) {
        this.comparator = comparator;
    }
 
    public int count() {return count;}
    public T min() {return min;}
    public T max() {return max;}
 
    public void accept(T val) {
        if (count==0) min = max = val;
        else if (comparator.compare(val, min) < 0) min = val;
        else if (comparator.compare(val, max) > 0) max = val;
        count++;
    }
 
    public Stats<T> combine(Stats<T> that) {
        if (this.count == 0) return that;
        if (that.count == 0) return this;
 
        this.count += that.count;
        if (comparator.compare(that.min, this.min) < 0) this.min = that.min;
        if (comparator.compare(that.max, this.max) > 0) this.max = that.max;
 
        return this;
    }
 
    public static <T> Collector<T, Stats<T>, Stats<T>> collector(Comparator<? super T> comparator) {
        return Collector.of(
            () -> new Stats<>(comparator),
            Stats::accept,
            Stats::combine,
            Collector.Characteristics.UNORDERED,
            Collector.Characteristics.IDENTITY_FINISH
        );
    }
 
    public static <T extends Comparable<? super T>> Collector<T, Stats<T>, Stats<T>> collector() {
        return collector(Comparator.naturalOrder());
    }
}
 
public class CustomCollectorExample {
    public static void main(String[] args) {
        Stream<Integer> numberStream = Stream.of(5, 3, 8, 4, 7, 5);
        Stats<Integer> result = numberStream.collect(Stats.collector());
        System.out.println(result.min() + ", " + result.max());
    }
}

A Java 8 hatása a Java Collection API-ra

A Java 8-ban a funkcionális programozási elemek bevezetése és a folyamok bevezetése mellett a Java Collection API is kapott egy komolyabb ráncfelvarrást, melyből megnézünk néhány példát.

  • forEach(): végigiterál az elemeket, és végrehajt egy műveletet.
  • removeIf(): ha adott feltétel teljesül, akkor (helyben) törli az elemet.
  • replacAll(): az összes elemet kicseréli
  • A map-ben getOrDefault(): ha a map tartalmazza a kulcsot, akkor a hozzá tartozó értéket adja vissza, egyébként egy alapértelmezett értéket. Ez az utasítás egy gyakran előforduló több soros kódrészletet vált ki.
  • A map-ben putIfAbsent(): csak akkor adja hozzá a kulcs-érték párt, ha még nem volt benne.

Lássunk ezekre is egy példát!

import java.util.*;
 
public class Java8CollectionAPI {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<String>(Arrays.asList("apple", "banana", "peach", "banana"));
        fruits.removeIf(f -> f.startsWith("ban"));
        fruits.replaceAll(f -> f.toUpperCase());
        fruits.forEach(f -> System.out.println(f));
    }
}

Megjegyzés: az inicializálásnál nem elég csak az Arrays.asList(), mert az egy olyan szerkezetet eredményez, mely nem támogatja a helyben törlést.

Összegzés

A Java 8-ban komoly előrelépés történt a folyamok világába. Azzal, hogy a Java Collection API-ba is bevezették az új elemeket, kifejezetten jól olvasható és tömör kódot tudunk készíteni. Ugyanakkor komoly sajnos hiányosságok is maradtak. A legfontosabb talán az, hogy a folyamok természetszerűleg végtelenek: folyamatosan jönnek az elemek, melyeket fel kell dolgozni, és például nem definiálható az a fogalom, hogy utolsó elem. Márpedig a Java stream-ek esetén ez a fogalom nélkülözhetetlen. Ami leginkább hiányzik, az talán a kötegelt feldolgozás: pl. százasával vehetné az elemeket, és azon belül értelmezné pl. az utolsó elemet, de amúgy futhatna a végtelenségig. Ezen kívül nincs természetes integráció a gyakori folyam megvalósításokkal, pl. Apache Kafka vagy Event Hubs.

A funkcionális programozáshoz hasonlóan ez esetben is elmondható tehát, hogy komoly előrelépéseket tettek, de még van hová fejlődni.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License