Programmieren in Java: Einführung


Kapitel 10: Generics


Inhaltsverzeichnis

Dieses Buch ist unter einer Creative Commons-Lizenz lizensiert.


10.1 Allgemeines

Generische Programmierung

In Java 5 führte Sun Generics ein - sogenannte parametrisierte Datentypen. Dies erfolgte demnach relativ spät. So können Sie in allen Vorgängerversionen von Java 1.0 bis 1.4 all das, was Sie in diesem Kapitel kennenlernen werden, nicht anwenden.

Generics verkomplizieren auf den ersten Blick Quellcode, so dass Sun diese Spracheigenschaften anfangs nicht in Java aufnahm. Sie sind der Schlüssel für ein Programmierparadigma, das sich generische Programmierung nennt. Die ersten Java-Versionen unterstützten demnach die generische Programmierung nicht. Das lag unter anderem daran, dass man von anderen Programmiersprachen wie C++ wusste, dass die generische Programmierung höhere Anforderungen an Softwareentwickler stellt und daher nicht ganz so einfach zu erlernen und anzuwenden ist. In den Vorgängerversionen von Java 5 konnte demnach objektorientiert, aber nicht generisch programmiert werden, während in C++ seit langer Zeit beide Programmierparadigma gemischt werden können.

Sun entschied sich, die generische Programmierung ab Java 5 zu unterstützen, da zu diesem Zeitpunkt ausreichend viele Entwickler mit Java vertraut waren und diese bei einem Umstieg auf die neue Version lediglich Generics hinzulernen mussten. Außerdem geriet Java durch C# unter Druck, das bereits ab der Version 2.0 die generische Programmierung unterstützte. Wollte Java nicht zurückfallen, musste die in der Praxis der Softwareentwicklung etwas komplizierte, aber durchaus nützliche generische Programmierung unterstützt werden.

In diesem Kapitel erfahren Sie, was man grundsätzlich unter der generischen Programmierung versteht und wie Sie sie in Java mit Generics einsetzen. Voraussetzung, um die Beispiele in diesem Kapitel übersetzen und ausführen zu können, ist Java 5 oder höher.


10.2 Generische Klassen

Container als Paradebeispiel

Um zu verstehen, welchen Vorteil die generische Programmierung bietet, sehen wir uns im Folgenden ein Beispiel an, wie es in Java 1.4 ohne generische Programmierung entwickelt sein könnte.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList Names = new ArrayList(); 
  private Choice NamesChoice = new Choice(); 

  public void init() 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    for (int i = 0; i < Names.size(); ++i) 
      NamesChoice.add((String)Names.get(i)); 

    add(NamesChoice); 
  } 
} 

Im obigen Applet wird ein Container vom Typ java.util.ArrayList verwendet. Diese Klasse nennt sich Container, weil ein Objekt vom Typ java.util.ArrayList mehrere andere Objekte aufnehmen kann. So werden im obigen Beispiel mehrere Strings durch den wiederholten Aufruf von add() dem Container Names hinzugefügt.

Die Klasse java.util.ArrayList repräsentiert ein Array, das dynamisch wächst. Während Sie bei Arrays bei der Definition angeben müssen, aus wie vielen Elementen das Array bestehen soll, können Sie einem Container vom Typ java.util.ArrayList jederzeit neue Elemente hinzufügen - Sie müssen nur add() aufrufen. Über die Methoden size() und get() können Sie die Anzahl der momentan gespeicherten Elemente ermitteln und auf einzelne Elemente über einen Index zugreifen. Ein Objekt vom Typ java.util.ArrayList verhält sich demnach sehr ähnlich wie ein Array.

Wenn Sie sich den obigen Quellcode ansehen, stellen Sie fest, dass in der for-Schleife der Rückgabewert der Methode get() zu einem String gecastet wird. Das ist zwingend erforderlich, weil Objekte vom Typ java.util.ArrayList Objekte beliebiger Datentypen speichern können - also nicht nur Strings, sondern wirklich alles. So erwartet die Methode add() keine Parameter vom Typ String, sondern vom Typ Object. Das ist auch grundsätzlich richtig so, denn sonst müsste es für jeden Datentypen in Java eine entsprechend angepasste Klasse java.util.ArrayList geben. Weil java.util.ArrayList intern Referenzen vom Typ Object speichert, kann diese Klasse verwendet werden, um Objekte beliebiger Datentypen zu speichern.

