[Teil 5: Ein-/Ausgabe] [Teil 7: Applet und Applikation]

Einstieg in Java
Teil 6: Praktische Standardklassen

Von Enno Runne und Hendrik C.R. Lock

Im diesem Kursteil werden wir die Klassen StreamTokenizer, Hashtable und Enumeration der Standardbibliothek behandeln, die sich im praktischen Einsatz oft als sehr nützlich erweisen. Sie bieten Funktionalität, deren Programmierung für den Benutzer zumeist mühsam und fehlerträchtig wäre.

StreamTokenizer - Eingabe scheibchenweise

Die Klasse StreamTokenizer dient dazu, Eingaben aus Strömen zu lesen und diese in ihre einzelnen Symbole (sogenannte Tokens) zu zerlegen. Ein Token kann dabei ein Wort, eine Zahl, ein Sonderzeichen oder das Zeilenende sein. Die Klasse StreamTokenizer lässt sich flexibel konfigurieren. Ihr kann z.B. mitgeteilt werden, welche Zeichen den Text in Token zertrennen, und welche Zeichen überspringbare Leerzeichen sind.

Für unsere Adresskartei führen wir ein selbsterklärendes Textdateiformat ein, mit dem sich die Daten auch leicht in anderen Programmen verwenden lassen. Textdateien haben außerdem den Vorteil, dass sie mit jedem normalen Editor bearbeitet werden können. In diesem Format füllt eine Adresse eine einzige Zeile, in der die Reihenfolge der Felder (z.B. Name, Adresse, Alter) beliebig ist. Außerdem dürfen Felder unbenutzt sein. Gültige Zeilen sehen dann so aus:

name='Graf Hardenberg' vorname = 'Otto'     alter=103
alter=1 vorname='Mathias'
name=         'Müller' vorname= 'Erika'
name='Müller' vorname='Martin' alter=45
Das Format ist also auch tolerant bezüglich Leerraum. Ein Feld beginnt immer mit feldname=. Bei Zeichenketten, die Leerzeichen enthalten, verwenden wir die einfachen Anführungszeichen. Als kleines Extra sollen auch einzeilige Kommentare im Java-Stil erlaubt sein. Zeilen, die also mit // beginnen, bleiben unbeachtet.

Der StreamTokenizer erledigt die meistem dieser Anforderungen quasi standardmäßig. So werden Füllzeichen automatisch überlesen, Anführungszeichen erkannt und die enthaltenen Zeichenketten als Token zurückgeliefert. Sogar die gewünschten Kommentare sind frei verfügbar. (Übrigens werden sogar mehrzeilige Kommentare mit /* */ auf Wunsch erkannt und ausgeblendet.)

Erkennung des Zeilenendes eolIsSignificant(false);
//-Kommentare slashSlashComments(false);
/* */-Kommentare slashStarComments(false);
Wort-Zeichen wordChars('a', 'z');
wordChars('A', 'Z');
wordChars(128 + 32, 255);
Zwischenraumzeichen whiteSpaceChars(0, ' ');
einzeiliger Kommentar commentChar('/');
Anführungszeichen quoteChar('\"');
quoteChar('\'');
Lesen von Zahlen parseNumbers();
Tabelle 1: Standardvoreinstellung von StreamTokenizer

