Het doorbreken van type-safety in Java: you never know what you’re gonna get
Java staat bekend als type-safe. Maar wist je dat je die type safety ook kunt doorbreken?
Om te doorgronden hoe dat werkt, is het belangrijk om drie soorten variance te doorgronden: co-, contra- en invariance. De meeste developers hebben hier wel eens van gehoord, maar weten niet precies wat ze betekenen of hoe je ze effectief gebruikt.
Dat gold tot voor kort ook voor mij, ondanks mijn ruim tien jaar ervaring met Java. In dit artikel leg ik deze concepten uit, zodat je beter voorbereid bent om herbruikbare (framework)code te begrijpen en te schrijven.
Stel dat we een app bouwen voor het bedrijf ‘NullPointer Solutions’, waar elk probleem op mysterieuze wijze verdwijnt [1]. De architect bij NullPointer Solutions ontwierp het domain model in Figuur 1.
Types
Type safety zorgt voor betrouwbaarheid, maar ook voor complexiteit. Waarom hebben we eigenlijk types? En waar hebben we het dan over?
Type safety zorgt ervoor dat we zeker weten dat waarden in lijn zijn met onze verwachtingen,
zodat we problemen vroegtijdig ontdekken, tijdens compile-time in plaats van runtime.
Dat voorkomt productieproblemen (en telefoontjes in het midden van de nacht).
Dus elk stukje data:
- Moet een type hebben tijdens compile-time (strong)
- En dat type kan niet veranderen (static)
We noemen dit strong en static typing.
Classes en types
Zodra je een class hebt gedeclareerd (1), kun je die gebruiken als type (2) op plekken waar een type wordt verwacht (zie Listing 1).
Listing 1
// 1. Declaration site
public class Employee { }
// ^^^^^^^^^
// 2. Use site
void pay(Employee e) { }
// ^^^^^^^
Zijn een class en een type hetzelfde? Hebben ze een één-op-één-relatie? Nee. Eén class kan één type opleveren, maar een andere class kan er twee of zelfs oneindig veel opleveren.
De class Employee levert één type op: Employee. Maar de class List<T> levert een oneindig aantal types op: List<String>, List<Employee> …
En daar wordt het ingewikkelder. Laten we het eerst hebben over subtypes.
Subtypes
Classes en types zijn niet hetzelfde. Dat geldt ook voor subclasses en subtypes [2]. Een type Child is een subtype van type Parent als je een Child kunt meegeven aan een method die een Parent verwacht. Het draait om substitutability: vervangbaarheid. Met andere woorden, elke instantie van Child kan veilig worden gebruikt binnen de context waar een Parent verwacht wordt. De woorden ‘veilig’ en ‘context’ zijn hier belangrijk (en blijven dat in de rest van dit artikel).
In Java’s type system kun je een eenvoudig subtype maken door een subclass aan te maken (zie Listing 2). Met ‘eenvoudig’ bedoel ik: niet-generic.
Listing 2
class Developer extends Employee { /* ... */ }
Stel dat de method uit Listing 3 bestaat. Deze heeft een parameter van een ‘eenvoudig’ type.
Listing 3
void pay(Employee e) {
/* ... */
}
In deze context kunnen we zowel een Employee als een Developer doorgeven. We kunnen een Developer veilig gebruiken binnen de body van deze method: we kunnen er veilig pay() op aanroepen zonder dat dit fout gaat.
De implementatie van de method pay() moet correct werken voor zowel Employee als Developer.
Dat is het Liskov Substitution Principle. We kunnen dus zeggen dat Developer een subtype is van Employee. En dat schrijven we als: Developer <: Employee
In dit geval zijn subclass en subtype gelijk. Maar wat gebeurt er bij een generic type?
Generics
Een generic type is een ‘type dat een type heeft’: een container die bepaalde inhoud bevat.
De inhoud heeft een variabel type T, bijvoorbeeld List<T> of Optional<T>.
Stel dat de method in Listing 4a bestaat. Vergeleken met de method in Listing 3 heeft deze een parameter van een generic type.
Listing 4a
void payAll(List<Employee> es) {
/* ... */
}
Kunnen we bij het aanroepen een List<Developer> doorgeven (Listing 4b)?
Listing 4b
List<Developer> devs = new ArrayList<>();
payAll(devs); // allowed?
Je gevoel zegt waarschijnlijk: natuurlijk! Want als Developer <: Employee is, dan is List<Developer>, <: List<Employee> ook waar, toch? Wat kan er misgaan?
Nou, laten we eens kijken…
Invariant
Binnen de body van payAll kunnen we dingen met de inhoud van de lijst (Listing 5).
Listing 5
void payAll(List<Employee> es) {
es.forEach(Employee::pay); // read
es.add(new ProductOwner()); // write
}
Dit is perfecte type-safe code in deze context. Maar stel je voor dat je devs (een List<Developer>) had mogen doorgeven, dan zou de method payAll een ProductOwner kunnen toevoegen aan die lijst! En dan heb je ineens een product owner tussen de developers staan.
Dat breekt type safety – een ProductOwner is geen Developer en kan de methode prog(code) niet uitvoeren.
Gelukkig gooit de compiler een foutmelding:
Cannot resolve method payAll(List<Developer>).
Daarom is List<Developer> geen subtype van List<Employee>.
List<Employee> kan door niets anders worden vervangen, wat betekent dat List<T> invariant is.
Covariant
Standaard is een generic type in Java invariant aan de declaration site. Maar je kunt die beperking versoepelen aan de use site, dus daar waar je het type gebruikt als parameter van een method. Dat legt vervolgens beperkingen op aan wat je er binnen die method mee kunt doen.
NullPointer Solutions wil alle medewerkers tegelijk kunnen betalen, maar ook slechts een subset, bijvoorbeeld alleen de developers (want die verdienen een bonus).
We kunnen de compiler laten weten dat we allerlei soorten Employees willen kunnen doorgeven (Listing 6).
Listing 6
void payAll(List<? extends Employee> es) {
/* ... */
}
Nu volgt het generic type dezelfde subtype-richting als het gewone type zelf:
Omdat Developer <: Employee, geldt ook List<-Developer> <: List<? extends Employee>.
Een ander woord voor ‘dezelfde richting’ is covariant. We zeggen daarom dat het type List<? extends T> covariant is.
Maar we mogen – omwille van type safety – binnen deze context niets naar schrijven; we mogen er alleen uit lezen.
De lijst fungeert hier dus alleen als producer, niet als consumer.
Contravariant
NullPointer Solutions heeft teams van zowel Employees als Persons (bijv. tijdelijke krachten).
Beide teamtypen kunnen opgeschaald worden. Daarvoor hebben we een method nodig zoals in Listing 7.
Listing 7
void scaleUp(List<???> team) {
team.add(new Developer());
team.add(new ProductOwner());
team.add(new Manager());
}
De parameter team heeft dus een bepaald type nodig. De enige opties die we tot nu toe kennen zijn:
List<Person>, maar omdat die invariant is, kun je er geen List<Employee> aan doorgeven.
List<? extends Person>, maar omdat die covariant is, kun je er niets naar schrijven.
De oplossing die we eigenlijk zoeken is:
List<? super Employee>.
Dit betekent dat we een List kunnen doorgeven van Employee en van elk super- en subtype van Employee: List<Person> en List<Object>.
We kunnen er veilig elk subtype van Employee aan toevoegen: een Developer, een ProductOwner, enzovoort. Het team is immers minstens een lijst van Employees.
Nu volgt het generic type de omgekeerde subtype-richting vergeleken met het gewone type:
Employee <: Person, maar
List<? super Employee> :> List<Person>.
Let op de tegengestelde richting van de <: en :>tekens.
Een ander woord voor ‘tegengesteld’ is ‘contra’.
Daarom zeggen we dat het type List<? super T> contravariant is.
We kunnen er dus veilig items van type Employee naar schrijven. Deze lijst functioneert als een consumer. Maar als we er een item uit lezen, kan het van alles zijn: Object.
Een nieuwe eis
NullPointer Solutions wil medewerkers kunnen migreren van het ene team naar het andere.
Dat kunnen we veilig doen met de method in Listing 8.
Listing 8
void migrate(List<? extends Employee> from,
List<? super Employee> to) {
/* ... */
}
Hier is:
- From = covariant: het kan een lijst zijn van Employee of een subtype daarvan, en we kunnen er alleen uit lezen.
- To = contravariant: het kan een lijst zijn van Employee of een supertype daarvan,
en we kunnen er alleen naar schrijven.
Dat past perfect bij een migratiefunctie.
Type-veiligheid doorbreken
Stel nu dat het team in sprint 13 zit en de product owner is klaar met het prioriteren van de backlog. Hij heeft wat tijd over en besluit om de developers te helpen. Als onervaren programmeur schrijft hij de method in Listing 9.
Listing 9
void fillWithPros(Employee[] team) {
team[0] = new Developer();
team[1] = new Manager();
}
In Listing 10 zie je hoe hij deze method aanroept:
Listing
Developer[] resources = new Developer[2];
fillWithPros(resources);
De code compileert zonder foutmelding en hij pusht zijn changes trots naar de main branch.
Maar om 03:42 ’s nachts wordt hij wakker van een alert: de applicatie is gecrasht met een ArrayStoreException.
Wat ging hier mis? Wat zegt dit over de manier waarop arrays in Java omgaan met variance?
Zie [4] voor het antwoord.
Samenvatting
Het juiste gebruik van generics stelt je in staat te doen wat je wilt, zonder type safety te verliezen.
In Java:
- Een generic type is standaard invariant.
Je kunt alleen exact T doorgeven.
Je kunt er veilig uit lezen en naar schrijven. - Een covariant type wordt gedeclareerd aan de use site met ? extends T.
Je kunt T en alle subtypes ervan doorgeven.
Het kan alleen produceren (leesbaar, niet schrijfbaar). - Een contravariant type wordt gedeclareerd met ? super T.
Je kunt T en alle supertypes ervan doorgeven.
Het kan consumeren (schrijfbaar, niet leesbaar als specifiek type).
Figuur 2 vat dit samen. Een handig ezelsbruggetje om dit te onthouden is PECS:
Producer Extends, Consumer Super.
Now you know what you’re gonna get. Happy coding!
Referenties
- Alle code uit dit artikel is beschikbaar op:
https://github.com/sajanssens/generics - https://en.wikipedia.org/wiki/Subtyping
- https://en.wikipedia.org/wiki/Liskov_substitution_principle
- Arrays zijn standaard covariant, maar de compiler verhindert niet dat je erin schrijft. Arrays zijn onveilig. Arrays laten problemen soms op mysterieuze wijze verdwijnen.
