Pattern Matching maakt Java in de komende jaren nóg krachtiger

Lambda’s en streams hebben zich sinds Java 8 in onze geliefde taal gemanifesteerd en zijn een krachtig middel geworden. In de komende versies van Java worden meer bewezen features uit functionele talen geïntegreerd. Eén daarvan is pattern matching (niet te verwarren met reguliere expressies): een elegante manier om de kenmerken van een object te testen. Wij zochten uit wat er nu al mogelijk is door de introductie van JEP 305 (‘Pattern Matching for instanceof’) en hoe verdere pattern matching-plannen Java een nóg krachtigere taal kunnen maken.

Type patterns

Iedereen maakt het wel eens mee: je wilt bepalen of een object van een bepaald type is om er een methode van dat type op uit te voeren. Zo’n constructie vergt meestal dezelfde handelingen: een instanceof-test, het casten van het object en het toekennen van het resultaat aan een variabele. Zulke taalconstructies zijn repetitief, foutgevoelig en bovenal vervelend om te moeten schrijven. Dat moet toch simpeler kunnen!

‘Pattern Matching for instanceof’ [1], sinds Java 14 een preview feature, beschrijft een eerste stap in het toevoegen van pattern matching aan Java. Met deze functionaliteit kunnen we de drie eerder genoemde stappen vervangen door slechts één expressie [2].

// Java 13
@Override
public boolean equals(Object o) {
    // As pattern matching is music to our ears, we defined a domain model of an orchestra. More details can be found at our Github repository[2].
    if (!(o instanceof Musical)) {
        return false;
    }
    
    Musical m = (Musical) o;
    return name.equals(m.name) && isPrincipal == m.isPrincipal;
}

// Java 14
@Override
public boolean equals(Object o) {
    return o instanceof Musical m && name.equals(m.name) && isPrincipal == m.isPrincipal;
}

In het voorbeeld definiëren we een pattern Musical m, dat bestaat uit een type Musical en een label m. In een instanceof-test wordt het type gebruikt om een object van dat type te matchen. Nadat de match heeft plaatsgevonden, wordt het object automatisch gecast en toegekend aan de variabele met het gedefinieerde label. Zo vervang je met een enkel pattern de expliciete cast en het toekennen aan een variabele. In een equals-methode is de impact wellicht nog beperkt, maar de kracht wordt pas echt duidelijk wanneer we in de toekomst pattern matching kunnen gaan gebruiken in een switch-expressie [3]. De syntax kan nog wijzigen [4], maar een voorbeeld zie je hieronder.

String whatDoesTheMusicianSay = switch (musical) {
    case Vocal v  -> String.format("The singer goes %s", v.sing()); 
    // The singer goes "Aaaa"
    case Guitar g -> String.format("The guitar goes %s", g.play());
    // The guitar goes "Pling pling"
    case Drums d  -> String.format("The drums go %s", d.hit());
    // The drums goes "Pats Boem"
    default       -> String.format("Unknown Musical goes %s", obj.play()); 
};

Je ziet het; het combineren van de nieuwe switch-expressie met pattern matching levert verrassend korte en bondige code op. En dit is nog maar het begin.

Constant patterns

Naast type patterns is een tweede soort pattern jullie al bekend: de constante case-labels in een switch-statement. De numerieke, String- en enum-waardes die daarbij horen noemen we in het vervolg constant patterns. Een nieuwe naam voor iets dat al lang bestaat, om zo duidelijker te maken dat de case-labels in de toekomst allerlei soorten patterns aankunnen.

Deconstruction patterns

Deconstruction patterns brengt de ondersteuning voor pattern matching nog een stapje verder door na het matchen op type, ook het object ‘uit te pakken’. Zo hoeven we niet meer afzonderlijke getters te gebruiken om interne velden te benaderen, maar kunnen we dat in een enkel statement. Zo’n deconstruction pattern gebruikt een pattern-definitie, een soort omgekeerde van een constructor, om bij matchen van een object de waardes van interne velden toe te kennen aan variabelen. In onderstaand codevoorbeeld zien we zo’n deconstruction pattern [4].

