1.4-Software-Development-Principles

Generieke klassen (Generics)

Leerdoelen

Hoe je dit probleem NIET moet oplossen…

Je denkt misschien: “Wacht, kan ik niet gewoon een Object opslaan? Daar kan immers elk ander type in opgeslagen worden…”. Dat klopt, wegens polymorfisme kunnen alle java objecten aan het type Object worden toegewezen.

Daar zit echter een nadeel aan. Als je lijst het toestaat om alles van type Object op te slaan, past alles daadwerkelijk in de lijst, maar moet je wel zien uit te vinden wat er nu precies in de lijst staat voordat je er weer iets zinnigs mee kunt.

Zelfs als je van plan bent om maar één speciek type op te slaan, heb je nog geen garantie dat er geen fouten gemaakt worden.

Een generieke lijst dwingt het gebruik van een specifiek type af en garandeert dat alles wat je uit de lijst haalt van hetzelfde type is. Je kunt daardoor geen fouten maken met het type.

Een klein voorbeeld met generieke typen

Soms wil je dat een functie twee waarden teruggeeft, en niet maar één, bijvoorbeeld de index in de lijst en ook het element dat op die locatie in de lijst staat. Je bent er namelijk net langskomen tijdens het itereren, dus waarom zou je het later nog opnieuw opvragen, op basis van de index?

Daarom introduceren we het Tupel, een kleine class die twee velden bevat. De definitie is hierbij een klein beetje aangepast, want formeel is dit een 2-tupel, maar classnamen in Java mogen niet met een cijfer beginnen.

Ons Tupel bevat dus twee velden. Het is aan jou om zelf een 3-tupel aan te maken.

Het verkeerde ontwerp

public class Tuple {
    private Object left;
    private Object right;
	
    public Tuple(Object left, Object right) {
		this.left = left;
		this.right = right;
    }
	
	// Getters and setters for left and right
} 

Deze class doet wat we willen. We kunnen twee waarden opslaan, die elk van een willekeurig type kunnen zijn.

Om deze class te gebruiken maken we een Tuple met twee velden:

public class Example {
    public void example() {
        Tuple myBadTuple = new Tuple("Text", new Student(12345, "John", "Doe"));
		
        String textFromTuple = (String)myBadTuple.getLeft();
        Student studentFromTuple = (Student)myBadTuple.getRight();
    }
}

De regels code die data uit de tupel lezen, moeten deze data casten naar het juiste type. In dit geval kunnen we zien dat de correcte types worden gebruikt, maar als je dit tupel meegeeft aan een andere functie is dat al veel minder duidelijk en neemt de kans op fouten toe.

Het generieke ontwerp

Als we generieke klassen maken, willen je juist het gebruik van een bepaald type afdwingen. Het gekke is alleen dat wij, als ontwerpers van de klasse, niet de keuze maken welk type dat feitelijk is…

In plaats van een specifiek type te kiezen gebruiken we een placeholder of formeel type parameter om aan te geven welk type gebruikt moet worden. Dat ziet er bijvoorbeeld zo uit: <T>, of zo: <P, Q>.

De conventie is om een enkele hoofdletter te gebruiken voor elk type (dat komt de leesbaarheid alleen niet altijd ten goede). Dit is even wennen, je gebruikt een type parameter in plaats van het daadwerkelijke type:

public class Tuple<L,R> {
    private L left;
    private R right;
    
    public Tuple(L left, R right) {
        setLeft(left);
        setRight(right);
    }
    
    public L getLeft() { return left; }
    public R getRight() { return right; }
    public void setLeft(L left) { this.left = left; }
    public void setRight(R right) { this.right = right; }     
} 

Omdat ik, als ontwerper van de class, niet weet welke types er daadwerkelijk gebruikt worden, kies ik voor left en right. Daarom kies ik ook voor de type parameters <L, R>.

Als de waarde van left wordt opgevraagd, wordt het type L gebruikt. Bij het opslaan van een waarde voor right wordt het type R gebruikt.

Om een instantie van deze klasse te maken, moet je concrete types opgeven:

Tuple<String, Integer> result = new Tuple<>("Frederik", 17);

of

Tuple<Double, Student> other = new Tuple<>(7.5, new Student(...));

Merk op dat, omdat je al aangegeven hebt welke typen er opgeslagen worden in het tupel, je dat niet nogmaals hoeft op te geven bij het new statement. Java weet welk type er moet komen.

Als je het tupel eenmaal een waarde hebt gegeven, wordt je gedwongen de juiste types te gebruiken. Bij het eerste voorbeeld:

String name = result.getLeft();

en bij het tweede:

Student student = other.getRight();

Bewust generieke klassen gebruiken

Om de meerwaarde hiervan aan te tonen, zie onderstaande code:

public class StudentManager {
    // I need a list that can only contain Students.
    private ArrayList<Student> students;
    
    public StudentManager() {
        students = loadStudentData();
    }
    
    // Returns both the index at which the student was found and
    // the student object instance.
    public Tuple<Integer, Student> findStudentWithIndex(String firstName) {
        for (int i=0;i<students.size();i++) {
            Student student = students.get(i);
            if (student.matches(firstName)) {
                return new Tuple<Integer, Student>(i, student);
            }
        }
        return null;
    }
	
    public static void test() {
        StudentManager mgr = new StudentManager();
        Tuple<Integer, Student> found = mgr.findStudentWithIndex("John");
        // No need to cast since we KNOW the actual type used.
        int index = found.getLeft();
        Student student = found.getRight();
    }
}

Deze code definieert een zoekfunctie die garandeert dat het tupel twee specifieke types heeft. Als we het resultaat toekennen, worden we gedwongen daarvan bewust te zijn. Merk op dat we ook makkelijk kunnen wisselen tussen int en Integer.

Terugblik

Kijk nog eens naar de code van de Tuple class. We hebben daar geen keuze gemaakt voor Integer en Student; die klasse is generiek! We kunnen elke andere combinatie van twee parameters gebruiken:

en zo verder. Alle combinaties zijn mogelijk.

Aanvullende informatie

Kan ik een generiek type in een ArrayList opslaan?

Ja, dat kan! Het volgende is bijvoorbeeld toegestaan:

ArrayList<Tuple<Integer, Student>> myList;

Deze ArrayList dwingt af dat de elementen van het type Tuple<Integer, Student> zijn.

Merk op dat het ook de andere kant op werkt:

Tuple<Chicken, ArrayList<String>> myVariable;

Kan ik overerven van een generieke klasse?

Ja, dat kan:

public class Student implements Comparable<Student> {
	@Overide
	public int compareTo(Student other) {
		return Integer.compare(this.getStudentNumber(), other.getStudentNumber());
    }
}

Deze klasse implementeert de generieke Comparable interface en omdat we de ene Student met de andere Student willen kunnen vergelijken, verwacht de compareTo function een Student.

Kan ik de toegestane types beperken?

Ja, dat kan:

public class MyOrderedList<T extends Comparable<T>> {
	// ...
}

Deze klassedefinitie staat elk type toe, zolang deze maar de Comparable interface implementeert. Omdat we items van type T met elkaar willen vergelijken is de precieze definitie Comparable<T>.

Dit werkt uiteraard ook met abstracte classes, niet alleen met interfaces.

// Only allow Person instances and their descendants.
public class MyListOfPeople<P extends Person> {
	// ...
}

Elk item in de lijst implementeert nu in ieder geval de Person interface, maar de actuele types kunnen ook subclasses daarvan zijn, zoals Teacher of Student.