Java (2)

Listen und „Zeiger"

Nach der bisherigen Lektüre dieses Kapitels sollte dem Leser die Verwendung von Klassen, Variablen und Typen in allen Feinheiten bekannt sein. Ein Detail, das bisher noch nicht beschrieben wurde, sind Listen und Zeiger.

Oftmals muss eine große Anzahl ähnlicher Daten geeignet gespeichert werden, bspw. Adressen. Dabei ist zwar die maximale Anzahl zu speichernder Adressen leicht abzuschätzen, eine derartige Zahl ist aber meist auch sehr groß, wodurch die Verwendung von bspw. 10.000 verschiedenen Variablen, die die Adressen speichern könnten, ineffizient wäre. In vielen Programmiersprachen werden daher Listen verwendet, die in der Lage sind, eine bestimmte Anzahl von Elementen desselben Datentyps (auch derselben Klasse) zu speichern. Hierbei ist zu unterscheiden, ob die Länge der Liste vorab bekannt ist oder nicht. Im Folgenden werden solche Listen, deren Länge bekannt ist, als Liste bezeichnet, wohingegen Listen, deren Länge unbekannt ist, als Vektor bezeichnet werden.

Zur Deklaration einer Liste sind alternativ die zwei folgenden Schreibweisen möglich:

syntax 

Datentyp[] Variablenname;
Datentyp Variablenname[];

Beide Schreibweisen sind äquivalent. Nach der Definition der Listenvariable muss für das Objekt Speicherplatz angelegt werden. Alternativ kann dies mit dem new-Operator oder durch eine direkte Zuweisung von Inhalten erfolgen. Möchte man bspw. eine Liste aus 10 Zeichenketten erzeugen, so lautet der hierzu notwendige Programmausdruck

syntax 

String[] zeichenketten = new String[10];

Ein Beispiel für eine direkte Zuweisung an eine Liste von 5 Elementen wäre bspw.

syntax 

String[] zeichenketten = {"Abed","Stephan","Ines", "Anja","Thomas"};

Im Falle der direkten Zuweisung belegt Java automatisch den hierzu notwendigen Speicherplatz. Hierbei ist zu beachten, dass die zugewiesenen Objekte alle denselben Datentyp haben müssen.

