7.2 Generics

In andere programmeertalen

De concepten in andere programmeertalen die het dichtst aanleunen bij Java generics zijn

  • templates in C++
  • generic types in Python (as type hints)
  • generics in C#

In dit hoofdstuk behandelen we generics. Die worden veelvuldig gebruikt in datastructuren, en een goed begrip ervan is dan ook essentieel.

Generics zijn een manier om klassen en methodes te voorzien van type-parameters. Bijvoorbeeld, neem de volgende klasse ArrayList1:

class ArrayList {
  private Object[] elements;
  public void add(Object element) { /* ... */  }
  public Object get(int index) { /* ... */  }
}

Stel dat we deze klasse makkelijk willen kunnen herbruiken, telkens met een ander type van elementen in de lijst. We kunnen nu nog niet zeggen wat het type wordt van die elementen. Gaan er Student-objecten in de lijst terechtkomen? Of Animal-objecten? Dat weten we nog niet. We kiezen daarom voor Object, het meest algemene type in Java.

Maar dat betekent ook dat je nu objecten van verschillende, niet-gerelateerde types kan opnemen in één en dezelfde lijst, hoewel dat niet de bedoeling is! Stel bijvoorbeeld dat je een lijst van studenten wil bijhouden, dan houdt de compiler je niet tegen om ook andere types van objecten toe te voegen:

ArrayList students = new ArrayList();

Student student = new Student();
students.add(student);

Animal animal = new Animal();
students.add(animal); // <-- compiler vindt dit OK 🙁
 

Om dat tegen te gaan, zou je afzonderlijke klassen ArrayListOfStudents, ArrayListOfAnimals, … kunnen maken, waar het bedoelde type van elementen wel duidelijk is, en ook wordt afgedwongen door de compiler. Bijvoorbeeld:

class ArrayListOfStudents {
  private Student[] elements;
  public void add(Student element) { /* ... */  }
  public Student get(int index) { /* ... */  }
}

class ArrayListOfAnimals {
  private Animal[] elements;
  public void add(Animal element) { /* ... */  }
  public Animal get(int index) { /* ... */  }
}

Met deze implementaties is het probleem hierboven opgelost:

ArrayListOfStudents students = new ArrayListOfStudents();
students.add(student); // OK
students.add(animal);  // compiler error 👍

De prijs die we hiervoor betalen is echter dat we nu veel quasi-identieke implementaties moeten maken, die enkel verschillen in het type van hun elementen. Dat leidt tot veel onnodige en ongewenste code-duplicatie.

Met generics kan je een type gebruiken als parameter voor een klasse (of methode, zie later) om code-duplicatie zoals hierboven te vermijden. Dat ziet er dan als volgt uit (we gaan zodadelijk verder in op de details):

class ArrayList<T> { 
  private T[] elements;
  // ...
}

Generics geven je dus een combinatie van de beste eigenschappen van de twee opties die we overwogen hebben:

  1. er moet slechts één implementatie gemaakt worden (zoals bij ArrayList hierboven), en
  2. deze implementatie kan gebruikt worden om lijsten te maken waarbij het gegarandeerd is dat alle elementen een specifiek type hebben (zoals bij ArrayListOfStudents).

In de volgende secties vind je meer informatie over het gebruik van generics.


  1. Deze klasse is geïnspireerd op de ArrayList-klasse die standaard in Java zit. ↩︎

Subsections of 7.2 Generics

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:

  1. Bij de definitie van een klasse (of interface, record, …)
  2. 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 (bijna1) 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:

  1. vlak na de naam van een klasse (of record, interface, …) die je definieert (class Foo<T> { ... }); of
  2. 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) {}.


  1. De generische parameter kan niet gebruikt worden in de statische velden, methodes, inner classes, … van de klasse. ↩︎

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 interface1 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.


  1. Een interface kan je zien als een abstracte klasse waarvan alle methodes abstract zijn. Het defineert alle methodes die geïmplementeerd moeten worden, maar bevat zelf geen implementatie. ↩︎

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

  1. 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();
}
  1. 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);
  }
}
  1. 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();
}
  1. (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 object
  • findById(id): opvragen van een object aan de hand van de id
  • findAll(): opvragen van alle objecten in de repository
  • update(id, obj): vervangen van een object met gegeven id door het meegegeven object
  • remove(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:

  1. 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.
  2. 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.

  1. 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!