Der StreamTokenizer besitzt bestimmte standardisierte Voreinstellungen, die wir in Tabelle 1 zusammengestellt haben. Bevor wir nun den Eingabestrom mittels StreamTokenizer zerlegen, stellen wir noch diejenigen Parameter ein, die von den Standardwerten abweichen:

  void laden(InputStream is)
       throws IOException, InvalidAgeException
  {
    StreamTokenizer st = new StreamTokenizer(is);
    st.slashSlashComments(true);
    st.eolIsSignificant(true);
    st.whitespaceChars('=', '=');
Damit stellen wir ein, dass // eine Kommentarzeile einleiten soll. Weiterhin möchten wir das Zeilenende als extra Zeichen signalisiert bekommen. Durch whitespaceChars('=', '=') schleusen wir das Gleichheitszeichen als zusätzliches Trennzeichen ein - es trennt Tokens und wird danach einfach überlesen.

Als nächstes können wir nun Token für Token aus dem Eingabestrom lesen. Beispielsweise ist durch unsere Voreinstellungen "name" oder "'Graf Hardenberg'" jeweils ein Token. Bild 1 zeigt, wie eine Zeile aus dem Eingabestrom in einzelne Tokens aufgebrochen wird. Wir empfangen die einzelnen Tokens in einer Schleife:

    int token;
    String nameNeu = "", vornameNeu = "";
    int alt = 0;
    boolean feldGelesen = false;
    do {
      token = st.nextToken();
      if (token == st.TT_WORD) {
	if ("name".equals(st.sval)) {
	  token = st.nextToken();
	  if (token == '\'') {
	    nameNeu = st.sval;
	    feldGelesen = true;
	  }
	}
	if ("vorname".equals(st.sval)) {
	  token = st.nextToken();
	  if (token == '\'') {
	    vornameNeu = st.sval;
	    feldGelesen = true;
	  }
	}
	if ("alter".equals(st.sval)) {
	  token = st.nextToken();
	  if (token == st.TT_NUMBER) {
	    alt = new Double(st.nval
                            ).intValue();
	    feldGelesen = true;
	  }
	}
      }
      if (  (   token == st.TT_EOL 
             || token == st.TT_EOF ) 
          && feldGelesen) { 
	Adresse adr = new Adresse(nameNeu
                       ,vornameNeu, alt);
	einfügen(adr);
	feldGelesen = false;
	nameNeu = "";
	vornameNeu = "";
	alt = 0;
      }
    } while (token != st.TT_EOF);
  }
Liest nextToken() ein Wort, so ist die Bedingung token == st.TT_WORD erfüllt. Andernfalls kann mit den Konstanten TT_NUMBER und TT_EOL bzw TT_EOF ein numerischer Wert, ein Zeilenende und das Dateiende erkannt werden. Falls ein Wort gelesen wurde, testen wir, ob es eines unserer Schlüsselworte ist. In jedem diese Fälle lesen wir jeweils das nächste Token. Einen gewissen Sonderfall, den StreamTokenizer bereits unterstützt, sind durch Hochkommata eingeschlossene Zeichenketten. Ihre Behandlung als Tokens wird durch die Methode quoteChar('\'') voreingestellt. In diesem Fall gilt token=='\'', aber der Wert sval des Stromes enthält die Zeichenkette ohne die Hochkommata. Durch eine Modifikation der Vergleiche in if (token=='\'' || token=='\"') wird es möglich, auch in ".." eingeschlossene Texte als Tokens zu behandeln.

Im Falle von token==st.TT_NUMBER wird eine Zahl erwartet. Da der StreamTokenizer Zahlen immer als Double liest, benutzen wir für die Umwandlung nach int den Ausdruck:

  new Double(st.nval).intValue();

Weil eine Adresse immer in einer einzigen Zeile stehen soll, signalisiert das Zeilenende (TT_EOL) den Beginn eines neuen Adressdatensatzes, falls zuvor mindestens ein Feld eingelesen worden ist. Fehlt eines der Adressfelder, so wird seine Voreinstellung "" in die Adresse übertragen.

Und schon haben wir eine flexible Einlesenmethode. Um das Gelesene in Dateien zu speichern, kommen noch zwei kleine Methoden hinzu, durch die Adressen und Karteien im Dateiformat abgelegt werden.

// neu in Klasse Adresse:
  void speichern(PrintStream ps)
       throws IOException
  {
    ps.println("name='" + name 
       + "' vorname='"+vorname
       +"' alter="+alter);
  }

// neu in Klasse Adresskartei:
  void speichern(PrintStream ps)
       throws IOException
  {
    for (Enumeration 
         e=adrTabelle.elements(); 
         e.hasMoreElements(); ) {   
      Object obj = e.nextElement();
      if (obj instanceof Adresse) {
	((Adresse)obj).speichern(ps);
      }
    }
  }    