Ein Casting wie im obigen Beispiel funktioniert. Und so wie oben sahen auch alle Java-Programme bis zur Version 1.4 aus. Das Problem mit Castings aber ist, dass wir als Entwickler scheinbar etwas besser wissen als die Java-Laufzeitumgebung. Wir teilen der Java-Laufzeitumgebung mit, dass beim Zugriff auf Elemente eine Umwandlung vom intern verwendeten Datentypen Object zu String stattfinden soll. Der Java-Laufzeitumgebung bleibt nichts anderes übrig als darauf zu vertrauen, dass wir Recht haben. Denn würden wir ein Casting zu einem anderen Datentypen wie Integer durchführen, würde eine Ausnahme geworfen werden. Castings werden demnach als problematisch angesehen, weil sie leicht zu Programmierfehlern führen. Diese lassen sich mit Hilfe der generischen Programmierung vermeiden.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private Choice NamesChoice = new Choice(); 

  public void init() 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    for (int i = 0; i < Names.size(); ++i) 
      NamesChoice.add(Names.get(i)); 

    add(NamesChoice); 
  } 
} 

Im obigen Beispiel wird nun die generische Klasse java.util.ArrayList verwendet, wie sie seit Java 5 existiert. Wie Sie sehen wird dabei auf genau die gleiche Klasse java.util.ArrayList zugegriffen wie zuvor.

Der generischen Klasse java.util.ArrayList muss in spitzen Klammern ein Parameter übergeben werden. Bei diesem Parameter muss es sich um einen Datentypen handeln. So wird im obigen Beispiel angegeben, dass die Liste Names Elemente vom Typ String speichern wird. Eine generische Klasse speichert demnach nicht Objekte beliebiger Datentypen - also Objekte vom Typ Object - sondern ausschließlich Objekte von dem Typ, der als Parameter in spitzen Klammern angegeben wurde.

Der Einsatz der generischen Klasse java.util.ArrayList führt dazu, dass kein Casting mehr notwendig ist. Weil der Java-Compiler nun weiß, dass in Names ausschließlich Strings gespeichert sind, ist auch klar, dass get() ausschließlich Strings zurückgibt. Würden wir versuchen, den Rückgabewert von get() zu einem inkompatiblen Datentypen zu casten, würde der Java-Compiler meckern und sich weigern, den Code zu übersetzen. Das fehlerhafte Casting würde also bereits zur Kompilierung entdeckt werden und nicht erst zur Laufzeit.

Die generische Programmierung macht es möglich, Datentypen wie java.util.ArrayList an andere Datentypen anzupassen. Container wie java.util.ArrayList gelten dabei als Paradebeispiel für die generische Programmierung, da Container per Definition Objekte in sich aufnehmen. Wenn Containern mitgeteilt werden kann, welchen Datentyp die entsprechenden Objekte haben, kann der Java-Compiler diese Information zu unserem Vorteil nutzen und zur Kompilierung überprüfen, ob unser Code korrekt ist. So ist es zum Beispiel dank der generischen Klasse java.util.ArrayList unmöglich, versehentlich mit add() ein Objekt zu speichern, das kein String ist - etwas, was im vorherigen Beispiel möglich wäre und erst zur Laufzeit entdeckt werden würde, wenn das Casting fehlschlägt. Die generische Programmierung ist demnach ein Hilfsmittel, sichereren Code zu schreiben, weil dieser zu einem früheren Zeitpunkt - nämlich zur Kompilierung und nicht erst zur Laufzeit - auf Richtigkeit überprüft werden kann.


10.3 Generische Interfaces

Iterable und die erweiterte for-Schleife

Die generische Programmierung wird nicht nur durch Klassen unterstützt, sondern auch durch Interfaces. Zu den parametrisierten Interfaces zählt unter anderem java.lang.Iterable. Dieses Interface wird von zahlreichen Containern implementiert - unter anderem von der Klasse java.util.ArrayList. Auf diesem Interface, das erst seit Java 5 existiert, basiert eine besondere Variante der for-Schleife, die es dementsprechend ebenfalls erst seit Java 5 gibt.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private Choice NamesChoice = new Choice(); 

  public void init() 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    for (String s: Names) 
      NamesChoice.add(s); 

    add(NamesChoice); 
  } 
} 

Die im obigen Beispiel verwendete for-Schleife iteriert über alle Elemente in der Liste Names. In jedem Schleifendurchgang wird das entsprechende Element in der Variablen s zur Verfügung gestellt und kann so NamesChoice hinzugefügt werden. Das funktioniert, weil java.util.ArrayList das Interface java.lang.Iterable implementiert.

