7.5 Streams
In andere programmeertalen
De concepten in andere programmeertalen die het dichtst aanleunen bij Java streams zijn
- ranges in C++
- iterators, generators en comprehensions in Python
- LINQ in C#
Wat en waarom?
Streams vormen een krachtig concept om efficiënt data te verwerken. Ze vormen een abstractie voor sequentiële en parallele operaties op datasets, zoals filteren, transformeren, en aggregeren, zonder de onderliggende datastructuur te wijzigen. Bovendien maakt het gebruik van streams het mogelijk om declaratief te programmeren: je beschrijft op hoog niveau wát je met de dataset wilt doen, in plaats van stap voor stap te beschrijven hoe dat moet gebeuren.
Een snel voorbeeldje om dat wat concreter te maken is onderstaande code, die streams gebruikt om de gemiddelde leeftijd te berekenen van de eerste 20 meerderjarige Person
-objecten in de lijst people
(we gaan later dieper in op de details):
List<Person> people = ...
var average = people.stream()
.mapToInt(Person::age)
.filter(a -> a >= 18)
.limit(20)
.average();
System.out.println(average.getAsDouble());
Een stream zelf is geen datastructuur of collectie; een stream is een pijplijn — een ketting van operaties die uitgevoerd moeten worden op de data. Die operaties kunnen de data filteren, transformeren, groeperen, reduceren, … Een stream stelt dus één grote bewerking voor op de data, samengesteld uit meerdere operaties. Elke stream bestaat uit 3 delen:
- één bron voor de data (een stroom van data, vandaar de naam). Die bron kan een datastructuur zijn (bv. een array, ArrayList, HashSet, …), maar ook andere bronnen zijn mogelijk (bijvoorbeeld een oneindige sequentie van getallen, zoals de natuurlijke getallen). In het voorbeeld hierboven is de lijst
people
de bron. - intermediaire operaties (mogelijk meerdere na elkaar) die de data verwerken (transformeren). Elke operatie neemt het resultaat van de vorige operatie (of de bron) en doet daar iets mee; het resultaat daarvan dient als invoer voor de volgende operatie. Zo krijg je een pijplijn waar de data doorheen stroomt terwijl ze bewerkt wordt. In het voorbeeld hierboven zijn
mapToInt
,filter
enlimit
intermediaire operaties. - één terminale operatie: deze beëindigt de ketting en geeft het uiteindelijke resultaat terug. In het voorbeeld hierboven is
average
de terminale operatie.
graph LR Source --> Op1 --> Op2 --> Op3 --> Terminal style Source stroke:black,fill:#afa style Terminal stroke:black,fill:#faa
Je kan een stream-pijplijn slechts éénmaal doorlopen. Als je na het uitvoeren van de operaties nog een sequentie van operaties wil uitvoeren op dezelfde bron-elementen, moet je een nieuwe stream maken.
Streams zijn ook lazy: er worden slechts zoveel elementen verwerkt als nodig om het resultaat te berekenen. Dat maakt dat de bron oneindig veel elementen mag aanleveren; zolang de rest van de pijplijn er slechts een eindig aantal nodig heeft, vormt dat geen probleem. Enkel de elementen die nuttig zijn om het resultaat te bekomen worden gebruikt. We zullen hier later nog op terugkomen.
Een zeer concrete situatie waarin streams nuttig zijn, is wanneer je van plan bent om code te schrijven met volgende vorm:
Collection<E> source = ...
Type1 result = ...
Type2 temp_var = ...
for (E element : source) {
update temp_var
if (condition) {
update result
}
}
return result;
Dit patroon komt zeer vaak voor in code. Neem, als eenvoudig voorbeeld om te starten, de situatie uit het voorbeeld hierboven: de gemiddelde leeftijd berekenen van de eerste 20 meerderjarige personen in een lijst. Zonder streams zou je dat waarschijnlijk ongeveer als volgt schrijven (merk op hoe dit het patroon van hierboven volgt):
List<Person> people = ...
double average = 0;
int count = 0;
for (var person : people) {
if (person.age() >= 18) {
average += person.age();
count++;
if (count >= 20)
break;
}
}
average /= count;
System.out.println(average);
De versie met streams ziet er helemaal anders uit — je schrijft zelf geen lussen, maar focust je op het beschrijven van de uit te voeren operaties. Hier ter vergelijking nogmaals de code met streams:
List<Person> people = ...
var average = people.stream()
.mapToInt(Person::age)
.filter(a -> a >= 18)
.limit(20)
.average();
System.out.println(average.getAsDouble());
Beide versies doen hetzelfde, maar in de tweede versie is het veel duidelijker wat de bedoeling is:
- neem eerst van elke persoon de leeftijd (
mapToInt
) - kijk dan enkel naar de leeftijden >= 18 (
filter
) - kijk vervolgens enkel naar de eerste 20 van die leeftijden (
limit
) - neem tenslotte het gemiddelde (
average
).
Deze manier van programmeren ligt veel dichter bij bijvoorbeeld SQL:
SELECT AVG(age)
FROM (
SELECT age FROM Person
WHERE age >= 18
LIMIT 20
) AS limited_people;
Weetje
In C# is LINQ het equivalent van streams. Dat is een taal die erg nauw aansluit bij SQL. Het voorbeeld van hierboven kan er in C# als volgt uitzien:
var averageAge = (
from p in people
where p.Age >= 18
select p.Age
).Take(20).Average();
Lambda functies en methode-referenties
Bij het gebruik van streams zal je veelvuldig gebruik maken van methode-referenties en lambda-functies.
Wat?
Stel dat we een record Person
hebben:
record Person(String firstName, String lastName, int age) {
public boolean isAdult() {
return this.age() >= 18;
}
}
Met lambda-functies kun je in Java kort en bondig een functie schrijven. Die functie heeft geen naam te geven; je zal ze typisch dus maar één keer gebruiken. Een lambda-functie in Java, die de voor- en achternaam van een persoon aan elkaar plakt met een spatie, ziet er bijvoorbeeld als volgt uit:
(Person p) -> p.firstName() + " " + p.lastName()
De ->
wijst op een lambda-functie. Links ervan staan de parameters, rechts de body van de methode.
Je kan zo’n lambda-functie bijvoorbeeld gebruiken om een Comparator te maken die een lijst van personen sorteert volgens hun voor- en achternaam:
var people = new java.util.ArrayList<>(List.of(
new Person("Jane", "Doe", 17),
new Person("John", "Doe", 18),
new Person("Alex", "Jones", 20)));
people.sort(Comparator.comparing((Person p) -> p.firstName() + " " + p.lastName()));
System.out.println(people);
Je hoeft niet expliciet te zeggen welk type p
heeft; Java kan dat vaak zelf afleiden. Hetvolgende kan dus ook:
people.sort(Comparator.comparing(p -> p.firstName() + " " + p.lastName()));
Methode-referenties zijn een manier om direct te verwijzen naar een reeds bestaande methode, in plaats van er een lambda voor te schrijven.
Waar je een lambda van de vorm (Person p) -> p.age()
zou gebruiken, kun je ook gewoon Person::age
schrijven.
De ::
wijst op een methode-referentie. Links ervan staat de naam van de klasse, rechts de naam van de (bestaande) methode.
Merk op dat er geen haakjes staan (dus niet Person::age()
). Het is enkel een verwijzing naar de methode; de methode zelf wordt niet meteen opgeroepen.
Een methode-referentie is vooral handig als de lambda precies één methode-aanroep doet. Achter de schermen gebeurt hetzelfde, maar de code is nog compacter. Om de lijst van people te sorteren volgens leeftijd, kan je dus ook een methode-referentie gebruiken als volgt:
people.sort(Comparator.comparing(Person::age));
Types van lambda’s en methode-referenties
Omdat Java een sterk getypeerde taal is, moeten lambda-functies en methode-referenties ook een type hebben.
Dat gebeurt door een interface te definiëren.
Elke interface met daarin precies één methode kan automatisch gebruikt worden als type voor lambda-functies en methode-referenties (als de types overeen komen).
Als het expliciet de bedoeling is om de interface daarvoor te gebruiken, kan je die interface ook met @FunctionalInterface
annoteren; de compiler komt dan klagen als je later een extra methode zou proberen toe te voegen.
Bijvoorbeeld:
@FunctionalInterface
interface PersonPredicate {
boolean test(Person person);
}
public List<Person> selectPeople(List<Person> people, PersonPredicate predicate) {
List<Person> result = new ArrayList<>();
for (Person p : people) {
if (predicate.test(p)) {
result.add(p);
}
}
return result;
}
var people = List.of(
new Person("Jane", "Doe", 17),
new Person("John", "Doe", 18),
new Person("Alex", "Jones", 20));
In de code hierboven zie je de PersonPredicate
-interface, geannoteerd met @FunctionalInterface
.
Deze definieert één methode die true of false teruggeeft voor een persoon.
De methode selectPeople
gebruikt deze interface om alle personen te selecteren die voldoen aan de meegegeven voorwaarde.
Note
De implementatie van selectPeople
voldoet aan het patroon van code waarvoor streams een oplossing kunnen bieden.
Inderdaad, deze operatie komt overeen met de filter
-methode op streams.
We overlopen nu 5 manieren om de selectPeople
methode te gebruiken.
Een eerste manier is een klasse maken (bv. IsAdult
) die de interface implementeert, en die nagaat of de persoon meerderjarig is.
Dat werkt, maar is nogal omslachtig:
/* [1] */
class IsAdult implements PersonPredicate {
@Override
public boolean test(Person person) {
return person.isAdult();
}
}
System.out.println(selectPeople(people, new IsAdult()));
// => [Person[firstName=Alex, lastName=Jones, age=20], Person[firstName=John, lastName=Doe, age=18]]
Een tweede optie is om een anonieme klasse te gebruiken. In het voorbeeld hieronder is dat een anonieme klasse die nagaat of de achternaam van de persoon begint met “Do”. Ook dat blijft omslachtig:
/* [2] */
System.out.println(selectPeople(people, new PersonPredicate() {
@Override
public boolean test(Person person) {
return person.lastName().startsWith("Do");
}
}));
// => [Person[firstName=Jane, lastName=Doe, age=17], Person[firstName=John, lastName=Doe, age=18]]
Met lambda-functies kan je dergelijke code veel eenvoudiger schrijven.
In [3] en [4] hieronder zie je hoe je een lambda-functie kan gebruiken die hetzelfde doet als de vorige voorbeelden, maar dan zonder een klasse te schrijven.
Merk op dat het toegelaten is om de lambda-functies te gebruiken waar een PersonPredicate
verwacht wordt.
De lambda-functies zijn inderdaad functies die een Person-object als argument hebben, en een boolean teruggeven, en komen dus qua type overeen met de test
-methode in PersonPredicate
.
/* [3] */
System.out.println(selectPeople(people, p -> p.isAdult()));
// => [Person[firstName=Alex, lastName=Jones, age=20], Person[firstName=John, lastName=Doe, age=18]]
/* [4] */
System.out.println(selectPeople(people, p -> p.lastName().startsWith("Do")));
// => [Person[firstName=Jane, lastName=Doe, age=17], Person[firstName=John, lastName=Doe, age=18]]
Tenslotte kunnen we ook een methode-referentie gebruiken:
/* [5] */
System.out.println(selectPeople(people, Person::isAdult));
Dat is vooral nuttig als er al een methode bestaat, of als de implementatie van de methode te omslachtig is om als lambda te schrijven.
Voorgedefinieerde types voor functies
In plaats van zelf een interface zoals PersonPredicate
te schrijven, kan je vaak beroep doen op een voorgedefinieerde functie-interface.
Je vindt de lijst daarvan in de documentatie.
We lijsten hier de belangrijkste functionele interfaces op die gebruikt worden in de context van streams:
Function<T, R>
: een functie met 1 argument, die eenT
omzet in eenR
. Er zijn ook varianten voor primitieve resultaat-types, zoalsToIntFunction<T>
, die eenT
omzet in een int. Bijvoorbeeld:Function<Person, String> getLowercaseName = person -> person.name().toLowerCase(); ToIntFunction<Person> getAge = Person::age;
Predicate<T>
: een functie met 1 argument van typeT
, dietrue
offalse
teruggeeft. Ook hier bestaan varianten voor primitieve types, bijvoorbeeldIntPredicate
. Bijvoorbeeld:Predicate<Person> isAdult = person -> person.age() >= 18; IntPredicate isNonNegative = i -> i >= 0;
BiFunction<T, U, R>
: een functie met 2 argumenten, die eenT
en eenU
omzet in eenR
. Bijvoorbeeld:BiFunction<Person, Integer, String> f = (person, nbPets) -> "Person " + person.name() + " has " + nbPets + " pets";
UnaryOperator<T>
: een operator met 1 argument van typeT
, en een resultaat van typeT
. Dit is dus een speciaal geval van eenFunction
, namelijk eenFunction<T, T>
. Bijvoorbeeld:UnaryOperator<String> indentOnce = s -> " " + s;
BinaryOperator<T>
: een functie met 2 argumenten, beide van typeT
, die eenT
teruggeeft. Dit is dus een speciaal geval van eenBiFunction
, namelijk eenBiFunction<T, T, T>
. Bijvoorbeeld:BinaryOperator<String> joinWithSpace = (s1, s2) -> s1 + " " + s2;
Supplier<T>
: een operatie zonder argumenten, die eenT
teruggeeft. Een invocatie van de supplier mag telkens hetzelfde of een ander object teruggeven. Een supplier kan dus gebruikt worden als generator. Bijvoorbeeld:Supplier<String> constant = () -> "Hello"; Random rnd = new Random(); Supplier<Integer> randomInt = rnd::nextInt();
Consumer<T>
: een operatie met 1 argument van typeT
, met een void return type. De consumer ‘verbruikt’ het meegegeven object, zonder een resultaat terug te geven. Bijvoorbeeld:Consumer<Person> printPerson = (person) -> System.out.println(person.name() + " (age " + person.age() + ")");
BiConsumer<T, U>
: een consumer met 2 argumenten van typeT
enU
, die niets teruggeeft. Bijvoorbeeld:BiConsumer<Person, Integer> printNTimes = (person, n) -> { for (int i = 0; i < n; i++) System.out.println(person.name() + " (age " + person.age() + ")"); };
Streams aanmaken
Een stream van T
-objecten wordt aangeduid met de interface Stream<T>
.
Voor streams van primitieve types zijn er ook specifieke interfaces, bijvoorbeeld IntStream
, DoubleStream
, …
Om een stream aan te maken, start je steeds met een bron voor de data die verwerkt zal worden. Dat kan op verschillende manieren.
Je kan eenvoudig een stream maken van alle elementen in een collectie: elke collectie heeft een
.stream()
operatie die een stream teruggeeft van alle elementen. Dit is de meest courante manier om te werken met streams. Merk op dat een stream zelf geen datastructuur is, en dus ook geen elementen bevat. Een stream lijkt dus meer op een iterator (die de elementen uit de bron een voor een teruggeeft, maar deze niet zelf bevat) dan op een collectie.people.stream()
Stream.of(T t1, T t2, ...)
maakt een stream met als data exact de opgegeven objecten. Gelijkaardig kan je bijvoorbeeld ookIntStream.of(int i1, int i2, ...)
gebruiken voor een stream van getallen.Stream.of(person1, person2, person3)
Specifiek voor
IntStream
is er ookIntStream.range
enIntStream.rangeClosed
om een IntStream te maken van alle getallen in een bepaald bereik.IntStream.range(2, 6) // => 2, 3, 4, 5 IntStream.rangeClosed(2, 6) // => 2, 3, 4, 5, 6
Om een stream te maken van een array, gebruik je
Arrays.stream(arr)
.String[] names = { "Alice", "Bob", "Eve"}; Arrays.stream(names)
Stream.concat(s1, s2)
maakt een nieuwe stream door twee streams samen te voegen: eerst komen alle elementen van de eerste stream, daarna die van de tweede stream. Opnieuw is er ook een gelijkaardige operatie opIntStream
.IntStream.concat(IntStream.range(2, 4), IntStream.range(10, 12)) // => 2, 3, 10, 11
Stream.generate(supplier)
maakt een stream waarbij de elementen aangeleverd worden door de meegegeven Supplier. Die dient dus als generator voor nieuwe elementen.Stream.generate(() -> "Hello") // => "Hello", "Hello", "Hello", "Hello", ... Random rnd = new Random(); Stream.generate(() -> rnd.nextBoolean()) // => true, false, true, true, true, false, false, true, false, ...
Stream.iterate(seed, unaryOp)
maakt een stream waarvan de elementen gegenereerd worden doorunaryOp
herhaald toe te passen, beginnend bij seed. De elementen van de stream zijn dusseed, unaryOp(seed), unaryOp(unaryOp(seed)), ...
. Dit is bijvoorbeeld een (oneindige) stream van alle strikt positieve getallen1:Stream.iterate(1, (n) -> n + 1) // => 1, 2, 3, 4, ...
Je kan ook een stream maken via een
StreamBuilder
, die je maakt viaStream.builder()
. Dat laat toe om elementen één voor één toe te voegen (viaadd(...)
), en daar uiteindelijk een stream van te maken via debuild()
-methode. Deze manier geeft je veel controle en is daardoor zeer flexibel, maar zal slechts zeer uitzonderlijk nodig zijn.var builder = Stream.<String>builder(); builder.add(str1); builder.add(str2); var stream = builder.build(); // => str1, str2
Tussentijdse (intermediate) operaties
Tussentijdse (intermediate) operaties worden uitgevoerd op een stream, en geven een nieuwe stream terug.
De elementen in de nieuwe stream zijn gebaseerd op die van de originele stream, na het toepassen van de operatie.
Je kan de operaties dus na elkaar toepassen om een pijplijn te definiëren (dat is precies de bedoeling van een stream).
Het voorbeeld van daarstraks toont 3 tussentijdse operaties die na elkaar toegepast worden: mapToInt
, filter
, en limit
(average
is een terminale operatie; zie later).
var average = people.stream()
.mapToInt(Person::age)
.filter(a -> a >= 18)
.limit(20)
.average();
We bespreken hier de meest voorkomende bewerkingen.
filter
De filter
-operatie filtert sommige data-elementen uit de stream: enkel de elementen die voldoen aan het meegegeven predicaat worden verder doorgegeven.
Bijvoorbeeld, onderstaande filter
-operatie verwijdert alle klinkers uit een stream van letters:
Stream.of("A", "B", "C", "D", "E", "F").filter(l -> !"AEIOUY".contains(l)) // => B, C, D, F
block-beta columns 1 block:in A B C D E F end arrow<["filter"]>(down) block:out space BB["B"] CC["C"] DD["D"] space FF["F"] end classDef str stroke:black,fill:orange class in,out str
map, mapToInt, mapToLong, mapToDouble, mapToObject
De map
-operatie transformeert elk elementen in een stream naar een ander element door een functie toe te passen op elk element.
Bijvoorbeeld, onderstaande map-operatie zet alle strings om naar lowercase:
Stream.of("ALICE", "Bob", "ChaRLIe").map(String::toLowerCase) // => "alice", "bob", "charlie"
block-beta columns 1 block:in ALICE Bob ChaRLIe end arrow<["map"]>(down) block:out alice bob charlie end classDef str stroke:black,fill:orange class in,out str
In tegenstelling tot de filter-operatie kan de map
-operatie het type van de elementen in de stream veranderen. Bijvoorbeeld, onderstaande code vertrekt van een stream van Strings, en eindigt met een stream van Integer-objecten:
Stream.of("ALICE", "Bob", "ChaRLIe").map(String::toLowerCase).map(String::length) // => 5, 3, 7
block-beta columns 1 block:in ALICE Bob ChaRLIe end arrow<["map"]>(down) block:out alice bob charlie end arrow2<["map"]>(down) block:out2 5 3 7 end classDef str stroke:black,fill:orange class in,out,out2 str
Er zijn ook specifieke map-operaties voor wanneer het resultaat een int, long, of double is.
Bijvoorbeeld, mapToInt
geeft een IntStream
terug.
Stream.of("ALICE", "Bob", "ChaRLIe").mapToInt(String::length) // => 5, 3, 7
Bij een IntStream
, LongStream
en DoubleStream
geeft de map
-operatie altijd opnieuw een stream van hetzelfde type (IntStream, LongStream, of DoubleStream).
Wanneer je de getallen in zo’n stream wil omzetten naar een object, kan dat met mapToObject
:
IntStream.of(1, 2, 3).mapToObject(i -> "Number " + i) // => "Number 1", "Number 2", "Number 3"
limit en takeWhile
De limit
-operatie beperkt de resultaat-stream tot de eerste \( n \) elementen van de bron-stream:
Stream.of("A", "B", "C", "D", "E", "F").limit(3) // => A, B, C
block-beta columns 1 block:in A B C D E F end arrow<["limit"]>(down) block:out AA["A"] BB["B"] CC["C"] space:3 end classDef str stroke:black,fill:orange class in,out str
Merk op dat de plaats van limit
(zoals die van de meeste tussentijdse operaties trouwens) belangrijk is:
Stream.of("A", "B", "C", "D", "E", "F")
.filter(l -> !"AEIOUY".contains(l))
.limit(3)
// => B, C, D
is iets anders dan
Stream.of("A", "B", "C", "D", "E", "F")
.limit(3)
.filter(l -> !"AEIOUY".contains(l))
// => B, C
De eerste variant resulteert in de eerste 3 medeklinkers uit de oorspronkelijke stream (je filtert eerst alle klinkers eruit, en neemt dan de eerste 3 overblijvende elementen). In de tweede variant begin je met de eerste 3 letters, en filtert daaruit dan de klinkers weg.
Soms ken je het aantal elementen niet, maar wil je elementen blijven doorgeven zolang aan een bepaalde voorwaarde voldaan is.
Dat kan met takeWhile
.
Merk op dat takeWhile
niet hetzelfde is als filter
: eenmaal de voorwaarde niet meer voldaan is, worden de volgende elementen niet meer bekeken, ook al zouden die opnieuw voldoen aan de voorwaarde.
Stream.of("Alpha", "Bravo", "Charlie", "Delta").takeWhile(s -> s.length < 6) // => "Alpha", "Bravo"
block-beta columns 1 block:in Alpha Bravo Charlie Delta end arrow<["takeWhile"]>(down) block:out AA["Alpha"] BB["Bravo"] space:2 end classDef str stroke:black,fill:orange class in,out str
skip en dropWhile
De skip
-operatie doet het omgekeerde van limit
: ze slaat de eerste \( n \) elementen over.
Stream.of("A", "B", "C", "D", "E", "F").skip(3) // => D, E, F
block-beta columns 1 block:in A B C D E F end arrow<["skip"]>(down) block:out space:3 DD["D"] EE["E"] FF["F"] end classDef str stroke:black,fill:orange class in,out str
De dropWhile
-operatie is het omgekeerde van takeWhile
: ze negeert elementen zolang aan een gegeven voorwaarde voldaan is.
Stream.of("Alpha", "Bravo", "Charlie", "Delta").dropWhile(s -> s.length < 6) // => "Charlie", "Delta"
block-beta columns 1 block:in Alpha Bravo Charlie Delta end arrow<["dropWhile"]>(down) block:out space:2 CC["Charlie"] DD["Delta"] end classDef str stroke:black,fill:orange class in,out str
distinct
De distinct
-operatie filtert alle dubbele waarden uit de stream. Deze operatie is stateful: de waarden die reeds gezien zijn moeten bijgehouden worden.
Stream.of("A", "B", "A", "C", "A", "D").distinct() // => A, B, C, D
block-beta columns 1 block:in A B A2["A"] C A3["A"] D end arrow<["distinct"]>(down) block:out AA["A"] BB["B"] space CC["C"] space DD["D"] end classDef str stroke:black,fill:orange class in,out str
sorted
De sorted
-operatie sorteert de waarden in de stream. Deze operatie is stateful (de reeds geziene waarden moeten bijgehouden worden om ze gesorteerd terug te geven), en de operatie vereist ook dat de stream eindig is. Het sorteren kan immers pas uitgevoerd worden wanneer alle elementen gekend zijn.
Stream.of("C", "D", "A", "F", "E", "B").sorted() // => A, B, C, D, E, F
block-beta columns 1 block:in C D A F E B end arrow<["sorted"]>(down) block:out AA["A"] BB["B"] CC["C"] DD["D"] EE["E"] FF["F"] end classDef str stroke:black,fill:orange class in,out str
peek
De peek
-operatie doet eigenlijk niets: ze geeft alle elementen gewoon door, maar laat toe om een functie uit te voeren voor elk element.
Deze operatie kan bijvoorbeeld handig zijn om te debuggen: je kan ze middenin een pijplijn toevoegen om te kijken welke elementen daar voorbijkomen.
Stream.of("A", "B", "C", "D", "E", "F")
.limit(3)
.peek(System.out::println) // => A, B, C
.filter(l -> !"AEIOUY".contains(l))
block-beta columns 1 block:in A B C D E F end arrow<["limit"]>(down) block:out AA["A"] BB["B"] CC["C"] space:3 end arrow2<["peek"]>(down) block:out2 AAA["A"] BBB["B"] CCC["C"] space:3 end arrow3<["filter"]>(down) block:out3 space BBBB["B"] CCCC["C"] space:3 end classDef str stroke:black,fill:orange class in,out,out2,out3 str
flatMap
De flatMap
operatie is gelijkaardig aan de map
-operatie, maar wordt gebruikt wanneer het resultaat van de map-functie op één element opnieuw een stream geeft.
Opnieuw bestaan er specifieke versies flatMapToInt
, flatMapToLong
, en flatMapToDouble
voor wanneer het resultaat een IntStream, LongStream of DoubleStream moet worden.
We gebruiken de String::chars
methode als voorbeeld; die geeft voor een String een IntStream terug met de char-values van elk karakter (denk: de ASCII of Unicode-waarde van elke letter).
Als we gewoon map
zouden toepassen, krijgen we een stream van streams (hier: een stream van 3 streams, elk met 2 elementen):
Stream<IntStream> result = Stream.of("Aa", "Bb", "Cc").map(String::chars);
// => ['A', 'a'], ['B', 'b'], ['C', 'c']
block-beta columns 1 block:in Aa Bb Cc end arrow<["map"]>(down) block:out columns 3 block:blockA columns 2 A a end block:blockB columns 2 B b end block:blockC columns 2 C c end end classDef str stroke:black,fill:orange classDef str2 stroke:red,fill:yellow class in,out str class blockA,blockB,blockC str2
Vaak is dat niet wat we willen: stel dat we één lange stream van char-waarden (ints) willen maken, met daarin de karakters van alle woorden uit de oorspronkelijke stream na elkaar.
In dat geval gebruiken we flatMap
(of, in dit geval, flatMapToInt
omdat chars
een IntStream teruggeeft):
IntStream result = Stream.of("Aa", "Bb", "Cc").flatMapToInt(String::chars);
// => 'A', 'a', 'B', 'b', 'C', 'c'
block-beta columns 1 block:in Aa Bb Cc end arrow<["flatMap"]>(down) block:out columns 6 A a B b C c end classDef str stroke:black,fill:orange class in,out str
flatMap
maakt dus in zekere zin een combinatie van een gewone map
-operatie (die telkens een stream teruggeeft voor elk element), gevolgd door een concatenatie van al die resulterende streams.
De naam komt van het feit dat de resulterende structuur een ‘flattened’ versie is, waarbij één niveau van nesting weggehaald werd: waar map
een stream van streams oplevert (bv. Stream<IntStream>
), krijgen we bij flatMap
(of hier flatMapToInt
) één gewone stream terug (hier een IntStream
).
mapMulti
De mapMulti
operatie kan je gebruiken wanneer elk object in de bronstream kan leiden tot meerdere objecten in de doelstream, net zoals bij flatMap
.
In tegenstelling tot flatMap
, waar de mapping-operatie een stream moet teruggeven, werkt mapMulti
anders.
Je moet hier namelijk geen stream teruggeven.
Aan mapMulti
geef je een BiConsumer
mee; dat is een functie met 2 argumenten (we noemen die hier value
en output
):
- Het eerste argument (
value
) is het element uit de bronstream waarvoor de overeenkomstige elementen in de doelstream bepaald moeten worden - Het tweede argument (
output
) is een Consumer-functie: elk element wat je daaraan doorgeeft (via deaccept
-methode), komt terecht in de doelstream.
De functie die meegegeven wordt aan mapMulti
moet dus alle objecten bepalen die voor het gegeven bronobject (value
) in de doelstream terecht moeten komen, en die één voor één doorgeven aan output
.
Een voorbeeld maakt dit hopelijk wat duidelijker.
Stel dat we de lijst \( [1, 2, 3] \) willen omzetten in \( [1, 2, 2, 3, 3, 3] \): elk getal in de bronstream wordt even vaak als zijn waarde herhaald in de doelstream.
We zouden dat met flatMap
kunnen doen, door telkens een stream te maken van het juiste aantal elementen.
Dat doen we hieronder door een oneindige stream te maken (via generate
) van telkens hetzelfde element (value
), en dan elk van die streams te beperken tot de eerste value
elementen:
IntStream.range(1, 4) // => 1 2 3
.flatMap( (value) -> IntStream.generate(() -> value).limit(value) ) // => 1 2 2 3 3 3
Dankzij de laziness van streams (zie later) moeten we niet bang zijn dat de oneindige stream zal leiden tot een oneindige uitvoeringstijd; het aantal elementen dat we gebruiken wordt beperkt door de limit
-operatie, en er zullen er nooit meer dan dat gegenereerd worden.
De versie met mapMulti
ziet er wat anders (en misschien vertrouwder) uit — hier kunnen we gewone imperatieve code (lussen) schrijven:
IntStream.range(1, 4) // => 1 2 3
.mapMulti((value, output) -> {
for (int i = 1; i <= value; i++) {
output.accept(value);
}
}) // => 1 2 2 3 3 3
block-beta columns 1 block:in AA["1"] BB["2"] CC["3"] end arrow<["mapMulti"]>(down) block:out A1["1"] B1["2"] B2["2"] C1["3"] C2["3"] C3["3"] end classDef str stroke:black,fill:orange classDef aa stroke:black,fill:#afa classDef cc stroke:black,fill:#aaf class AA,A1 aa class CC,C1,C2,C3 cc class in,out str
Terminale (terminal) operaties
Een terminale operatie staat op het einde van de pijplijn, na alle tussentijdse operaties. Na een terminale operatie kunnen dus geen extra operaties meer uitgevoerd worden met de elementen uit de stream.
De voorbeelden hierboven hadden geen terminale operaties. Een stream zonder terminale operatie doet niets: er wordt geen enkel element verwerkt, en er wordt geen enkele tussentijdse operatie uitgevoerd. Het is de terminale operatie die alle verwerking in gang zet. De reden daarvoor is de laziness van streams.
Laziness (luiheid)
We zeiden eerder al dat streams lazy zijn. Dat houdt in dat de gedefinieerde operaties niet uitgevoerd worden, tenzij het echt niet anders kan.
Bekijk bijvoorbeeld onderstaande code:
var list = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Stream<Integer> incrementAll = list.stream()
.map(n -> {
System.out.println("Processing " + n);
return n + 1;
})
.limit(1);
// [1] hier werd nog niets uitgeprint
incrementAll
.forEach(n -> {
System.out.println("Got " + n);
});
// [2] print "Processing 1" en vervolgens "Got 2"
We maken een lijst van 10 getallen, voeren map
uit op een stream van die getallen, en beperken het resultaat tot het eerste element.
Aangekomen op punt [1] werd er, misschien verrassend, helemaal niets uitgeprint.
Dat betekent dat de lambda-functie die \( n \) met 1 verhoogt ook niet uitgevoerd werd: er gebeurde helemaal niets met de getallen uit de lijst.
We definiëren met streams enkel de pijplijn: wat moet er later eventueel gebeuren met de getallen?
Pas wanneer we op punt [2] komen, en de terminale operatie forEach
(zie later) uitgevoerd werd, werd er voor de eerste keer “Processing 1” uitgeprint.
Maar dan enkel voor het eerste element: door de limit
-operatie is er geen nood aan het verwerken van het tweede, derde, … element, dus dat gebeurt ook nooit.
De code hierboven verwerkt dus enkel het eerste element uit de lijst; met alle andere elementen wordt nooit iets gedaan.
Het is dus belangrijk om in het achterhoofd te houden dat stream-operaties enkel uitgevoerd worden wanneer dat noodzakelijk is, en pas op het allerlaatste moment (als gevolg van een terminale operatie). Dat is efficiënt, maar kan soms leiden tot verrassende situaties (zoals hierboven).
We bekijken nu enkele vaak voorkomende terminale operaties.
toList, toArray
De terminale operatie toList
maakt een nieuwe lijst met daarin de elementen op het einde van de pijplijn, in de volgorde dat ze daar toekomen.
Bijvoorbeeld:
List<String> result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.toList();
// => result == [b, c, d, f]
Gelijkaardig is er ook toArray
. Deze maakt een Object[]
array aan met daarin de elementen.
Object[] result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.toArray();
Door de beperkingen van generics in Java (i.e., je mag niet new T[n]
schrijven voor een generisch type T
) kan er enkel een Object[]
array gemaakt worden, geen String[]
.
Indien je dat wel wil, moet je een functie meegeven om een lege array van het juiste type en de juiste lengte aan te maken, bijvoorbeeld:
String[] result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.toArray(n -> new String[n]); // OF: toArray(String[]::new);
count
Zoals de naam aangeeft, telt de count()
-operatie het aantal elementen op het einde van de stream.
Dat aantal wordt als een long
teruggegeven, niet als int
.
Bijvoorbeeld:
long nbConsonants = Stream.of("A", "B", "C", "D", "E", "F")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.count();
// => nbConsonants == 4L
findFirst en findAny
De operaties findFirst
en findAny
geven respectievelijk het eerste en een niet nader bepaald element terug uit de stream.
Merk op dat findAny
niet ‘random’ is; er zijn gewoon geen garanties over welk element precies teruggegeven wordt.
findAny
is vooral nuttig bij parallelle streams (zie later).
Het resultaat van beide methodes is een Optional
; die is leeg wanneer de stream leeg is, er er dus geen element is om terug te geven.
Optional<String> result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.findFirst();
// => result == Optional["B"]
Optional<String> result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> "XYZ".contains(l))
.findAny();
// => result == Optional.empty
anyMatch, allMatch, noneMatch
Deze methodes geven true of false terug, afhankelijk van of respectievelijk ten minste één, elk, of geen enkel element in de stream voldoet aan de meegegeven voorwaarde.
Stream.of("Alpha", "Bravo", "Charlie", "Delta")
.anyMatch(s -> s.startsWith("A")); // => true
Stream.of("Alpha", "Bravo", "Charlie", "Delta")
.allMatch(s -> s.length() < 6); // => false
Stream.of("Alpha", "Bravo", "Charlie", "Delta")
.noneMatch(s -> s.startsWith("X")); // => true
min en max
De min
en max
operatie vereisten een Comparator
-object om elementen te vergelijken met elkaar.
Ze geven een Optional
terug met het kleinste respectievelijk grootste element, of de lege optional indien de stream geen elementen bevat.
Bijvoorbeeld, onderstaande code geeft het element terug waarin de letter ‘a’ het vaakst voorkomt.
We maken dus een Comparator gebaseerd op het aantal ‘a’s in het woord.
Om dat aantal te tellen, maken we opnieuw gebruik van een stream-pipeline, namelijk chars().filter().count()
:
Stream.of("Alpha", "Bravo", "Charlie", "Delta")
.max(Comparator.comparing(s -> s.toLowerCase().chars().filter(c -> c == 'a').count())); // => Optional["alpha"]
Voor een IntStream
, LongStream
en DoubleStream
hoef je geen Comparator mee te geven; daar worden uiteraard gewoon de getallen zelf vergeleken.
sum, average, summaryStatistics
De sum
, average
en summaryStatistics
operaties zijn enkel beschikbaar op IntStream
, LongStream
en DoubleStream
.
De eerste twee geven, weinig verrassend, de som en het gemiddelde van de waarden terug.
De summaryStatistics
operatie geeft een object terug met daarin
- het aantal waarden (count)
- de kleinste waarde (min)
- de gemiddelde waarde (average)
- de grootste waarde (max)
- de totale waarde (sum)
Indien je meer dan één van die resultaten zoekt, is het dus efficiënter om deze methode te gebruiken dan de afzonderlijke operaties (die elk een nieuwe stream moeten doorlopen).
Een voorbeeld:
Stream.of("Alpha", "Bravo", "Charlie", "Delta")
.mapToInt(String::length)
.summaryStatistics();
// => IntSummaryStatistics{count=4, min=5, average=5.5, max=7, sum=22}
reduce
De reduce
-operatie is een zeer veelzijdige operatie.
Ze combineert alle elementen in de stream tot één nieuwe waarde (dit is dus bijna de definitie van een terminale operatie).
We beschouwen hier de versie van reduce
met twee argumenten, die een resultaat teruggeeft van hetzelfde type als de elementen in de stream:
- een startwaarde
identity
van typeT
- een accumulator-functie
accumulator
(een BinaryOperator) die een vorigeT
combineert met de huidigeT
uit de stream, en de nieuweT
teruggeeft
Met andere woorden, de reduce
-operatie op een stream heeft hetvolgende effect:
T result = identity;
for (T element : stream) {
result = accumulator.apply(result, element);
}
return result;
Een andere manier om hiernaar te kijken is dat de accumulator een bewerking is die ingevoegd wordt tussen alle elementen van de stream;
de linker-parameter van de accumulator is de waarde tot dan toe, en de rechterparameter de volgende waarde om mee in rekening te brengen.
Bijvoorbeeld, we kunnen de som van alle elementen ook berekenen via reduce
in plaats van via sum
:
IntStream.rangeClosed(1, 5) // => 1, 2, 3, 4, 5
.reduce(0, (sum, value) -> sum + value); // => (((((0 + 1) + 2) + 3) + 4) + 5) = 15
Of grafisch:
Weetje: in sommige andere programmeertalen wordt reduce
ook fold
genoemd (en de Java-versie van reduce
is meer specifiek foldLeft
).
collect
De laatste terminale operatie die we bekijken is collect
.
Deze is gelijkaardig aan reduce
, maar laat toe om de set van terminale operaties uit te breiden via Collector-objecten.
Een Collector-object bevat 4 elementen:
- een
supplier
-functie (type Supplier) die wordt gebruikt om een startwaarde (state) te verkrijgen, vergelijkbaar met deidentity
bijreduce
- een
accumulator
-functie (type BiConsumer) die een nieuw data-element toevoegt aan het voorlopige resultaat (de state); vergelijkbaar met de accumulator vanreduce
- een
combiner
-functie (type BinaryOperator) die twee voorlopige resultaten kan combineren (vooral nuttig bij parallelle uitvoering; zie later) - een
finisher
-functie (type Function) die, op het einde, het voorlopige resultaat omzet naar het finale resultaat.
De supplier, accumulator, combiner en finisher werken conceptueel op de volgende manier:
var tempResult1 = collector.supplier();
for (T element : part 1 of stream) {
collector.accumulator(tempResult1, element);
}
var tempResult2 = collector.supplier();
for (T element : part 2 of stream) {
collector.accumulator(tempResult2, element);
}
var tempResult = collector.combiner(tempResult1, tempResult2);
return collector.finisher(tempResult);
Schematisch ziet dat er zo uit:
Merk op dat, in tegenstelling tot de accumulator bij reduce
, er bij die van een collector niets wordt teruggegeven; de verwachting is dat het tijdelijke resultaat zelf geüpdated wordt (en dus stateful is).
Info
De combiner
is enkel nuttig indien er meerdere deelresultaten zijn.
Dat is enkel het geval als de invoer-stream in meerdere delen opgedeeld kan worden.
Gewoonlijk gebeurt dat enkel bij parallelle streams (zie later).
Een gewone (sequentiële) stream heeft geen combiner nodig.
We geven één voorbeeld van een Collector-implementatie, namelijk een collector die (voor een stream van Strings) de Strings aan elkaar plakt tot één lange String, gescheiden door komma’s.
De state van de collector is een object van de klasse JoiningState.
Die state houdt de tot dan toe aan elkaar geplakte string bij (current
), alsook of het eerste element nog toegevoegd moet worden (first
).
class JoiningState {
private boolean first = true;
private String current = "";
public void add(String s) {
if (!first)
current += ", ";
current += s;
first = false;
}
public JoiningState merge(JoiningState other) {
var result = new JoiningState();
result.current = this.current + (other.first ? "" : ", " + other.current);
result.first = this.first && other.first;
return result;
}
public String result() {
return this.current;
}
}
class StringJoiningCollector implements Collector<String, JoiningState, String> {
@Override
public Supplier<JoiningState> supplier() {
return JoiningState::new;
}
@Override
public BiConsumer<JoiningState, String> accumulator() {
return (state, s) -> state.add(s);
}
@Override
public BinaryOperator<JoiningState> combiner() {
return (state1, state2) -> state1.merge(state2);
}
@Override
public Function<JoiningState, String> finisher() {
return JoiningState::result;
}
@Override
public Set<Characteristics> characteristics() {
return Set.of();
}
}
We kunnen deze collector nu als volgt gebruiken:
Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(new StringJoiningCollector()); // => "Alpha, Bravo, Charlie, Delta"
Java bevat reeds enkele handige voorgedefinieerde collectors.
Deze vind je in de Collectors
-klasse. We bekijken er enkele.
Collectors.joining
Deze collector doet wat we hierboven zelf implementeerden: Strings aan elkaar plakken, gescheiden door een separator-string. We hadden onszelf dus wat werk kunnen besparen, en gewoon hetvolgende schrijven:
Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(Collectors.joining(", ")); // => "Alpha, Bravo, Charlie, Delta"
Collectors.toList, Collectors.toSet, Collectors.toCollection
Deze collectors doen wat de naam zegt: ze maken een List, Set, of Collection met daarin de elementen van de stream.
De toList
terminale operatie die we eerder zagen is gewoon een methode om makkelijk deze collector te gebruiken.
Bij toCollection
ligt niet vast welk type collectie gemaakt moet worden; je moet zelf een functie meegeven om een lege collectie van het gewenste type te maken:
Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(Collectors.toCollection(() -> new LinkedList<>()));
Collectors.groupingBy
Deze collector groepeert de elementen in een Map
, volgens de meegegeven functie om de key te bepalen:
Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(Collectors.groupingBy(String::length));
// => {5=["Alpha", "Bravo", "Delta"], 7=["Charlie"]}
Collectors.partitioningBy
Deze collector groepeert de elementen ook in een Map
, maar nu met als key true
of false
, afhankelijk van of het object voldoet aan de meegegeven voorwaarde of niet:
Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(Collectors.partitioningBy(s -> s.length() < 6))
// => {false=["Charlie"], true=["Alpha", "Bravo", "Delta"]}
forEach
De forEach(fn)
terminale operatie voert de meegegeven functie fn
uit voor elk element dat het einde van de pijplijn bereikt.
Een heel eenvoudig gebruik hiervan is het uitprinten van alle elementen via System.out.println
:
Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.forEach(x -> System.out.println(x)); // => b, c, d, f
// OF: .forEach(System.out::println);
De forEach
-operatie is dus zowat het streams-equivalent van de enhanced for-loop (for (var x : collection)
) bij collecties.
Opgelet
Vermijd het gebruik een forEach
om een variabele/lijst/… buiten de forEach
-lambda aan te passen, zoals het toevoegen aan de result
-lijst in onderstaand voorbeeld.
Meestal is dit een antipattern, en is er een betere manier om hetzelfde resultaat te bekomen.
List<String> result = new ArrayList<>();
Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.forEach(x -> result.add(x)); // Niet doen! ❌
De betere manier hier is uiteraard
List<String> result = Stream.of("C", "D", "A", "F", "E", "B")
.map(String::toLowerCase)
.filter(l -> !"aeiouy".contains(l))
.sorted()
.toList();
Parallelle streams
Vaak kunnen operaties in een stream efficiënt in parallel gebeuren.
Denk bijvoorbeeld aan map
of filter
: deze gebeuren element per element, en zijn dus onafhankelijk van wat er met de andere elementen gebeurt.
Je kan in Java daarom ook een parallelle stream aanmaken.
Deze zal, bij uitvoering, meerdere threads gebruiken (via een ForkJoinPool, zie vroeger) om de stream te verwerken.
Het is eenvoudig om een stream parallel te maken: dat kan via de parallel()
intermediate operatie, of de parallelStream()
-operatie op collecties.
Er is ook een sequential()
operatie die de stream sequentieel maakt.
Merk op dat de mode waarin de stream zich bevindt op het moment van de terminale operatie bepaalt hoe de hele stream uitgevoerd wordt; je kan dus geen deel van de operaties parallel en een ander deel sequentieel uitvoeren.
We gaan niet in meer detail in op parallelle streams.
Nog meer streams? (optioneel)
In onderstaande presentatie zie je hoe er momenteel gewerkt wordt om streams nog uitbreidbaarder te maken.
Via de Collector-API kan je, zoals we gezien hebben, zelf al nieuwe collectors toevoegen.
Voor de tussentijdse operaties ben je in de huidige versie van de streams API echter beperkt tot de voorgedefinieerde tussentijdse operaties; maar je kan er veel meer bedenken.
De oplossing die onderzocht wordt komt in de vorm van een Gatherer
-interface.
Streams (en parallelle streams in het bijzonder) maken gebruik van Spliterators
.
Dat is een variant op een iterator, die (naast de elementen overlopen, zoals bij een gewone iterator) ook de elementen van een bron (bv. een collectie) in 2 delen kan splitsen.
Bij parallelle streams zal dan elk van die delen afzonderlijk (in een aparte thread) verwerkt worden.
Meer info over Spliterators vind je in onderstaande presentatie.
Omdat
int
maar een eindig aantal waarden kan hebben (de hoogste waarde is \( 2^{31}-1 \)), zal deze stream ooit negatieve getallen beginnen produceren: …, 2147483646, 2147483647, -2147483648, -2147483647, …. De stream is wel nog steeds oneindig. ↩︎