De nieuwe Java versie 8 wordt al lang verwacht en bevat de meest spectaculaire wijzigingen sinds de introductie van generics in Java 5. Na een aantal keren uitstel is de nieuwe versie eind maart definitief uitgeleverd. In dit artikel bespreken we de nieuwe concepten van Java 8 op het gebied van lambda’s en nieuwe interface mogelijkheden. Deze nieuwe taalconcepten zijn beschreven in JSR-335.
We laten in dit artikel zien dat we met de nieuwe toevoegingen in de taal met minder regels, beter te onderhouden en begrijpelijke code kunnen schrijven. In dit artikel zullen we ook zien dat de nieuwe taalconcepten bijna tot geen complexiteit toevoegen en dat het gebruik in de praktijk vanzelfsprekend is. We gaan niet in op de andere JSR’s, die zijn toegevoegd aan Java 8. Een compleet overzicht van alle nieuwe features is te vinden op https://jdk8.java.net/
Lambda expressies
In 2008 is voor het eerst overwogen om “lambda expressies” (in het kort: lambda’s) aan Java toe te voegen. Na een zes jaar lang durend, intensief debat hebben ze nu eindelijk hun weg naar Java gevonden.
Vanuit taalperspectief zijn lambda expressies de meest invloedrijke toevoeging aan Java. Lambda’s opent de deuren naar de veelbelovende wereld van functioneel programmeren en introduceren daarmee een paradigma-verschuiving over hoe software met Java geschreven wordt.
Syntactisch bekeken zijn lambda’s slechts syntactic sugar. Om dit te verduidelijken kijken we naar de methode listFiles(FileFilter ff) van java.io.File. Om bijvoorbeeld directories te filteren, diende deze methode tot en met Java 7 als volgt gebruikt te worden:
new File("/").listFiles(new FileFilter() {
public boolean accept(File f) {
return f.isDirectory();
}
});
Deze constructie is uitermate verbose en moeilijk leesbaar. Met lambda’s kan de code voor het instantiëren van de klasse FileFilter terug gebracht worden tot één regel:
new File("/").listFiles((File f) -> f.isDirectory());
Semantisch zijn bovenstaande voorbeelden identiek. Dit werkt als volgt: als we de signature van de FileFilter interface bestuderen, zien we dat deze interface een enkele, abstracte methode heeft. Zulke interfaces hebben in Java 8 een specifieke naam gekregen, namelijk: functional interfaces. Zij worden vanaf Java 8 met de @FunctionacalInterfe annotatie voorzien.
Overal, waar een methode een functional interface als input parameter verwacht, zoals listFiles(FileFilter ff), kan een lambda expressie in plaats van een anonymous inner class of implementatie gebruikt worden. Dit impliceert dat bovenstaande lambda expressie aan een FileFilter variabele toegewezen kan worden:
FileFilter ff = (File f) -> f.isDirectory();
De enige voorwaarde is, dat de signature van de lambda overeenkomt met de signature van de enige method in de functional interface. In ons voorbeeld gebruikt de lambda expressie File als input parameter en boolean als output, wat overeenkomt met de signature van de enige method in FileFilter: boolean accept(File f).
Matchen op signature in plaats van type is een nieuw concept in Java. Dit concept is zeer krachtig, maar vergt enige gewenning.
Lambda syntax opties
Lambda’s kunnen in verschillende syntactische gedaante gebruikt worden. Het bovenstaande voorbeeld vertegenwoordigd de meest uitgebreide variant. Daarnaast kan het type van de input parameter van een lambda weggelaten worden:
new File("/").listFiles(f -> f.isDirectory());
Het mechanisme, die dat mogelijk maakt, wordt ‘Type Inference’ genoemd. De compiler kent de signature van de enige method (boolean accept(File f)) van FileFilter en kan daardoor de input type van de lambda achterhalen oftewel interfereren, waardoor deze niet expliciet genoemd hoeft worden. Het voordeel van type inference is voornamelijk code-reductie.
Als een lambda expressie niets anders doet dan een bestaande methode aanroepen, komen ‘Method References’ van pas. Bovenstaand voorbeeld kan hiermee nog beknopter uitgedrukt worden:
new File("/").listFiles(File::isDirectory);
File::isDirectory is dus semantisch hetzelfde als f -> f.isDirectory().
Method references komen in verschillende smaken. File::isDirectory is van het type ‘Instance Method Reference’, omdat de implementatie van listFiles de isDirectory() methode op elke File aanroept, waarover heen gelopen wordt. Instance method references zijn syntactisch compacter en (na wat gewenning) eenvoudiger leesbaar dan gewone lambda’s. Dit is ook de voornaamste reden om ze te gebruiken.
Stel, je wilt met behulp van de listFiles methode bestanden filteren, die een symbolic link zijn. Hiervoor wil je gebruik maken van de static utility method boolean isSymLink(File f) van de commons FileUtils. Static Method References bieden hier uitkomst:
new File("/").listFiles(FileUtils::isSymLink);
Semantisch is FileUtils::isSymLink hetzelfde als f -> FileUtils.isSymLink(f).
Static method references zijn ervoor gemaakt om functionaliteit van statische utility methode op een elegante manier in de context van een lambda te hergebruiken.
Functional interfaces
In Java 8 is de package java.util.function toegevoegd met een reeks nieuwe functional interfaces. Deze interfaces zijn in te delen in de volgende groepen:
interface Function<T, R> { R apply(T t); }
Een interface van het type Function definieert een operatie met een input parameter en als output van hetzelfde of een andere type als de input. De vertaling naar een lambda ziet er als volgt uit:
Function<Integer, Integer> square = in -> in * in
interface Predicate<T> { boolean test(T t); }
De Predicate interface definieert een operatie op met een input parameter en met een boolean als output, bijvoorbeeld:
Predicate<File> isDirectory = file -> file.isDirectory()
interface Consumer<T> { void accept(T t); }
Een Consumer definieert een operatie met een input parameter zonder resultaat, bijvoorbeeld:
Consumer<String> printMe = in -> System.out.println(in)
interface Supplier<T> { T get(); }
Een Supplier definieert een operatie zonder argumenten en met als output van een willekeurig type. Een voorbeeld hiervan is:
Supplier<LocalDate> now = () -> LocalDate.now();
De java.util.function package is al voorzien in een flink aantal variaties op bovenstaande interfaces. Zo is er voor elke primitieve variant een functional interface beschikbaar, zoals IntPredicate, IntToLongFunction. Functional interfaces met twee input parameters worden voorafgegaan door de prefix “Bi”, zoals BiPredicate of BiFunction.
Deze nieuwe functional interfaces worden vooral toegepast in de vernieuwde Collections API van Java 8. Deze interfaces kunnen natuurlijk ook als input parameter(s) voor zelf gedefinieerde methoden gebruikt worden.
Een voorbeeld van het gebruik van een Function interface van het package java.util.function is te vinden in java.util.Map. Aan een Map is in Java 8 de volgende handige methode toegevoegd:
V merge(K key, V value,
BiFunction<? super V, ? super V, ? extends V> remappingFunction)
Aan de BiFunction kunnen met een lambda de volgende implementatie geven:
BiFunction<Integer, Integer, Integer> maxFun1 =
(oldValue, newValue) -> Math.max(oldValue, newValue);
Of nog beknopter, gebruik makende van een static method reference:
BiFunction<Integer, Integer, Integer> maxFun2 = Math::max;
Als de merge methode wordt aanroepen, zal de expliciete verwijzing naar BiFunction over het algemeen niet gebruikt worden. In plaats daarvan zal de lambda expressie, die hoort bij de BiFunction inline, gebruikt worden:
Map<String, String> map = new HashMap<>();
map.put("java", 7);
map.merge("java", 8, Math::max);
Na het uitvoeren van de merge levert dit de waarde 8.
Het is dus belangrijk om te weten hoe een functional interface vertaald wordt naar een lambda. In het begin zal dat wat tijd nodig hebben, omdat de javadoc bij een methode, die een functional interface als argument gebruikt, slechts het type van deze interface vermeld. De input en output parameters, van de door de functional interface gedefinieerde methode, zijn niet direct af te leiden. Dit is echter juist noodzakelijk voor de vertaling naar een lambda expressie.
Default methoden
In Java 8 is het mogelijk om methodes van een interface te voorzien van een concrete implementatie. Voor de betreffende methode dient hiervoor het nieuwe keyword ‘default’ gedeclareerd te worden, gevolgd door een implementatie. Als een klasse de interface implementeert, hoeft er voor de default methoden van de interface geen implementatie geleverd te worden. Het is echter wel mogelijk om de default methode te overschrijven.
Met default methoden is het mogelijk om nieuwe methoden aan bestaande interfaces toe te voegen, zonder hierbij bestaande implementaties te breken. In die zin kun je een interface uitbreiden met behoud van backward compatibility.
In de vernieuwde Collections API zijn veel voorbeelden van default methoden te vinden, zoals onder andere in de java.util.Collection interface:
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
Zou je als programmeur in een versie voor Java 8 zelf een implementatie van java.util.Collection hebben geschreven, dan is die nog steeds op een Java 8 JVM te gebruiken. Zelfs als de implementatie geen removeIf methode bevat. In plaats daarvan wordt de default implementatie van removeIf gebruikt.
Door de toepassing van default methoden is het nu mogelijk om myPeopleList.sort(…) te schrijven in plaats van Collections.sort(myPeopleList, …). Dit kan omdat aan de java.util.List interface een default methode sort(…) is toegevoegd, waarvan de implementatie de methode van Collections.sort(…) aanroept.
Klassen die interfaces met default methoden implementeren, krijgen rijkere functionaliteit en zijn in het gebruik meer objectgeoriënteerd. Een ander voorbeeld vinden we in de Predicate interface, die is voorzien van een aantal default methoden, bijvoorbeeld:
default Predicate<T> and(Predicate<? super T> other) {
return (t) -> test(t) && other.test(t);
}
We kunnen nu dus direct de volgende compositie toepassen zonder een extra helper klasse te moeten schrijven:
Predicate<Person> lastNameStartsWithA = person -> person.startsWith("A")
Predicate<Person> ageAbove30 = person -> person.getAge() > 30
Predicate<Person> composed = lastNameStartsWithA.and(ageAbove30);
Default methoden maken multiple inheritance in Java 8 mogelijk
Met default methoden is zelf een beperkte vorm van multiple inheritance mogelijk. Voor de komst van Java 8 was het alleen mogelijk om functionaliteit buiten een klasse via overerving van een enkele superklasse of via delegatie van een andere klasse te verkrijgen.
Door default methoden kan functionaliteit van verschillende interfaces in een enkele klasse samengebracht worden. Daarnaast kan de klasse nog steeds functionaliteit van een superklasse overerven. Een hypothetisch voorbeeld:
abstract class Person {
public Integer nextBirthdayInDays() {...}
}
interface RichComparable<T> extends Comparable<T> {
public default boolean greaterThan(T other) {
return compareTo(other) > 0;
}
//aanvullend: smallerThan, greaterThanEquals, smallerThanEquals etc.
}
class Employee extends Person implements RichComparable<Employee> {
public int compareTo(Employee other) {
return this.name.compareTo(other.name);
}
}
Employee e1 = new Employee("Bakker", "25-10-1981");
Employee e2 = new Employee("Jansen", "12-12-1970");
e1.greaterThan(e2);
e1.nextBirthdayInDays();
Met bovenstaande constructie heeft de klasse Employee niet alleen beschikking over de functionaliteit van de superklasse Person, zoals bijvoorbeeld nextBirthdayInDays, maar ook van alle default methoden van de RichComparable interface, zoals greaterThan, smallerThan etc.
Omdat de logica van default methoden van meerdere interfaces in een klasse ‘gemixt’ kan worden en deze klasse bovendien logica van een superklasse kan overerven, beschikt Java 8 over een beperkte vorm van multiple inheritance. Hiermee kunnen aanzienlijk flexibelere API’s gemaakt worden, die een API-ontwerper niet meer beperken tot het onderbrengen van functionaliteit in abstracte klasses alleen.
Een belangrijke kanttekening is echter wel dat multiple inheritance met default methoden beperkt is tot gedrag. Interfaces kunnen namelijk geen state bevatten. Als een API gemaakt wordt, waar state een belangrijke rol speelt, biedt alleen single inheritance (oftewel klasse inheritance) uitkomst.
The best of both worlds
Lambda’s en default methoden bieden ongekende mogelijkheden voor hergebruik, flexibiliteit en code-reductie, waarmee krachtige API’s geschreven kunnen worden. Ter afronding een voorbeeld over de in dit artikel beschreven concepten.
Stel, je krijgt de opdracht persoonsgegevens uit een CSV bestand te filteren op basis van verschillende criteria. Gebruikmakend van de nieuwe mogelijkheden zou dit probleem met de volgende paar regels code opgelost kunnen worden:
class PersonParser {
public static Person parse(String csvLine) {...}
}
List<Person> filterPersons(URL url, Predicate<Person> personFilter) {
try {
BufferedReader br = new BufferedReader(
new InputStreamReader(url.openStream())))
return br.lines()
.map(PersonParser::parse)
.filter(personFilter)
.collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
Sinds Java 8 is de BufferedReader voorzien van een lines() methode, die een java.util.Stream teruggeeft. Een Stream is een soort tijdelijke collectie, die een grote hoeveelheid krachtige methoden bevat, zoals map, filter etc., die lambda’s als input parameter verwachten. Met onder andere de aanroep naar de filter methode op de stream, worden alleen die personen geselecteerd die voldoen aan het opgegeven Predicate.
Met deze opzet zijn we als gebruiker van deze API ongekend flexibel. Met behulp van lambda’s kunnen wij filter expressie op beknopte wijze bepalen om zo de lijst van personen op verschillende manieren te filteren:
filterPersons(csvUrl, person -> person.getAge() > 30)
filterPersons(csvUrl, person -> person.startsWith(„A"))
Zonder lambda’s en default methoden zou de filterPersons functionaliteit ongeveer twee keer zoveel code bevatten en bovendien aanzienlijk minder gebruiksvriendelijk zijn. Dit geeft aan dat we met Java 8 een mooie toekomst tegemoet gaan, waarbij we met minder aanzienlijk meer voor elkaar kunnen krijgen.
Nanne Baars is software developer bij Xebia
Urs Peter is senior consultant bij Xebia