Wenn Sie in der Dokumentation für das Interface nachschlagen, sehen Sie, dass es lediglich eine Methode iterator() definiert. Diese Methode gibt einen sogenannten Iterator zurück, mit dem über Elemente in einem Container iteriert werden kann. Wichtig ist, dass es sich beim zurückgegebenen Iterator ebenfalls um einen parametrisierten Datentypen handelt. Wird die Klasse java.util.ArrayList so wie im obigen Beispiel mit dem Datentyp String instantiiert, ist das Interface java.lang.Iterable ebenfalls mit String instantiiert, womit wiederum der Iterator, der von iterator() zurückgegeben wird, mit String instantiiert ist. Der Datentyp wird weitergereicht, so dass an allen Stellen im Code klar ist, dass mit Strings gearbeitet wird. Das ist wichtig, denn sonst würde die for-Schleife so wie oben nicht funktionieren. Wenn Sie beispielsweise den Parameter entfernen und die nicht-generische Version von java.util.ArrayList verwenden, wie es bis Java 1.4 notwendig war, meldet der Compiler einen Fehler. Immerhin soll in der for-Schleife eine String-Variable Elemente aus dem Container speichern - die nicht-generische Klasse java.util.ArrayList speichert jedoch nur Objekte vom Typ Object. Sie können an dieser Stelle noch nicht einmal mit Castings arbeiten, da Sie keinen Zugriff auf den Code haben, den der Compiler für die oben verwendete for-Schleife im Hintergrund erstellt, um zum Beispiel iterator() aufzurufen.

Wie sieht nun in etwa die Definition der Klasse java.util.ArrayList aus?

package java.util; 

import java.lang.*; 

public class ArrayList<T> implements Iterable<T> 
{ 
  public Iterator<T> iterator() 
  { 
  } 

  public boolean add(T t) 
  { 
  } 

  public T get(int index) 
  { 
  } 
} 

Um eine Klasse generisch zu machen, wird hinter dem Klassennamen ein Paar spitzer Klammern gesetzt. In diesen spitzen Klammern wird ein Parameter angegeben, der üblicherweise T genannt wird - T für Typ. Dieses T stellt den Platzhalter für den Datentypen dar, der später in spitzen Klammern angegeben wird, wenn die Klasse eingesetzt wird. Diesen Vorgang nennt man auch Instantiierung.

Der Parameter T kann nun an unterschiedlichen Stellen auftauchen - und zwar überall dort, wo die Verwendung eines Datentypen Sinn macht. So kann das T zum Beispiel in spitzen Klammern an ein Interface wie Iterable weitergegeben werden. Da es sich bei Iterable um ein generisches Interface handelt, muss hinter Iterable sowieso ein Paar spitzer Klammern angegeben werden. In diesen spitzen Klammern kann nun wie oben zu sehen der Parameter T gesetzt werden: Der Datentyp, mit dem die Klasse ArrayList später beim Einsatz instantiiert wird, wird demnach an das Interface Iterable weitergegeben. Daraus folgt wiederum, dass T auch an die Klasse java.util.Iterator weitergegeben werden muss - dem Datentyp des Rückgabewerts von iterator().

Generische Klassen sind demnach unvollständiger Code, der an zahlreichen Stellen Platzhalter verwendet. Erst dann, wenn auf die generische Klasse zugegriffen wird und in spitzen Klammern ein Datentyp als Parameter angegeben wird, wird die Klasse zu einem vollständigen Typen. Sie können sich diesen Typen so vorstellen, dass an all den Stellen, an denen sich im Quellcode der Platzhalter T befindet, der als Parameter angegebene Datentyp steht. Auf diese Weise ist es möglich, Container wie java.util.ArrayList einmal zu definieren und dann für beliebige Datentypen so anzupassen, dass sicherer Code geschrieben werden kann.


10.4 Einschränkungen von Platzhaltern

Ober- und Untergrenzen

Wenn Sie eine generische Klasse definieren, können Sie spezifizieren, mit welchen Datentypen die Klasse instantiiert werden kann. Sehen Sie sich zum Beispiel folgende Klasse Length an, die eine Methode anbietet, um die Anzahl der Elemente in einem Container zu ermitteln.

import java.util.*; 

public class Length<T extends Collection> 
{ 
  T t; 

  public Length(T t) 
  { 
    this.t = t; 
  } 

  public int length() 
  { 
    return t.size(); 
  } 
} 

In der spitzen Klammer hinter dem Klassennamen Length ist nun nicht mehr nur ein Parameter T angegeben. Hinter T befindet sich eine Typeinschränkung, die besagt, dass Datentypen, mit denen Length instantiiert wird, Kindklassen von java.util.Collection sein müssen.

