Was ist eine generische Klasse und wie lässt sie sich erzeugen?v.5.0

Bei der Implementierung einer generischen Klasse werden die Datentypen ihrer Variablen nicht endgültig festgelegt, sondern statt dessen als Platzhalter sog. Typparameter angegeben.

Ein Ausgangsbeispiel ohne Generics

Java-Klassen besitzen Instanz- und/oder Klassenvariablen. Die Typen dieser Variablen sind durch den Entwurf der Klasse festgelegt. Beispielsweise sei eine Klasse Rechner gegeben, die zwei Variablen vom Typ double besitzt, mit denen hier nicht näher betrachtete Berechnungen durchgeführt werden können.

public class Rechner {
    private double num1;
    private double num2;
    
    public Rechner() {      
    }
    
    public void setNum1(double d) {
        num1 = d;
    }
    
    public void setNum2(double d) {
        num2 = d;
    }  
    //...
}

Ein Objekt dieser Klasse könnte auf die folgende Weise erzeugt und seine Instanzvariablen dann über die Setter mit zwei double-Werten initialisiert werden.

Rechner rechner = new Rechner();
rechner.setNum1(3.14159d);
rechner.setNum2(1.4d);

Die beiden Setter-Methoden verlangen zwingend double als Parameter1). Soll jedoch sichergestellt werden, dass die Klasse - aus welchem Grund auch immer - typsicher mit von Fall zu Fall unterschiedlichen Datentypen verwendet werden kann, bietet sich die Verwednung von Generics an.

Erzeugung einer generische Klasse

Erweitert man nun die obige Klasse durch Generics, so besteht die Möglichkeit, erst bei ihrer Verwendung festzulegen, ob für die enthaltenen Variablen ein Typ Double, Integer oder ein anderer passender numerischer Typ Verwendung finden soll. Die Implementierung einer Klasse wird auf diese Weise universell gehalten und jedes ihrer Objekte kann mit einem beliebigen, natürlich zur Funktionalität der Klasse passenden Datentypen instanziert werden.

Um dies zu erreichen wird sie statt mit konkreten Typangaben mit sog. Typparametern ausgestattet. Sie werden als frei wählbare einzelne Großbuchstaben in spitzen Klammern hinter dem Klassenbezeichner notiert und dann an all den Stellen verwendet, an denen sonst der jeweilige konkrete Datentyp angegeben würde, wie im Beispiel als Parameter der Accessor-Methoden.
Typparameter dürfen als Platzhalter für beliebige Referenztypen, jedoch nicht für primitive Datentypen Verwendung finden. Wird, wie hier, ein primitiver Typ benötigt, so kann dies durch Referenzieren der entsprechenen Wrapper-Klasse gelöst werden. E steht hier im Beispiel für Element. Es wird später bei der Bildung eines Objektes der Klasse durch einen konkreten Datentyp ersetzt.
Das obige Beispiel als generische Klasse sieht wie folgt aus:

public class Rechner<E> {
    private E num1;
    private E num2;
    
    public Rechner() {      
    }
    
    public void setNum1(E e) {
        num1 = e;
    }
    
    public void setNum2(E e) {
        num2 = e;
    }
    //...
}

Wird für eine oder mehrere Variablen nur ein Datentyp verwendet, reicht wie oben die einfache Nennung eines einzigen Typparameters. Sollen in der Klasse jedoch verschiedene Typen Verwendung finden, so werden diese als einfache Komma-separierte Liste hinter dem Klassenbezeichner angegeben. In diesem Fall besteht dann die Möglichkeit, die beiden Variablen später mit Werten unterschiedlicher Datentypen zu belegen.

public class Rechner<E, F> {
    private E num1;
    private F num2;
    
    public Rechner() {      
    }
    
    public void setNum1(E e) {
        num1 = e;
    }
    
    public void setNum2(F f) {
        num2 = f;
    }
    //...
}

Selbstverständlich ist bei einer generischen Klasse auch eine Parametrisierung des Konstruktors möglich.

//...
public Rechner(E e, F f) {
    this.num1 = e;
    this.num2 = f;
}
//...

Es ist nicht selten der Fall, dass die Typparameter einer generischen Klasse zwingend einer bestimmten Superklasse angehören müssen, um ungeeignete Datentypen zu vermeiden. So ist es sinnvoll, beispielsweise die Variablen eines Rechners nur mit numerischen Typen zu initialisieren. Dies kann durch Erweiterungsangaben der Typparameter erfolgen:

public class Rechner<E extends Number, F extends Number> {
    private E num1;
    private F num2;
    //...
}

Auf diese Weise können bei der Objektbildung nur von Number abgeleitete Typen für die Parametrisierung Verwendung finden.

Verwendung einer generische Klasse

Wird das Objekt einer generischen Klasse (ein sog. generischer Consumer) erzeugt, so legt der Entwickler bei der Objektbildung an der Stelle der Typparameter i.a. konkret existierende Datentypen fest, die selbstverständlich zur Funktionalität der Klasse passen müssen (s.o.). Wie bereits erwähnt, muss es sich hierbei grundsätzlich um Referenztypen handeln, elementare Datentypen sind an dieser Stelle nicht zulässig. Statt dieser können ggf. jedoch deren Wrapper-Klassen verwendet werden.
Ein Consumer muss nicht zwingend parametrisiert werden. Wird hierauf verzichtet, so werden an Stelle der Typparameter die Typen derer Elternklassen eingesetzt, im vorliegenden Beispiel ohne erweiternde Angabe also entweder der Typ Object oder bei Erweiterung durch extends Number der Typ Number.

Die Bildung eines Objektes des obigen Beispiels mit einem Typparameter gestaltet sich bei der Verwendung von double-Werten folgendermaßen. Man beachte die Verwendung der groß geschriebenen Wrapper-Klasse Double.

Rechner<Double> rechner = new Rechner<Double>();
rechner.setNum1(Double.valueOf(3.14d));
rechner.setNum2(0.5d);

Analog gestaltet sich die Verwendung der generischen Klasse mit zwei verschiedenen Datentypen, z.B. Integer und Float, folgendermaßen:

Rechner<Integer, Float> rechner = new Rechner<Integer, Float>();
rechner.setNum1(17);
rechner.setNum2(0.5f);

Bei der Instanzierung kann die Typangabe rechts vom Zuweisungsoperator auch weggelassen werden. Der Compiler ermittelt sie dann durch die Angaben bei der Deklaration auf der linken Seite. Aufgrund des Aussehens der beiden spitzen Klammern, spricht man hier auch vom Diamond-Operator.

Rechner<Double> rechner = new Rechner<>();

Es soll nochmals erwähnt werden, dass die Verwendung von Generics dazu führt, dass der Compiler die beim Consumer aufgeführten Datentypen prüft und entsprechend ersetzt und somit die Sicherstellung der Typsicherheit vor der Laufzeit gewährleistet.

1) Selbstverständlich können an dieser Stelle auch andere numerische Datentypen übergeben werden. Sie werden intern jedoch durch implizites casting in double konvertiert.