7.5 Lambdas
In andere programmeertalen
De concepten in andere programmeertalen die het dichtst aanleunen bij Java lambda’s zijn
- in Python: lambda’s (met het keyword
lambda) en callables - in C++: lambda expressies, function pointers
- in C#: lambda expressies Daarenboven zijn lambda’s en methode-referenties alomtegenwoordig in zogenaamde pure functionele programmeertalen (bv. Haskell).
Wat en waarom?
Met lambda-functies kun je in Java kort en bondig een functie schrijven zonder de functie een naam te geven. Dat is handig als je de functie maar op één plaats zal gebruiken.
Bijvoorbeeld, stel dat we een record Person hebben:
en we willen die sorteren volgens "Voornaam Achternaam", maar ons Person-record heeft geen methode fullName().
In plaats van die functie te schrijven, enkel voor het sorteren, kunnen we een lambda-functie gebruiken. Een lambda-functie die de voor- en achternaam van een persoon aan elkaar plakt met een spatie ziet er als volgt uit:
De -> wijst op een lambda-functie. Links ervan staan de parameters, rechts de body van de methode.
Je kan met zo’n lambda-functie dan een Comparator maken die gebruikt wordt om een lijst van personen sorteert volgens hun voor- en achternaam:
Je hoeft niet expliciet te zeggen welk type parameter p heeft; Java kan dat vaak zelf afleiden. Het volgende kan dus ook:
Syntax van lambda-expressies
Een lambda-expressie heeft in het algemeen deze vorm:
of
Merk op dat een lambda zonder { } geen ; bevat.
Enkele belangrijke varianten:
Geen parameters:
Exact 1 parameter (haakjes mogen weg als je geen expliciet type schrijft):
Meerdere parameters:
Body met meerdere statements:
Notitie
Gebruik je een blok-body met { ... }, dan moet je return expliciet schrijven (behalve bij void-lambdas).
Bij een expression-body (x -> x + 1) gebeurt de return impliciet.
Methode-referenties
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 wat compacter.
Om bijvoorbeeld de lijst people te sorteren volgens hun leeftijd, kan je een methode-referentie gebruiken als volgt:
Java kent vier standaardvormen van methode-referenties:
- Statische methode:
ClassName::staticMethod - Instantiemethode op specifiek object:
instanceRef::method - Instantiemethode op willekeurig object van een type:
ClassName::instanceMethod - Constructor:
ClassName::new
Merk op dat alle variabelen hierboven (toInt, print, lower, emptyList) verwijzen naar een methode (of functie), en niet naar een waarde (int, String, …) zoals je gewoon bent.
Deze variabelen hebben type Function<String, Integer>, Consumer<Object>, etc.
We gaan zodadelijk dieper in op deze types.
Het is erg belangrijk om te begrijpen dat in het voorbeeld hierboven geen van de methodes (parseInt, println, toLowerCase, of de constructor van ArrayList) opgeroepen of uitgevoerd wordt.
We maken enkel de verwijzing naar deze methode, en kennen die toe aan een variabele.
We kunnen deze variabelen nadien wel gebruiken om de methodes effectief uit te voeren, bijvoorbeeld:
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.
Functional interface
Elke interface met daarin precies één methode kan automatisch gebruikt worden als type voor lambda-functies en methode-referenties, tenminste als de types van parameters en het resultaat overeen komen.
Als het expliciet de bedoeling is om de interface op deze manier 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 aan die interface.
Bijvoorbeeld:
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 hieronder gebruikt de PersonPredicate interface om alle personen te selecteren die voldoen aan de meegegeven voorwaarde.
We overlopen nu vier 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, zeker als we dit slechts op 1 plaats nodig hebben:Een tweede optie is om een anonieme klasse te gebruiken. In het voorbeeld gaat de anonieme klasse na of de achternaam van de persoon begint met “Do”. Ook dat blijft omslachtig, het bespaart ons enkel de moeite om een naam voor een klasse te verzinnen:
Met lambda-functies kan je dergelijke code veel eenvoudiger schrijven.
In codefragment 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
PersonPredicateverwacht wordt. De twee lambda-functies hieronder zijn inderdaad functies die een Person-object als argument hebben, en een boolean teruggeven, en komen dus qua type overeen met detest-methode inPersonPredicate.Tenslotte kunnen we ook een methode-referentie gebruiken:
Dat is vooral nuttig als deze methode al bestaat en je er gebruik van wil maken.
Voorgedefinieerde types voor functies
In plaats van zelf een interface zoals PersonPredicate te schrijven, kan je vaak ook beroep doen op een voorgedefinieerde functie-interface.
Je vindt de lijst daarvan in de documentatie.
We lijsten hier de belangrijkste reeds bestaande functionele interfaces op die gebruikt worden:
Function<T, R>: stelt een functie met 1 parameter voor die eenTomzet in eenR:Er zijn ook varianten voor primitieve resultaat-types, zoals
ToIntFunction<T>, die eenTomzet in een int. Bijvoorbeeld:Predicate<T>: stelt een functie voor met 1 parameter van typeT, dietrueoffalseteruggeeft:Ook hier bestaan varianten voor primitieve types, bijvoorbeeld
IntPredicate. Bijvoorbeeld:BiFunction<T, U, R>: stelt een functie met 2 parameters voor, die eenTen eenUomzet in eenR:Bijvoorbeeld:
UnaryOperator<T>: een operator met 1 parameter van typeT, en een resultaat van typeT:Dit is dus een speciaal geval (en ook een subtype) van
Function, namelijk eenFunction<T, T>. Bijvoorbeeld:BinaryOperator<T>: stelt een functie met 2 parameters voor, beide van typeT, die eenTteruggeeft:Dit is dus een speciaal geval van een
BiFunction, namelijk eenBiFunction<T, T, T>. Bijvoorbeeld:Supplier<T>: stelt een operatie zonder parameters voor die eenTteruggeeft:Een invocatie van de supplier mag telkens hetzelfde object teruggeven, maar ook elke keer een ander object en alles daartussenin. Een supplier kan dus beschouwd worden als generator. Bijvoorbeeld:
Consumer<T>: een operatie met 1 parameter van typeT, met een void return type:De consumer ‘verbruikt’ het meegegeven object, zonder een resultaat terug te geven. Bijvoorbeeld:
BiConsumer<T, U>: een consumer met 2 argumenten van typeTenU, die niets teruggeeft:Bijvoorbeeld:
Op zich maakt het niet uit welke interface er gebruikt wordt, zolang de types van de enige methode erin maar overeenkomen met die van de lambda-expressie. Bijvoorbeeld, de lambda
kan dus overal gebruikt worden waar onze zelfgedefinieerde PersonPredicate-interface verwacht wordt, maar ook overal waar een Predicate<Person> of een Function<Person, Boolean> verwacht wordt.
De compiler gaat enkel na of de types van parameters en resultaat overeenkomen met die van de enige methode in de interface; de naam van de interface en de naam van de methode in deze interface doen niet terzake.
Capturing en ’effectively final’
Een lambda mag variabelen uit de omliggende scope gebruiken. Dat heet capturing.
Zo’n variabele (minAge hierboven) moet wel effectively final zijn: je mag ze na initialisatie niet meer aanpassen.
Met andere woorden: je zou de variabele final moeten kunnen maken zonder dat dat problemen geeft.
De Java compiler zal zelf uitzoeken of de variabelen die gebruikt worden in een lambda inderdaad effectively final zijn, en een fout geven als dat niet zo is. Dit voorkomt verwarrende situaties rond toestand en lifetime van variabelen. Bijvoorbeeld:
Als de code hierboven wel zou compileren, is het niet duidelijk welke leeftijd gebruikt zou moeten worden: 18 of 21?
De makkelijkste manier om hier geen last van te hebben is door geen variabelen te gebruiken die buiten de lambda gedefinieerd worden, en dat is dan ook ten zeerste aanbevolen.
Oefeningen
Gebruik in deze oefeningen waar mogelijk zowel lambda’s als methode-referenties. De startcode vind je op https://github.com/KULeuven-Diepenbeek/ses-deel2-oefeningen-04-lambdas.
Basislambda’s
Gegeven
schrijf lambda-expressies voor volgende types:
Function<Person, String> fullname: geef"firstName lastName"terugBiFunction<Person, Integer, Boolean> isAtLeast: true alsperson.age()groter of gelijk is aan de tweede parameterPredicate<Person> isSenior: true als de persoon minstens 65 jaar is. Herbruik de zonet gedefinieerde variabeleisAtLeast.Supplier<Person> newPerson: maak telkens een nieuwePerson("Jane", "Doe", 25)
Gebruik daarna ook elk van deze methodes.
Van anonieme klasse naar lambda
Herschrijf onderstaande code met een lambda:
Van lambda naar methode-referentie
Vervang elke lambda door een equivalente methode-referentie:
Functie-compositie
Deze oefening is ook een extra oefening op generics.
Schrijf een generische methode compose die twee functies als parameters heeft, en als resultaat een nieuwe functie teruggeeft die de compositie voorstelt: eerst wordt de eerste functie uitgevoerd, en dan wordt de tweede functie uitgevoerd op het resultaat van de eerste.
Dus: voor functies
moet compose(f1, f2) een Function<A, C> teruggeven, die als resultaat f2.apply(f1.apply(a)) teruggeeft.
Pas de PECS-regel toe om ook functies te kunnen samenstellen die niet exact overeenkomen qua type. Bijvoorbeeld, volgende code moet compileren en de test moet slagen:
Comparator
Gegeven onderstaande code:
Maak een comparator-object dat de lijst sorteert
- eerst op
lastName - dan op
firstName - en bij gelijke naam op dalende
age
Gebruik Comparator.comparing, thenComparing en methode-referenties.
TriFunction
Java heeft geen interface voor een functie met 3 parameters.
- Definieer zelf een (generische) functionele interface
TriFunctiondie een functie voorstelt met 3 parameters (van een verschillend type). - Definieer een functie
zip3die 3 lijsten als parameters heeft, samen met eenTriFunction. De 3 lijsten moeten even lang zijn. Deze functie geeft een lijst terug waarvan het i-de element gevormd wordt door de meegegeven tri-functie toe te passen op het i-de element van elk van de drie parameter-lijst.
Een voorbeeld van het gebruik van zip3 om een lijst van personen te maken op basis van een lijst van voornamen, achternamen, en leeftijden:
Mini-event dispatcher
Schrijf een generische klasse Dispatcher<E> waar klassen zich kunnen inschrijven om op de hoogte gebracht te worden van een event.
Het event heeft generisch type E.
Voorzie twee methodes:
- Een methode
subscribewaar je een methode kan registreren die opgeroepen moet worden telkens een eventEgepubliceerd wordt. - Een methode
publish(E event)die alle geregistreerde methodes oproept met het meegegeven event.
Een voorbeeld van het gebruik van de Dispatcher-klasse (met String als event-type):