return switch (musical) {
    case Orchestra(List musicians) -> String.format("Orchestra, consisting of %d musicians.", musicians.size());
    // Using definition "public pattern Orchestra(List list)"
    case InstrumentFamily(Guitar(true, guitarName), Trumpet(true, trumpetName)) -> String.format("Two principals of guitarist %s and trumpetist %s.", guitarName, trumpetName);
    // Using definitions "public pattern InstrumentFamily(Musical m1, Musical m2)" and "public pattern Musical(boolean isPrincipal, String name)"
};

Zoals je herkent, matchen we in het voorbeeld op het type InstrumentFamily en Orchestra. Verder zien we dat bij het case-statement van type Orchestra de match alleen plaatsvindt als het genoemde veld uitgepakt kan worden als een lijst van Musical-objecten. Bij een match is deze variabele direct in scope en kunnen we er methodes op aanroepen. Met deze deconstruction patterns kunnen we objecten dus in een enkel case-statement matchen, casten en velden direct benaderen.

Krachtiger dan dit wordt het niet, zou je denken. Behalve dan dat we alle beschreven patterns ook kunnen combineren, zoals staat weergegeven in het tweede case-statement van het codevoorbeeld hierboven. In dit geval combineren we een deconstruction pattern (het uitpakken van objecten bij een match), type patterns (het matchen op type) en een constant pattern (het matchen op een constante waarde). Samengevat matchen we op een InstrumentFamily als deze uitgepakt kan worden in een Guitar en Trumpet, waar voor beiden geldt dat isPrincipal gelijk is aan true. Zo staat de gehele collectie aan patterns tot je beschikking!

Var & Any patterns

Lokale variabelen kunnen sinds Java 10 ook met het keyword var worden aangeduid, in plaats van met een expliciet type [5]. Hetzelfde principe ligt ten grondslag aan var patterns: we kunnen in onze patterns var gebruiken in plaats van het expliciete type, en laten de compiler het juiste type pattern afleiden. Er is daarmee geen conditie op het type meer actief; de compiler gebruikt het eerste veld dat een valide type pattern oplevert en bindt de bijbehorende waarde aan de variabele.

case Guitar(var name) -> String.format("The guitar is called %s", name);

Een any pattern (aangegeven met een underscore) is als een var pattern: het matcht op het eerstgevonden veld, maar bindt daarbij geen waarde aan de variabele. Dat klinkt misschien niet erg nuttig, maar als onderdeel van een genest pattern kan het elegant uitdrukken dat een deel van een component niet-relevant is en genegeerd kan worden.

case Orchestra(_, VocalFamily vf), Orchestra(VocalFamily vf, _) -> "This orchestra contains a vocalist.";

Betere serialisatie

We zagen eerder al dat een deconstruction pattern een objectstructuur om kan zetten in losse, getypeerde velden. Eigenlijk net zoals een constructor losse, getypeerde velden omzet in een objectstructuur. Ze zijn elkaars tegenovergestelde. En dat inzicht zou wel eens een betere versie van serialisatie kunnen opleveren. Serialisatie is belangrijke functionaliteit in Java, maar veel mensen hebben een hekel aan de huidige implementatie. Het ondermijnt bijvoorbeeld het toegangsmodel, de serialisatielogica is geen ‘leesbare code’ en het omzeilt constructors en bijbehorende datavalidatie. Maar het gebruik van patterns zou de situatie drastisch kunnen verbeteren.

Wanneer klasses in de toekomst pattern-definities kunnen bevatten, zou dat een goede plek zijn om een instantie van die klasse te serialiseren. Deserialiseren gebeurt dan via een (overloaded) constructor of een factory-methode. Hiermee zou alle serialisatielogica ‘leesbare code’ worden en expliciet onderdeel van de klassedefinitie. Door de annotaties is het direct duidelijk waar de code voor dient. Ook zou bestaande datavalidatie eenvoudig aangeroepen kunnen worden.
Het ondersteunen van serialisatie op meerdere versies van een klasse zal een uitdaging blijven, al bestaan er wel plannen om de @Serializer– en @Deserializer-annotaties met een version-property uit te rusten die een klasseversie kan bevatten [6].

class Orchestra {
    @Deserializer
    public static Orchestra deserialize(Musical[] musicals) {
        return Orchestra.ensemble(musicals);
    }

