Stream API
Sissejuhatus
Eelmistes peatükkides tutvusime rohkem süvitsi sellega, mis asjad funktsioonid on. Samuti tegime tutvust funktsionaalsete liideste ja lambda-avaldistega. See kõik oli alus käesolevaks teemaks, milleks on Stream API.
Stream<T> (edaspidi voog) on elementide jada, mis võimaldab mõne andmeallika peal tehtavaid tegevusi omavahel aheldada.
Voog ise ei hoiusta andmeid, see kirjeldab töötlusahelat, mida rakendatakse mingile andmeallikale.
Andmeallikas võib olla kas kollektsioon, massiiv või mõni muu järjestus (sequence).
Voo loomine
Kõige sagedamini luuakse voog mõnest olemasolevast kollektsioonist:
List<Employee> employees = List.of(...);
Stream<Employee> stream = employees.stream();
Samuti on võimalik luua järgnevalt:
// From an array
Stream<String> s = Arrays.stream(new String[]{"a", "b", "c"});
// From individual values
Stream<Integer> s = Stream.of(1, 2, 3);
// Empty stream
Stream<String> empty = Stream.empty();
Töötlusahela struktuur
Tüüpiline töötlusahel koosneb kolmest osast:
- Andmeallikas ehk kust algandmed tulevad
- Vahepealsed operatsioonid, null või mitu muundust, millest igaüks tagastab uue voo.
- Lõppoperatsioon ehk lõpptulemuse tekitamine ning töötlusahela lõpetamine.
List<String> names = employees.stream() // source
.filter(e -> e.isActive()) // intermediate
.map(Employee::getName) // intermediate
.collect(Collectors.toList()); // terminal
Vahepealsed operatsioonid on loomult laisad (lazy). See tähendab, et neid operatsioone ei käivitata enne, kui kutsutakse välja lõppoperatsioon. Terve ahel läbitakse ja andmeid töödeldakse ainult ühel korral.
Vahepealsed operatsioonid
filter
Säilitab voos elemente, mis vastavad ette antud Predicate-le:
employees.stream()
.filter(e -> e.getDepartment().equals("Engineering"))
Antud juhul eemaldatakse voost kõik töötajad, kelle osakond pole inseneeria. Visuaalselt näeks see selline välja:
map
Võtab sisendiks Function-i ning vastavalt sellele muundab andmeid.
Tulemuseks tekib uus voog.
employees.stream() // Stream<Employee>
.map(Employee::getName) // Stream<String>
Antud näites tehakse töötajate voog ümber sõnede vooks.
mapToInt
Stream<T> on üldine objektide voog.
Sellel näiteks pole .sum() meetodit kuna Java-l pole võimalik kindel olla sellest, kas voos olevad elemendid on numbrid või mitte.
mapToInt teisendab kõik elemendid primitiivseteks int väärtusteks ning tagastab spetsialiseeritud voo IntStream.
int totalYears = employees.stream()
.mapToInt(Employee::getYearsOfExperience) // IntStream
.sum(); // terminal — returns int
IntStream-il on spetsiifilised arvulised operatsioonid olemas.
Antud näite puhul muundatakse töötajate voog ümber täisarvuliseks vooks, et saada teada töötajate tööstaaži kogusumma.
mapToLong ja mapToDouble töötavad samal põhimõttel long ja double andmetüüpidele.
Selleks, et IntStream tagasi teisendada Stream<Integer>-iks, tuleb kasutada .boxed() meetodit:
List<Integer> years = employees.stream()
.mapToInt(Employee::getYearsOfExperience) // IntStream
.boxed() // Stream<Integer>
.collect(Collectors.toList());
sorted
sorted tagastab uue voo, kus kõik elemendid on ära sorteeritud.
Seda on võimalik kasuda kas ilma argumendita (antud juhul kasutatakse siis naturaalset järjestust (Comparable)) või argumendiga, mille puhul tuleks siis ette anda Comparator koos vastava loogikaga.
employees.stream()
.sorted(Comparator.comparing(Employee::getName))
Selles näites sorteeritakse töötajad nime järgi ära:
distinct
Eemaldab voost duplikaat elemendid läbi equals kontrolli:
employees.stream()
.map(Employee::getDepartment)
.distinct()
limit
Tagastab uue voo, kuhu on alles jäetud esimesed n-arv elementi:
employees.stream()
.sorted(Comparator.comparing(Employee::getSalary).reversed())
.limit(5)
Kuna vood on laisad, peatab ka limit töötlusahela varakult: kui allikas sisaldab 10,000 elementi, kuid limit(5) saavutatakse pärast viit, siis ülejäänud elemente ei töödelda kunagi.
skip
Jätab esimesed n elementi vahele ja edastab ülejäänud edasi teistele operatsioonidele:
employees.stream()
.skip(10) // skip the first 10
skip ja limit täiendavad teineteist ning neid kasutatakse sageli koos lehekülgede kaupa andmete töötlemiseks (paginatsiooniks):
employees.stream()
.skip(pageNumber * pageSize)
.limit(pageSize)
peek
Rakendab igale elemendile Consumer-i, muutmata voogu.
Selle peamine eesmärk on silumine ehk vaadata, mis antud hetkel töötlusahelas ringi liigub, ilma tulemust muutmata.
List<String> names = employees.stream()
.filter(e -> e.isActive())
.peek(e -> System.out.println("After filter: " + e.getId()))
.map(Employee::getName)
.peek(name -> System.out.println("After map: " + name))
.collect(Collectors.toList());
Toodangusse peek-kutsed ei tohiks jõuda, nende peamine eesmärk on koodi silumine.
Lõppoperatsioonid
collect
Kogub elemendid kollektsiooni või muusse konteinerisse, kasutades Collector-it:
List<Employee> active = employees.stream()
.filter(e -> e.isActive())
.collect(Collectors.toList());
Set<String> departments = employees.stream()
.map(Employee::getDepartment)
.collect(Collectors.toSet());
Alates Java 16-st on Stream-il lühivorm .toList(), mis on samaväärne .collect(Collectors.toList())-iga:
List<Employee> active = employees.stream()
.filter(e -> e.isActive())
.toList();
Collectors.joining(delimiter) ühendab sõnesid:
String names = employees.stream()
.map(Employee::getName)
.collect(Collectors.joining(", "));
count
Tagastab elementide arvu long-väärtusena:
long activeCount = employees.stream() // 5 elements
.filter(e -> e.isActive()) // 3 elements
.count(); // 3
max ja min
Tagastab suurima või väikseima elemendi vastavalt ette antud Comparator-ile.
Optional<Employee> highestPaid = employees.stream()
.max(Comparator.comparing(Employee::getSalary));
Optional<Employee> mostJunior = employees.stream()
.min(Comparator.comparing(Employee::getYearsOfExperience));
Tulemus on Optional<T>-tüüpi, et vältida vigu tühja voo korral.
findFirst ja findAny
Mõlemad tagastavad ühe elemendi Optional<T> konteineris.
findFirst tagastab alati esimese elemendi, findAny lubab tagastada suvalise elemendi.
Optional<Employee> lead = employees.stream()
.filter(e -> e.getRole() == Role.LEAD)
.filter(e -> e.isActive())
.findFirst();
// When any match is acceptable and order does not matter
Optional<Employee> anyRemote = employees.stream()
.filter(e -> e.isRemote())
.findAny();
Jadavoogudes annavad mõlemad sama tulemuse.
Erinevus ilmneb paralleelsete voogude puhul: findAny võib tagastada ükskõik millise leitud elemendi, mis teeb selle kiiremaks, kuna ei pea järjekorda jälgima.
anyMatch, allMatch ja noneMatch
Kontrollivad, kas voo elemendid vastavad ette antud Predicate-le.
Kõik kolm on lühisoperatsioonid (short-circuiting): nad lõpetavad voo läbimise niipea, kui tulemus on selge.
anyMatch— tagastabtrue, kui vähemalt üks element vastab tingimusele.allMatch— tagastabtrue, kui kõik elemendid vastavad tingimusele.noneMatch— tagastabtrue, kui mitte ükski element ei vasta tingimusele.
List<Employee> employees = List.of(...);
boolean hasRemoteWorker = employees.stream()
.anyMatch(e -> e.isRemote());
boolean allActive = employees.stream()
.allMatch(e -> e.isActive());
boolean noneUnderpaid = employees.stream()
.noneMatch(e -> e.getSalary().compareTo(minimumWage) < 0);
Kuna anyMatch leiab juba esimese elemendi puhul vaste, siis ülejäänud elemente ei töödelda.
forEach
Rakendab igale elemendile Consumer-i.
Kasutatakse kõrvalmõjude jaoks, nagu printimine või logimine.
forEach väärtust ei tagasta väärtust:
employees.stream()
.forEach(e -> System.out.println(e.getName()));
forEach on lõppoperatsioon ja lõpetab töötlusahela.
Sellele ei saa järgneda täiendavaid voooperatsioone.
Kui on vaja teisendatud tulemust, kasuta selle asemel collect.
reduce
Kombineerib kõik elemendid üheks väärtuseks, kasutades BinaryOperator-it.
Kahe argumendiga vorm võtab sisendiks identiteedi (algväärtuse, mis tagastatakse, kui voog on tühi) ja akumulaatori:
int totalExperience = employees.stream()
.map(Employee::getYearsOfExperience)
.reduce(0, Integer::sum);
Ühe argumendiga vorm võtab ainult akumulaatori ja tagastab Optional<T>, sest voog võib olla tühi ning puudub identiteet, millele toetuda:
Optional<Integer> total = employees.stream()
.map(Employee::getYearsOfExperience)
.reduce(Integer::sum);
Arvvoogude puhul eelista mapToInt(...).sum() - see on selgem ja väldib _boxing—’ut.
Meetodiviited töötlusahelates
Funktsionaalsete liideste peatükis tutvustatud meetodiviited esinevad sageli töötlusahelates:
// Instance method on argument — reads as "take each employee, get their name"
.map(Employee::getName)
// Static method
.map(String::valueOf)
// Constructor
.map(EmployeeReport::new)
Meetodiviidete kasutamine lambda-avaldise asemel vähendab müra, kui lambda ei tee muud kui edastab oma argumendi olemasolevale meetodile.
Vood on ühekordsed
Voogu saab kasutada ainult üks kord.
Lõppoperatsiooni väljakutsumine sulgeb voo, sama voo uuesti kasutamine põhjustab IllegalStateException-i.
Loo allikast uus voog iga kord, kui seda on vaja:
Stream<Employee> stream = employees.stream();
stream.count(); // OK
stream.count(); // IllegalStateException — stream has already been operated upon
Millal eelistada tsükkleid
Kuigi vood näiliselt teevad koodi lühemaks ja selgemaks, ei ole need alati kõige mõistlikumad. Tavaline tsükkel on sageli selgem või praktilisem järgmistes olukordades:
Kontrollitud erindid. Lambda-avaldistes ei saa kontrollitud erindeid otse visata. Sellest möödahiilimine nõuab nende mähkimist kontrollimata erinditesse, mis muudab koodi mürarikkamaks kui samaväärne tsükkel:
// Loop — clean and natural with checked exceptions
List<String> lines = new ArrayList<>();
for (Path file : files) {
lines.add(Files.readString(file)); // throws IOException — no problem
}
// Stream — requires wrapping, which obscures the intent
List<String> lines = files.stream()
.map(file -> {
try {
return Files.readString(file);
} catch (IOException e) {
throw new RuntimeException(e); // awkward
}
})
.collect(Collectors.toList());
Keerukas juhtvoog. Kui iteratsiooni sees olev loogika sisaldab mitut tingimust, varajasi väljumisi akumuleeruva oleku põhjal või elementidevahelisi sõltuvusi, väljendab tsükkel tavaliselt kavatsust selgemini kui töötlusahel.
Ühe jagatud tulemuse muutmine. Kui eesmärk on tulemust samm-sammult üles ehitada viisil, kus sammud sõltuvad üksteisest (näiteks 2D-massiivi täitmine või jooksev summa, mis mõjutab järgmist iteratsiooni), on tsükkel loomulik valik.
Üldine juhis on, et kasuta vooge, kui filtreerid, teisendad või agregeerid kollektsiooni iseseisval viisil. Kasuta tsüklit, kui iteratsiooni loogika on olekupõhine, juhtvoog on haruline või peate tegelema kontrollitud erinditega.