Diese Einschränkung scheint auf den ersten Blick willkürlich zu sein. Wenn Sie sich die Klasse jedoch näher ansehen, stellen Sie fest, dass in der Methode length() auf die Variable t zugegriffen wird, um eine Methode size() aufzurufen. Da der Datentyp der Variablen t der Platzhalter T ist und demnach von dem Datentypen abhängt, mit dem Length instantiiert wird, muss der Compiler irgendwie in der Lage sein sicherzustellen, dass der entsprechende Datentyp überhaupt eine Methode size() definiert. Würde die generische Klasse Length zum Beispiel mit dem Datentypen Object instantiiert werden, gäbe es ein Problem, da Object keine Methode size() besitzt.

Der Compiler überprüft für generische Klassen, ob entsprechende Methodenaufrufe für einen Platzhalter erlaubt sind. Rufen Sie Methoden auf, von denen Sie wissen, dass sie nicht von jedem beliebigen Datentypen angeboten werden, müssen Sie den Platzhalter entsprechend einschränken. Andernfalls könnte ein Entwickler auf die Idee kommen, ihre generische Klasse zum Beispiel mit dem Datentyp Object zu instantiieren - und was sollte dann passieren, wenn eine Methode aufgerufen wird, die Object gar nicht anbietet? Damit es zu einem derartigen Problem gar nicht erst kommen kann, führt der Compiler eine entsprechende Überprüfung durch, ob der Platzhalter richtig eingeschränkt ist.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private Length<ArrayList<String>> Length = new Length<ArrayList<String>>(Names); 

  public void paint(Graphics g) 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    g.drawString(Integer.toString(Length.length()), 0, 10); 
  } 
} 

Im obigen Beispiel-Code sehen Sie, wie die generische Klasse Length mit einem Container vom Typ java.util.ArrayList instantiiert wird. Sie sehen auch, dass es unproblematisch ist, wenn der entsprechende Datentyp seinerseits generisch ist und wie java.util.ArrayList ebenfalls einen Datentypen als Parameter erwartet.

Neben der Einschränkung mit dem Schlüsselwort extends, das eine Obergrenze für Datentypen vorgibt, kann auch eine Einschränkung mit dem Schlüsselwort super erfolgen. In diesem Fall wird eine Untergrenze vorgegeben. Es dürfen dann alle Datentypen zur Instantiierung verwendet werden, die Elternklassen des hinter super in der Einschränkung angegebenen Datentypen sind.


10.5 Wildcards

Instantiierungen in Abhängigkeit anderer Datentypen

In diesem Kapitel wird bisher generischen Klassen in spitzen Klammern ein Datentyp als Parameter übergeben, um sie zu instantiieren. Es ist jedoch auch möglich, eine Instantiierung mit einer Wildcard vorzunehmen - im Java-Code als Fragezeichen ausgedrückt. Sehen Sie sich dazu folgendes Beispiel an.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private ArrayList<Integer> Ages = new ArrayList<Integer>(); 

  private int length(ArrayList<?> list) 
  { 
    return list.size(); 
  } 

  public void paint(Graphics g) 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    Ages.add(25); 
    Ages.add(32); 
    Ages.add(41); 

    g.drawString(Integer.toString(length(Names)), 0, 10); 
    g.drawString(Integer.toString(length(Ages)), 0, 20); 
  } 
} 

Der Parameter list der Methode length() hat den Datentyp java.util.ArrayList<?>. Die generische Klasse java.util.ArrayList ist demnach nicht mit einem Datentypen wie String oder Integer instantiiert, sondern mit einer Wildcard. Das bedeutet, dass die Referenzvariable list auf Listen vom Typ java.util.ArrayList verweisen kann, völlig egal, mit welchem Datentyp die jeweiligen Listen instantiiert sind. So akzeptiert die Methode length() sowohl Names als auch Ages als Parameter.

Das obige Beispiel könnte jedoch auch mit Hilfe einer generischen Methode entwickelt werden.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private ArrayList<Integer> Ages = new ArrayList<Integer>(); 

  private <T> int length(ArrayList<T> list) 
  { 
    return list.size(); 
  } 

  public void paint(Graphics g) 
  { 
    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    Ages.add(25); 
    Ages.add(32); 
    Ages.add(41); 

    g.drawString(Integer.toString(length(Names)), 0, 10); 
    g.drawString(Integer.toString(length(Ages)), 0, 20); 
  } 
} 

Wie Sie sehen können demnach auch Methoden generisch sein - nicht nur Klassen und Interfaces.

