Java(3)

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.

kap39 

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:

syntax 

public interface Collection {

    // Basisoperationen
    int getSize();
    boolean isEmpty();
    boolean contains(Object element);
    boolean add(Object element);    // Optional
    boolean remove(Object element); // Optional
    Iterator iterator();

    // Sammeloperationen
    boolean containsAll(Collection c);
    boolean addAll(Collection c);    // Optional
    boolean removeAll(Collection c); // Optional
    boolean retainAll(Collection c); // Optional
    void clear(); // Optional

    // Listenoperationen
    Object[] toArray();
    Object[] toArray(Object a[]);

}

Das derart definierte Interface beinhaltet

  • Methoden, die die Anzahl der Elemente zurückgeben (getSize, isEmpty),
  • Methoden, die prüfen, ob ein gewisses Objekt in einer Collection ist (contains),
  • Methoden, die Elemente einer Collection hinzufügen oder löschen (add, remove) und
  • Methoden, die in einer Schleife auf die Elemente der Collection zugreifen (iterator).

Das von der iterator-Methode als Ergebniswert zurückgelieferte Objekt ist einer Aufzählung (Enumeration), die bereits bei Vektoren und Hash-Tabellen verwendet wurde, sehr ähnlich, unterscheidet sich aber in den folgenden zwei Aspekten:

  • Mittels des iterator-Objekts kann derjenige, der die Methode aufruft, Elemente der zugrunde liegenden Collection während der Iteration nicht nur abfragen, sondern auch in einer eindeutig vorgegebenen Semantik löschen.
  • Methodennamen wurden eindeutiger gewählt.

Das Iterator-Interface sieht wie folgt aus:

syntax 

public interface Iterator {

    boolean hasNext();
    Object next();
    void remove();    // Optional

}

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:

syntax 

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:

  • containsAll:
    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.
  • Die Methoden addAll, removeAll und retainAll liefern den Wert true zurück, wenn die Ziel-Collection während der Ausführung der Operation verändert wurde.

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.

syntax 

public interface Set {

    // Basisoperationen
    int getSize();
    boolean isEmpty();
    boolean contains(Object element);
    boolean add(Object element);    // Optional
    boolean remove(Object element); // Optional
    Iterator iterator();

    // Sammeloperationen
    boolean containsAll(Collection c);
    boolean addAll(Collection c);    // Optional
    boolean removeAll(Collection c); // Optional
    boolean retainAll(Collection c); // Optional
    void clear(); // Optional

    // Listenoperationen
    Object[] toArray();
    Object[] toArray(Object a[]);

}

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:

  • Positionszugriffe, die Listenelemente aufgrund einer numerischen Position verändern können.
  • Suche nach beliebigen Objekten, die in der Liste enthalten sein können und Rückgabe der numerischer Position eines Objekts.
  • Listeniteration, die zusätzlich zur bisher verwendeten Iteration auch die sequentielle Natur einer Liste ausnutzt.
  • Ausschnittbildung von Listen unter Angabe einer Start- und einer Endposition.

Die Interface-Definition des Objekts List sieht wie folgt aus:

syntax 

public interface List extends Collection {

    // Positionszugriffe
    Object get(int index);
    Object set(int index, Object element);          // Optional
    void add(int index, Object element);            // Optional
    Object remove(int index);                       // Optional
    abstract boolean addAll(int index, Collection c); //Optional

    // Suche
    int indexOf(Object o);
    int lastIndexOf(Object o);

    // Iteration
    ListIterator listIterator();
    ListIterator listIterator(int index);

    // Sublisten
    List subList(int from, int to);

}

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:

  • das Durchlaufen der Liste vorwärts oder rückwärts,
  • Listenmodifikation während einer Iteration bzw.
  • Abfrage der momentanen Position der Zählvariablen der Iteration.

Die drei Methoden, die ListIterator von Iterator erbt (hasNext, next und remove), werden in beiden Interfaces analog verwendet. Zusätzlich sind die Operationen hasPrevious und previous exakt analog zu hasNext und next. Während sich die ersteren Operationen auf das Element vor der aktuellen Listenposition beziehen, bezeichnen die letzteren zwei Operationen das Element nach der aktuellen Position. Die Operation previous bewegt die Listenposition rückwärts, im Gegensatz dazu die Operation next vorwärts. Eine Standardroutine zum rückwärtigen Durchlaufen einer Liste sieht dann wie folgt aus:

syntax 

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.

kap310 

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:

