7.2.1 Definiëren en gebruiken
Om een klasse generisch te maken, moet je een generische parameter (meestal aangegeven met een enkele letter) toevoegen, zoals de generische parameter T
bij ArrayList<T>
.
In het algemeen zijn er slechts twee plaatsen in je code waar je een nieuwe generische parameter mag introduceren:
- Bij de definitie van een klasse (of interface, record, …)
- Bij de definitie van een methode (of constructor)
Een generische klasse definiëren
Om een generische klasse te definiëren (de eerste optie), zet je de type-parameter tussen <
en >
achter de naam van de klasse die je definieert.
Vervolgens kan je die parameter (bijna) overal in die klasse gebruiken als type:
class MyGenericClass<E> {
// je kan hier (bijna) overal E gebruiken als type
}
Bijvoorbeeld, volgende klasse is een nieuwe versie van de ArrayList
-klasse van eerder, maar nu met type-parameter E
(waar E
staat voor ‘het type van de elementen’).
Deze E
wordt vervolgens gebruikt als type voor de elements-array, de parameter van de add-method, en het resultaat-type van de get-method:
class ArrayList<E> {
private E[] elements;
public void add(E element) { /* ... */ }
public E get(int index) { /* ... */ }
}
Je zal heel vaak zien dat generische type-parameters slechts bestaan uit 1 letter (populaire letters zijn bijvoorbeeld E
, R
, T
, U
, V
). Dat is geen vereiste: onderstaande code mag ook, en is volledig equivalent aan die van hierboven.
De reden waarom vaak met individuele letters gewerkt wordt, is om duidelijk te maken dat het over een type-parameter gaat, en niet over een bestaande klasse.
class ArrayList<Element> {
private Element[] elements;
public void add(Element element) { /* ... */ }
public Element get(int index) { /* ... */ }
}
Weetje
Je kan een generische klasse ook zien als een functie (soms een type constructor genoemd).
Die functie geeft geen object terug op basis van een of meerdere parameters zoals je dat gewoon bent van een functie, bijvoorbeeld getPet : (Person p) → Animal
, maar geeft een nieuw type (een nieuwe klasse) terug, gebaseerd op de type-parameters.
Bijvoorbeeld, de generische klasse ArrayList<T>
kan je beschouwen als een functie ArrayList : (Type T) → Type
, die het type ArrayListOfStudents
of ArrayListOfAnimals
teruggeeft wanneer je ze oproept met respectievelijk T=Student
of T=Animal
.
In plaats van ArrayListOfStudents
schrijven we dat type als ArrayList<Student>
.
Een generische klasse gebruiken
Bij het gebruik van een generische klasse (bijvoorbeeld ArrayList<E>
van hierboven) moet je een concreet type opgeven voor de type-parameter (E
).
Bijvoorbeeld, op plaatsen waar je een lijst met enkel studenten verwacht, gebruik je ArrayList<Student>
als type.
Je kan dan de klasse gebruiken op dezelfde manier als de ArrayListOfStudents
klasse van hierboven:
ArrayList<Student> students = new ArrayList<Student>();
Student someStudent = new Student();
students.add(someStudent); // <-- OK 👍
// students.add(animal); // <-- niet toegelaten (compiler error) 👍
Student firstStudent = students.get(0); // <-- OK 👍
Merk op hoe de compiler afdwingt en garandeert dat er enkel Student-objecten in deze lijst terecht kunnen komen.
Om wat typwerk te besparen, laat Java in veel gevallen ook toe om het type weg te laten bij het instantiëren, met behulp van <>
.
Dat type kan immers automatisch afgeleid worden van het type van de variabele:
ArrayList<Student> students = new ArrayList<>(); // <-- je hoeft geen tweede keer <Student> te typen
Generische parameters begrenzen (bounds)
Een type-parameter <E>
zoals we die tot nu toe gezien hebben kan om het even welk type voorstellen.
Soms willen we dat niet, en willen we beperkingen opleggen.
Stel bijvoorbeeld dat we volgende klasse-hierarchie hebben:
abstract class Animal {
/* ... */
abstract void showLike();
}
class Cat extends Animal {
/* ... */
void showLike() {
System.out.println("Purring");
}
}
class Dog extends Animal {
/* ... */
void showLike() {
System.out.println("Wagging tail");
}
}
graph BT
Cat --> Animal
Dog --> Animal
We maken nu een generische klasse Food
, geparametriseerd met het type dier (A
) dat dat voedsel eet:
class Food<A> {
public void giveTo(A animal) {
/* ... */
animal.showLike(); // <= compiler error 🙁
}
}
Food<Cat> catFood = new Food<>(); // OK
Food<String> stringFood = new Food<>(); // ook OK? 🙁
De Food
-klasse is enkel bedoeld om met Animal
(en de subklassen van Animal) gebruikt te worden, bijvoorbeeld Food<Cat>
en Food<Dog>
.
Maar niets houdt ons op dit moment tegen om ook een Food<Student
of een Food<String>
te maken.
Daarenboven zal de compiler (terecht) ook een compilatiefout geven in de methode giveTo
van Food
: er wordt een Animal
-specifieke methode opgeroepen (namelijk showLike
) op de parameter animal
, maar die heeft type A
en dat kan eender wat zijn, bijvoorbeeld ook String
.
En String
biedt natuurlijk geen methode showLike()
aan.
We kunnen daarom aangeven dat type A
een subtype moet zijn van Animal
door bij de definitie van de generische parameter <A extends Animal>
te schrijven.
Je zal dan niet langer Food<String>
mogen schrijven, aangezien String
geen subklasse is van Animal
.
We begrenzen dus de mogelijke types die gebruikt kunnen worden voor de type-parameter A
tot alle types die overerven van Animal
(inclusief Animal
zelf).
class Food<A extends Animal> {
public void giveTo(A animal) {
/* ... */
animal.showLike(); // <= OK! 👍
}
}
Food<Cat> catFood = new Food<>(); // nog steeds OK
Food<String> stringFood = new Food<>(); // <-- compiler error 👍
Note
Wanneer je deze materie later opnieuw doorneemt, heb je naast extends
ook al gehoord van super
en wildcards (?
) — dit wordt later besproken.
Het is belangrijk om op te merken dat je super
en ?
nooit kan gebruiken bij de definitie van een nieuwe generische parameter (de Java compiler laat dit niet toe).
Dat kan enkel op de plaatsen waar je een generische klasse of methode gebruikt.
Onthoud dus: op de plaatsen waar je een nieuwe parameter (een nieuwe ’letter’) introduceert, kan je enkel aangeven dat die een subtype van iets moet zijn met behulp van extends
.
Een generische methode definiëren en gebruiken
In de voorbeelden hierboven hebben we steeds een hele klasse generisch gemaakt.
Naast een generische klasse was er ook een tweede plaats om een generische parameter te definiëren, namelijk eentje die enkel in één methode gebruikt kan worden.
Dat doe je door de parameter te declareren vóór het terugkeertype van die methode, opnieuw tussen <
en >
.
Dat kan ook in een klasse die zelf geen type-parameters heeft.
Je kan die parameter dan gebruiken in de methode zelf, en ook in de types van de parameters en het terugkeertype (dus overal na de definitie ervan).
Bijvoorbeeld, onderstaande methodes doSomething
en doSomethingElse
hebben beiden een generische parameter T
.
Die parameter hoort enkel bij elke individuele methode; beide generische types staan dus volledig los van elkaar.
Ook NormalClass
is geen generische klasse; enkel de twee methodes zijn generisch.
class NormalClass {
public <T> int doSomething(ArrayList<T> elements) {
// je kan overal in deze methode type T gebruiken
}
public static <T> ArrayList<T> doSomethingElse(ArrayList<T> elements, T element) {
// deze T is onafhankelijk van die in doSomething
}
}
Het is trouwens ook mogelijk om generische klassen en generische methodes te combineren:
class Foo<T> {
public <U> ArrayList<U> doSomething(ArrayList<T> ts, ArrayList<U> us) {
// code met T en U
}
}
Type inference
Bij het gebruik van een generische methode zal de Java-compiler zelf proberen om de juiste types te vinden; dit heet type inference. Je kan de methode meestal gewoon oproepen zoals elke andere methode, en hoeft dus (in tegenstelling tot bij klassen) niet zelf aan te geven hoe de generische parameters geïnstantieerd worden.
ArrayList<Dog> dogs = ...
Dog dog = ...
var result = NormalClass.doSomethingElse(dogs, dog);
// result heeft type ArrayList<Dog>
In de uitzonderlijke gevallen waar type inference faalt, of wanneer je het type van de generische parameter expliciet wil maken, kan je die zelf opgeven als volgt:
ArrayList<Dog> dogs = ...
Dog dog = ...
var result = NormalClass.<Dog>doSomethingElse(animals, dog);
Merk op hoe we, tussen het .
en de naam van de methode, de generische parameter <Dog>
toevoegen.
Voorbeeld
Als voorbeeld definiëren we (in een niet-generische klasse AnimalHelper
) een generische (statische) methode findHappyAnimals
.
Deze heeft 1 generische parameter T
, en we leggen meteen ook op dat dat een subtype van Animal
moet zijn (<T extends Animal>
).
class AnimalHelper {
public static <T extends Animal> ArrayList<T> findHappyAnimals(ArrayList<T> animals) {
/* ... */
}
}
ArrayList<Cat> cats = new ArrayList<>();
/* ... */
ArrayList<Cat> happyCats = AnimalHelper.findHappyAnimals(cats);
Merk op dat we het type T
zowel gebruiken bij de animals
-parameter als bij het terugkeertype van de methode.
Zo kunnen we garanderen dat de teruggegeven lijst precies hetzelfde type elementen heeft als de lijst animals
, zonder dat we al moeten vastleggen welk type dier (bv. Cat
of Dog
) dat precies is.
Dus: als we een ArrayList<Cat>
meegeven aan de methode, krijgen we ook een ArrayList<Cat>
terug.
Op dezelfde manier kan je ook het type van meerdere parameters (en eventueel het terugkeertype) aan elkaar vastkoppelen.
In het voorbeeld hieronder zie je een methode die paren kan maken tussen dieren; de methode kan gebruikt worden voor elk type dier, maar kan enkel paren maken van dezelfde diersoort.
Je ziet meteen ook een voorbeeld van een generisch record-type AnimalPair
.
class AnimalHelper {
// voorbeeld van een generisch record
public record AnimalPair<T extends Animal>(T male, T female) {}
public static <T extends Animal> ArrayList<AnimalPair<T>> makePairs(
ArrayList<T> males, ArrayList<T> females) {
/* ... */
}
}
ArrayList<Cat> maleCats = ...
ArrayList<Cat> femaleCats = ...
ArrayList<Dog> femaleDogs = ...
ArrayList<AnimalPair<Cat>> pairedCats = makePairs(maleCats, femaleCats); // OK
ArrayList<AnimalPair<Animal>> pairedMix = makePairs(maleCats, femaleDogs); // niet OK (compiler error) 👍
Merk hierboven op hoe, door de parameter T
op verschillende plaatsen te gebruiken in de methode, deze methode enkel gebruikt kan worden om twee lijsten met dezelfde diersoorten te koppelen, en er meteen ook gegarandeerd wordt dat de AnimalPair-objecten die teruggegeven worden ook hetzelfde type dier bevatten.
Als het type T
niet van belang is omdat het nergens terugkomt (niet in het terugkeertype van de methode, niet bij een andere parameter, en ook niet in de body van de methode), dan heb je strikt gezien geen generische methode nodig.
Zoals we later bij het gebruik van wildcards zullen zien, kan je dan ook gewoon het wildcard-type <? extends X>
gebruiken, of <?>
indien het type niet begrensd moet worden.
In plaats van
public static <T extends Animal> void feedAll(ArrayList<T> animals) {
// code die T nergens vermeldt
}
kan je dus ook de generische parameter T
weglaten, en hetvolgende schrijven:
public static void feedAll(ArrayList<? extends Animal> animals) { /* ... */ }
Dit is nu geen generische methode meer (er wordt geen nieuwe generische parameter geïntroduceerd); de parameter animals
maakt wel gebruik van een generisch type.
Je leest deze methode-signatuur als ‘de methode feedAll
neemt als parameter een lijst met elementen van een willekeurig (niet nader bepaald) subtype van Animal
’.
Onthoud
Er zijn slechts 2 plaatsen waar je een nieuwe generische parameter (een ’letter’ zoals T
of U
) mag introduceren:
- vlak na de naam van een klasse (of record, interface, …) die je definieert (
class Foo<T> { ... }
); of - vlak vóór het terugkeertype van een methode (
public <T> void doSomething(...) { }
).
Op alle andere plaatsen waar je naar een generische parameter verwijst (door de letter te gebruiken), moet je ervoor zorgen dat deze eerst gedefinieerd werd op één van deze twee plaatsen.
Meerdere type-parameters
De ArrayList<E>
-klasse hierboven had één generische parameter (E
).
Een generische klasse of methode kan ook meerdere type-parameters hebben, bijvoorbeeld een tuple van 3 elementen van mogelijk verschillend type (we maken hier een record in plaats van een klasse):
record Tuple3<T1, T2, T3>(T1 first, T2 second, T3 third) {
/* ... */
}
Bij het gebruik van deze klasse (bijvoorbeeld bij het aanmaken van een nieuw object) moet je dan voor elke parameter (T1
, T2
, en T3
) een concreet type opgeven:
Tuple3<String, Integer, String> tuple = new Tuple3<>("John", 2025, "CS101");
Ook hier kan je met de verkorte notatie <>
werken om jezelf niet te moeten herhalen.
Note
Het lijkt erg handig om zo’n Tuple
-type overal in je code te gebruiken waar je drie objecten samen wil bundelen, maar dat wordt afgeraden.
Niet omdat het drie generische parameters heeft (dat is perfect legitiem), maar wel omdat het niets zegt over de betekenis van de velden (wat zit er in ‘first’, ‘second’, ’third’?).
Gebruik in plaats van een algemene Tuple
-klasse veel liever een record waar je de individuele componenten een zinvolle naam geeft.
Bijvoorbeeld: record Enrollment(String student, int year, String courseId) {}
of record Point3D(double x, double y, double x) {}
.
7.2.2 Generics en subtyping
Stel we hebben klassen Animal
, Mammal
, Cat
, Dog
, en Bird
met volgende overervingsrelatie:
class Animal { /* ... */ }
class Mammal extends Animal { /* ... */ }
class Cat extends Mammal { /* ... */ }
class Dog extends Mammal { /* ... */ }
class Bird extends Animal { /* ... */ }
graph BT
Cat --> Mammal
Dog --> Mammal
Mammal --> Animal
Bird --> Animal
Een van de basisregels van object-georiënteerd programmeren is dat overal waar een object van type X
verwacht wordt, ook een object van een subtype van X
toegelaten wordt.
De Java compiler respecteert deze regel uiteraard.
Volgende toekenningen zijn bijvoorbeeld toegelaten:
Animal animal = new Cat();
Mammal mammal = new Dog();
animal = new Bird();
maar mammal = new Bird();
is bijvoorbeeld niet toegelaten, want Bird
is geen subtype van Mammal
.
In onderstaande code is de eerste oproep toegelaten (cat heeft type Cat
, en dat is een subtype van Mammal
), maar de tweede niet (cat is geen Dog
) en de derde ook niet (Cat
is geen subtype van Bird
):
static void pet(Mammal mammal) { /* ... */ }
static void bark(Dog dog) { /* ... */ }
static void layEgg(Bird bird) { /* ... */ }
Cat cat = new Cat();
pet(cat); // <- toegelaten (voldoet aan principe)
bark(cat); // <- niet toegelaten (compiler error) 👍
layEgg(cat); // <- niet toegelaten (compiler error) 👍
Subtyping en generische lijsten
Een lijst in Java is een geordende groep van elementen van hetzelfde type.
List<E>
is de interface die aan de basis ligt van alle lijsten.
ArrayList<E>
is een klasse die een lijst implementeert met behulp van een array.
ArrayList<E>
is een subtype van List<E>
; dus overal waar een List
-object verwacht wordt, mag ook een ArrayList
gebruikt worden.
Later (in het hoofdstuk rond Collections) zullen we ook zien dat er een interface Collection<E>
bestaat, wat een willekeurige groep van elementen voorstelt: niet enkel een lijst, maar bijvoorbeeld ook verzamelingen (Set
) of wachtrijen (Queue
).
List<E>
is een subtype van Collection<E>
. Bijgevolg (via transitiviteit) is ArrayList<E>
dus ook subtype van Collection<E>
.
In code ziet deze situatie er als volgt uit:
interface Collection<E> {
public void add(E element);
public int size();
/* ... */
}
interface List<E> extends Collection<E> {
public E get(int index);
/* ... */
}
class ArrayList<E> implements List<E> {
private E[] elements;
/* ... */
}
interface Set<E> extends Collection<E> { /* ... */ }
interface Queue<E> extends Collection<E> { /* ... */ }
graph BT
Y1["Set#lt;E>"] --> Z0
X0["ArrayList#lt;E>"] --> Y0["List#lt;E>"] --> Z0["Collection#lt;E>"]
Y2["Queue#lt;E>"] --> Z0
style Y1 fill:#eee,stroke:#aaa,color:#888
style Y2 fill:#eee,stroke:#aaa,color:#888
Volgende code is geldig:
List<Cat> cats = new ArrayList<Cat>();
Collection<Dog> dogs = new ArrayList<Dog>();
List<Animal> animals = new ArrayList<Animal>();
maar hetvolgende kan uiteraard niet:
Collection<Dog> dogs = new ArrayList<Cat>(); // compileert niet 👍
graph BT
X1["ArrayList#lt;Cat>"] --> Y1["List#lt;Cat>"] --> Z1["Collection#lt;Cat>"]
X2["ArrayList#lt;Dog>"] --> Y2["List#lt;Dog>"] --> Z2["Collection#lt;Dog>"]
X3["ArrayList#lt;Animal>"] --> Y3["List#lt;Animal>"] --> Z3["Collection#lt;Animal>"]
Het lijkt intuïtief misschien logisch dat ArrayList<Cat>
ook een subtype moet zijn van ArrayList<Animal>
.
Een lijst van katten lijkt tenslotte toch een speciaal geval te zijn van een lijst van dieren?
Maar dat is niet het geval.
ArrayList<Animal> = new ArrayList<Cat>(); // compileert niet
graph BT
id1(Niet correct)
style id1 fill:#f99,stroke:#600,stroke-width:4px
X0["ArrayList#lt;Cat>"] -- #times; --> Y0["ArrayList#lt;Animal>"]
Waarom niet?
Stel dat ArrayList<Cat>
toch een subtype zou zijn van ArrayList<Animal>
. Dan zou volgende code ook geldig zijn:
ArrayList<Cat> cats = new ArrayList<Cat>();
ArrayList<Animal> animals = cats; // <- dit zou geldig zijn (maar is het niet!)
Dog dog = new Dog();
animals.add(dog); // <- OOPS: er zit nu een hond in de lijst van katten 🙁
Je zou dus honden kunnen toevoegen aan je lijst van katten zonder dat de compiler je waarschuwt, en dat is niet gewenst.
Om die reden beschouwt Java ArrayList<Cat>
dus niet als subtype van ArrayList<Animal>
, ondanks dat Cat
wél een subtype van Animal
is.
Onthoud
Zelfs als klasse Sub
een subtype is van klasse Super
, dan is ArrayList<Sub>
toch geen subtype van ArrayList<Super>
.
Later zullen we zien hoe we hier met wildcards in sommige gevallen wel flexibeler mee kunnen omgaan.
Overerven van een generisch type
Hierboven gebruikten we vooral ArrayList
als voorbeeld van een generische klasse.
We hebben echter ook gezien dat je zelf generische klassen kan definiëren, en daarvan kan je uiteraard ook overerven.
Bij de definitie van een subklasse moet je voor de generische parameter van de superklasse een waarde (type) meegeven. Je kan ervoor kiezen om je subklasse zelf generisch te maken (dus een nieuwe generische parameter te introduceren), of om een vooraf bepaald type mee te geven.
Bijvoorbeeld:
class Super<T> { ... }
class SubForAnimal<A extends Animal> extends Super<A> { ... }
class SubForCat extends SubAnimal<Cat> { ... }
De superklasse Super
heeft een generische parameter T
.
De subklasse SubForAnimal
definieert zelf een generische parameter A
(hier met begrenzing), en gebruikt parameter A
als type voor T
uit de superklasse.
De klasse SubForCat
tenslotte definieert zelf geen nieuwe generische parameter, maar geeft het type Cat
op als type voor parameter A
uit diens superklasse.
7.2.3 Wildcards
We zagen eerder dat de types List<Dog>
en List<Animal>
niets met elkaar te maken hebben, ondanks het feit dat Dog
een subtype is van Animal
.
Dat geldt in het algemeen voor generische types.
Als beide generische parameters hetzelfde type hebben, bestaat er wel een overervingsrelatie. Bijvoorbeeld, in volgende situatie:
class Shelter<T> { }
class AnimalShelter<A extends Animal> extends Shelter<A> { ... }
is AnimalShelter<Dog>
wel degelijk een subtype van Shelter<Dog>
, om dezelfde reden dat ArrayList<Dog>
een subtype is van List<Dog>
.
Volgende toekenning en methode-oproep zijn dus toegelaten:
Shelter<Dog> shelter = new AnimalShelter<Dog>(); // OK! 👍
public void protectDog(Shelter<Dog> s) { ... }
AnimalShelter<Dog> animalShelter = new AnimalShelter<Dog>();
protectDog(animalShelter); // OK! 👍
Dat komt omdat AnimalShelter
een subtype is van Shelter
, en de generische parameter bij beiden hetzelfde is.
Als de generische parameters verschillend zijn, is er echter geen overervingsrelatie.
Bijvoorveeld, tussen AnimalShelter<Cat>
en Shelter<Animal>
is er geen overervingsrelatie.
Ook is Shelter<Cat>
geen subtype van Shelter<Animal>
.
Het volgende is bijgevolg niet toegelaten:
Shelter<Animal> s = new AnimalShelter<Cat>(); // NIET toegelaten
public void protectAnimal(Shelter<Animal> s) { ... }
AnimalShelter<Cat> animalShelter = new AnimalShelter<Cat>(); // wel OK!
protectAnimal(animalShelter); // NIET toegelaten
In sommige situaties willen we wel zo’n overervingsrelatie kunnen maken.
We bekijken daarvoor twee soorten relaties, namelijk covariantie en contravariantie.
Note
Opgelet: Zowel covariantie als contravariantie gaan enkel over het gebruik van generische klassen.
Meer bepaald beïnvloeden ze wanneer twee generische klassen door de compiler als subtype van elkaar beschouwd worden.
Dat staat los van de definitie van een generische klasse — die definities (en bijhorende begrenzing) blijven onveranderd!
Covariantie (extends)
Wat als we een methode copyFromTo
willen schrijven die de dieren uit een gegeven (bron-)lijst toevoegt aan een andere (doel-)lijst van dieren? Bijvoorbeeld:
public static void copyFromTo(
ArrayList<Animal> source,
ArrayList<Animal> target) {
for (Animal a : source) { target.add(a); }
}
ArrayList<Animal> animals = new ArrayList<>();
ArrayList<Cat> cats = /* ... */
ArrayList<Dog> dogs = /* ... */
/* ... */
copyFromTo(dogs, animals); // niet toegelaten 🙁
copyFromTo(cats, animals); // niet toegelaten 🙁
Volgens de regels die we hierboven gezien hebben, kunnen we deze methode niet gebruiken om de dieren uit een lijst van honden (ArrayList<Dog>
) of katten (ArrayList<Cat>
) te kopiëren naar een lijst van dieren (ArrayList<Animal>
).
Maar dat lijkt wel een zinnige operatie.
Een oplossing kan zijn om verschillende versies van de methode te schrijven:
public static void copyFromCatsTo(
ArrayList<Cat> source,
ArrayList<Animal> target) {
for (Cat cat : source) { target.add(cat); }
}
public static void copyFromDogsTo(
ArrayList<Dog> source,
ArrayList<Animal> target) {
for (Dog dog : source) { target.add(dog); }
}
public static void copyFromBirdsTo(
ArrayList<Bird> source,
ArrayList<Animal> target) {
for (Bird bird : source) { target.add(bird); }
}
Note
Merk op dat de oproep target.add(cat)
, alsook die met dog
en bird
, toegelaten is, omdat Cat
, Dog
en Bird
subtypes zijn van Animal
.
Maar dan lopen we opnieuw tegen het probleem van gedupliceerde code aan.
Een eerste oplossing daarvoor is een generische methode, met een generische parameter die begrensd is (T extends Animal
):
public static <T extends Animal> void copyFromTo_generic(
ArrayList<T> source,
ArrayList<Animal> target) {
for (Animal a : source) { target.add(a); }
}
Dat werkt, maar de generische parameter T
wordt slechts eenmaal gebruikt, namelijk bij de parameter ArrayList<T> source
.
In zo’n situatie kunnen we ook gebruik maken van het wildcard-type <? extends X>
.
We kunnen bovenstaande methode dus ook zonder generische parameter schrijven als volgt:
public static void copyFromTo_wildcard(
ArrayList<? extends Animal> source,
ArrayList<Animal> target) {
for (Animal a : source) { target.add(a); }
}
Volgende code is nu toegelaten:
copyFromTo_wildcard(dogs, animals); // OK! 👍
copyFromTo_wildcard(cats, animals); // OK! 👍
Het type ArrayList<? extends Animal>
staat dus voor “elke ArrayList waar het element-type een (niet nader bepaald) subtype is van Animal
”.
Je kan dit ook bekijken alsof het type ArrayList<? extends Animal>
tegelijk staat voor de types ArrayList<Animal>
, ArrayList<Mammal>
, ArrayList<Cat>
, ArrayList<Dog>
, alsook een lijst van elk ander type dier.
Dit heet covariantie: omdat Cat
een subtype is van Animal
, is ArrayList<Cat>
een subtype van ArrayList<? extends Animal>
.
De ‘co’ in covariantie wijst erop dat de overervingsrelatie tussen Cat
en Animal
in dezelfde richting loopt als die tussen ArrayList<Cat>
en ArrayList<? extends Animal>
(in tegenstelling tot contravariantie, wat zodadelijk aan bod komt).
Dat zie je op de afbeelding hieronder:
graph BT
ALCat["ArrayList#lt;Cat>"]
ALextendsAnimal["ArrayList#lt;? extends Animal>"]
ALAnimal["ArrayList#lt;Animal>"]
ALAnimal --> ALextendsAnimal
ALCat --> ALextendsAnimal
Cat --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,Cat cat;
class ALAnimal,Animal,ALextendsAnimal animal;
Merk op dat ArrayList<Animal>
ook een subtype is van ArrayList<? extends Animal>
.
We kunnen ook de relatie met Mammal
toevoegen aan het plaatje:
graph BT
ALCat["ArrayList#lt;Cat>"]
ALextendsAnimal["ArrayList#lt;? extends Animal>"]
ALextendsMammal["ArrayList#lt;? extends Mammal>"]
ALextendsCat["ArrayList#lt;? extends Cat>"]
ALAnimal["ArrayList#lt;Animal>"]
ALMammal["ArrayList#lt;Mammal>"]
ALAnimal --> ALextendsAnimal
ALextendsMammal --> ALextendsAnimal
ALMammal --> ALextendsMammal
ALextendsCat --> ALextendsMammal
ALCat --> ALextendsCat
Cat --> Mammal
Mammal --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef mammal fill:#9f9,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,Cat,ALextendsCat cat;
class ALMammal,Mammal,ALextendsMammal mammal;
class ALAnimal,Animal,ALextendsAnimal animal;
Tenslotte kan je in Java ook <?>
schrijven (bijvoorbeeld ArrayList<?>
); dat is een verkorte notatie voor ArrayList<? extends Object>
. Je interpreteert ArrayList<?>
dus als een lijst van een willekeurig maar niet gekend type. Merk op dat ArrayList<?>
dus niet hetzelfde is als ArrayList<Object>
. Een ArrayList<Cat>
is een subtype van ArrayList<?>
, maar niet van ArrayList<Object>
.
Hou er ook rekening mee dat elk voorkomen van ?
voor een ander type staat (of kan staan). Hetvolgende kan dus niet:
public void copyMammalsFromTo(
ArrayList<? extends Mammal> source,
ArrayList<? extends Mammal> target) {
for (Mammal m : source) { target.add(m); } // compileert niet! 🙁
}
omdat de eerste ArrayList<? extends Mammal>
(source
) bijvoorbeeld een ArrayList<Cat>
kan zijn, en de tweede (target
) een ArrayList<Dog>
. Als je de types van beide parameters wil linken aan elkaar, moet je een generische methode gebruiken (zoals eerder gezien):
public <T extends Mammal> void copyMammalsFromTo(
ArrayList<T> source,
ArrayList<T> target) {
for (Mammal m : source) { target.add(m); } // OK! 👍
}
Onderstaande code is ook ongeldig. Waarom?
ArrayList<?> lijst = new ArrayList<String>();
lijst.add("Hello");
Antwoord
De lijst
-variabele is gedeclareerd als een ArrayList met elementen van een ongekend type. Op basis van het type van de variabele kan de compiler niet afleiden dat er Strings toegevoegd mogen worden aan de lijst (het zou evengoed een ArrayList van Animals kunnen zijn).
Het feit dat lijst
geinititialiseerd wordt met <String>
doet hier niet terzake; enkel het type van de declaratie is van belang.
Onthoud
Het type ArrayList<? extends Mammal>
staat tegelijk voor de types ArrayList<Mammal>
, ArrayList<Cat>
, ArrayList<Dog>
, en elk ander type dat overerft van Mammal.
Contravariantie (super)
Wat als we een methode willen die de objecten uit een gegeven bronlijst van katten kopieert naar een doellijst van willekeurige dieren? Bijvoorbeeld:
public static void copyFromCatsTo(
ArrayList<Cat> source,
ArrayList<Animal> target) {
for (Cat cat : source) { target.add(cat); }
}
ArrayList<Cat> cats = /* ... */
ArrayList<Cat> otherCats = new ArrayList<>();
ArrayList<Mammal> mammals = new ArrayList<>();
ArrayList<Animal> animals = new ArrayList<>();
copyFromTo(cats, otherCats); // niet toegelaten 🙁
copyFromTo(cats, mammals); // niet toegelaten 🙁
copyFromTo(cats, animals); // OK 👍
De eerste twee copyFromTo
-regels zijn niet toegelaten, maar zouden opnieuw erg nuttig kunnen zijn.
Co-variantie met extends helpt ook niet (target zou dan immers ook een ArrayList<Dog>
kunnen zijn):
public static void copyFromCatsTo(
ArrayList<Cat> source,
ArrayList<? extends Animal> target) {
for (Cat cat : source) { target.add(cat); } // ook niet toegelaten 🙁
}
En aparte methodes schrijven leidt opnieuw tot code-duplicatie:
public static void copyFromCatsToCats(
ArrayList<Cat> source,
ArrayList<Cat> target) {
for (Cat cat : source) { target.add(a); }
}
public static void copyFromCatsToMammals(
ArrayList<Cat> source,
ArrayList<Mammal> target) {
for (Cat cat : source) { target.add(a); }
}
public static void copyFromCatsToAnimals(
ArrayList<Cat> source,
ArrayList<Animal> target) {
for (Cat cat : source) { target.add(a); }
}
Denkvraag
Zou het nuttig zijn om een methode copyFromCatsToBirds(ArrayList<Cat> source, ArrayList<Bird> target)
te voorzien? Waarom (niet)?
De oplossing in dit geval is gebruik maken van het wildcard-type <? super T>
.
Het type ArrayList<? super Cat>
staat dus voor “elke ArrayList waar het element-type een supertype is van Cat
” (inclusief het type Cat
zelf).
Of nog: ArrayList<? super Cat>
staat tegelijk voor de types ArrayList<Cat>
, ArrayList<Mammal>
, ArrayList<Animal>
, en ArrayList<Object>
, alsook elke andere ArrayList met een supertype van Cat
als element-type.
We kunnen dus schrijven:
public static void copyFromCatsTo_wildcard(
ArrayList<Cat> source,
ArrayList<? super Cat> target) {
for (Cat cat : source) { target.add(a); }
}
en kunnen nu hetvolgende uitvoeren:
copyFromCatsTo_wildcard(cats, otherCats); // OK 👍
copyFromCatsTo_wildcard(cats, mammals); // OK 👍
copyFromCatsTo_wildcard(cats, animals); // OK 👍
Dit heet contravariantie: hoewel Cat
een subtype is van Animal
, is ArrayList<? super Cat>
een supertype vanArrayList<Animal>
.
De ‘contra’ in contravariantie wijst erop dat de overervingsrelatie tussen Cat
en Animal
in de omgekeerde richting loopt als die tussen ArrayList<? super Cat>
en ArrayList<Animal>
.
Bekijk volgende figuur aandachtig:
graph BT
ALsuperCat["ArrayList#lt;? super Cat>"]
ALAnimal["ArrayList#lt;Animal>"]
ALCat["ArrayList#lt;Cat>"]
ALAnimal --> ALsuperCat
ALCat --> ALsuperCat
Cat --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef mammal fill:#9f9,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,ALsuperCat,Cat cat;
class ALAnimal,Animal animal;
Als we ook ArrayList<Mammal>
, ArrayList<? super Mammal>
, en ArrayList<? super Animal>
toevoegen aan het plaatje, ziet dat er als volgt uit:
graph BT
ALCat["ArrayList#lt;Cat>"]
ALsuperCat["ArrayList#lt;? super Cat>"]
ALsuperMammal["ArrayList#lt;? super Mammal>"]
ALsuperAnimal["ArrayList#lt;? super Animal>"]
ALMammal["ArrayList#lt;Mammal>"]
ALAnimal["ArrayList#lt;Animal>"]
ALCat --> ALsuperCat
ALAnimal --> ALsuperAnimal
ALMammal --> ALsuperMammal
ALsuperAnimal --> ALsuperMammal
ALsuperMammal --> ALsuperCat
Cat --> Mammal
Mammal --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef mammal fill:#9f9,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,ALsuperCat,Cat cat;
class ALMammal,Mammal,ALsuperMammal mammal;
class ALAnimal,Animal,ALsuperAnimal animal;
Aan de hand van de kleuren kan je snel zien dat de overervingsrelatie links en rechts inderdaad omgekeerd verlopen.
Onthoud
Het type ArrayList<? super Mammal>
staat tegelijk voor de types ArrayList<Mammal>
, ArrayList<Animal>
, ArrayList<Object>
, en elk ander type dat een superklasse (of interface) is van Mammal.
Covariantie of contravariantie: PECS
Als we covariantie en contravariantie combineren, krijgen we volgend beeld (we focussen op de extends- en super-relatie vanaf Mammal
):
graph BT
ALAnimal["ArrayList#lt;Animal>"]
ALMammal["ArrayList#lt;Mammal>"]
ALCat["ArrayList#lt;Cat>"]
ALsuperMammal["ArrayList#lt;? super Mammal>"]
ALextendsMammal["ArrayList#lt;? extends Mammal>"]
ALMammal --> ALextendsMammal
ALCat --> ALextendsMammal
ALAnimal --> ALsuperMammal
ALMammal --> ALsuperMammal
Cat --> Mammal
Mammal --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef mammal fill:#9f9,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,Cat cat;
class ALMammal,Mammal,ALsuperMammal,ALextendsMammal mammal;
class ALAnimal,Animal animal;
Hier zien we dat ArrayList<? extends Mammal>
(covariant) als subtypes ArrayList<Mammal>
en ArrayList<Cat>
heeft.
Het contravariante ArrayList<? super Mammal>
heeft óók ArrayList<Mammal>
als subtype, maar ook ArrayList<Animal>
.
Hoe weet je nu wanneer je wat gebruikt als type voor een parameter? Wanneer kies je <? extends T>
, en wanneer <? super T>
?
Een goede vuistregel is het acroniem PECS, wat staat voor Producer Extends, Consumer Super.
Dus:
- Wanneer het object gebruikt wordt als een producent van
T
’s (met andere woorden, het object is een levancier van T
-objecten voor jouw code, die ze vervolgens gebruikt), gebruik je <? extends T>
. Dat is logisch: als jouw code met aangeleverde T
’s omkan, dan kan jouw code ook om met de aanlevering van een subklasse van T
(basisprincipe objectgeoriënteerd programmeren). - Wanneer het object gebruikt wordt als een consument van
T
’s (met andere woorden, het neemt T
-objecten aan van jouw code), gebruik je <? super T>
. Ook dat is logisch: een object dat beweert om te kunnen met elke superklasse van T
moet zeker overweg kunnen met een T
die jouw code aanlevert. - Wanneer het object zowel als consument als als producent gebruikt wordt, gebruik je gewoon
<T>
(dus geen co- of contra-variantie). Er is dan weinig tot geen flexibiliteit meer in het type.
Een voorbeeld om PECS toe te passen: we willen een methode copyFromTo
die zo flexibel mogelijk is, om elementen uit een lijst van zoogdieren te kopiëren naar een andere lijst.
void copyMammalsFromTo(
??? source,
??? target) {
for (Mammal m : source) {
target.add(m);
}
}
De source
-lijst is de producent: daaruit halen we Mammal-objecten op. Daar gebruiken we dus extends:
void copyMammalsFromTo(
List<? extends Mammal> source,
??? target) {
for (Mammal m : source) {
target.add(m);
}
}
De target
-lijst is de consument: daar sturen we Mammal-objecten naartoe. Daar gebruiken we dus super:
void copyMammalsFromTo(
List<? extends Mammal> source,
List<? super Mammal> target) {
for (Mammal m : source) {
target.add(m);
}
}
Met deze methode kunnen we nu alle zinvolle operaties uitvoeren, terwijl de zinloze operaties tegengehouden worden door de compiler:
ArrayList<Cat> cats = /* ... */
ArrayList<Dog> dogs = /* ... */
ArrayList<Bird> birds = /* ... */
ArrayList<Mammal> mammals = /* ... */
ArrayList<Animal> animals = /* ... */
copyMammalsFromTo(cats, animals); // OK 👍
copyMammalsFromTo(cats, mammals); // OK 👍
copyMammalsFromTo(cats, cats); // OK 👍
copyMammalsFromTo(mammals, animals); // OK 👍
copyMammalsFromTo(cats, dogs);
// compiler error (Dog is geen supertype van Mammal) 👍
copyMammalsFromTo(birds, animals);
// compiler error (Bird is geen subtype van Mammal) 👍
Merk op dat het type Mammal in onze laatste versie van copyMammalsFromTo
hierboven eigenlijk onnodig is. We kunnen de methode nog verder veralgemenen door er een generische methode van te maken, die werkt voor alle lijsten (niet enkel lijsten van zoogdieren):
<T> void copyFromTo(
List<? extends T> source,
List<? super T> target) {
for (T element : source) {
target.add(element);
}
}
Met deze versie kunnen we nu bijvoorbeeld ook Birds kopiëren naar een lijst van dieren:
copyFromTo(birds, animals); // OK 👍
Opmerking
Wanneer een parameter zowel een producent (co-variant) als een consument (contra-variant) is, gebruik je geen wildcards.
De generische parameter heet dan invariant.
Bijvoorbeeld:
public static <T> void reverse(List<T> list) {
int left = 0;
int right = list.size() - 1;
while (left < right) {
// Producent (get)
T temp = list.get(left);
// Consumer (set)
list.set(left, list.get(right));
list.set(right, temp);
left++;
right--;
}
}
Arrays en type erasure
In tegenstelling tot ArrayLists (en andere generische types), beschouwt Java arrays wél altijd als covariant.
Dat betekent dat Cat[]
een subtype is van Animal[]
.
Volgende code compileert dus (maar gooit een uitzondering bij het uitvoeren):
Animal[] cats = new Cat[2];
cats[0] = new Dog(); // compileert, maar faalt tijdens het uitvoeren
De reden hiervoor is, in het kort, dat informatie over generics gewist wordt bij het compileren van de code.
Dit heet type erasure.
In de gecompileerde code is een ArrayList<Animal>
en ArrayList<Cat>
dus exact hetzelfde.
Er kan dus, tijdens de uitvoering, niet gecontroleerd worden of je steeds het juiste type gebruikt.
Daarom moet de compiler dat doen, en die neemt het zekere voor het onzekere: alles wat mogelijk fout zou kunnen aflopen, wordt geweigerd.
Bij arrays wordt er wel type-informatie bijgehouden na het compileren, en kan dus tijdens de uitvoering nog gecontroleerd worden of je geen elementen met een ongeldig type toevoegt. De compiler hoeft het niet af te dwingen — maar het wordt wel nog steeds gecontroleerd tijdens de uitvoering, en kan leiden tot een exception.
Aandachtspunten
Enkel bij generische types!
Tenslotte nog een opmerking (op basis van vaak gemaakte fouten op examens).
Co- en contra-variantie (extends, super, en wildcards dus) zijn enkel van toepassing op generische types.
Alles wat we hierboven gezien hebben is dus enkel nuttig op plaatsen waar je een generisch type (List<T>
, Food<T>
, …) gebruikt voor een parameter, terugkeertype, variabele, ….
Dergelijke types kan je met behulp van co-/contra-variantie en wildcards verrijken tot bijvoorbeeld List<? extends T>
, Food<? super T>
, …
Maar je kan deze constructies niet gebruiken op plaatsen waar een gewoon type verwacht wordt, bijvoorbeeld bij een parameter of terugkeertype.
Onderstaande regels code zijn dus allemaal ongeldig:
public void pet(? extends Mammal mammal) { ... } // ONGELDIG! ❌
public void pet(<? extends Mammal> mammal) { ... } // ONGELDIG! ❌
public void pet(<? super Cat> mammal) { ... } // ONGELDIG! ❌
Schrijf in dat geval gewoon
public void pet(Mammal mammal) { ... }
Deze methode kan óók al opgeroepen worden met een Cat
-object, Dog
-object, of elk ander type Mammal
als argument.
Je hebt hier geen co- of contra-variantie van generische types nodig; je maakt gewoon gebruik van overerving uit objectgeoriënteerd programmeren.
Onthoud
Wildcards (?
), co-variantie (? extends
) en contra-variantie (? super
) zijn enkel van toepassing bij generische types! Je kan ze dus niet gebruiken als een op zichzelf staand type. Je kan ze ook niet gebruiken bij de definitie van een nieuwe generische parameter (voor een klasse of methode), maar enkel bij het gebruik ervan.
Bounds vs. co-/contravariantie en wildcards
Tot slot is het nuttig om nog eens te benadrukken dat er een verschil is tussen het begrenzen van een generische parameter (met extends
) enerzijds, en het gebruik van co-variantie, contra-variantie en wildcards (? extends T
, ? super T
) anderzijds. Het feit dat extends
in beide gevallen gebruikt wordt, kan misschien tot wat verwarring leiden.
Een begrenzing (via <T extends SomeClass>
) beperkt welke types geldige waarden zijn voor de type-parameter T
. Dus: elke keer wanneer je een concreet type wil meegeven in de plaats van T
moet dat type voldoen aan bepaalde eisen.
Je kan zo’n begrenzing enkel aangeven op de plaats waar je een nieuwe generische parameter (T
) introduceert (dus bij een nieuwe klasse-definitie of methode-definitie).
Bijvoorbeeld: class Food<T extends Animal>
laat later enkel toe om Food<X>
te schrijven als type wanneer X
ook een subtype is van Animal
.
Door co- en contra-variantie (met <? extends X>
en <? super X>
) te gebruiken verbreed je de toegelaten types.
Een methode-parameter met als type Food<? extends Animal>
laat een Food<Animal>
toe als argument, maar ook een Food<Cat>
of Food<Dog>
.
Omgekeerd zal een parameter met als type Food<? super Cat>
een Food<Cat>
toelaten, maar ook een Food<Animal>
.
Er wordt in beide gevallen dus meer toegelaten, wat meer flexibiliteit biedt.
Je kan co- en contravariantie toepassen op elke plaats waar je een generisch type gebruikt (en waar dat gepast is volgens de PECS regels).
Het kan dus perfect zijn dat je de ene keer in je code eens Food<Cat>
gebruikt, ergens anders Food<? extends Cat>
, en nog ergens anders Food<? super Cat>
.
Bij begrenzing is dat niet zo; dat legt de grenzen eenmalig vast, en die moeten overal gerespecteerd worden waar het generisch type gebruikt wordt.
Onthoud
Een begrenzing (T extends X
) is een eenmalige beperking op het type dat gebruikt kan worden als waarden voor een nieuw geïntroduceerde generische parameter. Dit kan enkel voorkomen in de definitie van een nieuwe generische parameter (bij een generische klasse of methode).
Co-en contravariantie (? extends X
, ? super X
) met wildcard ?
versoepelen de types die aanvaard worden door de compiler. Ze komen enkel voor op plaatsen waar een generisch type gebruikt wordt.
Arrays met generisch type
Als je een array wil maken van een generisch type, laat de Java-compiler dat niet toe:
class MyClass<T> {
private T[] array;
public MyClass() {
array = new T[10]; // <-- niet toegelaten ☹️
}
}
De reden is opnieuw type erasure.
Aangezien arrays covariant zijn, moet tijdens de uitvoering gecontroleerd kunnen worden of objecten die in de array terechtkomen een geschikt type hebben.
Aangezien generische parameters verwijderd worden door de compiler, kan dat niet.
Een oplossing voor bovenstaand probleem is om een cast toe te voegen. Met een @SuppressWarning
annotatie kan je de waarschuwing die door de compiler gegeven wordt negeren.
class MyClass<T> {
private T[] array;
@SuppressWarning("unchecked")
public MyClass() {
array = (T[])new Object[10]; // <-- ok! 👍
}
}
Het is natuurlijk ook mogelijk om gewoon een ArrayList te gebruiken; dan heb je dit probleem niet.
class MyClass<T> {
private ArrayList<T> list;
public MyClass() {
list = new ArrayList<>(); // <-- ok! 👍
}
}
Oefeningen
Voor de tests maken we gebruik van assertJ.
Maybe-klasse
- Schrijf een generische klasse (of record)
Maybe
die een object voorstelt dat nul of één waarde van een bepaald type kan bevatten.
Dat type wordt bepaald door een generische parameter. Je kan Maybe-objecten enkel aanmaken via de statische methodes some
en none
.
Hieronder vind je twee tests:
@Test
public void maybeWithValue() {
Maybe<String> maybe = Maybe.some("Yes");
assertThat(maybe.hasValue()).isTrue();
assertThat(maybe.getValue()).isEqualTo("Yes");
}
@Test
public void maybeWithoutValue() {
Maybe<String> maybe = Maybe.none();
assertThat(maybe.hasValue()).isFalse();
assertThat(maybe.getValue()).isNull();
}
- Maak de
print
-methode hieronder ook generisch, zodat deze niet enkel werkt voor een Maybe<String>
maar ook voor andere types dan String
.
class MaybePrint {
public static void print(Maybe<String> maybe) {
if (maybe.hasValue()) {
System.out.println("Contains a value: " + maybe.getValue());
} else {
System.out.println("No value :(");
}
}
public static void main(String[] args) {
Maybe<String> maybeAString = Maybe.some("yes");
Maybe<String> maybeAnotherString = Maybe.none();
print(maybeAString);
print(maybeAnotherString);
}
}
- Voeg aan
Maybe
een generische methode map
toe die een java.util.function.Function<T, R>
-object als parameter heeft, en die een nieuw Maybe-object teruggeeft, met daarin het resultaat van de functie toegepast op het element als er een element is, of een leeg Maybe-object in het andere geval.
Zie de tests hieronder voor een voorbeeld van hoe deze map-functie gebruikt wordt:
@Test
public void maybeMapWithValue() {
Maybe<String> maybe = Maybe.some("Hello");
Maybe<Integer> result = maybe.map((str) -> str.length());
assertThat(result.hasValue()).isTrue();
assertThat(result.getValue()).isEqualTo(5);
}
@Test
public void maybeMapWithValue2() {
Maybe<String> maybe = Maybe.some("Hello");
Maybe<String> result = maybe.map((str) -> str + "!");
assertThat(result.hasValue()).isTrue();
assertThat(result.getValue()).isEqualTo("Hello!");
}
@Test
public void maybeMapWithoutValue() {
Maybe<String> maybe = Maybe.none();
Maybe<Integer> result = maybe.map((str) -> str.length());
assertThat(result.hasValue()).isFalse();
}
- (optioneel) Herschrijf
Maybe
als een sealed interface met twee record-subklassen None
en Some
.
Geef een voorbeeld van hoe je deze klasse gebruikt met pattern matching.
Kan je ervoor zorgen dat je getValue() nooit kan oproepen als er geen waarde is (compiler error)?
Info
Java bevat een ingebouwd type gelijkaardig aan de Maybe-klasse uit deze oefening, namelijk Optional<T>
.
Repository
Schrijf een generische klasse Repository
die een repository van objecten voorstelt. De objecten hebben ook een ID. Zowel het type van objecten als het type van de ID moeten generische parameters zijn.
Definieer en implementeer volgende methodes (maak gebruik van een ArrayList):
add(id, obj)
: toevoegen van een objectfindById(id)
: opvragen van een object aan de hand van de idfindAll()
: opvragen van alle objecten in de repositoryupdate(id, obj)
: vervangen van een object met gegeven id door het meegegeven objectremove(id)
: verwijderen van een object aan de hand van een id
SuccessOrFail
Schrijf een generische klasse (of record) SuccessOrFail
die een object voorstelt dat precies één element bevat.
Dat element heeft 1 van 2 mogelijke types (die types zijn generische parameters).
Het eerste type stelt het type van een succesvol resultaat voor; het tweede type is dat van een fout.
Je kan objecten enkel aanmaken via de statische methodes success
en fail
.
Een voorbeeld van tests voor die klasse vind je hieronder:
@Test
public void success() {
SuccessOrFail<String, Exception> result = SuccessOrFail.success("This is the result");
assertThat(result.isSuccess()).isTrue();
assertThat(result.successValue()).isEqualTo("This is the result");
}
@Test
public void failure() {
SuccessOrFail<String, Exception> result = SuccessOrFail.fail(new IllegalStateException());
assertThat(result.isSuccess()).isFalse();
assertThat(result.failValue()).isInstanceOf(IllegalStateException.class);
}
Subtyping: voertuigen
Vetrek van volgende klasse-hiërarchie en zeg van elk van volgende lijnen code of ze toegelaten worden door de Java compiler:
graph BT
Bike --> Vehicle
Motorized --> Vehicle
Car --> Motorized
Plane --> Motorized
/* 1 */ Motorized myCar = new Car();
/* 2 */ Vehicle yourPlane = new Plane();
/* 3 */ Collection<Vehicle> vehicles = new ArrayList<Vehicle>();
/* 4 */ vehicles.add(myCar);
/* 5 */ List<Car> cars = new ArrayList<Car>();
/* 6 */ List<Vehicle> carsAsVehicles = cars;
Antwoord
Alles behalve lijn 6 is toegelaten.
Covariantie
Maak een schema met de overervingsrelaties tussen
List<Cat>
List<? extends Cat>
ArrayList<Cat>
ArrayList<? extends Cat>
List<Animal>
List<? extends Animal>
ArrayList<Animal>
ArrayList<? extends Animal>
Antwoord
graph BT
ALC["ArrayList#lt;Cat>"] --> LC["List#lt;Cat>"]
ALC --> ALeC["ArrayList#lt;? extends Cat>"]
LC --> LeC["List#lt;? extends Cat>"]
ALeC --> LeC
ALeC --> ALeA["ArrayList#lt;? extends Animal>"]
ALA["ArrayList#lt;Animal>"] --> ALeA
ALA --> LA["List#lt;Animal>"]
LeC --> LeA["List#lt;? extends Animal>"]
ALeA --> LeA
LA --> LeA
ArrayList<Cat>
is een subtype van List<Cat>
en van ArrayList<? extends Cat>
.List<Cat>
is een subtype van List<? extends Cat>
ArrayList<? extends Cat>
is een subtype van List<? extends Cat>
en van ArrayList<? extends Animal>
ArrayList<Animal>
is een subtype van ArrayList<? extends Animal>
en List<Animal>
List<? extends Cat>
, ArrayList<? extends Animal>
en List<Animal>
zijn alledrie subtypes van List<? extends Animal>
Shop
Maak een klasse Shop
die een winkel voorstelt die items (subklasse van StockItem
) aankoopt.
Een Shop-object wordt geparametriseerd met het type items dat aangekocht kan worden. We beschouwen hier Fruit
en Electronics
; daarmee kunnen we dus een fruitwinkel (Shop<Fruit>
) en elektronica-winkel (Shop<Electronics>
) maken.
Shop
heeft twee methodes:
buy
, die een lijst van items toevoegt aan de stock;addStockToInventory
, die de lijst van items in stock toevoegt aan de meegegeven inventaris-lijst.
Voor het fruit maak je een abstracte klasse Fruit
, en subklassen Apple
en Orange
.
Maak daarnaast nog een abstracte klasse Electronics
, met als subklasse Smartphone
.
Zorg dat onderstaande code (ongewijzigd) compileert en dat de test slaagt:
@Test
public void testGenerics() {
Shop<Fruit> fruitShop = new Shop<>();
Shop<Electronics> electronicsShop = new Shop<>();
List<Apple> apples = List.of(new Apple(), new Apple());
List<Fruit> oranges = List.of(new Orange(), new Orange(), new Orange());
List<Smartphone> phones = List.of(new Smartphone(), new Smartphone());
fruitShop.buy(apples);
fruitShop.buy(oranges);
electronicsShop.buy(phones);
List<StockItem> inventory = new ArrayList<>();
fruitShop.addStockToInventory(inventory);
Assertions.assertThat(inventory).hasSize(5);
electronicsShop.addStockToInventory(inventory);
Assertions.assertThat(inventory).hasSize(7);
}
Functie compositie
Java bevat een ingebouwde interface java.util.function.Function<T, R>
, wat een functie voorstelt met één parameter van type T
, en een resultaat van type R
. Deze interface voorziet 1 methode R apply(T value)
om de functie uit te voeren.
Schrijf nu een generische methode compose
die twee functie-objecten 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
Function<A, B> f1 = ...
Function<B, C> f2 = ...
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:
interface Ingredient {}
record Fruit() implements Ingredient {}
record PeeledFruit(Fruit fruit) implements Ingredient {}
record Chopped(Ingredient food) implements Ingredient {}
@Test
public void testCompose() {
Function<Fruit, PeeledFruit> peelFruit = (var fruit) -> new PeeledFruit(fruit);
Function<Ingredient, Chopped> chopIngredient = (var food) -> new Chopped(food);
var makeFruitSalad = compose(peelFruit, chopIngredient);
assertThat(makeFruitSalad.apply(new Fruit())).isEqualTo(new Chopped(new PeeledFruit(new Fruit())));
}
Game engine
Oud-examenvraag
Dit is een oud-examenvraag.
Vul de types en generische parameters aan op de 7 genummerde plaatsen zodat onderstaande code en main-methode compileert (behalve de laatste regel van de main-methode) en voldaan is aan volgende voorwaarden:
- Elk actie-type kan enkel uitgevoerd worden door een bepaald karakter-type. Bijvoorbeeld: een FightAction kan enkel uitgevoerd worden door een karakter dat CanFight implementeert.
- doAction mag enkel opgeroepen worden met een actie die uitgevoerd kan worden door alle karakters in de meegegeven lijst.
Als er op een bepaalde plaats geen type of generische parameter nodig is, vul je $\emptyset$ in.
- Verklaar je keuze voor de combinatie van (5), (6), en (7).
interface Character {}
interface CanFight extends Character {}
record Warrior() implements CanFight {}
record Knight() implements CanFight {}
record Wizard() implements Character {}
interface Action<___/* 1 */___> {
void execute(___/* 2 */____ character);
}
class FightAction implements Action<___/* 3 */_____> {
@Override
public void execute(___/* 4 */______ character) {
System.out.println(character + " fights!");
}
}
class GameEngine {
public <___/* 5 */______> void doAction(
List<___/* 6 */____> characters,
Action<___/* 7 */____> action) {
for (var character : characters) {
action.execute(character);
}
}
}
public static void main(String[] args) {
var engine = new GameEngine();
Action<CanFight> fight = new FightAction();
List<Warrior> warriors = List.of(new Warrior(), new Warrior());
engine.doAction(warriors, fight);
List<Wizard> wizards = List.of(new Wizard());
engine.doAction(wizards, fight); // deze regel mag NIET compileren
}
Antwoord
- 1:
C extends Character
: acties kunnen enkel uitgevoerd worden door subtypes van Character - 2:
C
: C is het type van Character dat de actie zal uitvoeren - 3:
CanFight
: FightAction is enkel mogelijk voor characters die CanFight
implementeren - 4:
CanFight
: aangezien de generische paremeter C
van superinterface Action
geinitialiseerd werd met CanFight
, moet hier ook CanFight
gebruikt worden. - 5:
T extends Character
: we noemen T het type van de objecten in de meegeven lijst; we hebben hier een begrenzing nodig (want we willen enkel subtypes van Character toelaten) - 6:
T
: lijst van T’s, zoals verondersteld in 5 - 7:
? super T
: de meegegeven actie moet een actie zijn die door alle T’s uitgevoerd kan worden (dus door T of een van de supertypes van T).
Redenering met behulp van PECS: de meegegeven actie gebruikt/consumeert het character, dus super.
Alternatieve keuze voor 5/6/7:
- 5:
T extends Character
: we noemen T het type dat bij de actie hoort; we hebben hier een begrenzing nodig (want we willen enkel subtypes van Character toelaten) - 6:
? extends T
: lijst van T’s of subtypes ervan.
Redenering met behulp van PECS: de lijst levert/produceert de characters, dus extends. - 7:
T
: het type van de actie, zoals verondersteld in 5
Animal food
Dit is een uitdagende oefening, voor als je je kennis over generics echt wil testen.
Voeg generics (met grenzen/bounds) toe aan de code hieronder, zodat de code (behalve de laatste regel) compileert,
en de compiler enkel toelaat om kattenvoer te geven aan katten, en hondenvoer aan honden:
public class AnimalFood {
static class Animal {
public void eat(Food food) {
System.out.println(this.getClass().getSimpleName() + " says 'Yummie!'");
}
}
static class Mammal extends Animal {
public void drink(Milk milk) {
this.eat(milk);
}
}
static class Cat extends Mammal {}
static class Kitten extends Cat {}
static class Dog extends Mammal {}
static class Food {}
static class Milk extends Food {}
static class Main {
public static void main(String[] args) {
Food catFood = new Food();
Milk catMilk = new Milk();
Food dogFood = new Food();
Milk dogMilk = new Milk();
Cat cat = new Cat();
Dog dog = new Dog();
Kitten kitten = new Kitten();
cat.eat(catFood); // OK 👍
cat.drink(catMilk); // OK 👍
dog.eat(dogFood); // OK 👍
dog.drink(dogMilk); // OK 👍
kitten.eat(catFood); // OK 👍
kitten.drink(catMilk); // OK 👍
cat.eat(dogFood); // <- moet een compiler error geven! ❌
kitten.eat(dogFood); // <- moet een compiler error geven! ❌
kitten.drink(dogMilk); // <- moet een compiler error geven! ❌
}
}
}
(Hint: Begin met het type Food
te parametriseren met een generische parameter die het Animal
-type voorstelt dat dit voedsel eet.)
Self-type
Dit is een uitdagende oefening, voor als je je kennis over generics echt wil testen.
Heb je je al eens afgevraagd hoe assertThat(obj)
uit AssertJ werkt?
Afhankelijk van het type van obj
dat je meegeeft, worden er andere assertions beschikbaar die door de compiler aanvaard worden:
// een List<String>
List<String> someListOfStrings = List.of("hello", "there", "how", "are", "you");
assertThat(someListOfStrings).isNotNull().hasSize(5).containsItem("hello");
// een String
String someString = "hello";
assertThat(someString).isNotNull().isEqualToIgnoringCase("hello");
// een Integer
int someInteger = 4;
assertThat(someInteger).isNotNull().isGreaterThan(4);
assertThat(someInteger).isNotNull().isEqualToIgnoringCase("hello"); // <= compileert niet ❌
Sommige assertions (zoals isNotNull
) zijn echter generiek, en wil je slechts op 1 plaats implementeren.
Probeer zelf een assertThat
-methode te schrijven die werkt zoals bovenstaande, maar waar isNotNull
slechts op 1 plaats geïmplementeerd is.
De assertThat
-methode moet een Assertion
-object teruggeven, waarop de verschillende methodes gedefinieerd zijn afhankelijk van het type dat meegegeven wordt aan assertThat
.
Hint 1: maak verschillende klassen, bijvoorbeeld ListAssertion
, StringAssertion
, IntegerAssertion
die de type-specifieke methodes bevatten. Begin met isNotNull
toe te voegen aan elk van die klassen (dus door de implementatie te kopiëren).
Hint 2: in een zogenaamde ‘fluent interface’ geeft elke operatie zoals isNotNull
en hasSize
het this-object op het einde terug (return this
), zodat je oproepen na elkaar kan doen. Bijvoorbeeld .isNotNull().hasSize(5)
.
Hint 3: maak nu een abstracte klasse GenericAssertion
die isNotNull
bevat, en waarvan de andere assertions overerven. Verwijder de andere implementaties van isNotNull
.
Hint 4: In isNotNull
is geen informatie beschikbaar over het type dat gebruikt moet worden als terugkeertype van isNotNull
. assertThat(someString).isNotNull()
moet bijvoorbeeld opnieuw een StringAssertion
teruggeven. Dat kan je oplossen met generics, en een abstracte methode die het juiste object teruggeeft.
Hint 5: Je zal een zogenaamd ‘self-type’ moeten gebruiken. Dat is een generische parameter die wijst naar de (sub)klasse zelf.
Hint 6: op deze pagina wordt uitgelegd hoe AssertJ dit doet. Probeer eerst zelf, zonder dit te lezen!