Zum Zugriff und auch zur Änderung ist das jeweilige Listenelement als listenelement[i] geeignet zu indizieren. Hierbei muss beachtet werden, dass der Index in diesem Fall von 0 bis (n-1) läuft, wenn n die Anzahl der zu speichernden Objekte angibt. Möchte man daher im obigen Beispiel auf die erste Zeichenkette zugreifen („Abed"), so ist die hierzu notwendige Adressierung zeichenketten[0]. Da der Index von 0 bis (n-1) läuft, darf logischerweise auf das Element mit dem Index n nicht zugegriffen werden. Ist man sich nicht sicher, wie groß eine Liste ist, so kann man die vordefinierte Funktion length verwenden, die für alle Listenobjekte standardmäßig zur Verfügung steht. Die Länge der Liste zeichenketten erfährt man also bspw. mittels der Anweisung

syntax 

int laenge = zeichenketten.length;

Änderungen von Einträgen in Arrays sind sehr einfach, bspw:

syntax 

zeichenketten[0] = "Peter";
zahlen[3] = 2001;

Auch in Java kann eine Zeigervariante verwendet werden, um Listen zu manipulieren – wenn auch in einer wesentlich eingeschränkteren Form als dies in Sprachen, wie bspw. C oder C++, möglich ist. Ein Zeiger ist prinzipiell eine Referenz auf ein Objekt. Fasst man nun eine Liste als Liste von Referenzen auf Objekte auf, so ist auch die folgende Zuweisung möglich, die den ersten Eintrag der Liste zeichenketten mit dem zweiten Eintrag überschreibt:

syntax 

zeichenketten[0] = zeichenketten [1];

Im Unterschied zu einer reinen Referenzierung wird hier der erste Eintrag der Liste physikalisch überschrieben. Es ist aber genauso denkbar, ausschließlich mit Referenzen zu arbeiten. Dies soll anhand des folgenden Beispiels verdeutlicht werden:

syntax 

String[] zeichenkette1, zeichenkette2;
zeichenkette1 = new String[10];
zeichenkette1[0] = "Hallo";
zeichenkette2 = zeichenkette1;

Nach Ausführung dieser Anweisungen stehen zwei Variablen zur Verfügung, die mit derselben Liste arbeiten. Abb. 3-8 verdeutlicht das Vorgehen grafisch.

kap38 

Abb. 3.8: Referenzen in Java

Manchmal ist es notwendig, mit mehrdimensionalen Listen zu arbeiten, bspw. wenn Elemente einer Matrix verwaltet werden sollen. Hierzu ist in Java eine Liste aus Listen anzulegen. Möchte man bspw. eine Matrix anlegen, die aus 10x10 Elementen besteht und auf deren Werte zugreifen, so kann dies mit dem folgenden Code erreicht werden:

syntax 

int [] [] matrix = new int [10][10];
matrix [0][0] = 10;
matrix[9][9] = 10;

Eine Anwendung, die hiermit realisiert werden kann, sind die Spielfelder der Beispielanwendung „Schiffe versenken", die in diesem Buch eine zentrale Rolle spielt. Die Erweiterung auf Listen, die eine noch größere Anzahl an Dimensionen speichern, ist in ähnlicher Art sehr einfach möglich.

Vektoren

Ein unangenehmer Nachteil von Listen besteht darin, dass deren Länge vor der Verwendung bekannt sein muss. In vielen Anwendungen kann dies nicht realisiert werden. Mittels der Klasse Vector kann aber ein Vektor generiert werden, dessen Länge variabel ist. Es liegt auf der Hand, dass das Arbeiten mit Vektoren komplexer ist als die Verwendung von Listen.

Im Gegensatz zu Listen können in Vektoren nur Objekte gespeichert werden. Ein Vektor, der int-Elemente enthält, ist daher unzulässig. Zur Verwendung eines Vector-Objekts sind die folgenden Schritte zu durchlaufen:

  1. Anlegen des Vektors durch Aufruf des Konstruktors Vector()
  2. Verwendung einer der Methoden, die in Tab. 3-8 angegeben sind.

Methode

Erläuterung

addElement(Object)

Hinzufügen eines Elements am Ende des Vektors

capacity()

Feststellen der Kapazität des Vektors

contains(Object)

Prüft, ob das als Argument übergebene Objekt Teil des Vektors ist

elementAt(int)

Gibt das Objekt an der durch das Argument festgelegten Position zurück

elements()

Erzeugt ein Enumeration-Objekt, das zur Iteration verwendet wird

firstElement()

Rückgabe des ersten Elements des Vektors

indexOf(Objekt)

Suche nach dem ersten Vorkommen des Arguments

getSize()

Größe des Vektors feststellen

insertElement(Object, int)

 

Fügt ein Objekt an einer bestimmten Position ein und verschiebt die nachfolgenden um eins

isEmpty()

Feststellen, ob Vektor leer ist

lastElement()

Rückgabe des letzten Elements des Vektors

removeAllElements()

Löschen des Vektors

removeElement(Object)

Löschen des ersten Vorkommens des Objekts, das als Argument übergeben wird

removeElementAt(int)

Löschen des Elements an einer bestimmten Position

trimToSize()

Anpassen der Kapazität an aktuelle Größe

Tab. 3.8: Methoden der Klasse Vector

Soll ein Vektor angelegt werden, so können die folgenden Konstruktoren verwendet werden:

  • Vector() legt einen leeren Vektor an.
  • Vector(int) legt einen Vektor mit einer bestimmten Kapazität an.
  • Vector(int, int) legt einen Vektor mit einer bestimmten Kapazität an. Der zweite Parameter bestimmt, in welchen Schritten die Kapazität eines Vektors erhöht wird, wenn neue Elemente hinzugefügt werden.

Zum Anlegen eines Vektors, der aus einer Zahl besteht, kann dann das folgende Programmstück verwendet werden:

syntax 

Vector v = new Vector();
v.addElement(new Integer(20));

An dieser Stelle soll bereits die Verwendung von Aufzählungen definiert werden, auch wenn die hierzu notwendige while-Schleife erst im Anschluss erläutert wird. Um alle Objekte eines Vektors nacheinander zu durchlaufen, erzeugt man zuerst eine Aufzählung und durchläuft diese anschließend in einer while-Schleife, wobei in jedem Durchlauf geprüft wird, ob noch ein weiteres Element enthalten ist. Dieses Vorgehen ist notwendig, da a priori unbekannt ist, wie viele Objekte ein Vektor enthält. Das folgende Programmsegment realisiert diese Funktionalität:

syntax 

Vector v = new Vector();
v.addElement(new Integer(20));
Enumeration e = v.elements();
while (e.hasMoreElements())

    System.out.println("Naechstes Element:"+(Integer)e.nextElement());

Hash-Tabellen

Um die Aufzählung von Feldobjekten zu vervollständigen, wird im Folgenden die Funktionsweise von Hash-Tabellen betrachtet. Hash-Tabellen verwalten Paare aus Schlüssel und Wert, bspw. eine Farbe, der eine Kombination aus Rot-, Grün- und Blauwert zugeordnet ist. Hash-Tabellen arbeiten mit Wertepaaren wesentlich effizienter als Vektoren. Eine wichtige Anwendung derartiger Objekte ist der Kryptographie-Bereich.

Zur Verwendung einer Hash-Tabelle ist zuerst mittels new HashTable() ein geeignetes Objekt anzulegen. Mittels HashTable(int) kann eine Tabelle einer bestimmten Kapazität angelegt werden, mittels HashTable(int, float) eine Tabelle einer bestimmten Kapazität, wobei der zweite Parameter einen Auslastungsfaktor zwischen 0 und 1 angibt. Falls die Anzahl der Tabelleneinträge das Produkt aus momentaner Kapazität und Auslastungsfaktor überschreitet, wird die Tabelle vergrößert und anschließend optimiert (sog. Rehashing). Hierdurch wird der Speicher zwar effizienter genutzt, gleichzeitig wird aber auch die Zugriffszeit länger. Anschließend kann mit den Operationen put und get ein neues Wertepaar gespeichert bzw. abgerufen werden. Mittels der Methode remove kann ein Wertepaar wieder gelöscht werden, mittels der Methode containsKey festgestellt werden, ob ein Schlüssel eines bestimmten Werts in einer Hash-Tabelle enthalten ist.

Wie auch bei Vektoren kann mit einem Enumeration-Objekt auf alle Elemente einer Hash-Tabelle zugegriffen werden. Das folgende Beispiel demonstriert den Einsatz der verschiedenen Methoden.

syntax 

//Anlegen einer Tabelle mit 5 Eintraegen
HashTable h= new HashTable(5);

//Einfuegen zweier Farben
h.put("weiss", new Color(255,255,255));
h.put("schwarz", new Color(0,0,0));

//Abfragen einer Farbe
Color c = (Color)h.get("schwarz");

//Loeschen einer Farbe
h.remove("schwarz");

//Feststellen des Schluessels einer Farbe
boolean s = h.containsKey("schwarz");

//Durchlaufen der Tabelle
Enumeration e = h.elements();
while (e.hasMoreElements())

    Color c = (Color) e.nextElement();

Programmanweisungen

Mit dem jetzigen Wissen können zwar bereits rudimentäre Java-Programme entwikkelt werden, eine wichtige Komponente wurde allerdings bisher nicht vorgestellt: Programmanweisungen, mit deren Hilfe Bedingungsabfragen und oft wiederkehrende Programmabläufe implementiert werden können. Die im Folgenden vorgestellten Abläufe sind Blockausdrücke, Bedingungsabläufe und Schleifen.

Programmblöcke

Anweisungsfolgen werden in Java immer zu Blöcken gruppiert, die in geschweifte Klammern eingeschlossen werden. Dies wurde bereits mehrfach demonstriert, ohne explizit genannt zu werden. Betrachtet man bspw. den Aufbau einer Klasse, so ist der Klassenrumpf immer in Klammern einzuschließen. Ein weiteres Beispiel für Blöcke sind die Anweisungsfolgen, die im Rumpf einer Methode enthalten sind. Auch diese sind stets von Klammern umgeben. Blöcke werden in Java auch als Blockanweisungen bezeichnet, da immer dort, wo eine Anweisung verwendet wird, auch ein vollständiger Anweisungsblock benutzt werden kann. Hieraus folgt in logischer Konsequenz, dass Blöcke auch geschachtelt werden können. Betrachtet man die in einem Block deklarierten Variablen, so haben diese nur im Block selbst eine Bedeutung. Außerhalb eines Blockes kann daher auf derart lokal definierte Variablen nicht zugegriffen werden. Ein gutes Beispiel hierfür ist die bereits dargestellte Zählschleife, in der die Zählervariable lokal im Block definiert wird:

syntax 

for (int i = 0; i < max; i++) {

    // Anweisungen

}

Bedingungsabfragen

Bedingungsabfragen verzweigen in Abhängigkeit von der Erfüllung einer oder mehrerer Bedingungen zur Ausführung verschiedener alternativer Anweisungen oder Anweisungsblöcke. In Java können if...then...else, der Konditionaloperator oder switch-Abläufe verwendet werden.

Das Konstrukt if...then...else kommt immer dann zum Einsatz, wenn lediglich eine Bedingung überprüft werden soll. Ein Beispiel hierfür ist die Bedingung „Wenn es regnet, nimm einen Schirm mit, sonst nicht." Hierbei wird ein boole'scher Ausdruck ausgewertet (es regnet). Ist das Ergebnis der Abfrage wahr (true), so wird eine Anweisung ausgeführt, anderenfalls (false) eine andere. Soll eine Anweisung lediglich ausgeführt werden, wenn eine Bedingung erfüllt ist, so kann der else-Teil auch entfallen. Das folgende Beispiel illustriert die Verwendung von if ... then ... else.

syntax 

if (kontostand < 0 ) {

    System.out.println("Ihr Konto ist im Minus!");
    System.out.println("Bitte gleichen Sie aus!");

} else

    System.out.println("Ihr Konto ist im Plus!");

Eine Alternative zur Verwendung von if...then...else ist die Verwendung des Bedingungsoperators (sog. Konditionaloperator), dessen Schreibweise kürzer ist als if...then...else. Die Syntax des Bedingungsoperators ist

syntax 

Bedingung ? Resultat_true : Resultat_false;

Ist also die Bedingung erfüllt, so wird die Anweisung oder der Anweisungsblock nach dem Fragezeichen ausgeführt, anderenfalls die Anweisung oder der Anweisungsblock nach dem Doppelpunkt. In dieser Notation lautet das Beispiel:

syntax 

String ausgabe = (kontostand < 0) ? "Ihr Konto ist im Minus!" + "Bitte gleichen Sie aus!" : "Ihr Konto ist im Plus!";

Hierbei ist anzumerken, dass die Klammerung der Bedingung entfallen kann. Da der Bedingungsoperator eine geringe Wichtigkeit hat, bindet das Kleinerzeichen die Operanden stärker als es dies das Fragezeichen erreicht.

Mehrfachbedingungen

if...then...else wird immer dann eingesetzt, wenn eine Bedingung überprüft werden soll. Oftmals sollen aber mehrere Bedingungen validiert werden. Eine Möglichkeit zur Lösung dieses Problems besteht in der Schachtelung von if...then...else-Anweisungsfolgen. Eine elegantere Variante ist die Verwendung der switch-Bedingung. switch wertet einen Test aus und wählt in Abhängigkeit von der Antwort eine zu bearbeitende Alternative aus. Trifft keine der Antworten zu, so wird in die Alternative default verzweigt, wenn diese vorhanden ist. Anderenfalls wird der switch-Block verlassen. In allgemeiner Notation ist die Syntax des switch-Blocks:

syntax 

switch(Variable) {

    case Antwort1:
    Anweisungsblock1;
    break;
    ...

    case Anwort n;
    Anweisungsblock n;
    break;

    default:
    Anweisungsblock default;

}

Das nachfolgende Beispiel illustriert die Verwendung eines switch-Blocks:

syntax 

char abfrage;
//Abfrageroutine
switch (abfrage) {

    case 'J':

    case 'j':

      System.out.println("Sie haben die Frage bejaht");
      break;

    case 'N':
    case 'n':

      System.out.println("Sie haben die Frage verneint");
      break;

    default:

      System.out.println("Sie haben einen falschen Buchstaben eingegeben");

}

Sollen mehrere Antworten zur Bearbeitung derselben Alternative führen, so sind die entsprechenden case-Anweisungen direkt hintereinander aufzuführen. Um zu verhindern, dass die Abarbeitung der Schleife nach der Bearbeitung einer Alternative mit einer weiteren fortfährt, setzt man jeweils am Ende einer Alternative die break-Anweisung. Anstelle der im Beispiel verwendeten Anweisungen können auch Anweisungsblöcke verwendet werden. Im Folgenden wird vorausgesetzt, dass einzelne Anweisungen bzw. Anweisungsblöcke stets als äquivalent angesehen werden.

Schleifen

Neben der Verwendung von Bedingungsabfragen gibt es in Java ein weiteres wichtiges Konzept von Programmanweisungen: Schleifen. Schleifen werden immer dann eingesetzt, wenn Programmabläufe wiederholt abgearbeitet werden sollen. Dabei kann vorab bekannt sein, wie oft die Wiederholung erfolgen soll (for-Schleifen) oder auch nicht (while-, do-Schleifen).

for-Schleifen werden dazu eingesetzt, Anweisungen wiederholt abzuarbeiten, bis eine Bedingung erfüllt ist. Das Hauptanwendungsgebiet dieser Schleifen sind daher Zählschleifen. Die allgemeine Notation der for-Schleife erfolgt nach der Syntax:

syntax 

for (Initialisierung einer Variable; Test;Inkrement/Dekrement){

    Anweisung(en);

}

In der Initialisierung der Zählvariablen wird der Startpunkt der Schleife festgelegt. Als Zählvariable wird meist die ganzzahlige Variable int i verwendet, die nur im Bereich dieses Blocks definiert ist. Hierbei können auch mehrere Variablen definiert werden, die dann durch Semikolons zu trennen sind. Die zweite Komponente der Definition ist ein boole'scher Test, der zur Beendigung der Schleife führt, wenn als Resultat false errechnet wird. Ist das Ergebnis hingegen true, so wird ein weiterer Schleifendurchlauf ausgeführt. In der Inkrement/Dekrement-Komponente wird die Zählvariable erhöht/verringert. Die Erhöhung/Verringerung der Zählvariablen geschieht üblicherweise in Einserschritten, wobei aber auch Werte größer als eins zugelassen sind. Auch in diesem Teil kann mehr als eine Variable verwendet werden, wobei die verschiedenen Teilausdrücke wiederum mit Semikolons zu trennen sind. Der Anweisungsblock wird in jedem Schleifendurchlauf ausgeführt, solange das Ergebnis des Tests true ist. Auch ein leerer Anweisungsblock ist zulässig. Diese Variante wird meist zur Verzögerung der Programmausführung verwendet. Die Programmzeile

syntax 

for (int i = 0; i < 100000; i++) ;

führt bspw. dazu, dass die Ausführung des Programms scheinbar für eine gewisse Zeit pausiert. An dieser Stelle soll auf einen schwer zu lokalisierenden Programmierfehler hingewiesen werden: Setzt man irrtümlich am Ende der Zählschleifendefinition ein Semikolon, so ist die Funktionalität eine grundsätzlich andere als wenn kein Semikolon verwendet worden wäre. Ein Beispiel hierfür ist das Code-Segment

syntax 

int x = 0;
for (int i = 0; i < 10; i++);

    x++;

dessen Resultat bei korrekter Ausführung 10 wäre. In diesem Fall aber erhält man für x das Ergebnis 1. Die folgenden Beispiele sind zur weiteren Verdeutlichung der Funktion der for-Schleife gedacht:

syntax 

for (int i = 0; int j = 0; i < 10; j < 16; i++; j+=2)

    System.out.println("Berechnung");

for (int i = 20; i > 0; i--)

    System.out.println("Schleife mit Dekrement");

while-Schleifen

for-Schleifen werden immer dann verwendet, wenn genau bekannt ist, wie viele Schleifendurchläufe abgearbeitet werden sollen. Eine Erweiterung hierzu sind while-Schleifen, die sowohl dann eingesetzt werden können, wenn die Anzahl der Schleifendurchläufe bekannt ist, als auch dann, wenn diese Anzahl a priori unbekannt ist. Die Syntax für eine while-Schleife sieht wie folgt aus:

syntax 

while (Bedingung) {

    // Programm-Code

}

Bei der Abarbeitung der while-Schleife wird in jedem Durchlauf die Erfüllung der Bedingung (Resultat true) geprüft und dann der Programm-Code abgearbeitet. Ist das Resultat der Bedingungsauswertung false, so wird die Abarbeitung der Schleife abgebrochen. Die folgende while-Schleife realisiert die bereits zu Anfang vorgestellte for-Schleife:

syntax 

int i = 0;
while (i < 100000)

    i++;

Während mit einer while-Schleife immer auch die Funktionalität einer for-Schleife realisiert werden kann, ist dies umgekehrt nicht der Fall. Ein Beispiel hierfür findet sich häufig in main-Methoden, die auf Servern aufgerufen werden. Sollen bspw. ohne zeitliche Begrenzung Anfragen aus dem Netz beantwortet werden, so kann dies folgendermaßen implementiert werden:

syntax 

while (true) {

    // erwarte Anfrage
    // bearbeite Anfrage
    //beantworte Anfrage

}

Eine derartige Funktionalität kann mit einer for-Schleife nur sehr umständlich realisiert werden.

do...while-Schleifen

Betrachtet man nun die while-Schleife unter dem Aspekt, dass vor der Ausführung des Programm-Codes eine Bedingungsprüfung steht, so wird ein Nachteil leicht erkennbar: Die while-Schleife wird nur dann komplett durchlaufen, wenn die Bedingung mindestens einmal true ist. Es sind aber auch Anwendungen denkbar, in denen der Programm-Code genau einmal ausgeführt werden soll, obwohl die Bedingung niemals erfüllt wird. Dies ist gleichbedeutend mit einer Überprüfung der Bedingung am Ende der Schleife. Der hierzu notwendige Programmausdruck ist die do...while-Schleife. Die hierzu notwendige Syntax ist

syntax 

do {

    // Programm-Code

} while (Bedingung);

Ein Beispiel für eine do...while-Schleife ist

syntax 

int i = 0;
do {

    i++;

} while (i < 100000);

Alle bisher betrachteten Schleifen haben gemeinsam, dass die Schleife genau dann verlassen wird, wenn die Auswertung einer Bedingung false als Resultat liefert. Es können nun aber Fälle eintreten, in denen die Schleife bereits vorher verlassen werden soll. Hierzu wird – wie schon bei der switch-Anweisung – das break-Kommando verwendet. break beendet unmittelbar die Ausführung eines Schleifendurchlaufs und setzt die Abarbeitung eines Programms außerhalb einer Schleife fort. Werden verschachtelte Schleifen verwendet, so wird die momentan bearbeitete Schleife verlassen und mit der nächstäußeren fortgefahren. Anderenfalls wird das Programm mit der nächsten Anweisung außerhalb der Schleife fortgesetzt. Während break zur sofortigen Beendigung einer Schleife führt, kann es aber durchaus wünschenswert sein, lediglich den momentanen Schleifendurchlauf zu beenden und mit dem nächsten regulär fortzufahren. Hierzu verwendet man das continue-Kommando. continue wertet in for-Schleifen den Inkrement/Dekrement-Ausdruck aus und führt dann den Anweisungsblock aus. In do- und while-Schleifen wird die Ausführung des Anweisungsblocks von Beginn an neu abgearbeitet. Das folgende Beispiel illustriert die Verwendung von continue:

syntax 

for(int i = 0; i < 100; i++) {

    if ((i%2) != 0)

      continue;

    System.out.println("Gerade Zahl: " + i);

}

In diesem Beispiel wird geprüft, ob der Zähler modulo 2 ungleich 0 ist (ungerade Zahl). Ist dies erfüllt, so wird mit dem nächsten Schleifendurchlauf fortgefahren, anderenfalls wird eine gerade Zahl ausgegeben.

Labels

Betrachtet man break und continue näher, so stellt man fest, dass offensichtlich implizit im Programm gewisse Stellen markiert worden sind, an denen die Programmausführung fortgesetzt wird. Derartige Stellen bezeichnet man auch als Labels. Labels können in Java auch explizit vereinbart werden, wodurch die Zielpunkte von break- als auch von continue-Anweisungen genau spezifizierbar sind. Hierzu benennt man ein Label an einer beliebigen Stelle und setzt anschließend einen Doppelpunkt:

syntax 

Label:

    Anweisungsblock

Mittels break Labelname kann nun zu einem Label gesprungen werden, mittels continue Labelname kann eine Schleife, die außen um die momentan verwendete liegt, fortgesetzt werden. Das folgende Beispiel illustriert die Verwendung eines Labels mit break:

syntax 

schleifenanfang:

    for (int i = 0; i<10; i++)

      if (i == 8) {

        System.out.Println("Vor letztem Durchlauf Sprung zu Label");
        break schleifenanfang;

      }

Es muss allerdings deutlich darauf hingewiesen werden, dass bei der Verwendung von Labels äußerste Vorsicht ratsam ist. Liegen Label und Sprungaufruf im Code weit auseinander, so wird der Code schwer lesbar und Fehler sind eine häufige Konsequenz. Die Verwendung von Sprüngen kann zudem bei einer sorgfältigen Programmierung meist vermieden werden, wodurch man auf die Benutzung von Labels leicht verzichten kann.

Parameter in Applications

Zum jetzigen Zeitpunkt hat der Leser alle Verfahren kennengelernt, die die Grundfunktionalität der Sprache Java darstellen und die die Programmierung von Anwendungen erlauben, deren Ausgabe in der Kommandozeile angezeigt werden. Zum Abschluss dieses Teilkapitels muss nun aber noch erläutert werden, wie Parameter an Applications übergeben werden können. Die eigentliche Parameterübergabe ist sehr einfach, da die Parameter in String-Form lediglich an den Aufruf des Interpreters mit dem Programmnamen angehängt werden. Möchte man bspw. die Application beispiel.java mit Parametern aufrufen, so übersetzt man diese zuerst mittels javac und ruft dann bspw. folgendermaßen auf:

syntax 

java beispiel 1 Hallo "Universitaet Darmstadt"

Hierbei wurden drei Parameter übergeben: 1, Hallo und "Universitaet Darmstadt". Es ist wichtig, Parameterkomponenten von Strings in Anführungszeichen zu setzen. Hätte man im obigen Beispiel die Anführungszeichen weggelassen, so hätte Java 4 Parameter verarbeitet.

Die Verarbeitung der Parameter im Programm selbst erfolgt mit Hilfe des Parameters, der in der Signatur der Methode main verwendet wird. Diese Signatur hat immer dann, wenn Parameter verwendet werden, folgendes Aussehen:

syntax 

public static void main (String argumente[]) {

    //Rumpf

}

Mit Ausnahme des Schlüsselwortes public wurden alle Konzepte, die zum Verständnis der Signatur nötig sind, bereits erklärt. Bei der Parameterübergabe werden die Parameter einzeln in die Zeichenkettenliste argumente[] eingetragen. Hier kann man sie leicht in der folgenden Form abrufen:

syntax 

for (int i = 0; i < argumente.length; i++)

    System.out.println(i + ". Argument: " + argumente[i]);

Hierbei ist zu beachten, dass Argumente, die anschließend im Programm nicht in Form von Zeichenketten verarbeitet werden sollen, vor der Weiterverarbeitung geeignet konvertiert werden müssen (siehe bspw. Casting). Ein wichtiger Unterschied zu Sprachen wie C oder C++ ist weiterhin, dass das erste Argument bereits als erstes Argument der Zeichenkettenliste steht (in diesem Fall in argumente[0]).

Die Parameterübergabe an Applets ist grundsätzlich verschieden von der Übergabe an Applications. Hierzu siehe Kapitel 4.1.

Objektorientiertes Programmieren mit Java

Obwohl bis zu diesem Teil des Buches bereits die Verwendung von Klassen und Objekten erläutert worden ist, kann man nicht von einer objektorientierten Java-Programmierung sprechen, da die dargestellten Konzepte auch in den meisten anderen prozeduralen Programmiersprachen bekannt sind. Zum Schluss dieses Teilkapitels wird daher detailliert beleuchtet, welche Konzepte Java anbietet, um objektorientierte Programme zu entwickeln. Hierzu werden die folgenden Punkte erläutert:

  • Entwurf einer Klassenhierarchie
  • Zugriffe auf Variablen und Methoden innerhalb der Klassenhierarchie
  • Abstrakte Klassen und Methoden
  • Entwicklung von Packages und
  • Collections/Interfaces

Entwurf einer Klassenhierarchie

Der Begriff der Klassenhierarchie, der bereits mehrfach verwendet wurde, bezeichnet einen Verbund von Klassen, die in Vererbungsbeziehungen zueinander stehen. Subklassen, die Eigenschaften von Superklassen erben, werden hierbei durch das Schlüsselwort extends gekennzeichnet. Es wurde bspw. bereits darauf hingewiesen, dass jedes Applet die Eigenschaften der Applet-Klasse erbt, weshalb auch jede Definition eines Applets nach der Klassendefinition ... extends Applet als Teil der Signatur aufweist. Neben diesem Schlüsselwort existieren aber eine Reihe weiterer Schlüsselworte, die den Zugriff auf Klassen und Methoden einer Klassenhierarchie regeln und die im nächsten Abschnitt erläutert werden.

Zugriff auf Variablen und Methoden innerhalb der Klassenhierarchie

In Java beginnen Klassen und Methoden standardmäßig mit den Schlüsselworten class bzw. Typ der Methode. Der Zugriff auf Klassen und Methoden und damit auch deren Verwendbarkeit kann aber durch sog. Modifier verändert werden. In Java können folgende Modifier verwendet werden:

  • Zugriffskontrolle auf Klassen und Methoden durch die Modifier public, protected und private.
  • Bezeichnung als Klassenmethode bzw. Klassenvariable durch den Modifier static.
  • Kennzeichnung der Unveränderbarkeit der Implementierung von Klassen, Methoden und Variablen durch den Modifier final.
  • Bezeichnung von abstrakten Klassen und Methoden durch den Modifier abstract.
  • Modifier synchronized und volatile für Threads.

Modifier werden der Deklaration einer Variablen, Methode oder Klasse immer einzeln oder in Kombination vorangestellt. Ein Beispiel hierfür ist die bereits mehrfach verwendete Signatur der Methode main:

syntax 

public static void main (String argumente[]) {}

Im Folgenden werden die verfügbaren Modifier detailliert erläutert. Betrachtet man die Arbeitsweise mit einer Klassenhierarchie näher, so erscheint es einleuchtend, dass gewisse Teile dieser Hierarchie nach außen hin sichtbar sind, während andere verborgen sind und damit lokal arbeiten. Es muss daher eine Möglichkeit geben, die Zugriffsart auf Klassen, Methoden und Variablen aus dieser Hierarchie heraus spezifizieren zu können bzw. auch, diese – falls notwendig – einzuschränken. Sicherlich wäre es eher verwirrend, wenn jede Variable, die an einer beliebigen Stelle des Programms verwendet wird, allen anderen Klassen bekannt wäre. Die Kontrolle, die ein Objekt darüber hat, welche Information an die Außenwelt vermittelt wird, und in welcher Art und Weise die Außenwelt mit ihm interagieren kann, bezeichnet man auch als Kapselungskonzept. Unter Kapselung von Variablen versteht man dann bspw., dass die Variablen einer Klasse von außerhalb der Klasse nicht gelesen oder verändert werden können. Dies kann nur unter Verwendung der Methoden derselben Klasse erfolgen und auch nur dann, wenn wiederum diese Methoden von außen sichtbar sind. Zur Angabe der Zugriffsrechte von Klassen, Methoden und Variablen werden die Modifier public, protected und private verwendet.

Grundsätzlich kann jede Variable oder Methode, die ohne einen dieser Modifier angegeben wird, von allen anderen Klassen eines Packages gelesen und auch verändert werden. Klassen anderer Packages sind dazu allerdings nicht in der Lage. Es ist offensichtlich, dass diese Art des Zugriffsschutzes nicht besonders effektiv arbeitet. Zur genaueren Angabe der Zugriffsrechte ist daher die Verwendung der drei Modifier empfehlenswert.

Wird der Modifier public verwendet, so steht eine Methode oder Variable allen anderen Klassen zur Verfügung. Deshalb ist bspw. auch die main-Methode als public deklariert, da diese ansonsten nicht durch den Java-Interpreter aufgerufen werden könnte. Es ist weiterhin auch logisch, dass innerhalb der Vererbungsstruktur der Klassen alle als public deklarierten Methoden und Variablen insbesondere auch allen Subklassen dieser Klasse bekannt sind. Oftmals werden vor allem Klassenvariablen als public deklariert, um deren Verfügbarkeitsgrad einerseits zu dokumentieren und andererseits auch für den Klassenverbund bekannt zu machen.

Das genaue Gegenteil des public-Modifiers ist das Schlüsselwort private. private verbirgt eine Methode oder Variable vollständig vor anderen Klassen. Hierdurch sind derart spezifizierte Methoden oder Variablen nur innerhalb der Klasse sichtbar, in der sie deklariert sind. Instanzvariablen, die als private deklariert sind, können also von anderen Methoden der eigenen Klasse verwendet werden; Methoden, die als private gekennzeichnet sind, nur von anderen Methoden der eigenen Klasse aufgerufen werden. Diese Regel betrifft auch die Vererbung: Als private deklarierte Methoden und Variablen können nicht an Subklassen vererbt werden. Die folgenden zwei Gründe zeigen die Nützlichkeit von als private deklarierten Variablen:

  • andere Klassen, für die keine Notwendigkeit besteht, eine Variable zu verwenden, können auf diese auch nicht zugreifen.
  • andere Klassen können daran gehindert werden, durch Veränderung dieser Variablen ein unvorhergesehenes Verhalten auszulösen.

Der private-Modifier ist aufgrund dieser Funktionalität das wichtigste Instrument zur Realisierung des Kapselungskonzepts.

Eine Mischform aus private und public ist der Modifier protected. Es liegt auf der Hand, dass in manchen Fällen eine Variable zwar vor unbegrenztem Zugriff geschützt werden soll, dass aber eventuell Subklassen auf diese Variable zugreifen sollen, da diese in unmittelbarem Zusammenhang zur Elternklasse stehen. protected erlaubt daher den Zugriff auf derart deklarierte Variablen und Methoden für die Subklassen einer Klasse und für andere Klassen desselben Packages.

Tab. 3-9 stellt eine Zusammenfassung der Eigenschaften der Modifier public, private und protected dar. Während die Verwendung von public, private und protected einfach zu verstehen ist, ist die Verwendung im Zusammenspiel mit Subklassen komplexer, da hier auch ein Überschreiben von Methoden in einer Subklasse zur Anwendung kommen kann. Folgende Regeln können angewandt werden, um die Zugriffsrechte von Subklassen zu setzen bzw. um diese festzustellen:

  • Methoden, die in einer Superklasse als public vereinbart werden, sind dies (trotz Überschreibens) auch in allen Subklassen.
  • Methoden, die in einer Superklasse als protected vereinbart werden, sind (trotz Überschreibens) in allen Subklassen entweder protected oder public, nicht aber private.
  • Methoden, die ohne Zugriffskontrolle deklariert werden, können in ihren Zugriffsrechten in Subklassen eingeschränkt werden (bspw. als private).
  • Methoden, die als private deklariert sind, können nicht vererbt werden, weshalb das Regelwerk in diesem Fall sowieso nicht anwendbar ist.

Sichtbarkeit

Ohne Modifier

public

private

protected

Aus derselben Klasse

Ja

Ja

Ja

Ja

Aus einer Klasse desselben Packages

Ja

Ja

Nein

Ja

Aus einer Klasse außerhalb des Packages

Nein

Ja

Nein

Nein

Aus einer Subklasse desselben Packages

Ja

Ja

Nein

Ja

Aus einer Subklasse außerhalb desselben Packages

 

Nein

Ja

Nein

Ja

Tab. 3.9: Zugriffskontrolle durch Modifier public, protected und private

Anhand dieses Regelwerks ist das korrekte Setzen und Abfragen von Zugriffsrechten einfach. Ein Problem ist aber hierbei nicht abgedeckt: Es kann nötig werden, dass eine Klasse, die von außerhalb einer anderen Klasse auf eine Variable zugreift, diese aber nur auf bestimmte Werte setzen darf. Ein Beispiel hierfür wäre eine Routine, die an einem Geldautomaten abzuhebende Beträge setzt. Sicherlich ist hier ein gewisser Wertebereich einzuhalten, der die abzuhebenden Beträge wiedergibt. Man kann daher eine derartige Variable nicht für jeglichen Zugriff freigeben. Andererseits kann die Variable aber auch nicht als private deklariert werden, da dann jeder Zugriff unmöglich wäre. Zur Lösung dieses Problems muss ein Trick angewendet werden: Zugriffsmethoden. Hierzu definiert man die Variable als private, wodurch jegliche Zugriffe von außen unmöglich werden, definiert aber gleichzeitig eine Methode innerhalb der Klasse (die per definitionem auf die Variable zugreifen darf und die das Setzen der korrekten Werte kontrolliert). Oft findet man zur Erfüllung dieser Aufgabe auch verschiedene Methoden, die das Setzen und das Abfragen von Variablen ermöglichen. In Java wird es mehr und mehr Standard, hierzu die Präfixe set und get zu setzen, bspw. getSize(), um eine Größe abzufragen. Die Verwendung von Methoden zum Zugriff auf Instanzvariablen ist eine oft verwendete Technik der objektorientierten Programmierung. Klassen werden hierdurch besser wiederverwendbar, da unrechtmäßige Zugriffe elegant verhindert werden können.

Innere Klassen

Die Klassen, die bisher beschrieben wurden, sind immer Teil eines Packages, also entweder eines vom Benutzer angelegten Packages oder des Standard-Packages. Derartige Klassen bezeichnet man auch als Top-Level-Klassen. Ab Java 1.1 wurde erweiternd erlaubt, auch Klassen innerhalb von Klassen zu definieren, die als innere Klassen bezeichnet werden. Vergleicht man die Funktion innerer Klassen mit der von Top-Level-Klassen, so weist die Verwendung innerer Klassen die folgenden Vorteile auf:

  • Innere Klassen sind für alle anderen Klassen unsichtbar. Namenskonflikte können hier also keinesfalls auftreten.
  • Im Gegensatz zu Top-Level-Klassen haben innere Klassen Zugriff auf Variablen und Methoden im Bereich der Top-Level-Klasse, den sie als separate Klasse nicht hätten.

Das Einsatzgebiet innerer Klassen kann leicht aus den Vorteilen abgeleitet werden: Innere Klassen werden immer dann verwendet, wenn eine Klasse eine sehr eingeschränkte Funktionalität bieten soll, bspw. als Hilfsklasse. Der Gültigkeitsbereich dieser Klassenart ist ähnlich wie der von Variablen. Innere Klassen sind nur innerhalb einer Klasse sichtbar, jedoch nicht in den jeweiligen Superklassen, es sei denn, der vollständige Name wird angegeben. Innerhalb der inneren Klasse können einfache Namen der umgebenden Klasse und deren Superklassen verwendet werden. Ist die Klassendefinition Bestandteil eines Programmblocks, so kann auch auf die dort lokal definierten Variablen zugegriffen werden.

Bei der Übersetzung von inneren Klassen ist zu beachten, dass der Compiler für jede innere Klasse eine weitere .class-Datei anlegt, die den Namen Klassenname$Name-der-inneren-Klasse.class bekommt. Bei der Verwendung des Programm-Codes ist deshalb darauf zu achten, dass alle erzeugten .class-Dateien zur Verfügung stehen.

Im Unterschied zu inneren Klassen können auch Top-Level-Klassen als Teil anderer Top-Level-Klassen definiert werden, indem sie als static-Komponente einer anderen Top-Level-Klasse beigefügt werden (die Erläuterung des Modifiers static erfolgt im nächsten Abschnitt). In diesem Fall bezeichnet man eine Klasse, die Teil einer anderen wird, auch als sekundäre Top-Level-Klasse. Es ist aber zu beachten, dass Top-Level-Klassen niemals auf die Instanzvariablen einer anderen Klasse zugreifen dürfen. Die Verschachtelung von Top-Level-Klassen dient daher eher zur Organisation von Klassen in Top-Level-Klassen und in untergeordnete sekundäre Top-Level-Klassen und erfolgt ähnlich wie bei Packages (siehe unten).

Statische Methoden und Variablen

static ist ein Modifier, dessen Funktionalität bereits erläutert wurde. Im Kontext objektorientierten Programmierens soll diese Erläuterung wiederholt und dadurch vertieft werden. static wird immer dann verwendet, wenn Klassenmethoden oder Klassenvariablen erzeugt werden sollen, Methoden oder Variablen also, die nicht nur für spezielle Instanzen eine Bedeutung haben, sondern für die Klasse allgemein. Auf Klassenmethoden und Klassenvariablen kann zugegriffen werden, indem der Klassenname gefolgt von einem Punkt und dem Namen der Methode oder Variable verwendet wird. Soll bspw. eine Zufallszahl erzeugt werden, so kann die Methode random(), die Teil der Klasse Math ist, folgendermaßen verwendet werden:

code 

float zufallszahl = Math.random();

Im Gegensatz hierzu kann auf Instanzvariablen bzw. auf Instanzmethoden nicht unter Verwendung des Klassennamens zugegriffen werden. Eine Gemeinsamkeit von Klassenmethoden bzw. Klassenvariablen und Instanzmethoden bzw. Instanzvariablen ist, dass beide in einer Klasse als private gekennzeichnet werden können und ein Zugriff nur durch eine spezielle Zugriffsmethode erfolgen kann. Das folgende Programm verdeutlicht die wichtigsten Unterschiede zwischen Klassen- und Instanzobjekten (Datei1, Datei2):

code 

public class zaehleInstanzen {

    static int anzahlInstanzen = 0;
    void erfrageAnzahlInstanzen (){

      System.out.println("Erzeugte " + anzahlInstanzen + " Instanzen");
      return;

    }

    zaehleInstanzen2 () {

      addiereInstanz();

    }

    public void addiereInstanz () {

      anzahlInstanzen++;
      erfrageAnzahlInstanzen();

    }

    public static void main (String argumente []) {

      for (int i = 0; i < 100; i++)

        new zaehleInstanzen2();

    }

}

Übersetzt man das Programm und führt es aus, so wird in jeder Anweisung der for-Zählschleife die als static deklarierte Variable anzahlInstanzen um eins hochgezählt. Die letzte Ausgabe ist daher „Erzeugte 100 Instanzen". Obwohl also 100 verschiedene Instanzen derselben Klasse erzeugt werden, verwenden alle Klassen dieselbe Variable. Dies ist grundlegend anders, wenn der Modifier nicht verwendet wird. In diesem Fall wird für jede neu erzeugte Klasse die Zeichenkette „Erzeugte 1 Instanz" ausgegeben. Für jede Klasse wird dann eine Instanzvariable erzeugt, die von den anderen Instanzen nicht verändert wird.

Finale Klassen, Methoden und Variablen

Der final-Modifier wird immer dann verwendet, wenn eine Klasse, Methode oder Variable nicht weiter verändert werden soll. Ein Beispiel für die Anwendung dieses Modifiers wurde bereits bei der Definition von Konstanten erläutert – in diesem Fall wird der Variablendeklaration der Modifier vorangestellt. Sicherlich macht es wenig Sinn, den Wert einer Konstanten im Programmablauf zu verändern, weshalb die entsprechende Variable bereits bei der Deklaration als final gekennzeichnet wird. final hat allerdings für Klassen, Methoden und Variablen jeweils eine unterschiedliche Bedeutung:

  • als final gekennzeichnete Klassen können ihre Funktionalität nicht an Subklassen vererben.
  • als final gekennzeichnete Methoden können nicht von Subklassen überschrieben werden.
  • die Werte von Variablen, die als final gekennzeichnet sind, können nicht verändert werden.

Variablen, die als final gekennzeichnet sind, werden oft zusätzlich als static vereinbart, da es wenig Sinn macht, für jede Instanz einer Klasse eine eigene Kopie einer Konstanten zu erzeugen. Ein Beispiel für eine derart vereinbarte Konstante ist

code 

public static final int pi = 3.14;

Eine Änderung, die ab Java 1.2 gilt, ist, dass jede Art von Variable – also Klassenvariable, Instanzvariable oder lokale Variable – als final vereinbart werden darf.

Methoden, die als final deklariert sind, können nicht von Subklassen überschrieben werden. Der Grund der Verwendung von final bei Methoden liegt in der Ausführungsgeschwindigkeit von Java-Programmen. Normalerweise lokalisiert die Java-Laufzeitumgebung (bspw. der Interpreter) eine Methode zuerst in der momentan abgearbeiteten Klasse und anschließend in allen Superklassen, die in der Klassenhierarchie implementiert sind. Wird eine Methode aber als final deklariert, so fügt der Java-Compiler den ausführbaren Bytecode direkt in das Programm ein, das die Methode aufruft. Dies ist möglich, da die Funktionalität dieser Methode sich aufgrund der fehlenden Überschreibungsmöglichkeit von Subklassen nicht mehr ändern kann. Dies erhöht die Ausführungsgeschwindigkeit wesentlich. Hierbei ist sorgfältig zu beachten, dass auch tatsächlich später keine weiteren Subklassen implementiert werden, die die als final vereinbarte Methode überschreiben. Betrachtet man unter diesem Aspekt nochmals den Modifier private, so folgt hieraus, dass als private vereinbarte Methoden auch automatisch implizit final sind, da diese nicht an Subklassen vererbt werden können.

Ebenfalls aus Geschwindigkeitsgründen können Klassen als final deklariert werden. Eine derart vereinbarte Klasse kann dann aber ihre Funktionalität nicht mehr an Subklassen vererben. Ist eine Klasse als final deklariert, so sind auch automatisch alle Methoden der Klasse final. Viele der in der Java-Klassenbibliothek definierten Klassen sind als final deklariert. Problematisch hierbei ist, dass derartige Klassen vom Benutzer nicht durch Vererbung erweitert werden können. Soll also eine Standardklasse dieser Art erweitert werden, so muss sie vollständig neu implementiert werden. Aufgrund der erzielbaren Geschwindigkeitszuwächse bei der Ausführung von Java-Programmen ist dies aber meist gerechtfertigt.

Abstrakte Klassen und Methoden

Grundlegende Eigenschaft einer Klassenhierarchie ist, dass die Funktionalität immer weiter verfeinert und ergänzt wird, je tiefer eine Klasse in der Hierarchie angesiedelt ist. Umgekehrt beschreiben die Klassen auf den höheren Ebenen der Hierarchie in abstrakter Art und Weise die Funktionalität, die tiefer angeordnete Klassen dann erfüllen. Es kann daher vorkommen, dass beim Entwurf einer Hierarchie Klassen generiert werden, die niemals instantiiert werden. Die Funktionalität derartiger Klassen ist es daher, Eigenschaften und Funktionen zu sammeln, die alle Subklassen gebrauchen können. Ein Beispiel für eine derartige Klasse ist die Klasse java.awt.Component, die Komponenten für grafische Benutzeroberflächen bereitstellt. Alle Subklassen dieser Klasse, die Benutzeroberflächen realisieren, erben diese Funktionalität von dieser Superklasse. In den seltensten Fällen wird es aber notwendig sein, diese Klasse zu instantiieren.

Derart abstrakte Klassen verwalten daher generische Komponenten, die anschließend in Teilen von Subklassen verwendet werden. Zur Kennzeichnung dieser Eigenschaft wird der Modifier abstract verwendet.

Als abstract gekennzeichnete Klassen können alle Komponenten beinhalten, die eine normale Klasse aufweist, also bspw. Konstruktoren oder Methoden. Konstruktormethoden werden dann an die jeweilige Subklasse vererbt und dort auch tatsächlich durch Instantiierung verwendet. Abstrakte Methoden bestehen lediglich aus einer Signatur, haben also keine Implementierung. Nach der Vererbung auf eine Subklasse wird die jeweilige Methode dort implementiert. Ein Beispiel hierfür findet sich in vielen Methoden, die Sicherheitsfunktionen implementieren. Auch abstrakte Methoden werden dazu mit dem Modifier abstract gekennzeichnet. Es versteht sich hierbei von selbst, dass abstrakte Methoden nicht in Klassen deklariert werden können, die selbst nicht abstrakt sind. Die Verwendung von abstrakten Klassen, die ausschließlich abstrakte Methoden enthalten, ist nicht ratsam. Eine derartige Funktion ist erheblich leichter mit den im Folgenden beschriebenen Interfaces zu erstellen.

Collections

Unter einer Collection bzw. einem Container versteht man ein Objekt, dessen Aufgabe es ist, andere Elemente in einer Einheit zu gruppieren. Der Zweck einer derartigen Sammlung kann in der Speicherung, in der Abfrage und Modifikation, aber auch in der Übertragung von Daten von einer Methode zu einer anderen liegen. Collections repräsentieren daher typischerweise Daten, die in einer bestimmten logischen Beziehung zueinander stehen. Ein gutes Beispiel hierfür sind Listen einer vorab unbekannten Länge, die Dateneinträge desselben Typs aufnehmen können. Im Unterschied zu Listen mit einer fest definierten Größe, die bereits vorgestellt wurden, können mit Collections elegant Daten gruppiert werden, deren Anzahl nicht unmittelbar bekannt ist. Bei Verwendung einer Datenbank dürfte bspw. vorab unbekannt sein, wie viele Daten einmal in der Datenbank gespeichert sein werden.

Frühe Versionen von Java implementierten ausschließlich die Collections Vector, HashTable und Array. In Java 1.2 steht nun aber auch ein Collections Framework zur Verfügung. In der Literatur finden sich verschiedene Definitionen des Begriffs Framework. Johnson [Joh91] definiert bspw. ein Framework als eine Menge von Objekten, die zusammenarbeiten, um eine Menge von Diensten für eine Anwendung oder für einen Subsystembereich zu erbringen. Mattson [Mat96] verwendet die Definition einer (generativen) Architektur, die zur maximalen Wiederverwendbarkeit entwickelt wird, und die als kollektive Menge von abstrakten und konkreten Klassen repräsentiert wird, wodurch das potentielle Verhalten der als Subklassen entwickelten Spezifikationen gekapselt wird. Im Folgenden verwenden wir eine eigene Definition des Begriffs, die spezifisch für die Entwicklung von Animationen ist: "Ein Framework wird als eine Menge von Regeln, Schnittstellen und Klassen verstanden. Es erleichtert die Entwicklung von Animationen dadurch, dass die Wiederverwendung von Code, Patterns und Klassenwissen gefördert wird."

Als Framework stellt auch das Collections Framework eine einheitliche Umgebung zur Repräsentation und Manipulation von Collections zur Verfügung. Diese Architektur enthält:

  • die anschließend beschriebenen Interfaces, die in Form von abstrakten Datentypen Collections darstellen. Interfaces erlauben es, Collections unabhängig von Implementierungsdetails der zugrunde liegenden Daten zu manipulieren. In Java formen Interfaces, ebenso wie Klassen, eine Vererbungshierarchie.
  • Implementierungen, die das abstrakt definierte Collections Interface üblicherweise als wiederverwendbare Datenstrukturen implementieren.
  • Methoden, die eine bestimmte Datenverarbeitung durchführen. Derartige Methoden werden auch als polymorph bezeichnet, da dieselbe Methode in vielen verschiedenen Implementierungen des geeigneten Collections Interfaces verwendet werden kann. Algorithmen sind daher eine Art von wiederverwendbarer Funktionalität.

Das Collections Framework bietet eine große Anzahl von Vorteilen:

  • Der Umfang der zur Umsetzung einer Aufgabe notwendigen Programmierung wird verringert. Durch geeignete Datenstrukturen und Algorithmen wird der Entwickler so in die Lage versetzt, sich auf wichtige Bereiche eines Programms zu konzentrieren, anstatt Programmteile auf niedriger Ebene zu entwickeln.
  • Da das Collections Framework hochgradig optimiert implementiert ist, gewinnen Anwendungen, die das Framework verwenden, deutlich an Geschwindigkeit. Da die verschiedenen Implementierungen eines Interfaces austauschbar sind, können Programme leicht verändert werden, wenn die Collection-Implementierung ausgetauscht wird.
  • Durch Collections können Application Programming Interfaces (APIs), die in keinerlei Beziehung zueinander stehen, miteinander verbunden werden, ohne sich um die genaue Anpassung oder Konversion von Code-Teilen kümmern zu müssen. Liefert bspw. eine Datenbankanfrage eine Collection von Tabellenattributen als Ergebnis und erwartet parallel dazu die grafische Benutzeroberfläche eine Collection von Spaltenbezeichnern, so können beide Anwendungen mittels des Collections Frameworks zusammenarbeiten, obwohl beide unabhängig voneinander entwickelt wurden.
  • Die Benutzung neuartiger APIs wird vereinfacht, da viele APIs bereits Collections als Eingabe erwarten bzw. eine derartige Ausgabe erzeugen.
  • Die Entwicklung neuer APIs wird vereinfacht, da APIs, die ausschließlich mit Collections entwickelt werden, nach einer Standardprozedur implementiert werden können.
  • Die Wiederverwendbarkeit von Software wird erleichtert. Neuentwickelte Datenstrukturen, die mit den Interfaces der Standard-Collection konform sind, sind auch wiederverwendbar. Dies gilt auch für neue Algorithmen, die auf Objekten operieren, die diese Interfaces implementieren.

Im Folgenden werden zunächst Interfaces vorgestellt. Hieran schließt sich die Erläuterung von Implementierungen und Methoden im Kontext der Collections an.

Interfaces

Interfaces stellen Programm-Muster zur Verfügung, die andere Klassen implementieren können. Die Funktionalität von Interfaces ist daher der von abstrakten Klassen und Methoden sehr ähnlich, erweitert diese aber erheblich. Das Hierarchiekonzept von Klassen einschließlich der Vererbung stellt sicherlich eine umfangreiche Funktionalität zur Verfügung, ist aber zu unflexibel, um ähnliche Verhaltensmuster effizient zu handhaben, die in verschiedenen Ästen des Hierarchiebaums verwendet werden sollen. Dieses Problem ist äquivalent mit dem Problem der Mehrfachvererbung, die in Java verboten ist. Eine Subklasse kann per Definition eben nur eine Superklasse haben, auch wenn die Verwendung mehrer Superklassen vielleicht sehr sinnvoll wäre. Das Verbot der Mehrfachvererbung wurde allerdings nach reiflicher Überlegung eingeführt. Bedenkt man, welche Komplexität die Verwendung einer beliebigen Mehrfachvererbung von Klassen bedeuten würde, so wird verständlich, dass diese potentielle Fehlerquelle ausgeschaltet werden sollte. Gleichwohl war den Entwicklern der Sprache Java sehr wohl bewusst, dass das Verbot der Mehrfachvererbung eine erhebliche Redundanz des Programm-Codes erzeugt, die letztendlich das Programm unübersichtlich und schwer lesbar macht. Um hier einen Kompromiss zu finden, können in Java zwei verschiedene Hierarchiebäume verwendet werden. Den ersten Baum, die Verwendung einer Vererbungshierarchie, hat der Leser bereits detailliert kennengelernt. Die zweite Hierarchie ist die sog. Interface-Hierarchie. Ein Interface stellt eine Sammlung von abstrakten Verhaltensmustern dar, die einer Klasse hinzugefügt werden können, um Funktionen zu erzeugen, die in keiner der Superklassen enthalten sind und die daher auch nicht geerbt werden können. Ein Interface enthält ausschließlich abstrakte Methodendefinitionen und Konstanten, aber keine Implementierungen oder Instanzvariablen. Legt man nun eine neue Klasse an, so erbt diese wie bisher auch Verhaltensmuster von ihrer Superklasse. Zusätzlich können aber beliebige Funktionen der Interface-Hierarchie hinzugefügt werden. Diese müssen dann in der Klasse implementiert werden. Es sollte nun auch verständlich werden, warum die Verwendung einer abstrakten Klasse, die ausschließlich abstrakt definierte Methoden enthält, nicht besonders sinnvoll ist. Eine derartige Klasse stellt nichts weiter als eine Teilmenge einer Interface-Definition dar und ein Programmierer, der die abstrakte Klasse anstelle des Interfaces verwendet, verzichtet auf die zusätzliche Funktionalität, die Interfaces anbieten.

Ein großer Vorteil von Interfaces ist weiterhin, dass Konstanten zentral definiert werden können, die dann in verschiedenen Klassen der Hierarchie zum Einsatz kommen. Wird es notwendig, den Wert einer Konstanten zu ändern, so reicht die Modifikation der Interface-Definition aus und ein Durchforsten der Klassenhierarchie auf eventuelle Vorkommen der Konstantendefinition kann entfallen.

Ähnlich wie auch Klassen werden Interfaces in Quelldateien definiert, wobei genau ein Interface pro Datei zulässig ist. Auch Interfaces werden nach dem Übersetzen in .class-Dateien abgelegt und auch die Zuweisung ist ähnlich wie bei Klassen, da stets dann, wenn eine Klasse verwendet wird, auch ein Interface anwendbar wäre. Der Begriff Klasse bekommt in diesem Zusammenhang eine neue Bedeutung, da er nun als Sammelbegriff für Klasse oder Interface verstanden wird. Interfaces und Klassen sind von daher fast identisch mit dem einen Unterschied, dass Interfaces nicht mittels des new-Operators instantiiert werden dürfen. Die Hauptaufgabe der Interfaces ist daher die Ergänzung und Erweiterung von Klassen.

Um ein Interface zu erzeugen, muss eine Syntax wie im folgenden Beispiel verwendet werden:

syntax 

public interface Interface-Name {

    // Konstantendefinitionen, bspw. public static final int
    //zahl = 1;
    public abstract void Methodenname();
    //weitere Methoden

}

Der Modifier public kann bei Methoden zwar entfallen, implizit wird dann aber angenommen, dass alle im Interface definierten abstrakten Methoden public und abstract sind. Da Interfaces in anderen Klassen implementiert werden, ist die Verwendung der Modifier protected oder private ohnehin nicht zulässig. Konstanten sind immer public, static und final. Werden diese Modifier weggelassen, so wird die Konstante automatisch auf diese Modifier gesetzt.

Betrachtet man den Zugriffsschutz des Interfaces, so kann dieser entweder als public oder als Package-Schutz definiert werden. Während Interfaces, die als public deklariert sind, automatisch alle Konstanten und Methoden als public betrachten, ist dies für Interfaces, die nicht derart vereinbart sind, nicht der Fall. Dann können die Methoden und Konstanten des Interfaces nur von anderen Klassen und Interfaces desselben Packages verwendet werden. Um ein Interface in ein Package zu integrieren, ist dasselbe Vorgehen wie bei Klassen nötig; zu Beginn der Klassendatei wird also die package-Anweisung verwendet (siehe unten).

Die Ähnlichkeit von Klassen und Interfaces wurde bereits mehrfach deutlich gemacht, auch, dass Interfaces neben dem Vererbungsbaum einen zweiten Hierarchiebaum realisieren. Es ist daher kaum verwunderlich, dass die Vererbungsmechanismen auch bei Interfaces verwendet werden. Ein Sub-Interface erbt daher alle abstrakten Methoden und Konstanten des Super-Interfaces. Analog zur Definition von Klassen lautet die hierzu notwendige Syntax:

syntax 

public interface Sub-Interface-Name extends Super-Interface-Name

Ein wesentlicher Unterschied zwischen Klassen und Interfaces wird hier jedoch offensichtlich: Es ist immer möglich, eine Klasse in der in Java verwendeten Klassenhierarchie zu lokalisieren. Dies gilt für Interfaces nicht, da Interfaces sowohl einzeln als auch als Sub-Interface eines anderen Interfaces existieren können. Ein Ausgangspunkt wie die Klasse Object, die die Wurzel der Klassenhierarchie darstellt, existiert bei Interfaces nicht. Während also alle in einem Java-Programm verwendeten Klassen letztendlich Eigenschaften der Klasse Object erben, findet man einen solchen Ausgangspunkt bei Interfaces nicht.

Ein weiterer gravierender Unterschied zwischen Klasse und Interface ist, dass Klassen genau eine Superklasse haben, bei Interfaces jedoch die Mehrfachvererbung zugelassen ist. Soll bspw. ein Interface Methoden und Konstanten von zwei anderen Interfaces erben, so muss folgende Syntax verwendet werden:

syntax 

public interface Sub-Interface-Name extends Super-Interface-Name 1,Super-Interface-Name 2

Arbeiten mit Interfaces

Soll mit Interfaces gearbeitet werden, so können entweder vordefinierte (externe) Interfaces verwendet werden oder neue Interfaces in derselben Klasse angelegt werden. Die Einbindung eines Interfaces erfolgt nach folgender Syntax:

syntax 

Modifier class Klassenname extends Superklasse implements Interface-Name

Die Teilausdrücke Modifier und extends Superklasse sind hierbei optional. Wird die vollständige Syntax verwendet, so erbt die jeweilige Klasse Eigenschaften von einer Superklasse, die zusätzlich durch das Interface erweitert werden. Da Interfaces ausschließlich abstrakte Methodendefinitionen beinhalten, muss jede dieser Methoden in der Klasse implementiert werden, wobei dieselbe Methodensignatur wie im Interface verwendet werden muss. Hierbei sei ausdrücklich darauf hingewiesen, dass jede Methode implementiert werden muss. Eine Auswahl der Methoden, die wirklich benutzt werden sollen, darf also nicht stattfinden. Implementiert man ein Interface, so wird den Benutzern einer Klasse automatisch mitgeteilt, dass das gesamte Interface unterstützt wird. Diese Regel stellt einen weiteren Unterschied zu abstrakten Klassen dar, da es Subklassen von abstrakten Klassen freisteht, welche Methoden der Superklasse implementiert oder überschrieben werden sollen. Nach der Implementierung des Interfaces erben alle Subklassen der Klasse, die das Interface implementiert, die neuen Methoden. Das implements-Schlüsselwort muss daher in den Subklassen nicht weiter verwendet werden.

Mit dem folgenden Beispiel soll die Arbeit mit Interfaces verdeutlicht werden. Hierzu sollen geometrische Objekte, bspw. Dreiecke und Quadrate, verarbeitet werden, die in der Klasse class GeoObjekt spezifiziert werden. Zusätzlich wird ein Interface interface GeoObjektArt definiert, das angibt, wie grundsätzlich mit geometrischen Objekten gearbeitet wird. Mit Hilfe von GeoObjekt und GeoObjektArt kann durch Vererbung leicht eine Klasse class Dreieck erzeugt werden. Weiterhin sollen neben geometrischen Operationen die Dreiecke auch dreidimensional im Raum darstellbar sein. Hierzu wird ein weiteres Interface interface RaumOperation entworfen, das von der Klasse Dreieck implementiert wird:

syntax 

interface GeoObjektArt {

    void verschieben();
    void strecken();

}

class GeoObjekt implements geoObjektArt {

    private Point koordinaten[];
    private int verschiebungsvektor, dehnungsfaktor;
    //....

}

interface RaumOperation {

    void x_drehen();
    void y_drehen();
    void z_drehen();

}

class Dreieck extends GeoObjekt implements RaumOperation {

    // ....

}

Hierbei muss die Klasse Dreieck keine Implementierung des Interfaces GeoObjektArt vornehmen, da dieses bereits durch die Superklasse geoObjekt implementiert wird.

Es sei darauf hingewiesen, dass eine Klasse auch mehrere Interfaces implementieren kann, wobei die Namen der Interfaces durch Kommata getrennt werden müssen, bspw. nach der Syntax

syntax 

Modifier class Klassenname extends Superklasse implements Interface-Name 1, Interface-Name 2, Interface-Name 3 {

    //...

}

Werden von einer Klasse mehrere Interfaces implementiert, so können Namenskonflikte auftreten, wenn in den verschiedenen Interfaces dieselben Methodennamen verwendet werden. Diese Konflikte können auf die folgenden Arten gelöst werden:

  • Haben die Methoden, die den Namenskonflikt verursachen, denselben Namen und dieselbe Parameterliste, aber einen unterschiedlichen Rückgabewert, so wird der Compiler mit einer Fehlermeldung abbrechen. Es ist zwar gestattet, eine Methode durch eine andere zu überschreiben, nicht aber, zwei Methoden zu verwenden, die bis auf den Rückgabewert die gleiche Signatur haben. In diesem Fall muss einer der Methodennamen in einem der beiden Interfaces geändert werden.
  • Haben beide Methoden verschiedene Parameterlisten, so reicht es aus, beide Methoden mit ihren Parameterlisten und damit mit den Anforderungen der Interfaces zu implementieren. Es wurde bereits dargestellt, dass im Falle gleicher Methodennamen mit unterschiedlichen Parameterlisten ein Überladen stattfindet, also je nach Übergabeparametern der korrekte Aufruf der jeweiligen Methode erfolgt.
  • Wenn die Methoden, die den Namenskonflikt verursachen, die gleiche Signatur haben, reicht es aus, eine dieser Methoden in der Klasse zu implementieren. Diese eine Implementierung gilt dann für beide Interfaces.

Dieselben Regeln gelten auch, wenn Namenskonflikte auftreten, weil ein Sub-Interface Methoden gleichen Namens von mehreren Super-Interfaces erbt.

Es wurde bereits mehrfach darauf hingewiesen, dass immer dann auch ein Interface verwendet werden kann, wenn eine Klasse zum Einsatz kommen würde. Anstelle der Deklaration einer Variablen, deren Typ eine Klasse ist, kann also ebenfalls eine Variable deklariert werden, die vom Typ Interface ist, bspw.

syntax 

GeoObjektArt einDreieck = new Dreieck();

Eine Variable, deren Typ Interface ist, impliziert folglich, dass jedes Objekt, auf das sich die Variable bezieht, dieses Interface implementiert haben muss. Im obigen Beispiel folgt hieraus, dass die Methode einDreieck.verschieben() referenzierbar sein muss. Interfaces bieten also eine elegante Möglichkeit, Verhaltensweisen in Form von Methoden zu spezifizieren, bevor eine Klasse erzeugt wird, die diese Methode überhaupt nutzt. Eigenschaft der traditionellen objektorientierten Programmierung ist im Gegensatz hierzu, dass sog. Stub-Routinen erzeugt werden müssen, die dadurch gekennzeichnet sind, dass der Methodenrumpf leer ist.

Ebenso wie ein Casting auf Klassen stattfinden kann, ist auch ein Casting auf Interfaces möglich. Das folgende Beispiel, das wiederum das Dreiecksbeispiel aufgreift, macht dies deutlich:

syntax 

Dreieck einDreieck = new Dreieck();

// Casting auf eine Klasse
GeoObjekt einGeoObjekt = (GeoObjekt) einDreieck;

// Casting auf Interfaces
GeoObjektArt eineGeoObjektArt = (GeoObjektArt) einDreieck;
RaumOperation eineRaumOperation = (Raumoperation) einDreieck;

//Methodenaufrufe
einGeoObjekt.verschieben();
eineGeoObjektArt.strecken();
einDreieck.verschieben();
einDreieck.strecken();

In diesen Beispielen wird das Casting hauptsächlich dazu eingesetzt, ein Dreieck eher als geometrische Form oder als Objekt für dreidimensionale Operationen anzusehen. Eine weitere Einsatzmöglichkeit besteht in der Definition geeigneter Parameter für Methodendeklarationen. Hierbei tritt oft das Problem auf, dass die Auswahl geeigneter Parameter außerordentlich schwierig ist. Da vorab unbekannt ist, welche Klasse das Interface einmal einsetzen wird und da weiterhin auch die genaue Funktionalität der Methode unbekannt ist, da sie abstrakt definiert wird, muss ein Weg gefunden werden, generische Parameter anlegen zu können, die je nach Einsatzzweck veränderbar sind. Hierzu legt man die notwendigen Parameter so an, dass sie vom Typ Interface sind. Dies ist deshalb möglich, da immer dort, wo eine Klasse einsetzbar wäre, auch ein Interface anwendbar ist. Der große Vorteil dieses Vorgehens liegt darin, dass die generisch vereinbarten Parameter im tatsächlichen Einsatzfall durch ein Casting auf die geeigneten Objekte abgebildet werden können. Das folgende Beispiel, das die Dreiecksanwendung aufgreift, verdeutlicht die prinzipielle Idee. Hierzu wird zunächst die Interface-Definition von GeoObjektArt folgendermaßen erweitert:

syntax 

public interface GeoObjektArt {

    void verschieben();
    void strecken();
    public abstract float flaecheninhalt (GeoObjektArt einObjekt);

}

An diesem Beispiel ist genau erkennbar, warum das beschriebene Vorgehen so sinnvoll ist: Hätte man den Klassennamen GeoObjekt verwendet, so wäre das Interface für andere Klassen, die es zu implementieren versuchten, nicht mehr einsetzbar gewesen. Ein weiteres Problem ist, dass die Klasse GeoObjekt geometrische Objekte in abstrakter Art und Weise definiert. Die Flächenberechnung geometrischer Objekte variiert aber in Abhängigkeit vom gewählten Objekt stark, da der Flächeninhalt von Dreiecken bspw. mit einer vollständig anderen Formel berechnet werden muss als der von Kreisausschnitten. In der tatsächlichen Implementierung der Methode dieser Klasse wird dann der generische Parameter vom Typ GeoObjektArt aufgegriffen und dieser durch ein Casting auf das geeignete Objekt abgebildet:

syntax 

public class Dreieck extends GeoObjekt {

    public float flaecheninhalt (GeoObjektArt einObjekt) {

      float flaeche;
      Dreieck einDreieck = (Dreieck) einObjekt;
      // ....
      return (flaeche);

    }

}

 


SPNavRight SPNavRight SPNavRight
BuiltByNOF