![]() |
|
Interfaces und Collections Nach der einführenden Betrachtung der Interfaces soll der besondere Zusammenhang mit dem Collections Framework verdeutlicht werden. Die in Collections verwendeten Interfaces manipulieren Collections, wenn sie implementiert werden. Besonders attraktiv ist hierbei, dass Collections außerordentlich flexibel sind. Als Menge von Interfaces kann praktisch jede Menge beliebiger Objekte in der Art eines Vektorfeldes (Collection) bzw. einer Hash-Tabelle (Map) bearbeitet werden.
Abb. 3.9: Core-Interfaces des Collections Frameworks Zusätzlich stehen in Collections eine Reihe weiterer Funktionen zur Verfügung. Ist man daher bei der Verwendung von Vektoren bezüglich der an Methoden übergebenen Argumenttypen eingeschränkt, so gewinnt man durch die Verwendung von Collections eine weitgehende Freiheit. Durch diese Vorzüge können Collections unabhängig von Details ihrer Repräsentation verändert werden. Die auch als Core Collection Interfaces bezeichneten Interfaces stehen daher im Collections Framework an zentraler Stelle. Hat der Leser einmal deren Funktion genau verstanden, so sollte auch das Verständnis des Collections Framework keine großen Schwierigkeiten bereiten. Die Struktur der im Folgenden betrachteten Interfaces ist in Abb. 3-9 dargestellt. Ähnlich wie Subklassen formen auch die Core Collection Interfaces eine Hierarchie, die aber aus zwei voneinander unabhängigen Bäumen besteht: Dem Collection-Baum und dem Map-Baum. Hierbei ist zu bemerken, dass Maps keine Collections im eigentlichen Sinne darstellen (sonst wären sie Teil des ersten Baums). Eine Collection stellt grundsätzlich eine Gruppierung beliebiger Objekte dar. Ein davon abgeleitetes Set verhält sich wie eine Menge im mathematischen Sinne (bspw. keine doppelt vorkommenden Elemente), während auf einer Liste stets eine Reihenfolge definiert ist (vergleichbar zur Klasse Vector). Eine Map wird immer dann verwendet, wenn mit Wertepaaren aus Schlüssel und Wert gearbeitet werden soll (vergleichbar mit der Klasse HashTable). Die davon abgeleiteten Interfaces SortedMap und SortedSet ermöglichen es, die Elemente einer Collection bzw. einer Map geordnet zu durchlaufen. Um die Anzahl der Core Collection Interfaces in einem verwaltbaren Rahmen zu halten, wurden im JDK keine separaten Interfaces für jede Variante eines Collection-Typs (bspw. List oder Set) eingerichtet. Anstelle dessen sind die Modifikationsoperationen in jedem Interface optional. Dies impliziert, dass zur Entwicklungszeit einer Anwendung sichergestellt werden muss, dass die notwendigen Methoden implementiert werden. Wird eine Methode aufgerufen, die nicht unterstützt wird, so wird eine Exception (siehe Kapitel 3.4) ausgelöst. Im Folgenden werden die verschiedenen Komponenten, die in Abb. 3-9 aufgezählt sind, näher betrachtet. Das Collection Interface ist die Wurzel der Collection-Hierarchie. Das JDK bietet keine direkte Implementierung dieses Interfaces an, lediglich in Form spezifischer Sub-Interfaces, wie bspw. Set. Das Collection Interface ist daher sozusagen der kleinste gemeinsame Nenner, den alle Collections implementieren. Nach Konvention verwenden alle allgemeinen Implementierungen von Collections (bspw. Implementierungen der Sub-Interfaces Set oder List) einen Konstruktor, der ein Argument vom Typ Collection erwartet, und der eine neue Collection derart initialisiert, dass alle Elemente geeignet vorhanden sind. Das Collection-Interface hat folgende Syntax:
public interface Collection { // Basisoperationen // Sammeloperationen // Listenoperationen } Das derart definierte Interface beinhaltet
Das von der Das Iterator-Interface sieht wie folgt aus: boolean hasNext(); } Die Methode remove entfernt das letzte Element einer Collection, das von next zurückgeliefert wurde. Es ist daher offensichtlich, dass pro Aufruf von next nur ein remove-Aufruf erfolgen darf. Iterator.remove sollte immer zur Modifikation einer Collection während einer Iteration eingesetzt werden, da es die einzige Methode ist, mit der eine sauber definierte Veränderung einer Collection in diesem Zusammenhang durchgeführt werden kann. Das folgende Code-Stück macht deutlich, wie remove in einem iterator funktioniert. Hierbei wird zusätzlich eine Bedingung geprüft, bei deren Nicht-Erfüllung das jeweilige Element gelöscht wird:
static void beispiel (Collection c) { for (Iterator i = c.iterator(); i.hasNext(); ) if (! Bedingung (i.next())) i.remove(); } Das Beispiel ist polymorph: Es funktioniert unabhängig von der Implementierung für jede Collection, die das Löschen von Elementen unterstützt. Die in den Collections anschließend definierten Sammeloperationen führen in einer einzigen Operation Verarbeitungsschritte durch, die sich auf die gesamte Collection auswirken. Java stellt die folgenden Sammeloperationen zur Verfügung:
Diese Funktion überprüft, ob die Ziel-Collection alle Elemente enthält, die auch die als Argument übergebene Collection enthält. addAll: Diese Funktion fügt alle Elemente der im Argument spezifizierten Collection der Ziel-Collection hinzu. removeAll: Diese Funktion löscht aus der Ziel-Collection alle Elemente, die auch in der als Argument angegebenen Collection enthalten sind. retainAll: Diese Funktion löscht aus der Ziel-Collection alle Elemente, die nicht ebenfalls in der als Argument angegebenen Collection enthalten sind. clear: Löscht alle Elemente einer Collection. Der Aufbau der Collections wurde bis hierher sehr detailliert erläutert. Weiterer Inhalt von Abb. 3-9 sind aber auch Sets, Listen und Maps. Sets Ein Set ist eine spezielle Collection, die kein Elemente doppelt enthalten kann. Die Liste der Karten eines Kartenspiels könnte daher bspw. als Set gespeichert werden, da in einem Kartenspiel eine Karte nur dann doppelt vorkommt, wenn Betrug im Spiel ist. Das hier verwendete Modell entspricht daher dem Begriff der Menge im mathematischen Sinne. Betrachtet man die im Folgenden angegebene Interface-Definition eines Sets, so ist es kaum überraschend, dass alle Methoden vom Eltern-Interface Collection geerbt werden. Ein Set fügt hier die Einschränkung hinzu, dass alle Elemente nur einmal vorkommen dürfen.
public interface Set { // Basisoperationen // Sammeloperationen // Listenoperationen } List Im Gegensatz zu einem Set ist eine Liste eine geordnete Collection, in der Elemente durchaus doppelt vorkommen können, bspw. Artikel in einer alphabetisch geordneten Inventurliste. Der Benutzer einer Liste hat im Allgemeinen auch eine präzise Kontrolle darüber, wo jedes Element in eine Liste eingefügt wird. Auf Listenelemente wird dann mit einem Positionsindex, wie auch bei Arrays, zugegriffen. Es ist offensichtlich, dass der Interface-Typ List neben den Methoden, die er vom Typ Collection erbt, noch weitere enthalten muss, beispielsweise:
Die Interface-Definition des Objekts
public interface List extends Collection { // Positionszugriffe // Suche // Iteration // Sublisten } Eine Besonderheit dieser Definition findet sich im ListIterator-Objekt, das neben der Standardaufgabe der Iteratoren, die Elemente einer Liste in der richtigen Reihenfolge zu durchlaufen, noch weitergehende Operationen ermöglicht, beispielsweise:
Die drei Methoden, die
for (ListIterator i=l.listIterator(l.getSize()); i.hasPrevious(); ) { ... } Im obigen Code-Beispiel ist insbesondere die Argumentübergabe an das listIterator-Objekt interessant, da innerhalb des List-Interfaces zwei Arten der listIterator-Methode definiert sind. Werden keine Argumente angegeben, so liefert die Methode ein ListIterator-Objekt zurück, das an den Anfang der Liste positioniert wurde. Die Positionierung bezeichnet man in diesem Kontext auch als Cursor-Positionierung. Wird aber ein Index als Argument übergeben, so wird ein ListIterator zurückgegeben, der auf diesen Index positioniert wurde. Dieser Index bezieht sich wiederum auf das Element, das zurückgegeben werden müsste, wenn ein erster Aufruf der next-Methode erfolgen würde. Es ist leicht verständlich, dass ein Aufruf der next-Methode genau dann Null zurückliefert, wenn dieser Index auf n gesetzt wurde, also der listIterator bereits auf das Ende der Liste zeigen würde. Ebenso würde ein Aufruf der previous-Methode -1 zurückgeben, wenn der Index auf 0 und damit auf den Listenanfang gesetzt wurde.
Abb. 3.10: Cursor-Positionen in Listen Die beschriebene Methodik der Cursor-Positionierung kann nun auch so aufgefasst werden, als ob der Cursor immer zwischen zwei Elementen stehen würde: Dem Element, auf das durch die previous-Methode zugegriffen werden kann und dem, das durch die next-Methode referenziert würde. Die (n+1) Indexwerte (von 0 bis n) entsprechen daher den (n+1) Lücken zwischen den Elementen (siehe Abb. 3-10). Aufrufe von next und previous können auch gemischt verwendet werden, wenn die nötige Vorsicht angewendet wird. Der erste Aufruf von previous nach einer Folge von next-Aufrufen liefert so dasselbe Element zurück wie der letzte Aufruf von next. Analog liefert der erste next-Aufruf nach einer Sequenz von previous-Aufrufen dasselbe Element zurück wie der letzte previous-Aufruf. Um die gemischte Verwendung von next und previous zu erleichtern, können auch nextIndex (liefert den Index des Elements zurück, auf das im nächsten next-Aufruf zugegriffen werden soll) und previousIndex (analog den Index des Elements, auf das im nächsten previous-Aufruf zugegriffen werden soll) verwendet werden. Man wendet diese beiden Methoden meist an, um entweder die Position, an der ein Element gefunden wurde, zu dokumentieren, oder um die Position des List-Iterator darzustellen, so dass ein weiterer ListIterator mit identischer Position erzeugt werden kann. In diesem Zusammenhang überrascht auch nicht, dass der Wert, der von nextIndex als Ergebnis zurückgeliefert wird, immer um eins größer ist als der von previousIndex zurückgelieferte. Betrachtet man die beiden Grenzindizes 0 und n, so versteht man dieses Verhalten besser: Ein Aufruf von previousIndex liefert dann den Wert (-1) zurück, wenn der Cursor vor dem ersten Element steht, ein Aufruf von nextIndex aber list.getSize()+1, wenn der Cursor hinter dem letzten Element steht. Die folgende Beispielimplementierung verdeutlicht diesen Sachverhalt:
public int indexOf(Object o) { for (ListIterator i = listIterator(); i.hasNext(); ) if (o==null ? i.next()==null : o.equals(i.next())) return i.previousIndex(); return -1; // Object not found } Anhand dieses Beispiels erkennt der Leser sehr schnell, wie beliebig komplex selbst kleinste Java-Programme werden können. Deshalb soll hier nochmals Zeile für Zeile erläutert werden. Aus der Signaturdefinition in Zeile 1 geht hervor, dass die Methode indefOf den Index herausfinden soll, an dem das Objekt o, das als Argument übergeben wird, steht. Hierzu wird in einer Iteration die Liste der Objekte ab Zeile 2 durchlaufen. Es ist hierbei zu beachten, dass die Zählschleife nicht selber für das Erhöhen der Zählervariablen verantwortlich ist. In der Definition in Zeile 2 wird daher nur geprüft, ob noch weitere Elemente vorhanden sind. Das eigentliche Weiterschalten erfolgt in Zeile 3, die in Pseudo-Code folgendermaßen aussehen würde:
Wenn (Objekt == Null) Wenn (i.next() == Null) // Schleife beendet Sonst Wenn o.equals (i.next()) // Gleiches Objekt folgt Sonst nächster Schleifendurchlauf Es ist weiterhin zu beachten, dass die Methode indexOf das Ergebnis i.previousIndex() zurückliefert, obwohl die Liste in Vorwärts-Richtung durchlaufen wird. Dies liegt daran, dass i.nextIndex() den Index des Elements, das gerade untersucht wird, zurückliefern würde, nicht aber den gewünschten Index desjenigen Elements, das bereits untersucht wurde. Während das Iterator-Interface die remove-Methode anbietet, um aus einer Collection das Element zu löschen, das die next-Methode zurückliefert, werden im ListIterator-Interface zwei weitere Operationen zur Modifikation von Listen angeboten: set und add. Die set-Methode überschreibt das letzte Element, das von next oder previous geliefert wurde, mit einem angegebenen Element. Möchte man bspw. alle Vorkommen eines bestimmten Wertes in einer Liste durch einen anderen ersetzen, so kann folgender (polymorpher) Code verwendet werden:
public static void ersetze (List l, Object wert, Object neuerWert) { for (ListIterator i = l.listIterator(); i.hasNext(); ) if (wert==null ? i.next()==null : wert.equals(i.next())) i.set(neuerWert); } Mittels der add-Methode wird ein neues Element in die Liste unmittelbar vor der aktuellen Position des Cursors eingefügt. Im folgenden Beispiel werden alle Vorkommen eines bestimmten Wertes mit einer Wertefolge aus einer Liste ersetzt:
public static void ersetze (List l, Object wert, List neueWerte) { for (ListIterator i = l.listIterator(); i.hasNext(); ) { if (wert==null ? i.next()==null : wert.equals(i.next())) { i.remove(); for (Iterator j = neueWerte.iterator(); j.hasNext(); ) i.add(j.next()); } } }
Abb. 3.11: Core-Interfaces des Collections Frameworks
Maps Zur besseren Übersicht sei nun nochmals Abb. 3-9 dargestellt. Während der linke Baum, die Collections, bereits detailliert erklärt wurden, folgt nun die notwendige Erläuterung der Maps (siehe Abb. 3-11). Eine Map ist ein Objekt aus Wertepaaren (Schlüssel, Wert), ähnlich einer Hash-Tabelle. Die Schlüssel in einer Map müssen eindeutig sein, da jeder Schlüssel auf höchstens einen Wert abgebildet werden soll. Derartige Abbildungen werden häufig im Security-Bereich verwendet, bspw. im Zusammenhang mit Hash-Tabellen [FBSS98]. Das Map-Interface hat folgendes Aussehen:
public interface Map { // Basisoperationen // Sammeloperationen // Sammelansichten // Interface für Eintragselemente Object getKey(); } } Zum Verständnis der Funktionsweise von Maps sei folgendes Beispiel angegeben, das die Häufigkeit der Worte zählt, die auf der Kommandozeilenebene als Argumente übergeben werden. Hierbei sei insbesondere auf die Ähnlichkeit mit Hash-Tabellen hingewiesen.
import java.util.*; private static final Integer EINS = new Integer(1); Map m = new HashMap(); Integer frequenz = (Integer) m.get(args[i]); } System.out.println(m.getSize()+" unterschiedliche Worte entdeckt:"); } } In diesem Beispiel wird eine HashMap erzeugt. Die eigentliche Funktion wird in der for-Schleife geleistet, in deren put-Ausdruck ein Bedingungsausdruck verwendet wird. Wenn ein Wort noch nicht erfasst wurde, wird hier die Frequenz auf eins gesetzt, anderenfalls um eins erhöht. Ruft man das Programm mit der Anweisung
java Frequenz es ist wie es ist weil es ist auf, so wird die Ausgabe
4 worte entdeckt:{es=3, ist=3, wie=1, weil=1} erzeugt. Möchte man zusätzlich die Häufigkeiten in alphabetischer Reihenfolge sehen, so muss der Implementierungstyp von HashMap auf TreeMap geändert werden. Das JDK enthält hierzu zwei neue allgemeine Map-Implementierungen:
Es ergibt sich dann die folgende sortierte Ausgabe, wenn eine
4 worte entdeckt:{es=3, ist=3, weil=1, wie=1} Betrachtet man die Sammeloperationen der Interface-Definition näher, so
static Map neueAttributeMap(Map standards, Map ueberschreiben) { Map resultat = new HashMap(standards); } Maps können durch Sammelansichten in den folgenden drei Sichten (Views) betrachtet werden:
Mittels dieser Ansichten sind Iterationen in Maps realisierbar. Der Standardaufbau einer Iteration über die Schlüssel einer Map ist im folgenden Code-Beispiel angegeben: System.out.println(i.next()); Analog dazu lautet die Schleife zur Iteration über Schlüssel-Werte-Paare:
for (Iterator i=m.entrySet().iterator(); i.hasNext(); ) { Map.Entry e = (Map.Entry) i.next(); } Kombiniert man Collection-Ansichten mit Sammeloperationen (containsAll, removeAll und retainAll), so ergibt sich eine Vielzahl an überraschenden Möglichkeiten. Möchte ein Anwender bspw. wissen, ob eine Map vollständig in einer anderen enthalten ist, ob also eine Map alle Schlüssel-Werte-Paare einer anderen enthält, so kann dies mit einer einzigen Zeile in Erfahrung gebracht werden:
if (map1.entrySet().containsAll(map2.entrySet())) { ... } Analog prüft das folgende Segment, ob zwei Maps Abbildungen für dieselben Schlüssel enthalten:
if (map1.keySet().equals(map2.keySet())) { ... } Es ist zu beachten, dass Java sog. Multimaps nicht unterstützt. Eine Multimap kann neben den Fähigkeiten einer traditionellen Map auch jeden Schlüssel auf mehrere Werte abbilden. Das Collections Framework beinhaltet hierfür kein Interface. Es ist aber relativ einfach, einen derartigen Mechanismus zu schaffen, bspw. wenn eine Map verwendet wird, deren Werte wiederum List-Objekte sind. Um die letzten in Abb. 3-11 noch offenen Punkte, SortedSet und SortedMap, verstehen zu können, muss eine Einführung in die Mechanismen erfolgen, die zum Sortieren von Objekten notwendig sind. Sortieroperationen Generell kann eine Liste l mit dem Kommando Collections.sort(l); sortiert werden. Sind in der Liste Zeichenketten enthalten, so werden diese in alphabetischer Reihenfolge sortiert. Datumselemente werden chronologisch, numerische Elemente aufsteigend sortiert. Um zu wissen, wie eine derartige Sortierung funktionieren soll, müssen die Typen String oder Date das Comparable-Interface implementieren, das die natürliche Ordnung von Klassen festlegt und damit die Sortierbarkeit der Objekte realisiert. In Tab. 3-10 ist angegeben, welche Klassen des JDKs das Interface Comparable implementieren. Es ist offensichtlich, dass Sortierversuche auf Listen, deren Elemente Comparable nicht implementieren, in einer Fehlermeldung resultieren, die durch Collections.sort(list) ausgelöst wird. Analog wird ein Fehler ausgelöst, wenn Listen sortiert werden, deren Elemente aufgrund eines unterschiedlichen Typs nicht miteinander verglichen werden können. Element, die miteinander verglichen werden können, werden in Java auch als gegenseitig vergleichbar bezeichnet. Es ist möglich, dass verschiedene Datentypen gegenseitig vergleichbar sind. Dies gilt aber nicht für die in Tab. 3-10 angegebenen Basistypen. Hierzu sind eigene Comparable-Typen zu implementieren. |
|
|
Tab. 3.10: Ordnungskriterien von Datentypen Nachdem nun sowohl die Funktionsweise von Collections und Maps als auch die Sortierung mittels des Comparable-Objekts besprochen wurden, sind die zur Erklärung von SortedSet und SortedMap erforderlichen Kenntnisse vorhanden. SortedSets Ein SortedSet ist eine Menge, die Elemente in aufsteigender Reihenfolge enthält, wobei die Reihenfolge nach der natürlichen Ordnung der Elemente (siehe Tab. 3-10) oder über ein Vergleichskriterium definiert ist. Zusätzlich zu den bereits in Set definierten Operationen können in einem SortedSet
Die Methoden, die SortedMaps Eine SortedMap ist eine Map, die ihre Einträge in aufsteigender Reihenfolge verwaltet. Ähnlich wie auch bei Sets wird die Reihenfolge über die natürliche Ordnung der Schlüssel oder über ein Vergleichskriterium, das zur Erstellungszeit der SortedMap zur Verfügung stehen muss, hergestellt. Zusätzlich zu normalen Map-Operationen kann eine SortedMap:
Die Methoden, die Das Implementierungen von Collections Wie bereits beschrieben, sind Interfaces und damit auch das Collections Framework abstrakte Container, deren genaues Verhalten noch implementiert werden muss. Implementierungen sind daher die Datenobjekte, die Collections speichern und die die in den bisherigen Abschnitten beschriebenen Core Collection Interfaces implementieren. Im Folgenden werden drei Typen von Implementierungen näher betrachtet:
Allgemeine Implementierungen Bisher wurden eine Reihe von Standardimplementierungen angesprochen, ohne diese näher zu erklären. Tab. 3-11 bietet einen Überblick, welche Implementierungen für welche Interfaces zur Verfügung stehen. |
|
|
Tab. 3.11: Implementierungen von Collection-Interfaces JDK 1.2 bietet mit Ausnahme des Eltern-Interfaces Collection für jedes Interface zwei Implementierungen an. Beispiele hierfür sind HashSet, ArrayList und HashMap. Die Interfaces für SortedSet und SortedMap sind hierbei nicht erfasst, da jedes dieser Interfaces genau eine Implementierung hat (TreeSet und TreeMap), die wiederum in der Tabelle enthalten sind. Die zwei Set-Implementierungen sind HashSet und TreeSet. HashSet ist erheblich schneller, bietet aber keine Reihenfolgegarantien an. Wenn bspw. Operationen eines SortedSet benötigt werden oder eine Iteration in der richtigen Reihenfolge, so muss TreeSet verwendet werden, anderenfalls aber HashSet. Die zwei List-Implementierungen sind ArrayList und LinkedList. Es bietet sich meist an, ArrayList zu verwenden, da diese Implementierung einen Zugriff in konstanter Zeit anbietet und nicht für jedes Listenelement Speicher für ein Knotenobjekt belegt. Wenn sehr häufig Elemente am Anfang der Liste hinzugefügt werden oder Listeniterationen stattfinden, die Elemente aus dem Inneren der Liste löschen, so sollte die LinkedList-Implementierung verwendet werden, da derartige Operationen in einer LinkedList in konstanter Zeit, in einer ArrayList aber nur in linearer Zeit durchgeführt werden können. Umgekehrt aber ist der Positionszugriff in einer LinkedList nur in linearer Zeit möglich (im Gegensatz zur konstanten Zeit in einer ArrayList). Der konstante Faktor ist weiterhin in einer LinkedList erheblich größer als in einer ArrayList. Die zwei List-Implementierungen sind HashMap und TreeMap. Die Entscheidung für HashMap oder TreeMap ist vollkommen analog zu einem Set. Werden SortedMap-Operationen oder eine Iteration über eine Collection-Ansicht in der entsprechenden Reihenfolge benötigt, so ist eine TreeMap zu verwenden, anderenfalls eine HashMap. Wrapper-Implementierungen Wrapper-Implementierungen delegieren ihre eigentliche Arbeit an eine spezifizierte Collection und fügen dieser eine erweiterte Funktionalität hinzu. Betrachtet man dieses Vorgehen im Kontext von Design Patterns (hierzu siehe auch Kapitel 2.3), so entspricht dieses Vorgehen der Verwendung eines Decorator Patterns. Derartige Implementierungen verwenden meist keine als public deklarierte Klasse, sondern eine statische Methode. Die Implementierungen können in der Collections API nachgeschlagen werden, die ausschließlich aus statischen Methoden besteht. Im Folgenden werden einige wichtige Anwendungsgebiete von Wrappern betrachtet. Aufgabe der Synchronisations-Wrapper ist es, einer beliebigen Collection eine automatische Synchronisation hinzuzufügen. Für jedes der sechs Core Collection Interfaces besteht eine derartige Synchronisierungsdefinition:
Jede dieser Methoden gibt nach dem Aufruf eine synchronisierte Collection zurück, die die zugrunde liegende (unsynchronisierte) Collection erweitert. Ein definierter serieller Zugriff auf die Daten der Collection kann aber nur garantiert werden, wenn alle Zugriffe auf die zugrunde liegende Collection über die synchronisierte Collection erfolgen. Dies kann sehr einfach erreicht werden, indem man grundsätzlich nicht mit der Referenz auf die zugrunde liegende Collection arbeitet. Die folgende Programmzeile illustriert, wie diese Versuchung vermieden werden kann:
List liste = Collections.synchronizedList(new ArrayList()); Finden konkurrierende Zugriffe auf eine derartige Collection statt, so muss die Synchronisation der Collection dann manuell durchgeführt werden, wenn Iterationen auf der Collection durchgeführt werden. Dies liegt daran, dass während der Iteration mehrfache Aufrufe der Collection stattfinden, die zwangsläufig zu Problemen führen, wenn mehrere Prozesse gleichzeitig auf die Daten zugreifen. Die Lösung dieses Problems besteht darin, diese Zugriffe in einer einzigen atomaren Operation durchzuführen. Dieser Ratschlag sollte in jedem Fall befolgt werden, da anderenfalls ein nichtdeterministisches Verhalten zu erwarten ist. Das folgende Programmsegment zeigt beispielhaft die Iteration in einer Collection, die mit einem Wrapper synchronisiert wird.
Collection c = Collections.synchronizedCollection(eigeneCollection); Iterator i = c.iterator(); //Operation(i.next()); } Soll eine Iteration über eine Sicht einer Collection mittels einer synchronisierten Map durchgeführt werden, so kann ein ähnlicher Ablauf verwendet werden. Hierzu muss die synchronisierte Map manuell synchronisiert werden, wenn über eine der Collection-Ansichten iteriert wird. Die Synchronisation darf daher nicht über die Ansicht der Collection selbst erfolgen:
Map m = Collections.synchronizedMap(new HashMap()); Iterator i = s.iterator(); //Operation(i.next()); } Wird der Wrapper-Implementierungsansatz verwendet, so besteht keine Möglichkeit, Operationen einer Wrapper-Implementierung auszuführen, die nicht Teil eines Interfaces sind. Ein Konzept, das mit den synchronisierten Wrappern vergleichbar ist, ist der Ansatz der nichtmodifizierbaren Wrapper. Anstelle einer Erweiterung der Funktionalität der Collection durch einen Wrapper wird die Collection durch einen nichtmodifizierbaren Wrapper in ihrem Funktionsumfang eingeschränkt. Dies betrifft speziell die Fähigkeit, die Collection zu modifizieren, indem alle Operationen nicht mehr verwendet werden dürfen, die die Collection modifizieren würden. Wird eine Änderung einer Collection trotzdem versucht, so wird eine Fehlerroutine aufgerufen. Nichtmodifizierbare Wrapper werden in folgenden Fällen eingesetzt:
Wie bereits die Synchronisations-Wrapper kann auch diese Wrapper-Art die folgenden Methodendefinitionen für jedes der sechs Core Collection Interfaces verwenden:
Spezialimplementierungen Machmal ist es komfortabler und effizienter, verschiedene Implementierungen anstelle der Standardimplementierungen der Collections zu verwenden, bspw. wenn nicht der gesamte Umfang der Standard-Implementierungen verwendet werden soll. Alle Implementierungen, die in diesem Abschnitt beschrieben werden, verwenden statische Methodendeklarationen anstelle von Klassen, die als public deklariert werden. Verwendet man bspw. die Arrays.asList-Methode, die eine Listensicht des als Argument verwendeten Arrays erzeugt, so wirken sich Änderungen der Liste auf den Array aus und umgekehrt. Die Größe der so erzeugten Collection entspricht der des Arrays und kann nicht verändert werden, da Array-Größen stets statisch sind. Aus diesem Grund resultieren auch aus dem Aufruf der Methoden add oder remove Fehlermeldungen. Normalerweise wird eine derartige Implementierung dann verwendet, wenn eine Brücke zwischen Array-basierten und Collection-basierten APIs geschlagen werden soll. Hierdurch kann ein Array als Argument an eine Methode übergeben werden, die eine Collection oder eine List erwartet. Diese Implementierungsform wird aber auch dann eingesetzt, wenn eine Liste fester Größe verwendet werden soll, anstelle der üblichen Form, in der die Listengröße variabel ist. Das folgende Programmsegment illustriert die Verwendung der Spezialimplementierung. Es sollte beachtet werden, dass eine Referenz zum zugrunde liegenden Array nicht zur Verfügung gestellt wird.
List liste = Arrays.asList(new Object[size]); Machmal ist es sinnvoll, eine unveränderbare Liste zu verwenden, die aus mehrfachen Kopien desselben Elements besteht. Eine derartige Liste wird durch die Methode Collections.nCopies erzeugt. Das Einsatzgebiet dieser Implementierung liegt meist in der Initialisierung einer neu erzeugten Liste. Soll bspw. eine Liste mit 1000 Nullen initialisiert werden, so kann die folgende Anweisung verwendet werden:
List liste = new ArrayList(Collections.nCopies(1000, null)); Das zweite Anwendungsgebiet besteht in der Erweiterung einer bereits bestehenden Liste. Möchte man bspw. 1000 Kopien der Zeichenkette "Hallo" an das Ende einer Liste anfügen, so kann dies durch die folgende Anweisung erreicht werden:
liste.addAll(Collections.nCopies(1000, "Hallo")); Verwendet man die Methode addAll, die als Argument sowohl einen Index als auch eine Collection akzeptiert, so können Elemente auch in der Mitte einer Liste eingefügt werden. In manchen Fällen muss ein unveränderbares Set verwendet werden, das lediglich aus einem einzigen Element besteht. Hierzu kann die Methode Collections.singleton verwendet werden, mit der bspw. folgendermaßen alle Vorkommen eines spezifizierten Elements aus einer Collection gelöscht werden können:
collection_1.removeAll(Collections.singleton(element)); Dieselbe Aufgabe kann auch in einer Map durchgeführt werden, in der alle Elemente eines bestimmten Werts gelöscht werden sollen. Betrachtet man eine Map, in der Zulieferer auf Produkte abgebildet werden, so kann es durchaus vorkommen, dass nach Ausfall eines Zulieferers alle Vorkommen eines Produkts gelöscht werden sollen. Die folgende Programmzeile erfüllt diese Aufgabe:
zulieferer.values().removeAll(Collections.singleton(MULL ER)); Ein weiteres Anwendungsgebiet dieser Implementierung besteht in der Generierung eines einzelnen Eingabewerts, der an eine Methode übergeben wird, die lediglich eine Collection von Werten akzeptiert. Die Collections-Klasse definiert zwei Konstanten, die jeweils das leere Set bzw. die leere Liste repräsentieren, Collections.EMPTY_SET und Collections.EMPTY_LIST. Diese Konstanten werden hauptsächlich dann verwendet, wenn Argumente an Methoden übergeben werden sollen, die ausschließlich eine Collection von Werten akzeptieren, wenn der Benutzer allerdings keine Werte übergeben will. Benutzerdefinierte Implementierungen von Collections Obwohl die Implementierungen, die bisher beschrieben wurden, ausreichen, um sehr komplexe Anwendungen zu entwickeln, sollte doch der Entwickler die Möglichkeit haben, eigene Collections-Klassen zu implementieren. Dies betrifft speziell die Implementierung der Core Collection Interfaces. In diesem Abschnitt wird erläutert, wie hierzu vorzugehen ist. Vor der Erklärung der Richtlinien für die Implementierung muss aber zunächst die Nützlichkeit eines derartigen Vorgehens begründet werden:
Kennzeichen aller Implementierungen von Collections, die Teil von Java sind, ist ihre Speicherung im Hauptspeicher. Wird die Virtual Machine (VM) verlassen, so werden diese Implementierungen gelöscht. Soll bspw. eine Collection erzeugt werden, die beim nächsten Aufruf der VM präsent ist, so können die Daten in einer externen Datenbank gespeichert werden. Da derart gespeicherte Daten außerhalb der VM angesiedelt sind, können auch konkurrierende Zugriffe durch mehrere VMs erfolgen. Werden die abstrakten Implementierungen verwendet, die Teil der Java-Plattform sind, so ist das Schreiben eigener Implementierungen keine komplexe Aufgabe. Abstrakte Implementierungen sind Grundgerüste der Core Collection Interfaces, die speziell zur Entwicklung eigener Implementierungen entwickelt wurden. Zu Beginn soll ein Beispiel angeführt werden, das die Implementierung von Arrays.asList darstellt.
public static List asList(Object[] a) { return new ArrayList(a); } private Object[] a; a = array; } return a[index]; } Object oldValue = a[index]; } return a.length; } } Die eigene Implementierung von ArrayList bzw. asList stimmt fast vollständig mit der Implementierung überein, die Teil des JDK ist. Hierzu wurden der Konstruktor und die Methoden get, set und getSize implementiert, AbstractList bietet die noch fehlende Funktionalität (ListIterator, Sammel- und Suchoperationen, Berechnung der Hash-Codes, Vergleiche und String-Repräsentation) an. Es muss aber darauf hingewiesen werden, dass die Verwendung anderer abstrakter Implementierungen etwas komplexer ist, da eigene Iterationen implementiert werden müssen. In Java stehen die folgenden abstrakten Implementierungen zur Verfügung:
Eine Collection, die weder Set noch Liste ist, bspw. eine Multimap. Minimale Anforderung ist die Implementierung des Iterators und der getSize-Methode. AbstractSet: Ein Set, das ansonsten analog zu AbstractCollection zu behandeln ist. AbstractList: Eine Liste, die auf einen Datenspeicher wahlfrei zugreift (bspw. ein Array). Minimale Anforderung ist die Implementierung der Methode getSize und der positionsabhängigen Zugriffsmethoden get(int) und optional set(int), remove(int) und add(int). Die abstrakte Klasse realisiert die Funktionalität des listIterator (und damit auch von iterator). AbstractSequentialList: Eine List, die auf einen Datenspeicher wahlfrei zugreift (bspw. eine gelinkte Liste). Minimale Anforderung ist die Implementierung des listIterator und der getSize-Methode. Die abstrakte Klasse übernimmt die Realisierung der positionsabhängigen Zugriffsmethoden. AbstractSequentialList ist somit das Gegenteil von AbstractList. AbstractMap: Eine Map, für die minimal die entrySet-Ansicht implementiert werden muss. Typischerweise implementiert man diese mit der AbstractSet-Klasse. Wenn die Map modifizierbar sein soll, muss zusätzlich die put-Methode spezifiziert werden. Die einzelnen Schritte zur eigenen Implementierung sind zusammengefasst:
Möchte man die Systemleistung verbessern, so sollte die Dokumentation der abstrakten Implementierungsklasse zu Rate gezogen werden. Dies betrifft alle Methoden, deren Implementierung vererbt werden. Sollte eine dieser Methoden die Implementierung verlangsamen, so muss diese überschrieben werden. Hierbei ist große Vorsicht angebracht. Meist wird daher der vierte Schritt ausgelassen. Interoperabilität Zum Abschluss der Erläuterung der Collections wird noch der Aspekt API-Design betrachtet. Hierzu werden Richtlinien vorgestellt, die es eigenen APIs erlauben, mit anderen APIs ohne Probleme zusammenzuarbeiten, die ebenfalls diese Richtlinien befolgen. Wenn ein API eine Methode enthält, die als Eingabe eine Collection erwartet, so ist es von essentieller Bedeutung, dass der relevante Parametertyp einer der Interface-Typen der Collection ist. Ein Implementierungstyp sollte hier niemals zum Einsatz kommen, da dieses Vorgehen das absolute Gegenteil der Grundidee des Interface-basierten Collections Frameworks ist, welches es Collections erlaubt, ohne Rücksicht auf Implementierungsdetails verändert zu werden. Weiterhin sollte immer der Typ verwendet werden, der (soweit zulässig) am allgemeinsten ist. Es sollte daher weder eine List noch ein Set verwendet werden, wenn eine Collection bereits ausreichen würde. Dies bedeutet allerdings nicht, dass niemals eine Liste oder ein Set als Eingabe verwendet werden sollte. Das Vorgehen ist durchaus korrekt, wenn eine Methode von einigen Eigenschaften dieser beiden Interfaces abhängt. Viele der von der Java-Plattform angebotenen Algorithmen erfordern eine Liste als Eingabe, da sie davon abhängen, dass Listen Daten geordnet verwalten. Generelle Regel ist aber immer, dass die besten Eingabetypen auch gleichzeitig die allgemeinsten sind, also Map oder Collection. Unbedingt zu beachten ist, dass niemals eine eigene Collection-Klasse definiert werden sollte und anschließend Objekte dieser Klasse als Eingabe erwartet werden. Ein derartiges Vorgehen eliminiert umgehend alle Vorteile, die das Collections Framework bietet. Rückgabewerte können im Allgemeinen wesentlich flexibler gehandhabt werden als Eingabeparameter. So ist es durchaus in Ordnung, ein Objekt eines beliebigen Typs zurückzugeben, das eines der Collections-Interfaces implementiert oder erweitert. Im Gegensatz zu Eingabeparametern sollte ein Rückgabewert immer das Collections-Interface zurückgeben, das am speziellsten ist. Ist es bspw. sicher, dass eine Map, die von einer Methode als Resultat geliefert wird, auch immer eine SortedMap ist, so sollte eher der Rückgabetyp SortedMap anstelle von Map verwendet werden. SortedMap-Objekte bieten zwar weitergehende Funktionen als Maps an, benötigen aber auch eine längere Initialisierungszeit. Wenn also bereits eine SortedMap erzeugt wurde, so ist es sinnvoll, dem Benutzer auch die Möglichkeit einzuräumen, die weitergehenden Funktionen auszunutzen. Weiterhin ist es dann auch möglich, das zurückgegebene Objekt an Methoden zu übergeben, die eine SortedMap erwarten, wie auch an solche, die allgemein nur eine Map erwarten. Nochmals sollte darauf hingewiesen werden, dass niemals eine eigene Collection-Klasse definiert werden sollte und Objekte dieser Klasse als Rückgabewerte geliefert werden sollten. Hierdurch verliert man alle Vorteile des Collections Frameworks. Heutzutage findet man eine Vielzahl von APIs, die eigene Collection-Typen definieren. Dies ist zwar unglücklich, aber kaum zu umgehen, da in den ersten zwei Ausgaben von Java (JDK 1.0.1 und JDK 1.1) kein Collections Framework existierte. Wird ein derartiges API verwendet, so sollte versucht werden, dieses so umzuschreiben, dass es eines der Standard-Collections-Interfaces implementiert. In diesem Fall werden alle Collections, die zurückgegeben werden, mit anderen Collection-basierten APIs zusammenarbeiten. Sollte dies unmöglich sein, so sollte eine Adapterklasse definiert werden, die die eigenen Collection-Objekte derart abbildet, dass sie wie eine Standard-Collection funktionieren. Meist ist ein Umschreiben dann sinnlos, wenn bereits existierende Typsignaturen mit denen der Standard-Collections-Interfaces in Konflikt stehen. Eine Adapterklasse, die die Anpassung vornimmt, ist ein gutes Beispiel für die oben beschriebenen eigenen Implementierungen. Ein eigenes API sollte weiterhin um neue Aufrufe erweitert werden, die den Eingaberichtlinien derart folgen, dass Objekte eines Standard-Collection-Interfaces akzeptiert werden können. Derartige Aufrufe können durchaus mit bereits existierenden Aufrufen der eigenen Collection koexistieren. Sollte dies unmöglich sein, so sollte ein Konstruktor oder eine statische Methode für den eigenen Typ bereitgestellt werden, die ein Objekt eines der Standard-Interfaces entgegennimmt und eine eigene Collection zurückgibt, die dasselbe Element (oder dieselben Abbildungen) enthält. Jeder dieser Ansätze erlaubt es Benutzern, beliebige Collections an das eigene API zu übergeben. Packages Im Laufe dieses Kapitels war bereits mehrfach die Rede von Packages, ohne diesen Begriff detailliert zu erläutern. Ein Package stellt eine Möglichkeit dar, inhaltlich zusammengehörige Komponenten zu gruppieren. Beispiele für Packages sind bspw. das Package Math, das mathematische Routinen zur Verfügung stellt, oder auch das Package awt, das die Verarbeitung grafischer Benutzeroberflächen ermöglicht. Die inhaltliche Zusammengehörigkeit der Komponenten von Packages kann sowohl durch lediglich eine Vererbungshierarchie bestimmt sein als auch durch verschiedene Hierarchien, die zueinander in Bezug stehen. Auch Packages können Teil eines anderen Packages sein. Werden umfangreiche Projekte implementiert, die unter Umständen aus hunderten von Klassen bestehen, so kann auf das Organisationsinstrument Package nicht verzichtet werden. Die Nützlichkeit von Packages kann hauptsächlich folgendermaßen belegt werden:
Zur Verwendung eines Packages muss dieses importiert werden. Die hierzu notwendige Syntax lautet:
import Name_des_Packages Beim Importieren von Klassen gelten die folgenden Regeln:
Importprobleme treten immer dann auf, wenn zwei Packages Klassen desselben Namens enthalten. In diesem Fall wird der Java-Compiler immer dann einen Namenskonflikt melden, wenn versucht wird, diesen Namen direkt zu verwenden. Die einzige Möglichkeit, dies zu umgehen, ist die vollständige Namensangabe der jeweiligen Klasse, die in diesem Fall aus dem Namen des Packages und dem Klassennamen besteht. Ein weiteres Problem, das auftreten kann, ist der Versuch, auf Klassen zuzugreifen, die nicht importiert werden können. Es wurde bereits beschrieben, dass Klassen immer dann Standard-Zugriffsrechte haben, wenn keine speziellen Modifier angegeben wurden. Eine derartige Klasse steht allen anderen Klassen desselben Packages zur Verfügung, nicht aber Klassen außerhalb eines Packages (Kapselungskonzept). Dies impliziert aber, dass auch Sub-Packages auf derartige Klassen nicht zugreifen dürfen. Klassen, die ohne Modifier spezifiziert werden, können daher weder importiert noch durch einen entsprechenden Namen referenziert werden. Ruft man sich die Regelung der Zugriffsrechte aus Tab. 3-9 in Erinnerung, so wird schnell klar, dass Klassen nur dann importiert und referenziert werden können, wenn sie als Eine wichtige Regelung der Deklaration von public-Klassen ist, dass in einer Datei zwar eine beliebige Anzahl von Klassen enthalten sein kann, dass aber nur eine dieser Klassen als public definiert werden darf. Der Name dieser Klasse muss dann auch mit dem Dateinamen übereinstimmen. Zwar können aus anderen Packages nur solche Klassen verwendet werden, die public sind, andere Klassen werden aber dennoch hinzugeladen, wenn aus der als public deklarierten Klasse Zugriffe auf ebendiese anderen Klassen erfolgen. Dieses Konzept wurde bereits im Zusammenhang mit Zugriffsmethoden erläutert. Klassen können auf diese Art und Weise sehr elegant vor unberechtigtem Zugriff geschützt werden. Allgemein gilt es als guter Programmierstil, eine kleine Anzahl von Klassen als public zu deklarieren und aus diesen auf eine Vielzahl von Klassen desselben Packages zuzugreifen. Zum Abschluss der Einführung des Package-Konzepts muss weiterhin darauf hingewiesen werden, dass das Importieren eines Packages nur dann funktioniert, wenn der Pfad, der den Speicherort der Datei angibt, in der Variable CLASSPATH definiert ist (hierzu siehe auch Kapitel 3.1). Sind die Programmdateien eines Projekts also in Unterverzeichnissen organisiert und eventuell auch in Packages gruppiert, so müssen auch die Unterverzeichnisse in der Variablen CLASSPATH angegeben sein. Arbeiten mit Packages Bisher wurde vor allem das Arbeiten mit bereits vordefinierten Packages betrachtet. Im Folgenden wird detailliert erläutert, wie eigene Projekte in Packages organisiert werden können. Hierzu sind die folgenden drei Schritte nötig, die anschließend im Detail erklärt werden:
Die Auswahl des Namens eines Packages sollte derart erfolgen, dass der Name eindeutig ist. Auch wenn hierzu außer der Konvention, dass der Name des Packages im Unterschied zu Klassennamen mit kleinen Buchstaben beginnen sollte, keine Vorschriften existieren, lohnt es sich, sich an die von der Firma Sun Microsystems vorgeschlagene Konvention zu halten. Diese sieht vor, den eigenen Domänennamen in umgekehrter Reihenfolge als Namen zu verwenden, für Sun also bspw. com.sun.java. Um den Einsatzzweck des Packages zu dokumentieren, sollte anschließend noch ein Name, der das Package identifiziert, angehängt werden. Eindeutige Namen sind das beste Mittel, spätere Namenskonflikte zu umgehen. Zwar können Namenskonflikte bei Klassen unter Angabe des Package-Namens verhindert werden, Namenskonflikte bei zusätzlich gleichen Package-Namen sind jedoch nicht lösbar. Im zweiten Schritt muss die Verzeichnisstruktur angelegt werden, in der die Klassen des Packages abgelegt werden sollen. Hierzu muss jeder Namensteil eines Package-Namens in einer eigenen Stufe der Verzeichnishierarchie stehen. Im Falle von com.sun.java muss also zuerst ein Verzeichnis com angelegt werden, in diesem ein Verzeichnis sun und wiederum in sun ein Verzeichnis java. Im Verzeichnis java wird dann der Programm-Code abgelegt. Im dritten Schritt werden die Klassen dem Package zugefügt, indem in den Klassendateien oberhalb der import-Befehle die Anweisung
package Package-Name angegeben wird. Im Fall des Packages com.sun.java lautet der einzufügende Befehl daher package com.sun.java. Sicherlich ist die Lektüre dieses Kapitels bis hierher eher mühsam, da eine Vielzahl technischer Definitionen und Konzepte erläutert wurden. Der Leser sollte aber nun in der Lage sein, jegliche Strukturen von Java-Programmen zu verstehen. Um diese Kenntnisse zu überprüfen, kann der in Kapitel 3.6 vorgestellte Programm-Code des Spiels „Schiffe versenken" betrachtet werden. Auch wenn sicherlich eine Vielzahl der dort verwendeten Klassen bisher noch nicht näher erklärt wurde, sollte zumindest die Syntax der verwendeten Anweisungen verständlich sein. |
|
|