Motivation und Einführung

Motivation für Generics

Wie abstrahiere ich vom konkreten Typ eines Objekts?

  • Wie kann ich (typsichere) Containerklassen bauen, die jedes beliebige Objekt aufnehmen können?
  • Wie baue ich typsichere Frameworks?
  • Wie vermeide ich Casts und mache dadurch meinen Code typsicher?

In der fortgeschrittenen Programmierung kommt es häufig vor, dass man Klassen schreibt, die dafür zuständig sind Objekte zu verwalten (Containerklassen). So fallen z. B. die Datenstrukturen Stack, Liste, Tabelle in diese Kategorie. Jetzt ist es bei der Entwicklung einer solchen Klasse eigentlich egal, ob letztendlich Strings oder Datumsobjekte verwaltet werden sollen. Die grundlegenden Operationen eines Stacks ändern sich nicht.

Mit den bisher bekannten Programmiertechniken kann man solche Datenstrukturen zwar bauen, wird aber letztendlich immer auf Object als kleinsten gemeinsamen Nenner zurückgreifen. D. h. in allen Schnittstellen dieser Klasse taucht Object als Parameter bzw. Rückgabetyp auf.

Die Verwendung von Object ist aber unbefriedigend, da

  1. die Klassen dann nicht typsicher sind (man kann jedes beliebige Objekt auf den Stack legen),
  2. man beim Herausholen der Objekte ständig casten muss.

Gesucht ist also ein Mechanismus, der es erlaubt diese Probleme zu umgehen.

Beispiel: Einfacher Stack

public class SimpleStack {

    private Object[] stack;
    private int pos;

    public SimpleStack(int size) {
        stack = new Object[size];
        pos = 0;
    }

    public void push(Object o) {
        stack[pos++] = o;
    }

    public Object pop() {
       return stack[--pos];
    }
}

Der hier dargestellte Stack ist ausgesprochen schlecht programmiert. Er überprüft Über- und Unterschreitungen der Array-Grenzen nicht und es fehlen viele wichtige Methoden, wie z. B. swap(), peek(), clear() etc., die eine gute Stack-Klasse haben sollte. Für die Zwecke dieses Kapitels ist er aber ausreichend und besser verständlich als eine vollständige und korrekte Implementierung.

Wir werden später noch sehen, dass es in der Java-Klassenbibliothek bereits eine sehr gute Implementierung eines Stacks gibt (java.util.Deque), sodass es ohnehin meistens keinen Sinn ergibt, eigene Stack-Implementierungen zu programmieren.

Die Klasse SimpleStack ist jetzt eine Containerklasse, wie sie weiter oben beschrieben wurden: sie kann andere Objekte verwalten und verwendet Object, damit man sie für beliebige Arten von Objekten einsetzen kann.

SimpleStack stack = new SimpleStack(10);

stack.push("Hello");
String s = (String) stack.pop();
System.out.println(s);

stack.push(42);
s = (String) stack.pop(); // FEHLER!!
System.out.println(s);
Hello
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer
    at pr2.Verwender.main(Verwender.java:13)

Die fehlende Typsicherheit von SimpleStack hat zur Konsequenz, dass die Klasse beim Einfügen von Objekten nicht prüfen kann, ob diese den richtigen Typ haben. Dies fällt erst beim Herausholen auf, wenn der entsprechende Cast fehlschlägt und führt zu einem Laufzeitfehler.

Das Risiko eines Laufzeitfehlers kann man umgehen, wenn man für jeden Datentyp, der verarbeitet werden soll, eine eigene Klasse schreibt, also einen Stack für Strings, einen für Integer, Datum etc.

Beispiel: Einfacher Stack für Strings

public class SimpleStackString {

    private String[] stack;
    private int pos;

    public SimpleStackString(int size) {
        stack = new String[size];
        pos = 0;
    }

    public void push(String o) {
        stack[pos++] = o;
    }

    public String pop() {
       return stack[--pos];
    }
}

Durch Änderung der Datentypen in den Methoden auf String kann man die Klasse SimpleStack so umwandeln, dass sie typsicher nur noch Strings verwaltet und so die oben beschriebene Fehlerquelle beim Cast wegfällt.

Beispiel: Einfacher Stack für Integer

public class SimpleStackInteger {

    private Integer[] stack;
    private int pos;

    public SimpleStackInteger(int size) {
        stack = new Integer[size];
        pos = 0;
    }