syntax 

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:

syntax 

Wenn (Objekt == Null)

    Wenn (i.next() == Null)

      // Schleife beendet
      return i.previousIndex();

    Sonst Wenn o.equals (i.next())

      // Gleiches Objekt folgt
      return i.previousIndex();

    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:

syntax 

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:

syntax 

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());

      }

    }

}

kap311 

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:

syntax 

public interface Map {

    // Basisoperationen
    Object put(Object key, Object value);
    Object get(Object key);
    Object remove(Object key);
    boolean containsKey(Object key);
    boolean containsValue(Object value);
    int getSize();
    boolean isEmpty();

    // Sammeloperationen
    void putAll(Map t);
    void clear();

    // Sammelansichten
    public Set keySet();
    public Collection values();
    public Set entrySet();

    // Interface für Eintragselemente
    public interface Entry {

      Object getKey();
      Object getValue();
      Object setValue(Object value);

    }

}

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.

syntax 

import java.util.*;
public class Frequenz {

    private static final Integer EINS = new Integer(1);
    public static void main(String args[]) {

      Map m = new HashMap();
      // Initialisiere Haeufigkeitstabelle aus Kommandozeile
      for (int i=0; i<args.length; i++) {

        Integer frequenz = (Integer) m.get(args[i]);
        m.put(args[i], (frequenz==null ? EINS:
        new Integer(frequenz.intValue() + 1)));

      }

      System.out.println(m.getSize()+" unterschiedliche Worte entdeckt:");
      System.out.println(m);

    }

}

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

syntax 

java Frequenz es ist wie es ist weil es ist

auf, so wird die Ausgabe

syntax 

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:

  • HashMap, die Einträge in einer Hash-Tabelle speichert und
  • TreeMap, die Einträge in einem Baum speichert und hierbei deren Iterationsreihenfolge garantiert.

Es ergibt sich dann die folgende sortierte Ausgabe, wenn eine TreeMap für das obige Beispiel verwendet wird:

syntax 

4 worte entdeckt:{es=3, ist=3, weil=1, wie=1}

Betrachtet man die Sammeloperationen der Interface-Definition näher, so

  • löscht die clear-Operation alle Einträge einer Map,
  • ist die putAll-Operation das Analogon zur addAll-Operation, die in Collection-Interfaces verwendet wird. Zusätzlich zur offensichtlichen Anwendung, eine Map in eine andere zu kopieren, können Attribut-Maps mit Standardwerten belegt werden, wenn eine Map eine Sammlung von Attributnamen und dazugehörigen Werten repräsentiert. Die putAll-Operation kann dann in Kombination mit dem Map-Konstruktor dazu verwendet werden, eine derartige Map mit Standardwerten vorzubelegen. Das folgende Code-Segment illustriert dies:

 syntax

static Map neueAttributeMap(Map standards, Map ueberschreiben) {

    Map resultat =  new HashMap(standards);
    resultat.putAll(ueberschreiben);
    return resultat;

}

Maps können durch Sammelansichten in den folgenden drei Sichten (Views) betrachtet werden:

  • als Schlüsselmenge (keySet) der in der Map enthaltenen Schlüssel.
  • als Werte, die als Collection in der Map enthalten sind. Diese Menge ist allerdings kein Set, da verschiedene Schlüssel auf denselben Zielwert abgebildet werden können und
  • als Schlüssel-Wert-Paare (sog. entrySet) der Map.

Mittels dieser Ansichten sind Iterationen in Maps realisierbar. Der Standardaufbau einer Iteration über die Schlüssel einer Map ist im folgenden Code-Beispiel angegeben:

syntax 

for (Iterator i=m.keySet().iterator(); i.hasNext(); )

    System.out.println(i.next());

Analog dazu lautet die Schleife zur Iteration über Schlüssel-Werte-Paare:

syntax 

for (Iterator i=m.entrySet().iterator(); i.hasNext(); ) {

    Map.Entry e = (Map.Entry) i.next();
    System.out.println(e.getKey() + ": " + e.getValue());

}

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:

syntax 

if (map1.entrySet().containsAll(map2.entrySet())) {

    ...

}

Analog prüft das folgende Segment, ob zwei Maps Abbildungen für dieselben Schlüssel enthalten:

syntax 

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.

Klasse

Ordnungskriterium

BigDecimal

numerisch mit Vorzeichen

BigInteger

numerisch mit Vorzeichen

Byte

numerisch mit Vorzeichen