    @Serializer
    public pattern Orchestra(Musical[] musicals) {
        musicals = this.musicalPeople.toArray();
    }
}

Records & Sealed types

Dat pattern matching niet op zichzelf staat, maar deel uitmaakt van een groter plan wordt nog duidelijker als we naar records gaan kijken. Deze compacte manier om immutable data te modelleren werd eerder al geïntroduceerd in Java Magazine [7] en heeft als voordeel dat constructors, getters, equals()– en hashCode()-implementaties al beschikbaar zijn, zonder ze expliciet te definiëren. Wanneer deconstruction patterns beschikbaar komen in Java, zullen records ook automatisch pattern-definities bevatten [8], waarmee een record direct bruikbaar wordt als deconstruction pattern in een switch expression.

record Trumpet(boolean isPrincipal, String name) implements Musical {
    // This code is generated:
    public pattern Trumpet(boolean isPrincipal, String name) {
        isPrincipal = this.isPrincipal;
        name = this.name;
    }
}

Ook een andere, relatief nieuwe feature zal in de toekomst goed samenwerken met pattern matching, namelijk sealed types. Met sealed types – beschikbaar in preview vanaf Java 15 – leg je voor een interface of superklasse uitputtend vast welke implementaties er mogen zijn.

sealed interface Musical permits Vocal, Guitar, Drums {}

Wanneer we vervolgens een switch expression op een sealed type toepassen, zal de default branch van de switch niet meer nodig zijn. De compiler ‘weet’ immers dat Musical maar drie implementaties heeft (en blijft hebben).

String whatDoesTheMusicianSay = switch (musical) {
    case Vocal v -> String.format("The singer goes %s", v.sing()); 
    // The singer goes "Aaaa"
    case Guitar g -> String.format("The guitar goes %s", g.play()); 
    // The guitar goes "Pling pling"
    case Drums d -> String.format("The drums goes %s", d.hit()); 
    // The drums goes "Pats Boem"
};

Conclusie

Pattern matching gaat een heel stuk verder dan alleen het voorkomen van casts bij een instanceof. Het verbetert switch expressions, het kan complexe logica elegant uitdrukken en het deconstrueren van objecten is een ‘first-class citizen’ geworden, doordat pattern-definities het tegenovergestelde zijn van constructors. Bovendien is bij het ontwerpen van nieuwe features als sealed types en records de ondersteuning van pattern matching alvast voorzien. Dat laat zien dat pattern matching een belangrijke feature in Java gaat zijn én blijven.

Over de auteurs

Hanno Embregts
Hanno is software-architect, conferentiespreker en trainer bij Info Support. Hij houdt ervan als zijn werk afwisselend is; daarom combineert hij Java-softwareontwikkeling met spreken op conferenties en het geven van trainingen bij het Kenniscentrum van Info Support.

Peter Wessels
Peter is IT-consultant en trainer bij Info Support. Als software engineer bij onder andere ING, Translink en WWplus, zoekt hij altijd een manier om met Java impact te creëren; op zowel technisch als het menselijk vlak door bijvoorbeeld Domain-Driven-Design en Domain-Specific-Languages.

Referenties

[1] https://openjdk.java.net/jeps/305
[2] In dit artikel gebruiken we codevoorbeelden die gaan over een orkest, dat bestaat uit verschillende muzikanten. De code is beschikbaar via https://github.com/MrFix93/pattern-matching-orchestra.
[3] http://openjdk.java.net/jeps/406
[4] In dit artikel beschrijven we toekomstige features zoals die beschreven worden in JEP-drafts en de visie van de ontwikkelaars van Java. De exacte invulling kan dus nog wijzigen.
[5] https://openjdk.java.net/jeps/286
[6] https://cr.openjdk.java.net/~briangoetz/amber/serialization.html
[7] “Java 14” (Ivo Woltring) – Java Magazine #2-2020.
[8] “Pattern Matching for Java” (Gavin Bierman, Brian Goetz) – https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html

Colofon

Dit artikel verscheen oorspronkelijk in Java Magazine #2-2021 en is met permissie van de NLJUG op deze plek gepubliceerd.