    public void push(Integer o) {
        stack[pos++] = o;
    }

    public Integer pop() {
       return stack[--pos];
    }
}

Genauso kann man die Klasse für Integer-Werte anpassen.

Vergleicht man SimpleStack, SimpleStackString und SimpleStackInteger so wird sofort deutlich, dass sie sich nur an drei Stellen unterscheiden:

  1. dem Typ des Arrays stack
  2. dem Typ des Parameters der push-Methode
  3. dem Typ des Rückgabewertes der pop-Methode

In allen drei Fällen besteht der Unterschied also nur in dem Typ einer Variable bzw. dem Rückgabetyp einer Methode.

Elegant wäre es, wenn man hier den Typ konfigurierbar machen könnte, d. h. wenn man erst bei der Verwendung eines SimpleStack sagen könnte, welchen Typ die drei Elemente haben sollten. Genau dies leisten Generics.

Eigenschaften von Generics

Generics erlauben vom konkreten Typ zu abstrahieren

  • Der Typ einer Variable, eines Parameters, eines Rückgabewertes etc. ist selbst ein Variable Typ-Variable (type variable)
  • Klassen und Methoden haben zusätzliche Typ-Parameter (type parameter), welche die Typ-Variablen setzen
  • Der Verwender kann Typ-Parameter setzen und damit die Klassen/Methoden parametrieren
  • Klassen mit Typ-Parametern sind generische Typen (generic types) bzw. generische Klassen (generic classes)
  • Methoden mit Typ-Parametern sind generische Methoden (generic methods)

Durch die Verwendung von Generics kann man Typen konfigurierbar machen. D. h. welchen Typ eine Variable, oder ein Parameter hat, wird erst später festgelegt – der Typ selbst wird variable. Damit dies funktioniert, muss man eine Variable haben, die festlegt, welchen Typ ein Parameter haben soll, man spricht hier von einer Typ-Variable.

Wichtig ist jetzt der Unterschied zwischen Variablen und Typ-Variablen, da sie auf zwei unterschiedlichen Abstraktionsebenen liegen:

  • Eine Typ-Variable legt fest welchen Typ ein Element, z. B. ein Parameter haben soll. Sie kann nur einen Typ (z. B. String, Integer) enthalten.
  • Eine Variable hat einen Typ (der möglicherweise durch eine Typ-Variable festgelegt wurde) und enthält dann einen Wert (z. B. "Hallo" oder 65), der zu dem Typ, den sie hat, passen muss.

Zuerst legt also die Typ-Variable den Typ einer Variable fest. Die Variable trägt dann einen Wert entsprechend ihres gerade festgelegten Typs.

Die Typ-Variable muss gesetzt werden, hierzu dient der Typ-Parameter. So wie ein Parameter den Wert einer Variable bestimmt, bestimmt der Typ-Parameter den Wert einer Typ-Variable.

Wenn jetzt eine Klasse einen Typ-Parameter besitzt, spricht man von einer generischen Klasse oder einem generischen Typ. Bei Methoden analog von einer generischen Methode.

Beispiel: Stack mit Generics

public class SimpleStack<T> {
    private T[] stack;
    private int pos;

    @SuppressWarnings("unchecked")
    public SimpleStack(int size) {
        stack = (T[]) new Object[size];
        pos = 0;
    }

    public void push(T o) {
        stack[pos++] = o;
    }

    public T pop() {
       return stack[--pos];
    }
}

Das Beispiel zeigt den SimpleStack als generischen Typ. Sofort fällt das <T> nach dem Klassennamen auf. Hierbei handelt es sich um die Deklaration eines Typ-Parameters T für die Klasse SimpleStack. Der Name des Parameters kann frei gewählt werden, nur einen Buchstaben zu verwenden hat sich aber etabliert. Analog zu den Parametern einer Methode handelt es sich hier um den Formalparameter. Da wir bei den Typ-Parametern schon auf der höchsten Abstraktionsebene angekommen sind, hat der Typ-Parameter selbst keinen Typ, d. h. anders als bei einem Methodenparameter, der immer mit Typ deklariert wird (z. B. add(int a, int b)), wird dem Typ-Parameter kein Typ vorangestellt.

Innerhalb der Klasse wird an allen Stellen, an denen der Typ abhängig vom Typ-Parameter festgelegt werden soll T verwendet. Hierbei handelt es sich nicht um die Deklaration des Parameters, sondern seine Verwendung. Genauso, wie in einer Methode add(int a, int b) zwei Parameter deklariert und dann innerhalb der Methode verwendet werden (return a + b).

