Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 5. Auflage |
<< | < | > | >> | API | Kapitel 15 - Collections II |
Dieser Abschnitt beschreibt eine Erweiterung, die seit der J2SE 5.0 zur Verfügung steht und unter dem Namen »Generics« bekannt geworden ist. Es geht dabei vordergründig um die Möglichkeit, typsichere Collection-Klassen zu definieren. Also solche, in die nicht nur allgemein Objekte des Typs Object gesteckt werden können, sondern die durch vorhergehende Typisierung sicherstellen, dass nur Objekte des korrekten Typs (etwa Integer oder String) eingefügt werden können. Diese, von vielen Java-Entwicklern seit langer Zeit geforderte, Spracherweiterung bringt zwei wichtige Vorteile:
|
|
Genau genommen geht es nicht nur um Collections im eigentlichen Sinne, sondern um die Typisierung von beliebigen Java-Klassen. Also die Möglichkeit, festzulegen, dass eine bestimmte Klasse X zwar so implementiert wurde, dass sie prinzipiell mit allen anderen Klassen zusammen arbeitet (bzw. Objekte deren Typs aufnimmt), im konkreten Anwendungsfall von X aber die Möglichkeit besteht, die Zusammenarbeit (etwa aus Sicherheits- oder Konsistenzgründen) auf eine fest vorgegebene andere Klasse zu beschränken. |
|
Was sich etwas kompliziert anhört, wollen wir durch ein einfaches Beispiel illustrieren:
001 public static void printSorted1(int... args) 002 { 003 Vector v = new Vector(); 004 for (int i = 0; i < args.length; ++i) { 005 v.addElement(new Integer(args[i])); 006 } 007 Collections.sort(v); 008 for (int i = 0; i < v.size(); ++i) { 009 int wert = 10 * ((Integer)v.elementAt(i)).intValue(); 010 System.out.print(wert + " "); 011 } 012 System.out.println(); 013 } |
printSorted1 bekommt als Parameter eine Menge von Ganzzahlen übergeben und hat die Aufgabe, diese mit 10 zu multiplizieren und sortiert auf der Konsole auszugeben. Die Methode legt dazu einen Vector v an und fügt in diesen zunächst die in Integer konvertierten int-Werte ein. Anschließend sortiert sie den Vector, liest die Integer-Objekte aus, konvertiert sie in int-Werte zurück und gibt die mit 10 multiplizierten Ergebnisse auf der Konsole aus.
Seit der J2SE 5.0 kann die Methode nun typsicher gemacht werden:
001 public static void printSorted2(int... args) 002 { 003 Vector<Integer> v = new Vector<Integer>(); 004 for (int i = 0; i < args.length; ++i) { 005 v.addElement(new Integer(args[i])); 006 } 007 Collections.sort(v); 008 for (int i = 0; i < v.size(); ++i) { 009 int wert = 10 * v.elementAt(i).intValue(); 010 System.out.print(wert + " "); 011 } 012 System.out.println(); 013 } |
Der Vector
wurde hier mit einem Typ-Parameter versehen, der in spitzen Klammern
angegeben wird:
Vector<Integer> v = new Vector<Integer>();
Dadurch wird dem Compiler mitgeteilt, dass dieser Vector ausschließlich Integer-Objekte aufnehmen kann. Alle Versuche, darin einen String, ein Double oder irgendein anderes Nicht-Integer-Objekt zu speichern, werden vom Compiler unterbunden. Auch der zweite der oben genannten Vorteile kommt zum Tragen: beim Zugriff auf Vector-Elemente mit Hilfe der Methode elementAt werden diese automatisch in ein Integer konvertiert, der übliche Typecast kann also entfallen.
Auf diese Weise können nun seit der J2SE 5.0 alle Collection-Klassen typsicher verwendet werden: einfach den Datentyp in spitzen Klammern direkt hinter dem Klassennamen angeben! Auch bei Collections, die mit mehr als einem Parameter arbeiten, ist das möglich, also inbesondere bei den verschiedenen Maps. Hier werden beide Parameter in spitzen Klammern angegeben und durch Kommata voneinander getrennt. Wir werden dazu später ein Beispiel sehen.
Zunächst soll jedoch das obige Beispiel weiter vereinfacht werden. Tatsächlich ist printSorted2 nämlich etwas länger als printSorted1, d.h. wir haben uns die Typsicherheit durch zusätzlichen Code erkauft. Daß es wesentlich einfacher geht, zeigt folgende Variante:
Hier kommen zusätzlich folgende Techniken zum Einsatz:
Dieses Programm sieht wesentlich besser aus als die erste Fassung. Es ist nun sowohl typsicher als auch besser lesbar. Möglich gemacht wird dies durch verschiedene Neuerungen der J2SE 5.0, die hier im Zusammenspiel ihr Synergiepotential entfalten. Autoboxing und Autounboxing werden in Abschnitt 10.2.3 erläutert und die erweiterte for-Schleife in Abschnitt 6.3.3. Auch die variablen Parameterlisten sind eine Neuerung der J2SE 5.0; sie werden in Abschnitt 7.3.4 erläutert.
Wie bereits erwähnt können auch Collections typsicher gemacht
werden, deren Methoden üblicherweise mehr als einen Parameter
erwarten. Ein gutes Beispiel dafür ist das Interface Map
und dessen implementierende Klassen (etwa HashMap,
TreeMap
oder Hashtable).
Sie speichern nicht einzelne Werte, sondern Schlüssel-Wert-Paare.
Soll eine solche Klasse typsicher verwendet werden, sind bei der Deklaration
zwei Typ-Parameter anzugeben:
Hashtable<String, Integer> h = new Hashtable<String, Integer>();
An die Einfügeoperationen, die beide Parameter erwarten, muss nach einer solchen Deklaration zwangsweise ein String und ein Integer übergeben werden. Die Zugriffsmethoden dagegen erwarten einen String als Schlüssel und liefern einen Integer als Rückgabewert. Beispielhaft wollen wir uns eine Methode ansehen, die eine Liste von Strings erwartet und dann zählt, wie oft jedes einzelne Wort darin vorkommt. Eine Pre-5.0-Implementierung könnte so aussehen:
001 public static void wordCount1(String[] args) 002 { 003 Hashtable h = new Hashtable(); 004 for (int i = 0; i < args.length; ++i) { 005 int cnt = 1; 006 if (h.containsKey(args[i])) { 007 cnt = 1 + ((Integer)h.get(args[i])).intValue(); 008 } 009 h.put(args[i], new Integer(cnt)); 010 } 011 System.out.println(h); 012 } |
Für jedes Element des Parameter-Arrays wird geprüft, ob es schon in der Hashtable h enthalten ist. Ist das der Fall, wird das Wort als Schlüssel verwendet, der zugehörige Zählerstand aus h gelesen und um 1 erhöht. Ist das nicht der Fall, wird der Zähler mit 1 initialisiert. Anschließend wird der Zählerwert mit dem Wort als Schlüssel in die Hashtable geschrieben.
Seit der J2SE 5.0 kann man die Methode stark vereinfachen:
001 public static void wordCount2(String... args) 002 { 003 Hashtable<String, Integer> h = new Hashtable<String, Integer>(); 004 for (String key : args) { 005 if (h.containsKey(key)) { 006 h.put(key, 1 + h.get(key)); 007 } else { 008 h.put(key, 1); 009 } 010 } 011 System.out.println(h); 012 } |
Auch hier machen wir uns gleich alle drei oben genannten Erweiterungen der J2SE 5.0 zu Nutze. Zudem gibt es einen weiteren Vorteil. Da nun die Datentypen der Methoden put und get bekannt sind, können wir - dank der Verkürzung durch Autoboxing und Autounboxing - die Programmstruktur übersichtlicher machen. Wir schreiben dazu die put- und get-Operationen in eine Zeile, die Hilfsvariable cnt wird gar nicht mehr gebraucht.
Nachdem wir uns in den vorherigen Abschnitten angesehen haben, wie generische Collections verwendet werden, wollen wir nun eine eigene generische Listenklasse implementieren. Deren Interface soll bewußt schlank gehalten werden, um unnötige Verkomplizierungen zu vermeiden. Es besteht aus je einer Methode, um Elemente einzufügen und auszulesen, einer Methode zur Abfrage der Größe und aus einem Iterator, um die Elemente (u.a. mit den neuen foreach-Schleifen der J2SE 5.0) durchlaufen zu können.
Der Einfachheit halber wollen wir die Liste mit einem Array als interne Datenstruktur realisieren und definieren dazu folgende Klasse:
001 import java.util.*; 002 003 /** 004 * Die folgende Klasse realisiert eine einfache Liste mit einer 005 * festen Größe. Die Liste kann typisiert werden, so dass 006 * Zugriffs- und Hinzufügemethoden typsicher werden. Darüber 007 * hinaus implementiert sie das Interface Iterable und stellt 008 * einen typsicheren Iterator zur Verfügung, um die Verwendung 009 * in J2SE-5.0-foreach-Schleifen zu ermöglichen. 010 */ 011 public class MiniListe<E> 012 implements Iterable<E> 013 { 014 private Object[] data; 015 private int size; 016 017 /** 018 * Erzeugt eine leere Liste, die maximal maxSize Elemente 019 * aufnehmen kann. 020 */ 021 public MiniListe(int maxSize) 022 { 023 this.data = new Object[maxSize]; 024 this.size = 0; 025 } 026 027 /** 028 * Fügt ein Element zur Liste hinzu. Falls diese schon 029 * voll ist, wird eine Exception ausgelöst. 030 */ 031 public void addElement(E element) 032 { 033 if (size >= data.length) { 034 throw new ArrayIndexOutOfBoundsException(); 035 } 036 data[size++] = element; 037 } 038 039 /** 040 * Liefert die Anzahl der Elemente in der Liste. 041 */ 042 public int size() 043 { 044 return size; 045 } 046 047 /** 048 * Liefert das Element an Position pos. Falls kein solches 049 * Element vorhanden ist, wird eine Exception ausgelöst. 050 */ 051 public E elementAt(int pos) 052 { 053 if (pos >= size) { 054 throw new NoSuchElementException(); 055 } 056 return (E)data[pos]; 057 } 058 059 /** 060 * Liefert einen Iterator zum Durchlaufen der Elemente. 061 */ 062 public Iterator<E> iterator() 063 { 064 return new Iterator<E>() 065 { 066 int pos = 0; 067 068 public boolean hasNext() 069 { 070 return pos < size; 071 } 072 public E next() 073 { 074 if (pos >= size) { 075 throw new NoSuchElementException(); 076 } 077 return (E)data[pos++]; 078 } 079 public void remove() 080 { 081 throw new UnsupportedOperationException(); 082 } 083 }; 084 } 085 086 //------------------------------------------ 087 public static void main(String[] args) 088 { 089 //Untypisierte Verwendung 090 MiniListe l1 = new MiniListe(10); 091 l1.addElement(3.14); 092 l1.addElement("world"); 093 for (Object o : l1) { 094 System.out.println(o); 095 } 096 //Ganzzahlige Typisierung 097 System.out.println("---"); 098 MiniListe<Integer> l2 = new MiniListe<Integer>(5); 099 l2.addElement(3); 100 l2.addElement(1); 101 l2.addElement(4); 102 for (Integer i : l2) { 103 System.out.println(i + 1000); 104 } 105 //Verwendung read-only 106 System.out.println("---"); 107 MiniListe<? extends Number> l3 = l2; 108 for (Number i : l3) { 109 System.out.println(i.intValue() + 1000); 110 } 111 } 112 } |
MiniListe.java |
Die Ausgabe des Programms ist:
3.14
world
---
1003
1001
1004
---
1003
1001
1004
Wir wollen uns einige interessante Implementierungsdetails ansehen:
public void addElement(Integer element)
Damit ist die komplette Schnittstelle der Klasse typsicher, und wir können sie wie in den vorigen Abschnitten beschrieben verwenden. Die main-Methode zeigt einige Anwendungen, die nach den bisherigen Ausführungen selbsterklärend sein sollten.
Zu beachten ist, dass in diesem Beispiel »nur« die öffentliche
Schnittstelle der Klasse typsicher ist. Innerhalb der Klasse selbst
ist es nach wie vor möglich, fehlerhaft typisierte Werte in das
Datenarray einzufügen, denn als Object[]
kann es beliebige Objekte aufnehmen. Wir könnten eine solche
Schnittstelle sogar öffentlich machen:
Dieser Code wäre vollkommen korrekt und würde vom Compiler nicht beanstandet werden. Ein Aufruf der Methode in einer MiniListe<Double> würde tatsächlich einen String einfügen, und beim Zugriff auf dieses Element würde es zu einer ClassCastException kommen. »Typsichere« Klassen sind also nur dann wirklich typsicher, wenn die Implementierung sicherstellt, dass keine typfremden Werte gespeichert werden. |
|
Beim Umgang mit typisierten Collections gibt es einige Besonderheiten, die zu einer Verkomplizierung des ursprünglichen Mechanismus geführt haben. Wir wollen zunächst eine einfache Methode betrachten, um uns noch einmal das Zusammenspiel zwischen Ober- und Unterklassen anzusehen (es wurde unter dem Stichwort »Polymorphismus« bereits in den Abschnitten Abschnitt 7.1.6 und Abschnitt 8.4 erläutert):
001 public static void doesWork1() 002 { 003 Double pi = new Double(3.14); 004 Number num = pi; 005 System.out.println(num.toString()); 006 Double pi2 = (Double)num; 007 } |
Zunächst wird eine Double-Variable pi angelegt und mit dem Fließkommawert 3.14 initialisiert. In der nächsten Zeile machen wir uns die Tatsache zunutze, dass Double eine Unterklasse von Number ist und weisen der Variablen der Oberklasse einen Wert der Unterklasse zu. Dies entspricht unserem bisherigen Verständnis von objektorientierter Programmierung, denn ein Double ist eine Number, hat alle Eigenschaften von Number (und ein paar mehr), und kann daher problemlos als Number verwendet werden. Die nächsten beiden Zeilen beweisen, dass diese Annahme korrekt ist (der Inhalt von num ist tatsächlich 3.14) und dass man die Number-Variable auch zurückkonvertieren kann - in Wirklichkeit zeigt sie ja auf ein Double.
Überträgt man das Beispiel auf typisierte Collections, lassen
sich die dahinter stehenden Annahmen nicht ohne weiteres aufrecht
erhalten. Ein Vector<Double>
ist kein Subtyp eines Vector<Number>!
Die folgenden Codezeilen sind illegal und werden vom Compiler abgewiesen:
|
|
Wir wollen uns ansehen, warum das so ist. Wären sie nämlich erlaubt, könnten wir folgende Methode schreiben:
001 public static void doesntWork1() 002 { 003 Vector<Double> vd = new Vector<Double>(); 004 Vector<Number> vn = vd; 005 vn.addElement(new Integer(7)); 006 Double x = vd.elementAt(0); 007 } |
Das Programm erzeugt einen Vector<Double> vd und weist ihn einer Vector<Number>-Variable vn zu. Wäre diese eine Oberklasse von Vector<Double>, könnten wir natürlich auch Integer-Werte in den Vector einfügen wollen. Denn auch Integer ist eine Unterklasse von Number, und mit derselben Berechtigung würden wir annehmen, dass Vector<Number> Oberklasse von Vector<Integer> ist. Dann wäre aber nicht mehr sichergestellt, dass beim Zugriff auf vd nur noch Double-Elemente geliefert werden, denn über den Umweg vn haben wir ja auch ein Integer-Objekt eingefügt. Der Compiler könnte also nicht mehr garantieren, dass die vierte Zeile korrekt ausgeführt wird.
Daraus ist ein folgenschwerer Schluß zu ziehen: Ist U eine Unterklasse von O, so folgt daraus eben nicht, dass auch G<U> eine Unterklasse von G<O> ist. Das ist schwer zu verstehen, denn es widerspricht unseren bisherigen Erfahrungen im Umgang mit Ober- und Unterklassen. Das Problem dabei ist die Veränderlichkeit des Vectors, denn dadurch könnte man über den Alias-Zeiger vn Werte einzufügen, die nicht typkonform sind. Aus diesem Grunde würde der Compiler bereits die zweite Zeile der obigen Methode mit einem Typfehler ablehnen.
Diese neue Typinkompatibilität hat nun einige Konsequenzen, die sich vor allem dort bemerkbar machen, wo wir bisher intuitiv angenommen haben, dass Ober- und Unterklasse zuweisungskompatibel sind. Ein Beispiel ist etwa die Parametrisierung von Methoden, bei der man üblicherweise die formalen Parameter etwas allgemeiner fasst, um die Methode vielseitiger einsetzen zu können.
Betrachten wir eine Methode zur Ausgabe aller Elemente unserer Zahlenliste. Mit etwas Weitblick würde man sie so formulieren:
001 public static void printAllNumbers1(List<Number> numbers) 002 { 003 for (Number s : numbers) { 004 System.out.println(s); 005 } 006 } |
Anstelle eines Vector<Double> verallgemeinern wir auf List<Number>. In der Annahme, dann nicht nur den Inhalt eines Vektors ausgeben zu können, sondern auch den einer ArrayList oder LinkedList (die beide ebenfalls vom Typ List sind), und zwar auch dann, wenn sie nicht Double-, sondern auch Integer- oder Long-Werte enthalten (die ebenfalls vom Typ Number sind). Diese Methode enthält nicht mehr Code als die speziellere Form, ist aber viel universeller einzusetzen. Ergo würde sich ein erfahrener Programmierer normalerweise dafür entscheiden, sie auf diese Weise zu parametrisieren.
Leider hat die Sache einen Haken. Zwar akzeptiert printAllNumbers1 beliebige Collections vom Typ List, aber eben nur, wenn sie Werte vom Typ Number enthalten. Solche mit Double-, Integer- oder Long-Werten lehnt sie - aus den oben beschriebenen Gründen - ab. Der praktische Nutzen dieser Methode ist damit nur mehr gering.
Um mehr Flexibilität zu gewinnen, wurde der Wildcard »?« als Typparameter eingeführt. Wird das Fragezeichen anstelle eines konkreten Elementtyps angegeben, bedeutet dies, das die Collection beliebige Werte enthalten kann:
001 public static void printAllNumbers2(List<?> numbers) 002 { 003 for (Object o: numbers) { 004 System.out.println(o); 005 } 006 } |
An diese Methode können nun Listen mit beliebig typisierten Elementen übergeben werden. Allerdings gibt es beim Lesen der Elemente keine Typsicherheit mehr. Am Kopf der for-Schleife kann man erkennen, dass der Compiler die Listenelemente nun - wie in früheren JDK-Versionen - lediglich als Werte des Typs Object ansieht. Spezielle Eigenschaften sind damit unsichtbar bzw. müssen mit Hilfe einer expliziten Typkonvertierung wieder sichtbar gemacht werden.
Eine abgeschwächte Form des »?«-Wildcards sind die gebundenen Wildcards (»bounded wildcards«). Sie entstehen, wenn nach dem Fragezeichen das Schlüsselwort extends angegeben wird, gefolgt vom Namen des Elementtyps. Dadurch wird ausgedrückt, dass die Collection Elemente der angegebenen Klasse oder einer ihrer Unterklassen enthalten kann:
001 public static void printAllNumbers3(List<? extends Number> numbers) 002 { 003 for (Number s : numbers) { 004 System.out.println(s.doubleValue()); 005 } 006 } |
Gebundene Wildcards realisieren am ehesten die bisher bekannten Regeln von Typkonformität bei Ober- und Unterklassen von Elementen. ? extends O bedeutet, dass die Collection Elemente des Typs O oder einer (auch über mehrere Stufen) daraus abgeleiteten Unterklasse enthalten kann. An einen formalen Parameter vom Typ List<? extends Number> können also aktuelle Parameter des Typs Vector<Double>, ArrayList<Integer> usw. übergeben werden.
Zu beachten ist allerdings, dass die Wildcards nur das Lesen der Elemente flexibilisieren. Das Einfügen neuer Elemente ist dagegen nicht mehr erlaubt und wird vom Compiler unterbunden. Genauer gesagt, verboten ist der Aufruf von Methoden, die mindestens einen generisch typisierten Parameter haben. Die Gründe entsprechen den oben erläuterten. Wäre es nämlich zulässig, in eine List<? extends Number> einen Double einzufügen, so würde ein Problem entstehen, wenn es sich tatsächlich beispielsweise um einen Vector<Integer> handeln würde, denn dieser darf ja kein Element des Typs Double aufnehmen. Daher sorgt der Compiler dafür, dass bei der Verwendung von Wildcards und gebundenen Wildcards die Collection nur noch zum Lesen der Elemente verwendet werden darf. Versuche, neue Elemente einzufügen, werden mit einem Compilerfehler quittiert. |
|
Die Implementierung von generischen Collections ist in Java im Prinzip Sache des Compilers, die virtuelle Maschine merkt davon nichts. Der Compiler interpretiert den Quelltext, prüft die Typ-Parameter und erzeugt Bytecode mit den erforderlichen Typ-Konvertierungen, Warnungen und Fehlermeldungen. Das Laufzeitsystem, die virtuelle Maschine, arbeitet dabei im Grunde wie in früheren JDK-Versionen. Dabei bleibt insbesondere die Integrität der VM stets erhalten und Typfehler führen zu kontrollierten (ClassCast-) Exceptions, wie in früheren JDK-Versionen. Trotz allen Aufwands lassen sich nämlich Fälle konstruieren, bei denen fehlerhaft typisierte Werte in eine generische Collection eingefügt werden. Wir werden dazu im nächsten Abschnitt ein einfaches Beispiel sehen.
Anders als Templates in C++ erzeugen generische Java-Klassen keinen zusätzlichen Programmcode. Alle Instanzen einer generischen Klasse verwenden denselben Bytecode, getClass liefert ein und dasselbe Klassenobjekt und die statischen Variablen werden gemeinsam verwendet. Es ist nicht erlaubt, in statischen Initialisierern oder statischen Methoden auf den Typparameter einer Klasse zuzugreifen, und die Anwendung des instanceof-Operators auf eine typisierte Klasse ist illegal.
Es ist zulässig, generische Collections und herkömmliche
Collections gemeinsam zu verwenden. Erwartet beispielsweise ein Methodenparameter
eine typisierte Collection, so kann auch eine untypisierte Collection
übergeben werden, und umgekehrt. In diesem Fall kann der Compiler
die Typsicherheit des Programmcodes allerdings nicht mehr sicherstellen
und generiert vorsichtshalber eine »unchecked warning« :
Note: Some input files use unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
Durch Rekompilieren mit -Xlint:unchecked werden Detailinformationen ausgegeben. Die Entwickler des JDK gehen davon aus, dass in Zukunft nur noch typisierte Collections verwendet werden, und diese Warnungen nach einer gewissen Übergangszeit der Vergangenheit angehören werden.
Neben den hier beschriebenen Eigenschaften gibt es noch eine ganze Reihe weiterer Aspekte von generischen Klassen, auf die wir hier nicht näher eingehen wollen. Sie werden meist gebraucht, um spezielle Sonderfälle bei der Entwicklung von Collection-Klassen zu realisieren; für »Otto Normalprogrammierer« sind die meisten von ihnen weniger relevant. Die nachfolgende Aufzählung listet einige von ihnen auf, weitere Informationen können der Sprachspezifikation bzw. der Dokumentation der J2SE 5.0 oder 6.0 entnommen werden:
Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 5. Auflage, Addison Wesley, Version 5.0.2 |
<< | < | > | >> | API | © 1998, 2007 Guido Krüger & Thomas Stark, http://www.javabuch.de |