Character

numerisch mit Vorzeichen

CollationKey

lexikografisch in Abhängigkeit von der Lokale

Date

chronologisch

Double

numerisch mit Vorzeichen

File

systemabhängig lexikografisch nach dem Pfadnamen

Float

numerisch mit Vorzeichen

Integer

numerisch mit Vorzeichen

Long

numerisch mit Vorzeichen

Short

numerisch mit Vorzeichen

String

lexikografisch

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

  • Operationen auf Ausschnitten der sortierten Liste durchgeführt werden,
  • Endpunkte, wie bspw. das erste oder letzte Element einer sortierten Liste festgestellt werden,
  • das Vergleichskriterium (sog. Comparator) festgestellt werden, der zur Sortierung des Set verwendet wurde (falls existent).

Die Methoden, die SortedSet von Set erbt, verhalten sich bis auf die folgenden zwei Ausnahmen bezüglich der Arbeit mit sortierten und traditionellen Sets gleich:

  • der Iterator als Resultat der iterator-Operation durchläuft das Set in der Sortierreihenfolge.
  • die Liste, die durch das Kommando toArray wiedergegeben wird, enthält die Elemente in sortierter Reihenfolge.

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:

  • beliebige Ausschnittbildungen auf der sortierten Map durchführen,
  • den ersten oder letzten Schlüssel der sortierten Map zurückgeben und
  • auf den Comparator, der zur Sortierung der Map eingesetzt wurde, zugreifen (falls existent).

Die Methoden, die SortedMap von Map erbt, verhalten sich bis auf die folgenden zwei Ausnahmen bezüglich der Arbeit mit sortierten und traditionellen Maps gleich:

  • der Iterator als Resultat der iterator-Operation durchläuft die Collection hinsichtlich jeder durch die SortedMap definierten Ansicht in der Sortierreihenfolge.
  • die Listen, die von toArray-Operationen wiedergegeben werden, enthalten die Schlüssel, Werte oder Einträge in sortierter Reihenfolge.

Das SortedMap-Interface findet somit eine exakte Entsprechung in den SortedSets (bis auf die unterschiedliche Funktionalität).

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, die die als public deklarierten Klassen darstellen, welche die primäre Implementierung des Core Collection Interface realisieren,
  • Sogenannte Wrapper-Implementierungen, die in Kombination mit anderen Implementierungen deren Funktionalität erweitern und
  • Spezialimplementierungen, die meist einen geringen Umfang haben und effiziente Alternativen für spezielle Collections darstellen.

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.

     

Implementierungen

   
   

Hash-Tabelle

Größenveränderbare Liste

Balancierter Baum

Verbundene Liste

Interface

Set

HashSet

 

TreeSet

 

Interface

List

 

ArrayList

 

LinkedList

Interface

Map

HashMap

 

TreeMap

 

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:

syntax 

  • public static Collection synchronizedCollection(Collection c);
  • public static Set synchronizedSet(Set s);
  • public static List synchronizedList(List list);
  • public static Map synchronizedMap(Map m);
  • public static SortedSet synchronizedSortedSet(SortedSet s);
  • public static SortedMap synchronizedSortedMap(SortedMap m);

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:

syntax 

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.

syntax 