Innerhalb des Konstruktors gibt es eine widersinnige Konstruktion (stack = (T[]) new Object[size];) bei der ein Object-Array angelegt wird und dann auf den Typ T gecastet. Den Hintergrund können wir an dieser Stelle noch nicht liefern. Er wird aber klar, wenn über den Zusammenhang von Generics und Arrays gesprochen wird und die Type-Erasure eingeführt wurde. Daher müssen wir diese Konstruktion vorläufig einfach so hinnehmen.

var stack = new SimpleStack<String>(10);

stack.push("Hello");
String s = stack.pop();
System.out.println(s);

stack.push(42); // FEHLER!!
s = stack.pop();
System.out.println(s);
pr2/Verwender.java:12: push(java.lang.String) in pr2.SimpleStack
     <java.lang.String> cannot be applied to (int)
        stack.push(42);
             ^

Der Verwender unseres neuen Stacks gibt beim Anlegen der Referenz stack und bei der Erzeugung des Objektes an, welchen Wert der Typ-Parameter haben soll, es wird also der Aktual-Parameter gesetzt (hier String).

Versucht man jetzt ein Objekt auf den Stack zu legen, das nicht dem gesetzten Typ (hier String) entspricht, kommt es zu einer Fehlermeldung durch den Compiler. Der Laufzeitfehler aus dem ursprünglichen Beispiel ist also verschwunden und wir merken bereits beim Programmieren, dass 42 nicht auf dem Stack gespeichert werden kann.

Darstellung in UML

In UML wird ein generischer Typ durch eine zusätzliche Angabe des Typ-Parameters gekennzeichnet.

Verbesserte Typ-Inferenz

Seit Java 7 ist die Typ-Inferenz für Generics deutlich verbessert worden

  • In den meisten Situationen muss man den Typ-Parameter nur noch bei der Deklaration setzen
  • Bei der Objekterzeugung kann die Angabe des Typ-Parameters durch den diamond <> ersetzt werden
List<String> list = new ArrayList<>();
Map<Integer, String> map = new HashMap<>();
Set<?> set = new HashSet<>();

Seit Java 7 braucht der Typ-Parameter nicht angegeben zu werden, wenn er sich aus dem Kontext ergibt. Da es keine Polymorphie im klassischen Sinne bei generischen Typen gibt, kann in den allermeisten Fällen der Typ-Parameter bei der Objekterzeugung aus dem Typ-Parameter der Referenzvariable abgeleitet werden. Vor Java 7 musste man den Parameter sowohl bei der Deklaration der Referenzvariablen als auch bei der Objekterzeugung angeben.

Java 6
SimpleStack<String> stack = new SimpleStack<String>(10);
Java 7
SimpleStack<String> stack = new SimpleStack<>(10);

Wenn sich der Typ-Parameter nicht eindeutig ergibt, muss er allerdings auch bei Java 7 im Zusammenhang mit der Objekterzeugung noch einmal festgelegt werden.

Seit Java 10 kann man das neue Schlüsselwort var verwenden, das den Compiler anweist, den Datentyp der Variablen aus dem Datentyp des Wertes abzuleiten, mit dem die Variable initialisiert wird. In diesem Fall muss man den Typ-Parameter aber wieder angeben.

Generische Typen und var

  • entweder man gibt den Typ rechts wieder an oder
  • der Typ-Parameter wird automatisch auf Object gesetzt (meist unerwünscht)
Java 10
var stack = new SimpleStack<String>(10);

Lässt man den Typ-Parameter auch auf der rechten Seite weg, gibt es keinen Compile-Fehler, sondern der Typ wird auf Object gesetzt.

Fehlender Typ
var stack = new SimpleStack<>(10);

Es ist allerdings zu beachten, dass man bei Verwendung von var nicht mehr mit Polymorphie arbeiten kann, da der Typ der Variablen vom Compiler anhand der Zuweisung ermittelt wird. Im Zusammenhang mit Collections (siehe weiter unten) ist aber gerade die Verwendung des Interfaces als Typ der Variable sinnvoll, da man so flexibler bleibt.

// la ist vom statischem Typ List
List<String> la = new ArrayList<>();

// lb ist vom statischem Typ ArrayList
var lb = new ArrayList<String>();

Copyright © 2025 Thomas Smits