Der Unterschied zwischen diesem und dem vorherigen Beispiel ist, dass mit Wildcards eine Instantiierung erfolgt. Der Code einer generischen Klasse wird verkomplettiert, um eine entsprechende Variable anzulegen. Sehen Sie sich dazu das folgende Beispiel an, in dem die Variable nicht als Parameter einer Funktion definiert wird, sondern innerhalb von paint().

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 
  private ArrayList<Integer> Ages = new ArrayList<Integer>(); 

  public void paint(Graphics g) 
  { 
    ArrayList<?> list; 

    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    list = Names; 
    g.drawString(Names.get(0), 0, 10); 

    Ages.add(25); 
    Ages.add(32); 
    Ages.add(41); 

    list = Names; 
    g.drawString(Integer.toString(Ages.get(0)), 0, 20); 
  } 
} 

list ist eine Referenzvariable, die auf Objekte vom Typ java.util.ArrayList verweisen kann, die mit einem beliebigen Datentyp instantiiert sind. Hier könnte jedoch genausogut die nicht-generische Variante von java.util.ArrayList verwendet werden. Richtig Sinn machen Wildcards erst dann, wenn man wie im vorherigen Abschnitt Einschränkungen vornimmt.

import java.applet.*; 
import java.awt.*; 
import java.util.*; 

public class MyApplet extends Applet 
{ 
  private ArrayList<String> Names = new ArrayList<String>(); 

  public void paint(Graphics g) 
  { 
    ArrayList<? extends String> list; 

    Names.add("Anton"); 
    Names.add("Boris"); 
    Names.add("Caesar"); 

    list = Names; 
    g.drawString(Names.get(0), 0, 10); 
  } 
} 

Im obigen Code-Beispiel kann list nun lediglich auf Listen vom Typ java.util.ArrayList verweisen, die mit String oder einer Kindklasse instantiiert sind. Damit wird eine Einschränkung vorgenommen, wie sie ohne Generics nicht erfolgen kann.

Wildcards machen vor allem in der Entwicklung generischer Klassen Sinn. Weil in generischen Klassen Platzhalter verwendet werden, stellt sich die Frage, mit welchen Datentypen interne Variablen in generischen Klassen definiert werden sollen. Da Wildcards auch von Platzhaltern in generischen Klassen abhängen dürfen, können intern Variablen so definiert werden, dass ihre Datentypen von den Datentypen abhängen, mit denen generische Klassen später instantiiert werden. So wird zum Beispiel in der Definition der Methode addAll() im Interface java.util.Collection eine Wildcard verwendet, wie Sie anhand der Dokumentation erkennen können.


10.6 Aufgaben

Übung macht den Meister

Sie können die Lösungen zu allen Aufgaben in diesem Buch als ZIP-Datei erwerben.

  1. Entwickeln Sie eine Java-Application und implementieren Sie das generische Interface java.lang.Comparable für die Klasse, in der Sie die statische Methode main() definieren. Erstellen Sie zwei Objekte vom Typ dieser Klasse und vergleichen Sie sie, indem Sie die Methode aufrufen, die von java.lang.Comparable vorgegeben wird. Geben Sie das Ergebnis des Vergleichs auf die Standardausgabe aus. Es reicht aus, wenn Sie lediglich die Referenzvariablen mit == auf Gleichheit überprüfen.

  2. Entwickeln Sie eine Java-Application, der beim Aufruf beliebig viele Ganzzahlen als Kommandozeilenparameter übergeben werden können. Speichern Sie alle Zahlen in einem generischen Container vom Typ java.util.HashSet. Geben Sie anschließend die Anzahl gespeicherter Werte im Container auf die Standardausgabe aus. Testen Sie Ihre Anwendung, indem Sie einige Zahlen mehrmals als Kommandozeilenparameter übergeben.

  3. Entwickeln Sie eine Java-Application, die den Anwender zur Eingabe von Namen und Alter auffordert. Ein Anwender soll beliebig viele Eingaben machen können, bis er Enter drückt. Die Daten sollen in einem generischen Container vom Typ java.util.HashMap gespeichert werden. Anschließend soll das Durchschnittsalter aller gespeicherten Personen errechnet und auf die Standardausgabe ausgegeben werden.

  4. Erweitern Sie Ihre Lösung zu Aufgabe 3, dass die eingegebenen Alter in absteigender Reihenfolge auf die Standardausgabe ausgegeben werden. Verwenden Sie zur Lösung dieser Aufgabe die Methode sort() der Klasse java.util.Arrays.