Collection c = Collections.synchronizedCollection(eigeneCollection);
synchronized(c) {

    Iterator i = c.iterator();
    // Muss innerhalb des synchronisierten Blocks stehen!
    while (i.hasNext())

      //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:

syntax 

Map m = Collections.synchronizedMap(new HashMap());
...
Set s = m.keySet(); 
// Muss nicht innerhalb des synchronisierten Blocks stehen
...
synchronized(m) {  // Synchronisierung über m, nicht über s!

    Iterator i = s.iterator();
    // Muss innerhalb des synchronisierten Blocks stehen!
    while (i.hasNext())

      //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:

  • Eine Collection wird als unveränderbar gekennzeichnet, nachdem sie erstellt wurde. In diesem Fall sollte keine Referenz auf die zugrunde liegende Collection verwendet werden, ähnlich wie bereits oben beschrieben. Hierdurch wird die Unveränderbarkeit gewährleistet.
  • Anwender sollen ausschließlich Lesezugriff auf Datenstrukturen erhalten. Hierzu wird intern eine Referenz auf die zugrunde liegende Collection verwendet, während der Anwender nur eine Referenz auf den Wrapper erhält. Während der Programmierer so den vollen Zugriff behält, kann ein Anwender die Daten lediglich betrachten, nicht aber verändern.

Wie bereits die Synchronisations-Wrapper kann auch diese Wrapper-Art die folgenden Methodendefinitionen für jedes der sechs Core Collection Interfaces verwenden:

  • public static Collection unmodifiableCollection(Collection c);
  • public static Set unmodifiableSet(Set s);
  • public static List unmodifiableList(List list);
  • public static Map unmodifiableMap(Map m);
  • public static SortedSet unmodifiableSortedSet(SortedSet s);
  • public static SortedMap unmodifiableSortedMap(SortedMap m);

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.

syntax 

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:

syntax 

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:

syntax 

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:

syntax 

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:

syntax 

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:

  • Persistenz.
    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.
  • Anwendungsbezug.
    Die bereits zur Verfügung stehenden Implementierungen sind meist nicht in der Lage, Spezialaufgaben wie das Ablesen von Wettermessdaten zu erfüllen, auch wenn mit einer nichtmodifizierbaren Map, die die Erfassungsorte und die Daten speichert, die grundsätzlichen Möglichkeiten zur Verfügung stehen.
  • Konkurrierende Zugriffe.
    Die zur Verfügung stehenden Collections sind nicht auf die Verarbeitung von konkurrierenden Zugriffen, die permanent auftreten, ausgelegt. Die Synchronisierungs-Wrapper sperren daher die Collection jedes Mal, wenn Zugriffe erfolgen. Soll bspw. eine Server-Anwendung entwickelt werden, die eine Map-Implementierung verwendet, auf die permanent von verschiedenen Stellen zugegriffen wird, so empfiehlt sich die Verwendung einer Hash-Tabelle, die jeden Eintrag separat sperren kann. Hierdurch können mehrere Prozesse konkurrierend auf die Tabelle zugreifen, wenn Schlüssel verwendet werden, die auf verschiedene Einträge abgebildet werden.
  • Leistung des Systems bei speziellem Einsatz.
    Eine Vielzahl von Datenstrukturen sind dann besonders effizient, wenn sie ausschließlich spezielle Aufgaben erfüllen. Ein Beispiel hierfür ist ein Set, das als Bit-Vektor gespeichert wird. Zugriffe auf eine derartige Datenstruktur sind extrem schnell und der benötigte Speicherplatz ist gering. Andererseits hat ein solches Set aber auch einen sehr eingeschränkten Funktionsumfang.
  • Leistung des Systems bei allgemeinem Einsatz.
    Die Entwickler des Collections Frameworks versuchten, optimale Implementierungen für jedes Interface zu generieren. Sicherlich gibt es aber viele andere Möglichkeiten, die der Anwender jeweils als bequemer einstuft.
  • Erweitere Funktionalität.
    Ein Beispiel für eine erweiterte Funktionalität sind die bereits angesprochenen Multimaps, Collections, die Zugriffe in konstanter Zeit sowie Datenduplikate erlauben. Eine derartige Map könnte als Erweiterung von
    HashMap entwickelt werden.
  • Adapter.
    Angenommen, man verwendet ein eigenes API, das selbst definierte Collections benutzt. Zur Anpassung dieser Collections an das Java Collections Framework muss ein sog. Adapter geschrieben werden, der Objekte von einem Typ in den anderen bzw. Operationen der einen Collection in Operationen der anderen umwandelt.

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.

syntax 

public static List asList(Object[] a) {

    return new ArrayList(a);

}
private static class ArrayList extends AbstractList implements java.io.Serializable {

    private Object[] a;
    ArrayList(Object[] array) {

      a = array;

    }
    public Object get(int index) {

      return a[index];

    }
    public Object set(int index, Object element) {

      Object oldValue = a[index];
      a[index] = element;
      return oldValue;

    }
    public int getSize() {

      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:

  • AbstractCollection:
    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:

  • Auswahl einer geeigneten abstrakten Implementierung aus der Liste.
  • Implementierung der abstrakten Methoden der Klasse. Wenn die Collection modifizierbar sein soll, müssen eine oder auch mehrere konkrete Methoden überschrieben werden. Die API-Dokumentation der abstrakten Implementierung spezifiziert hierzu, welche Methoden überschrieben werden müssen.
  • Austesten und eventuell Debuggen der Implementierung.

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:

  • Klassen können in Einheiten gruppiert werden, analog zur Verwendung von Ordnern oder Verzeichnissen einer Festplatte. Diese Organisationsmöglichkeit stellt sicher, dass nur die Teile verwendet werden, die auch tatsächlich benötigt werden.
  • Die Auftrittswahrscheinlichkeit von Namenskonflikten kann reduziert werden. Je größer ein Projekt wird, desto höher wird auch die Wahrscheinlichkeit, dass aus Versehen Namen verwendet werden, die bereits an anderer Stelle definiert wurden, wodurch auch die Wahrscheinlichkeit von Seiteneffekten wächst. Mittels Packages können Klassen aufgrund der Zugriffsregeln leicht verborgen werden, wodurch die Fehlerwahrscheinlichkeit sinkt.
  • Packages ermöglichen weitergehende Zugriffsrechte als dies auf einer Klassen-zu-Klassen-Basis möglich wäre (siehe unten).
  • Packages können durch eine beliebige Namensgebung zur Identifikation des Eigentümers verwendet werden. Es ist hierbei Konvention, an oberster Stelle der Hierarchie den Namen des Eigentümers zu nennen. Dies findet sich bspw. bei Packages, die von der Firma Sun Microsystems entwickelt wurden (Packages sun.*) oder bei dem bereits vorgestellten LiveConnect, das von der Firma Netscape entwickelt wurde und das daher als netscape.* bezeichnet wird. Eine Ausnahme hierzu bildet das Package java, das als derart fundamental angesehen wird, dass hier die Namensgebung anders ist.

Zur Verwendung eines Packages muss dieses importiert werden. Die hierzu notwendige Syntax lautet:

syntax 

import Name_des_Packages

Beim Importieren von Klassen gelten die folgenden Regeln:

  • Ein oder mehrere import-Kommandos müssen immer am Anfang einer Programmdatei angegeben werden.
  • Klassen, die Teil des Packages java.lang sind, müssen nicht importiert werden, sie stehen in allen Anwendungen automatisch zur Verfügung. Zur Verwendung derartiger Klassen muss lediglich der Klassenname spezifiziert werden.
  • Klassen, die nicht in einem Paket gruppiert worden sind, werden automatisch in einem unbenannten Standard-Package gesammelt. Auch diese Klassen müssen nicht gesondert importiert werden.
  • Klassen, die sich in einem Paket befinden, können importiert werden, indem der volle Name des Pakets angegeben wird, bspw. import Math.random. Dies funktioniert allerdings nur dann, wenn die Klasse nach den entsprechenden Zugriffsrechten definiert ist (siehe unten).
  • Gruppen von Klassen können importiert werden, indem anstelle eines Klassennamens ein Asteriskus verwendet wird. Sollen bspw. alle Klassen des Packages Math importiert werden, so erfolgt dies mit dem Befehl import Math.*. Auch dies funktioniert nur, wenn die entsprechenden Zugriffsrechte vorhanden sind. Hierbei ist auch zu beachten, dass zwar alle Klassen der jeweiligen Hierarchieebene importiert werden, nicht aber die Klassen auf anderen Ebenen. Diese müssen gesondert (eventuell ebenfalls unter Verwendung des Asteriskus) importiert werden.
  • Alle Klassen, die auf eine beliebige Art importiert worden sind, können unter Verwendung ihres Namens angesprochen werden. Dies kann der vollständige Name einschließlich des Package-Namens sein (bspw. java.Math.random()). In diesem Fall muss das Package nicht importiert werden. Dies macht immer dann Sinn, wenn eine derartige Klasse nur selten verwendet wird. Eine Klasse kann auch nur mit ihrem Namen angesprochen werden (bspw. random). In diesem Fall ist das entsprechende Package zwingend zu importieren.

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 public deklariert sind. Genau aus diesem Grund werden bei import-Operationen, die mit einem Asteriskus angegeben werden (bspw. import Math.*) auch nur solche Klassen importiert, die als public deklariert sind. Verborgene Klassen eines Packages können nur von anderen Klassen desselben Packages verwendet werden. Der Grund für ein derartiges Verbergen von Klassen ist ähnlich wie bei allen anderen Kapselungsoperationen: Die Auswirkungen von Änderungen können so auf die Komponenten eines Programms begrenzt werden, die tatsächlich mit einer derart vereinbarten Klasse arbeiten sollen.

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:

  • Auswahl des Namens eines Packages
  • Erzeugen der Organisationsstruktur eines Packages
  • Hinzufügen von Klassen zum Package

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

syntax 

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.


SPNavRight SPNavRight SPNavRight
BuiltByNOF