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 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:
- een 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)
- 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.
- een terminale operatie: deze beëindigt de ketting en geeft het uiteindelijke resultaat terug.
graph LR
Source --> Op1 --> Op2 --> Op3 --> Terminal
style Source stroke:black,fill:#afa
style Terminal stroke:black,fill:#faa
Je kan een stream 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 waarin je de gemiddelde leeftijd wil berekenen van de eerste 20 meerderjarige personen in een lijst. Je kan dat 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++;
}
}
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:
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
).
Lambda functies en methode-referenties
Bij het gebruik van streams zal je veelvuldig gebruik maken van methode-referenties en lambda-functies.
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 {
public 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;
}
List<Person> people = List.of(new Person("Vandeneynde", 16), new Person("Vervoort", 23));
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.
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.age() >= 18;
}
}
System.out.println(selectPeople(people, new IsAdult()));
// => [Person[name=Vervoort, age=23]]
Een tweede optie is om een anonieme klasse te gebruiken. In het voorbeeld hieronder is dat een anonieme klasse die nagaat of de naam van de persoon begint met “Van”. Ook dat blijft omslachtig:
/* [2] */
System.out.println(selectPeople(people, new PersonPredicate() {
@Override
public boolean test(Person person) {
return person.name().startsWith("Van");
}
}));
// => [Person[name=Vandeneynde, age=16]]
Sinds Java lambda-functies ondersteunt, 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.age() >= 18));
// => [Person[name=Vervoort, age=23]]
/* [4] */
System.out.println(selectPeople(people, p -> p.name().startsWith("Van")));
// => [Person[name=Vandeneynde, age=16]]
Tenslotte kunnen we ook een methode-referentie gebruiken:
/* [5] */
boolean isAdult(Person person) {
return person.age() >= 18;
}
System.out.println(selectPeople(people, this::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.
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 1 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.
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.
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 1, 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 1 nieuw element (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).
We geven één voorbeeld van een Collector-implementatie, namelijk een collector die (voor een stream van Strings) de StringJoiner
-klasse gebruikt om de strings aan elkaar te plakken, gescheiden door komma’s.
(De StringJoiner is een stateful klasse, vergelijkbaar met een StringBuilder).
class StringJoiningCollector implements Collector<String, StringJoiner, String> {
@Override
public Supplier<StringJoiner> supplier() {
return () -> new StringJoiner(", ", "", ""); // separator, prefix, postfix
}
@Override
public BiConsumer<StringJoiner, String> accumulator() {
return StringJoiner::add;
}
@Override
public BinaryOperator<StringJoiner> combiner() {
return StringJoiner::merge;
}
@Override
public Function<StringJoiner, String> finisher() {
return StringJoiner::toString;
}
}
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"]}
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.
Oefeningen
Alle oefeningen moeten opgelost worden zonder for- of while-lussen.
Personen
We gaan werken met een dataset van personen, onderverdeeld in volwassenen en kinderen:
interface Person { String firstName(); String lastName(); int age(); String zipCode(); }
record Adult(String firstName, String lastName, int age, String zipCode, List<Child> children) implements Person {}
record Child(String firstName, String lastName, int age, String zipCode) implements Person {}
De dataset vind je in het bestand Data.java
, en ziet er als volgt uit:
public static final List<Adult> DATASET = List.of(
new Adult("John", "Doe", 30, "12345", List.of(
new Child("Alice", "Doe", 5, "12345"),
new Child("Liam", "Doe", 7, "12345"))),
...
);
Los volgende oefeningen op met streams.
- Print alle namen van de volwassenen in de dataset.
- Ga na dat alle volwassenen in de dataset inderdaad ouder dan 18 zijn.
- Ga na of er minstens één volwassene in de dataset zit waarvan de voornaam “Joseph” is.
- Geef enkele statistieken (minimum, maximum, gemiddelde) van de leeftijd van alle volwassenen in de dataset die ten minste 30 jaar oud zijn.
- Zoek de volwassene met de langste achternaam.
- Groepeer alle volwassenen in een Map volgens hun postcode.
- Geef een String met de 5 oudste volwassenen terug, in het formaat “voornaam achternaam leeftijd”, gesorteerd volgens voornaam, 1 persoon per lijn.
- Tel het totaal aantal kinderen in de dataset.
- Tel het aantal volwassenen met kinderen in de dataset.
- Geef het minimum en maximum aantal kinderen dat een volwassene heeft.
- Zoek alle personen die precies 6 keer zo oud zijn als een van hun kinderen.
- Maak een lijst van alle kind-objecten in de dataset.
- Maak een gesorteerde lijst met alle unieke voornamen van alle kinderen in de dataset.
- Bereken de gemiddelde leeftijd van alle kinderen in de dataset die een ouder hebben die ouder is dan 40.
- Maak een lijst van alle personen (volwassenen + kinderen) in de dataset, gesorteerd volgens voornaam.
- Bereken de gemiddelde leeftijd van alle personen (volwassenen + kinderen) in de dataset.
Extra: mapValues
Schrijf, gebruik makend van streams, een generische methode mapAllValues(map, function)
die je kan gebruiken om een nieuwe Map te maken waarbij alle waarden vervangen zijn door de gegeven functie erop toe te passen.
Bijvoorbeeld, in onderstaande code gebruiken we deze functie om alle String-values in de map te vervangen door hun lengte:
Map<String, String> myMap = Map.of("first", "333", "second", "55555"); // => {first="333", second="55555"}
Map<String, Integer> result = mapAllValues(myMap, String::length);
System.out.println(result); // => {first=3, second=5}
Hint: gebruik Collectors.toMap(...)
Denk ook na over geschikte grenzen voor je generische parameters (PECS). Ook hetvolgende zou moeten werken:
Map<String, String> myMap = Map.of("first", "333", "second", "55555");
Function<Object, Integer> fn = (x) -> x.toString().length();
Map<Object, Object> result = mapAllValues(myMap, fn);
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. ↩︎