Mit Hilfe von Enumeration werden Datensätze abgeholt und können dann mittels Methode nextElement() nacheinander aufgezählt werden, was ganz hilfreich für Schleifenkonstruktionen sein kann. Diese Aufzählungsklasse wird im übernächsten Abschnitt vorgestellt.

Hashtable: Schneller Zugriff auf Datensätze

Ein Vektor (Klasse Vector) speichert Datensätze (Klasse Object), bietet aber keine Suchfunktion. Um eine bestimmte Adresse auszulesen braucht man ihre Position im Vektor. Oft benötigt man jedoch die Möglichkeit, einen Datensatz mit Hilfe eines Schlüssels aufzufinden. Ein solcher Schlüssel kann z.B. bei Adressen aus Namen und Vornamen bestehen. Eine einfache Methode zum Wiederauffinden ist die lineare Suche: der gesamte Vektor wird abgetastet und jeder Datensatz auf Gleichheit mit dem Schlüssel getestet. Bei großen Datenmengen ist diese Methode wegen ihrer Ineffizienz nicht vertretbar, z.B. bei Telefonbüchern. Im Idealfall wollen wir in sehr wenigen Schritten zum gesuchten Datensatz finden, und die Anzahl der Schritte sollte dabei möglichst nicht von der Anzahl der Einträge abhängen. Die Grundidea bei der Hashtabelle ist es, den Schlüssel als Zahl (Hashwert) zu codieren, und mit diesem Wert direkt in die Tabelle einzustechen. Dort wird dann nochmals verglichen, ob der vorliegende Datensatz mit dem Schlüssel übereinstimmt. Wenn nicht, wird eine sogenannte Konfliktauflösung durchgeführt (siehe Bild 3). Letztere sucht im Vektor zielgerichtet nach allen Datensätzen mit gleichem Hashwert, bis der Schlüssel gefunden wird. Die Hashtabelle funktioniert ansonsten wie ein Vektor, ihre Funktionsweise wird in Bild 2 und 3 gezeigt. Die folgenden Programmteile zeigen, wie sich die Standardklasse Hashtable in der Adresskartei einsetzen läßt.
class AdressKartei
{
  Hashtable adrTabelle;

  AdressKartei()      // Konstruktor
  { adrTabelle = new Hashtable(16); }

  void einfügen(Adresse adr)
  { adrTabelle.put(adr, adr); }

  void löschen(Adresse adr)
  throws ArrayIndexOutOfBoundsException
  { adrTabelle.remove(adr); }
Die Hashtabelle verwaltet einen eigenen Vektor, der mitwächst. Das erste Argument der Einfügeoperation Object put() ist der Schlüssel, und das zweite der Datensatz. In unserem Fall nehmen wir den Datensatz zugleich als Schlüssel. Entscheidend ist hierbei die von der Hashtabelle benutzte Methode equals(), durch die festgelegt wird, welche Teile des Datensatzes den Schlüssel bilden. Wir überschreiben diese Methode in Klasse Adresse, da das von Object geerbte equals() nämlich die Identität der Objekte (bzw. ihrer Referenzen) testet.
  public boolean equals(Object obj)
  {
    if (obj instanceof Adresse) {
      Adresse a = (Adresse)obj;
      return (a.getKey()).equals(this.getKey());
    }
    return false;
  }

  public String getKey()
  { return name+vorname; }
Methode Object put() legt also eine Adresse ab. Ist die Kombination "name+vorname" bereits abgelegt, so ersetzt es die gespeicherte Adresse durch die neue, und gibt die alte zurück. Das bedeutet für die Adresskartei, dass keine zwei Adressen für gleiche Namen (Schlüssel) gespeichert werden können. Dies könnte man z.B. durch eine Reihe abgestufter Hashtabellen beheben, die die verdrängten Adressen aufnehmen.

Weiterhin erbt jedes Objekt eine Methode hashCode(), die ebenfalls von Hashtable benutzt wird. Wir überschreiben diese Methode in Klasse Adresse so, dass der Hashwert aus dem Schlüssel berechnet wird:

  public int hashCode() 
  { return (name+vorname).hashCode(); }
Die Suche nach einer Adresse über den Namen besteht nun aus einem einfachen Test mit einem Schlüssel und einem Zugriff. Wir nehmen dabei an, dass kartei vom Typ AdressKartei ist.
  Adresse key = new Adresse(name,vorname,0);
  if (kartei.contains(key)) 
    Adresse a = kartei.get(key);
Der Test ist nicht unwichtig, denn get() signalisiert das Fehlen des Schlüssels mit dem Rückgabewert null.

Enumeration: Datenzugriff stückweise

Mit Vektoren und Hashtabellen kennen wir bereits zwei Datenstrukturen, die eine Sammlung von Objekten speichern. Oft will man die Objekte solcher Sammlungen einzeln und nacheinander bearbeiten, z.B. in einer Schleife. Damit solche Sammlungen völlig gleichartig benutzt werden können, stellt Java dafür eine einheitliche Schnittstelle (engl. Interface) für Aufzählungen bereit:
public  interface  java.util.Enumeration
{
  public abstract boolean hasMoreElements();
  public abstract Object nextElement();     
}
Mit der Deklaration
  class A implements Enumeration
wird zugesichert, dass Klasse A beide Methoden implementiert. hasMoreElements() liefert true, solange noch nicht alle Elemente aus der Sammlung abgeholt worden sind, und Object nextElement() holt ein Element ab. Wie bei Vector und Hashtable kennen wir den Typ der Elemente nicht, so dass hier Object angenommen wird. Bei einer Zuweisung Adresse x = (Adresse) sammlung.nextElement() muss deshalb explizit auf den Zieltyp (hier Adresse)umgewandelt werden.

Das Interessante ist nun, dass sowohl Hashtable als Vector diese Schnittstelle implementieren. Dadurch können wir eine Schleife bauen, die die Inhalte sowohl einer Hashtabelle als auch eines Vektors ausliest. Dadurch sind wir in der Lage, einen Vektor gegen eine Hashtabelle auszutauschen, ohne den Schleifencode ändern zu müssen!

Wir hatten Enumeration bereits zuvor verwendet:

  for (Enumeration e=adrTabelle.elements();
       e.hasMoreElements(); )    {
    Object obj = e.nextElement();
    if (obj instanceof Adresse) 
      ((Adresse)obj).speichern(ps);
  }
Die Methode Enumeration elements() der Klasse Hashtable liefert ein Objekt zurück, das Enumeration implementiert (Beachten Sie bitte, dass dieser Aufruf im Initialisierungsteil einer for-Schleife auftritt). Das gleiche gilt übrigens auch für Klasse Vector. Die Klasse des zurückgegebenen Objektes kennen wir nicht, und sollen wir auch nicht kennen, denn nur so kann gewährleistet werden, dass diese Schleife unabhängig von der tatsächlichen Implementierung der Kartei ist. D.h., eine als Vektor realisierte Kartei funktioniert hier genauso gut. Im Schleifenrumpf wird dann eine Adresse nach der anderen aus der Datensammlung abgeholt und anschließend gespeichert.

Ausblick

Wie wir gesehen haben, lassen sich durch den Einsatz der Standardklassen Vector, StreamTokenizer, Hashtable und Enumeration aufwendigere Programmierarbeiten durch eine selbstgestrickte Verwaltung von Datensätzen vermeiden. Bisher geben wir Datensätze über eine recht armselige Schnittstelle ein. Im kommenden Teil beginnen wir deshalb mit der Programmierung von grafischen Oberflächen mit Hilfe von Javas AWT (Abstract Window Toolkit). Den Karlsruher Java Übersetzer, der übrigens 2-3 mal schneller ist als der von Sun ausgelieferte Übersetzer, findet man unter folgender Adresse: http://wwwipd.ira.uka.de/~pizza/. Die virtuelle Java Maschine und der Sun-Übersetzer javac für zahlreiche Zielrechner (SPARC Solaris, x86 Solaris, Windows NT, Windows 95, MacOS) befinden sich unter http://java.sun.com/products.


[Teil 5: Ein-/Ausgabe] [Teil 7: Applet und Applikation]