[Teil 3: Felder und Vektoren] [Teil 5: Ein-/Ausgabe]

Einstieg in Java
Teil 4: Ausnahmen

Von Enno Runne und Hendrik C.R. Lock

Schon im letzten Kursteil sind uns einige Fälle begegnet, in denen Fehler während des Programmlaufes entstehen konnten. Diese Laufzeitfehler entstehen nicht nur durch logische Programmfehler, sondern werden auch durch äußere Einflüsse hervorgerufen, z.B. wenn eine Datei nicht vorhanden oder nicht beschreibbar ist. Viele solcher Fehler können durch umfangreiche Sicherheitsabfragen vermieden werden und führen dann nicht zum Programmabbruch. Durch das Konzept der Ausnahmebehandlung vereinfacht Java die Realisierung von Sicherheitsabfragen b.z.w. der Reaktion auf fehlerhafte Programmzustände. Wir beginnen diesen Kursteil mit der Ausnahmebehandlung und führen dann die Ein- und Ausgabe ein, die sich wiederum auf das Konzept der Ausnahmen abstützt.

Laufzeitfehler können in den unterschiedlichsten Situationen entstehen. Dabei gibt es Fehler, die eigentlich nicht unbedingt ein Problem darstellen und andere, die so schwerwiegend sind, dass eine Fortsetzung des Programmes nicht mehr sinnvoll ist. Es wird aber zunehmend wichtiger, sichere Programme zu schreiben, die möglichst alle Fehlersituationen handhaben, ohne einen Programmabbruch zu erzwingen. Deshalb bietet Java einen Mechanismus an, um Laufzeitfehler abzufangen und per Programm zu behandeln. Dieser Mechanismus heißt Ausnahmebehandlung.

Laufzeitfehler werden in Java bestimmten Fehlerklassen zugeordnet. Die wichtigste Unterscheidung erfolgt zwischen den Kategorien Fehler (engl. error) und Ausnahmen (engl. exception). In die erste Kategorie fallen alle schwer behebbaren Fehler. Die zweite Kategorie beinhaltet "leichtere" Fehler. Die Fehlerklassen werden in Form eines Vererbungsbaumes weiter unterteilt. Zum Beispiel gehören beide Fehlerarten "Ein-/Ausgabe-Ausnahme" und "Datei nicht vorhanden" zur Kategorie der Ausnahme, und die zweite ist eine Verfeinerung der ersten, steht also im Klassifikationsbaum darunter.

Die Idee der Ausnahmebehandlung in Java ist es, zunächst ein Objekt zu generieren, das alle für die Behandlung dieser Ausnahme benötigten Daten speichert. Wird zur Laufzeit eine Ausnahme ausgelöst, so sucht die virtuelle Java-Maschine (JVM) eine Programmstelle, an der diese Ausnahmesituation bearbeitet wird. Dort wird dann das Ausnahmeobjekt empfangen. In dieser Weise werden also relevante Daten in einer Art Behälter übertragen. Um ein spezifisches Ausnahmeobjekt zu erzeugen, brauchen wir natürlich eine geeignete Klasse. Es ist daher naheliegend, jede Form einer Ausnahmesituation durch eine eigene Klasse anzuzeigen. Gleichartige Ausnahmen werden dann durch Objekte derselben Klasse realisiert. Damit kommen wir zur Klassifikation der Ausnahmen zurück. Wie bereits erwähnt bilden sie einen Baum. Da wir ja Ausnahmetypen mit Klassen darstellen, stellen wir gleichermaßen den Klassifikationsbaum auf eine Klassenhierarchie dar. Somit entspricht jede Verfeinerung in der Fehlerklassifikation einer Vererbungsbeziehung in der Klassenhierarchie. Ein Ausschnitt der Klassenhierarchie von in Java fest eingebauten Ausnahmetypen findet sich im Schaubild 1.

Schaubild 1, bitte einfuegen,
Ueberschrift: Klassenhierarchie der Ausnahmen

                   Throwable
                     /\
                    /  \
              Error         Exception
                              /\
                             /  \
                      
                      IOException  RuntimeException
                          /\            /\
     FileNotFoundException  ...        /  \

                    NullPointerException  ArrayIndexOutOfBoundsException

Alle Ausnahmen und Fehler werden durch ein Objekt einer Unterklasse der Klasse Throwable angezeigt. Von ihr leiten sich Error und Exception ab (siehe auch Schaubild 1). Unterklassen von Error sind wie gesagt ernsthafte Fehler, die nicht so einfach beseitigt werden können. Unter den Nachfahren von Exception sind wiederum zwei bedeutende Gruppen: IOException und RuntimeException. IOException meldet Ausnahmen bei der Ein- oder Ausgabe. RuntimeException hat eine besondere Bedeutung: sie steht für Fehler, die überall auftreten können. Um sich die endlose Wiederholung von throws RuntimeException zu ersparen, wird in Java allen Anweisungen unterstellt, dass sie eine RuntimeException auslösen können. Ein Beispiel hierfür ist die NullPointerException. Sie wird immer dann ausgelöst, wenn über eine uninitialisierte Referenz ein Methodenaufruf stattfindet (wir erinnern, dass uninitialiserte Referenzen den Wert null enthalten). Deshalb kann dieser Ausnahmetyp im Prinzip in den meisten Anweisungen entstehen - eine Angabe, dass dies so ist, wäre unnötige Schreibarbeit.

Wie wir sehen, ist die Ausnahme "Datei nicht vorhanden" eine Verfeinerung der "Ein-/Ausgabe-Ausnahme", was durch Vererbung ausgedrückt wird. Wird nun zum Beispiel auf eine nicht existierende Datei zugegriffen, so erzeugt die JVM automatisch ein Objekt der Klasse FileNotFoundException und löst eine Ausnahmebehandlung aus.

Hier wird der Vorteil der Unterklassen deutlich: Wir können das Fehlen der Datei behandeln, aber alle anderen Ein-/Ausgabefehler einfach unbeachtet lassen. Natürlich bleiben sie nicht unbeachtet: eine Ausnahme, die nicht vom Programm abgefangen wird, wird sofort auf der Ebene der JVM behandelt. Das Programm wird dann mit einer Fehlermeldung abgebrochen, und als Fehlermeldung wird der Inhalt des Ausnahmeobjektes ausgegeben, z.B.:

  java.lang.NullPointerException
          at kurs4Test.main(kurs4Test.java:15)
Hier meldet uns die JVM eine Ausnahme bei der Ausführung von Zeile 15 der Datei kurs4Test.java, und zwar innerhalb der Prozedur main().

Schaubild 2, fliessend
Ueberschrift: Die Klassen Throwable und Exception

public  class  java.lang.Throwable
    extends  java.lang.Object 
{
        // Constructors
    public Throwable();
    public Throwable(String  message); 

        // Methods
    public Throwable fillInStackTrace();       
    public void printStackTrace();  
    public void printStackTrace(PrintStream  s);   
    public String getMessage(); 
    public String toString(); 
}

public  class  java.lang.Exception
    extends  java.lang.Throwable 
{
        // Constructors
    public Exception();
    public Exception(String  s);     
}

Eine Klasse Ausnahme

In unserem Adressprogramm werden wir nun zwei Ausnahmen behandeln, nämlich fär die Fälle, dass entweder ein negatives Alter angegeben wird oder dass eine Namensangabe undefiniert ist. Um diese Ausnahmen behandeln zu können, führen wir eine Klasse AddressException ein, die wir dann entsprechend weiter verfeinern (siehe Bild unten).
                            Exception
                                /
                               /  ....
               AddressException
                     /\
                    /  \
  InvalidAgeException  AddressNullNameException
Jede der Unterklassen InvalidAgeException und AddressNullNameException behandelt eine der beiden möglichen Ausnahmen. Im folgenden Code definieren wir nur die Klasse InvalidAgeException, die Definition der anderen Klasse unterscheidet sich nur in den Namen der Klasse und der Konstruktoren.
public class AddresssException
  extends Exception
{}

public class InvalidAgeException 
  extends AddressException
{
  public InvalidAgeException()
    {
      super();
    }

  public InvalidAgeException(String s)
    {
      super(s);
    }
}

public class AddressNullNameException
  extends AddressException
{...}
Das war`s auch schon. Eine Ausnahme besitzt normalerweise zwei Konstruktoren, von denen der zweite eine Fehlermeldung als Argument aufnimmt. Der Text dieser Fehlermeldung kann bei einer später erfolgenden Ausnahmebehandlung ausgegeben werden. Diese Konstruktoren werden von der Oberklasse Exception geerbt. Normalerweise besitzen Unterklassen von Exception keine weiteren Methoden. Natürlich ist es möglich - und oft auch sinnvoll - der jeweiligen Ausnahmenklasse noch spezielle Elemente hinzuzufügen, die dann genauere Informationen über die Fehlersituation enthalten.

Wir werden jetzt den Konstruktor der Klasse Adresse so umschreiben, dass er eine Ausnahme auszulöst, wenn ein negatives Alter angegeben wird.

  Adresse(String vorname, String nachname, int alter)
    throws InvalidAgeException
  {
    this.vorname = vorname;
    this.name = nachname;  
    if (alter < 0) 
      throw new InvalidAgeException(
                   "Das Alter darf nicht negativ sein!"); 
    this.alter = alter;
  }
Jede Methode muss mitteilen, welche Ausnahmen sie erzeugen kann. Dies geschieht durch das Schluesselwort throws in der Methodendeklaration. Auf throws folgt eine Liste aller Ausnahmen, mit denen der Benutzer dieser Methode rechnen muss. In unserem Fall geben wir bekannt, dass der Konstruktor die Ausnahme InvalidAgeException auslösen kann. Im Falle einer Liste von Ausnahmen werden die Klassennamen syntaktisch durch Kommata getrennt.

Der nächste Schritt besteht darin, eine Ausnahme auch tatsächlich auszulösen. Dieser Fall tritt ein, wenn das angegebene Alter negativ ist. Wir erzwingen eine Ausnahmebehandlung, indem wir das Schlüsselwort throw einsetzen, gefolgt von der Referenz eines Objektes des entsprechenden Ausnahmetyps. Im Allgemeinen treffen wir dabei die folgende Anweisung an.

  throw new TypDerAusnahme( Text );
Es ist zu beachten, dass der Typ der Ausnahme auch tatsächlich in der Liste der möglichen Ausnahmen der Methodendeklaration auftritt, oder zu einer Unterklasse der genannten Klassen gehört. Eine Abweichung von dieser Regel bilden alle Ausnahmen, die durch Unterklassen des Typs RuntimeException gekennzeichnet sind.

Die throw-Anweisung hat zwei Aufgaben:

Enthält unser Programm keine explizite Behandlung der ausgelösten Ausnahme, so wird sie an die JVM weitergereicht. Diese beendet dann das Programm mit einer Fehlermeldung. In unserem Fall wäre dies der Text, den wir dem Konstruktor äbergeben haben.

Um unser Programm in seiner Ausfährung sicher zu gestalten, mässen wir nun alle Programmteile, die das Element Alter ändern, absichern. Entsprechend wird auch die Methode setzeAlter im Falle eines negativen Alters die Ausnahme InvalidAgeException auslösen.

  void setzeAlter(int alter)
    throws InvalidAgeException
  {
    if (alter < 0) throw new InvalidAgeException(
      "Das Alter darf nicht negativ sein!"); 
    this.alter = alter;
  }

Behandeln einer Ausnahme

Eine Ausnahme wird von der JVM behandelt, indem sie mit Angabe einer Fehlermeldung das Programm beendet. Alternativ dazu können wir auch selbst die Ausnahme abfangen und damit den Programmabbruch verhindern. Wir werden nun sehen, wie das vor sich geht.

Die Grundidee bei der Ausnahmebehandlung ist es, die Quelle einer möglichen Ausnahme einzufassen. Eine solche Quelle kann eine Anweisungsfolge, eine einzelne Anweisung, und ein Methodenaufruf sein. Die Quelle wird mit dem syntaktischen Konstrukt try eingefasst. Es wird also versucht, eine Anweisungsfolge auszuführen. try signalisiert, dass dieser Versuch fehlschlagen kann. Das heißt, dass die Ausfährung möglicherweise durch eine Ausnahme unterbrochen wird. Dem try-Konstrukt folgt eine Beschreibung der Ausnahmen, die abgefangen werden sollen. Alle anderen aus der Quelle austretenden Ausnahmen werden an der momentanen Programmstelle ignoriert und weitergereicht.

Im Detail teilt sich die Behandlung einer Ausnahme in drei Bereiche auf. Jeder dieser Bereiche ist ein Anweisungsblock.

  1. Der try-Block: Er enthält diejenigen Anweisungen, die die Quelle einer möglichen Ausnahme darstellen. Dies schließt direkte throw-Anweisungen ein.
  2. Der catch-Block: Hier wird durch Angabe des jeweiligen Typs angegeben, welche Ausnahmen an dieser Stelle behandelt werden. Jede Ausnahme benötigt genau einen catch-Block. Mehrere Ausnahmen werden durch aufeinanderfolgende Blöcke aufgefangen.
  3. Der finally-Block: Hier stehen Anweisungen, die auf jeden Fall (egal ob Fehler oder nicht) ausgeführt werden.
Jede Ausnahmebehandlung besteht also aus einem try-Block und mindestem einem catch-Block. Der finally-Block ist optional. Wir stellen nun diese Komponenten im Detail vor.

Der try-Bereich enthält eine Anweisungsfolge.

  try {
    Anweisung1;
    Anweisung2;
    Anweisung3;
  }
  catch (...) {  ...
  }
  Anweisung4;
Nehmen wir einmal an, zur Laufzeit würde die zweite Anweisung durch einen Fehler vorzeitig beendet, also z.B. durch eine throw-Anweisung. Dann wird auch die Abarbeitung der Anweisungsfolge im Block abgebrochen, d.h. keine der nachfolgenden Anweisungen wird ausgeführt. In unserem Fall wird die dritte Anweisung ausgelassen. Das Programm versucht dann, in einem der nachfolgenden catch-Anweisungen wiederaufzusetzen. Kann also ein passendes catch gefunden werden, so werden auch die nachfolgenden Anweisungen ausgeführt, in unserem Beispiel ist dies Anweisung4. Passt kein catch, so wird auch hier abgebrochen und die Ausnahme somit an den Aufrufer weitergereicht. In diesem Fall wird also Anweisung4 nicht mehr ausgeführt. Steht vor Anweisung4 ein finally-Block, so wird dessen Anweisungsteil auf jeden Fall noch ausgeführt.

Ein catch-Block besteht aus 3 Komponenten: dem Schlüsselwort, der Deklaration eines Bezeichners für den Behälter und einem Anweisungsblock. Der Typ dieses Bezeichners ist ein Klassenname. Wie wir bereits wissen, werden Ausnahmen durch Klassen vereinbart. Das heißt aber, dass dieser Name zugleich auch die Ausnahme benennt, die hier abgefangen und behandelt werden soll.

  catch (TypDerAusnahme ausnahme) {
   ...  // Behandlung
  }
Jede Ausnahme wird mittels eines Objektes gemeldet. Die Klasse dieses Objektes wird durch die Ausnahmeursache festgelegt, wie wir bereits bei der throw-Anweisung gesehen haben. Eine Ausnahme passt also auf ein catch, wenn die Klasse des Ausnahmeobjektes typkonform zur Klasse der catch-Anweiseung ist, d.h. wenn es von dieser oder einer Unterklasse ist. Passt der Catch, so wird das Ausnahmeobjekt an den Bezeichner zugewiesen und der Anweisungsteil ausgeführt. Alle weiteren catch-Anweisungen werden anschließend übersprungen.

Passt die Klasse beim ersten catch nicht, so wird die nachfolgende catch-Anweisung untersucht, und so fort. Passt keine Klasse, so wird die gesamte Anweisungsfolge abgebrochen und die Ausnahme an den Aufrufer weitergereicht. Die JVM ruft ein Benutzerprogramm sozusagen in einem try-Block auf. Dadurch kann sie jeden Fehler, den das Programm nicht abfängt selbst abfangen. Die Standardreaktion in diesem Fall ist die Ausgabe eines Fehlertextes (sofern vorhanden) über die Methode ausnahme.getMessage().

Auch innerhalb eines catch-Blocks kann wieder eine Ausnahme ausgelöst werden. In diesem Fall wird die Abarbeitung wie bei einer normalen Anweisung vorzeitig beendet und eine Ausnahmebehandlung in der nächsthöheren Ebene ausgelöst. Die nachfolgenden catch-Blöcke werden also dann übergangen. Wie sieht es mit fianlly aus?
Wie sieht es bei ueberlappenden Catches aus? >

Wir wenden nun die neugewonnen Kenntnisse auf unsere Adresskartei an.

  try {
    Adresse adr = new Adresse("Fritz", "Müller");
    adr.setzeAlter(-5);
  }
  catch (AdressException e) {
    System.out.println(e.getMessage());
  }
Wir provozieren durch Angabe eines negativen Alters eine Ausnahmebehandlung. Die im Methodenaufruf auftretende throw-Anweisung generiert deshalb ein Objekt des Typs InvalidAgeException. Java bricht nun den Methodenaufruf ab und taucht aus dem try-Block auf. Das nachfolgende catch passt, weil AdressException eine Oberklasse des tatsächlichen Ausnahmetyps InvalidAgeException ist. Die damit angestoßene Ausnahmebehandlung holt sich mittels
  e.getMessage();
die Fehlermeldung und gibt sie aus.

Hier wird deutlich, wie praktisch die Klassenhierachie für die Ausnahmen ist. Wir können Spezialfälle behandeln oder einfach eine ganze Familie von Ausnahmen. Eine Familie wird durch Angabe einer geeigneten Oberklasse abgefangen, im Beispiel werden alle mit dem Setzen von Adressen zusammenhängenden Fehler zusammen behandelt.

Tritt beim Belegen der Adresse jedoch eine andere Fehlerform auf, z.B. eine NullPointerException, so würde dies hier zum Programmabbruch führen, da keine Behandlung vorgesehen ist.

Wir reagieren bisher mit einer Fehlermeldung. Jetzt wollen wir zusätzlich die Adresse ausgeben, und zwar unabhängig davon, ob die Alterszuweisung erfolgreich oder fehlerhaft war. Wir erreichen dies durch Hinzunahme einer finally-Anweisung, denn diese wird in jedem Fall ausgeführt.

  ....
  finally {
    adr.ausgeben();
  }

Besonders wichtig sind Ausnahmen selbstverständlich für die Zusammenarbeit mit dem Betriebssystem, und dabei speziell bei der Ein- und Ausgabe. Dies ist auch unser naechstes Thema.


Eingabe und Ausgabe - Ströme von Daten

Bisher haben wir unsere Adressen immer direkt im Quelltext angeben müssen. Nun soll es darum gehen, wie wir Daten während des Programmlaufs einlesen können. Die Benutzer sollen zunächst eine Adresse eingeben können, die dann in die Kartei eingefügt und wieder auf dem Bildschirm ausgegeben wird.

Java arbeitet bei allen Ein- und Ausgaben mit Strömen (engl. stream). Die dazu notwendigen Klassen finden sich alle in der Standardbibliothek java.io. Eingaben wie Ausgaben werden jeweils mit Hilfe von Grundklassen dem Programm zugeführt. Jede Grundklasse steht für Ein- oder Ausgabe auf ein bestimmtes Gerät oder mit bestimmten Datenstrukturen.

Die Klasse InputStream (und jeweils analog OutputStream) definiert als Oberklasse einen Minimalstandard. Alle Eingabeströme erben rudimentäre Methoden zum Liefern von Daten, die es erlauben einzelne Bytes zu liefern. Die Unterklassen ByteArrayInputStream, FileInputStream, PipedInputStream und StringBufferInputStream stehen dabei jeweils für eine bestimmte Quellart, von der die Daten kommen (s. Tabelle 1). So wird ein FileInputStream benutzt, um Daten aus Dateien zu lesen.

Tabelle 1: Die einfachen Ströme in Java

Klassenname Funktionalität
InputStream
OutputStream
einfache Ein-/Ausgabe (Oberklassen)
ByteArrayInputStream
ByteArrayOutputStream
Einlesen aus einem Byte-Feld
Ausgeben in ein Byte-Feld
FileInputStream
FileOutputStream
Lesen aus Dateien
Schreiben in Dateien
PipedInputStream
PipedOutputStream
Datenaustausch zwischen parallel ablaufenden Programmteilen
StringBufferInputStream Einlesen aus einem StringBuffer
Diese Grundklassen bieten Programmieren wenig komfortable Ein- und Ausgabemethoden, da sie nur einzelne Bytes oder Bytefelder einlesen bzw. ausgeben. Mit FileInputStream können wir also nur einzelne Zeichen aus einer Datei lesen. Damit könnten wir jetzt eine eigene Eingaberoutine schreiben, was aber recht aufwendig und zum Glück unnötig ist.

Java bietet uns natürlich auch komfortablere Klassen, die z.B. auch zeilenweises Einlesen unterstützen. Dabei benutzt Java ein geschicktes Konzept, um den Programmieraufwand gering zu halten. Die einfachen Ströme lassen sich Verknüpfung mit spezielleren Stromklassen (s. Tabelle 2) verbessern! Ein speziellerer Strom muss aber nicht für jede Art von Ein-/Ausgabegerät getrennt programmiert werden, da er seine Daten jeweils aus einem beliebigen der Grundströme bezieht.

Tabelle 2: Speziellere Ströme in Java

Klassenname Funktionalität
BufferedInputStream
BufferedOutputStream
gepuffertes Einlesen
gepufferte Ausgabe
DataInputStream
DataOutputStream
Einlesen und Schreiben der Java-Grunddatentypen für Datendateien
FilterInputStream
FilterOutputStream
während der Ein- und Ausgabe können die Daten in ein anderes Format gebracht werden
LineNumberInputStream beim Einlesen wird die Zeilennummer gezählt
PushbackInputStream bietet die Möglichkeit ein Zeichen wieder als ungelesen zurückzuschreiben
PrintStream Zeilenweise Ausgabe
SequenceInputStream Verkettung mehrerer Eingabeströme

Diese Ströme können nun über die Ströme mit minderer Funktionalität "drübergestülpt" werden. Dazu geben wir den einfachen Strom als Quelle (bzw. Ziel) des gewünschten Stromes an. Der spezielle Strom nutzt dann die Ein- bzw. Ausgabemethoden des einfachen Stromes und baut daraus die komfortable Ein- oder Ausgabe auf.

In unserem Fall sollen die einzelnen Zeichen der Tastatur eingelesen werden und als ganze Zeile in unserem Programm ankommen. Die Eingaben der Tastatur werden durch den Strom System.in an ein Java-Programm gemeldet. Dies ist "nur" ein einfacher InputStream.

Die Klasse DataInputStream erkennt Zeilenenden in Eingaben und kann so ganze Zeilen als String an das Programm weiterliefern. Wir können jetzt den Strom System.in als Eingabe für einen DataInputStream verwenden:

  String zeilenweiseEingabe;
  zeilenweiseEingabe = new DataInputStream(System.in);
Der Konstruktor des DataInputStream nimmt also als Parameter einen Eingabestrom, aus dem die einzelnen Zeichen gelesen werden.

Schematisch können wir uns den Eingabevorgang dann ungefähr so vorstellen:

Bild:

Hintereinanderschaltung von Strömen

Quelle         einfacher Strom      spezieller Strom    Programm

einzelne                                                ganze Zeile
Zeichen von  -> InputStream     ->  DataInputStream  -> in einem
der Tastatur    (liest ein)       (erkennt Zeilenende)  String
Genauso können wir mit allen Grundklassen von Strömen verfahren. Wollen wir Zeilen aus einer Datei lesen, so nehmen wir einen FileInputStream und nutzen ihn als Eingabe für den DataInputStream:
  String zeileAusDatei;
  zeileAusDatei = new DataInputStream(new FileInputStream("testdatei.txt"));
Der Trick bei der Sache ist nun, dass alle spezielleren Eingabetröme wiederum als Eingabeströme dienen können. Durch Aneinanderreihung ist es möglich die Funktionen verschiedener Klassen zusammenzuführen. So könnten wir das Einlesen aus der Datei beschleunigen, indem wir die Eingaben puffern. Dazu steht der BufferedInputStream zur Verwendung bereit. Er benutzt einen Puffer, der immer komplett "vollgelesen" wird. Dadurch verringert sich die Zahl der Laufwerkszugriffe. Die Größe des Puffers können wir bei seiner Erzeugung angeben:

  String zeileAusDatei;
  zeileAusDatei = new DataInputStream(
    new BufferedInputStream(new FileInputStream("testdatei.txt"), 1024)));
Hier haben wir also noch einen Strom zwischengeschaltet, der uns ohne Veränderung genau das leistet, was wir haben wollten. Der klare Vorteil dieser Hintereinanderschaltung ist nun, dass der BufferedInputStream genauso für beliebige andere Eingabequellen benutzt werden kann.

Wir wollen den Benutzer jetzt eine Adresse über die Standardeingabe (Tastatur) eingeben lassen. Dabei lesen wir die Felder unserer Adresse als String ein. Dies organisiert der Strom DataInputStream, wie oben beschrieben.

import java.io.*;

//... in die Klasse Adresse einfügen...
  void einlesen()
    throws IOException
    {
      DataInputStream eingabeStrom = new DataInputStream(System.in);
      System.out.println("Name: ");
      name = eingabeStrom.readLine();
      System.out.println("Vorname: ");
      vorname = eingabeStrom.readLine();
    }
Zu Beginn der Quelldatei von Adresse müssen wir jetzt die Bibliothek java.io komplett einbinden, dies deuten wir mit java.io.* an. Alle Klassen, die zur Ein- und Ausgabe benötigt werden, sind in diesem Paket enthalten.

Unsere Klasse Adresse erweitern wir dann um die Methode einlesen(). Zunächst geben wir mit throws vorläufig an, dass alle Ausnahmen, die beim Einlesen entstehen, an die aufrufende Methode weitergeleitet werden sollen.

Um einen String von der Standardeingabe einzulesen, erzeugen wir unseren Eingabestrom von der Klasse DataInputStream mit dem Parameter System.in. Damit erreichen wir, dass alle Eingaben der Tastatur an unser Objekt der Klasse DataInputStream weitergegeben werden. Sie fügt die Eingaben zusammen zu einem String und übergibt uns diesen durch die Methode readLine(). readLine() liest jeweils die Zeichen aus dem Eingabestrom, bis ein Zeilenende erreicht wird. Hierbei ist bemerkenswert, dass auch DataInputStream maschinenunabhängig arbeitet und bei DOS-Systemen CR und LF erwartet, während unter Unix das CR ausreicht.

Unsere modifizierte Klasse Adresse enthält jetzt die neue Methode zum Einlesen. Allerdings haben wir dort noch kein Alter eingelesen. Wenn wir uns die Methoden von DataInputStream ansehen, finden wir dort auch

public final int readInt()
als Methode. Sie liefert uns scheinbar ein eingelesenes int. Doch DataInputStream dient auch zum Lesen von Dateien in einem systemunabhängigen Format. Deshalb erwartet diese Klasse genau vier Zeichen, aus denen dann der int-Wert berechnet wird. Eine Eingabe von Ziffern würde nicht das erwünschte Ergebnis liefern.

Wir müssen hier deshalb einen Umweg gehen: Wir lesen das Alter ebenfalls als String und versuchen anschließend die eingegebenen Zeichen in einen Wert zu wandeln. Die Klasse Integer bietet dazu Methoden an. Sie ist das Klassenpendant zum Grundtyp int.

      System.out.println("Alter: ");
      String stringAlter = eingabeStrom.readLine();
      Integer tmpAlter = new Integer(-1);
      setzeAlter(tmpAlter.parseInt(stringAlter));
Wir lesen also wie vorher auch eine Zeile als Eingabe und legen sie in stringAlter ab. Anschließend erzeugen wir ein leeres Objekt tmpAlter der Klasse Integer. Die Methode parseInt(String) wandelt die Eingabe in ein int um. Jetzt kommt es aber vor, dass ein Benutzer eine Eingabe macht, die keine Zahl darstellt. In diesem Fall löst parseInt eine NumberFormatException aus. Außerdem könnte unsere Methode setzeAlter die Ausnahme InvalidAgeException veranlassen. Wir werden den Benutzer solange Daten eingeben lassen, bis ein sinnvoller Wert eingegeben ist. Unsere vollständige Klasse sieht dann so aus:
class Adresse 
{
  String vorname, name;
  int alter;

  Adresse()
  {
    super();
  }

  Adresse(String vorname, String nachname, int alter)
    throws InvalidAgeException
  {
    this.vorname = vorname;
    name = nachname;  
    if (alter < 0) throw new InvalidAgeException(
      "Das Alter darf nicht negativ sein!"); 
    this.alter = alter;
  }
  
  Adresse(String vorname, String nachname)
  {
    this.vorname = vorname;
    name = nachname;  
  }

  void einlesen() throws IOException
    {
      DataInputStream eingabeStrom = new DataInputStream(System.in);
      System.out.println("Name: ");
      name = eingabeStrom.readLine();
      System.out.println("Vorname: ");
      vorname = eingabeStrom.readLine();
      boolean fehler;
      do {
        fehler = false;
        System.out.println("Alter: ");
        String stringAlter = eingabeStrom.readLine();
        Integer tmpAlter = new Integer(-1);
	try {
          setzeAlter(tmpAlter.parseInt(stringAlter));
	}
	catch (NumberFormatException e) {
	  System.out.println("Bitte eine Zahl eingeben!");
	  fehler = true;
	}
	catch (InvalidAgeException e) {
	  System.out.println("Ein Alter kann nicht negativ sein!");
	  fehler = true;
	}
       } while (fehler);
    }

  void ausgeben()
  {
     System.out.println(vorname + " " + name);
     System.out.println("Alter: " +alter );
  }

  public String toString()
  {
     return "Adresse (" + name + " " + vorname + " " + alter + ")" ;
  }
}
Hier benutzen wir eine do-while-Schleife. Sie wird immer einmal ausgeführt und solange wiederholt, bis fehler den Wert false enthält. Im try-Block steht die Anweisung, bei deren Ausführung die Ausnahmen ausgelöst werden könnten. Wir behandeln die beiden Ausnahmen jeweils mit einer Fehlermeldung. Natürlich können bei der Eingabe auch andere Ausnahmen entstehen. Sie sind alle Spezialfälle der IOException. Solche Ausnahmen behandeln wir in der Methode einlesen nicht, sondern geben sie mit throws weiter.

So können jetzt während des Programmlaufs Adressen eingegeben werden. Unser Hauptprogramm und die Kartei formulieren wir dann so:

class AdressKartei
{
  Vector adrVector = new Vector(16);

  void einfügen(Adresse adr)
    {
      adrVector.addElement( adr );
    }

 void ausgeben()
    throws ArrayIndexOutOfBoundsException
  {
    for (int i = 0; i < adrVector.size(); i++) 
      {   
        Object obj = adrVector.elementAt(i);
        if (obj instanceof Adresse)
          {
            ((Adresse)obj).ausgeben();
          };
      }
  }
}

public class AdressProgramm
{
  public static void main(String argv[])
    {
      AdressKartei kartei = new AdressKartei();
      Adresse adr;
      for (int i=0; i<5; i++)
        {
          try {
            adr = new Adresse();
            adr.einlesen();
	    kartei.einfügen(adr);
	  }
	  catch (IOException e) {
	    System.out.println(e.getMessage());
	  }
        }
      kartei.ausgeben();
    }
}
Im Hauptprogramm behandeln wir jetzt auch die Ein-/Ausgabe-Ausnahmen, indem wir jeweils die Meldung der Ausnahme ausgeben.

Dateien schreiben und lesen

Jetzt können wir schon fast beliebig viele Adressen eingeben, doch mit dem Programmende sind sie verloren. Deshalb werden wir jetzt lernen Dateien zu erzeugen, zu schreiben und wieder auszulesen.

Analog zur Eingabe von der Tastatur können wir auch aus einer Datei einlesen. Dazu benutzen wir nicht den Strom System.in der die Eingaben von der Tastatur einliest, sondern erzeugen einen neuen FileInputStream. Dies ist mit zwei verschiedenen Konstruktoren möglich. Zum einen können wir direkt einen Dateinamen angeben oder aber wir geben ein Objekt der Klasse File an, das uns auch Informationen über die Datei liefern kann.

Wir überladen unsere Methode einlesen() mit der gleichnamigen Methode einlesen(DataInputStream). Überladen bedeutet, dass mehrere gleichnamige Methoden existieren, die sich nur durch die Typen oder die Anzahl der Parameter unterscheiden. Anhand der Parameter wird immer die korrekte Methode gefunden.

  void einlesen(DataInputStream eingabeStrom) throws IOException
    {
      name = eingabeStrom.readLine();
      vorname = eingabeStrom.readLine();
      String stringAlter = eingabeStrom.readLine();
      if (stringAlter == null) throw new EOFException();
      Integer tmpAlter = new Integer(-1);
      try {
        int i = tmpAlter.parseInt(stringAlter);
	setzeAlter(i);
      }
      catch (NumberFormatException e) {
	System.out.println("Ungültige Zahl gelesen!");
      }
      catch (InvalidAgeException e) {
	System.out.println("Negativer Wert gelesen!");
      }
    }
Diese Methode liest Zeile für Zeile aus dem übergebenen Strom und betrachtet immer den Inhalt einer Zeile als Wert für das entsprechende Feld. Falls beim Versuch das Alter zu lesen ein Fehler entsteht, so gibt readLine eine null-Referenz zurück. Wir schließen daraus, dass das Dateiende erreicht ist und lösen eine EOFException aus.

Um den gesamten Inhalt einer Datei zu lesen, formulieren wir eine Endlosschleife, die erst bei Erreichen des Dateiendes durch eine EOFException beendet wird:

public class AdressProgramm
{
  public static void main(String argv[])
  {
    AdressKartei kartei = new AdressKartei();
    Adresse adr;
    try {
      DataInputStream eingabeStrom = new DataInputStream(
        new FileInputStream("textdatei.txt"));
      try {
	while (true)
	  {
	    adr = new Adresse();
	    adr.einlesen(eingabeStrom);
	    kartei.einfügen(adr);
	  }
      }
      catch (EOFException e) {
      }
      finally {
	eingabeStrom.close();
      }
    }
    catch (IOException e) {
      System.out.println("Dateifehler in "+e.getMessage());
    }
    kartei.ausgeben();
  }
}
Wir erzeugen zunächst einen DataInputStream, der seine Eingaben aus einem FileInputStream bezieht. Dieser wiederum liest Daten aus der Datei textfile.txt.

Im nächsten try-Block steht unsere Endlosschleife. Hier erzeugen wir immer wieder ein neues Adressobjekt, lesen mit der Methode einlesen die Felder aus dem erzeugten Stream und fügen die neue Adresse in die Kartei ein.

Wenn jetzt während der Endlosschleife das Dateiende erreicht wird, so bricht diese mit einer EOFException ab. Deshalb behandeln wir jetzt diese Ausnahme mit einem catch-Block, der keine Anweisungen enthält. Jede andere Ausnahme wird an den nächsthöheren try-Block weitergereicht. In ihm fangen wir alle Arten von Ein-/Ausgabefehlern ab, die ja mit Unterklassen von IOException gemeldet werden.

Analog dazu können wir auch alle Adressdaten in eine Textdatei schreiben, in dem wir einen PrintStream zur Ausgabe benutzen. PrintStream bietet dazu die Methoden print und println. Diese kennen wir schon von der Standardausgabe System.out, denn dies ist nichts anderes als ein PrintStream.

Wir wollen aber ein etwas kompakteres Format erreichen, mit dem wir auch das Alter ohne große Umwandlungsprobleme schreiben und lesen können. Dazu errinnern wir uns an den DataInputStream und sein Pendant DataOutputStream. Dieses Paar erlaubt das Lesen und Schreiben aller Grunddatentypen in einem genormten Format.

// in Klasse Adresse einfügen

  void einlesenAusDatei(DataInputStream datei) throws IOException
    {
      name = datei.readLine();
      vorname = datei.readLine();
      alter = datei.readInt();
    }

  void schreibenInDatei(DataOutputStream datei) throws IOException
    {
      datei.writeChars(name);
      datei.writeChar('\n');
      datei.writeChars(vorname);
      datei.writeChar('\n');
      datei.writeInt(alter);
    }
Für die Ausgabe der Daten in den Strings müssen wir einen kleinen Kunstgriff ansetzen, da DataOutputStream dafür keine Methode zum Schreiben vorsieht. Wir schreiben unsere Strings mit writeChars, die sie als Zeichenfolge in die Datei schreibt. Anschließend beenden wir die Ausgabe mit dem CR-Zeichen, dass einen Zeilenumbruch darstellt. In Java werden einzelne Zeichen mit den einfachen Anführungsstrichen angegeben. \n wird in das Zeichen CR übersetzt. Beim Einlesen können wir dann wieder mit der einfachen Methode readLine arbeiten.

Wir können jetzt die ganzen Daten im Vektor in eine Datei schreiben. Dazu erhält die Klasse AdressKartei eine neue Methode:

// in Klasse AdressKartei einfügen

 void speichern(String datName)
    throws ArrayIndexOutOfBoundsException, IOException
  {
    DataOutputStream os = new DataOutputStream(
      new FileOutputStream(datName));
    for (int i = 0; i < adrVector.size(); i++) 
      {   
        Object obj = adrVector.elementAt(i);
        if (obj instanceof Adresse)
          {
            ((Adresse)obj).schreibenInDatei(os);
          };
      }
    os.close();
  }
Genauso könnt ihr jetzt eine Methode für die Klasse AdressKartei schreiben, die die Dateidatei wieder ausliest. Im Hauptprogramm könnt ihr dann eine Datei öffnen, ein paar neue Daten eingeben und alle wieder speichern. Wir wünschen euch viel Spaß beim rumexperimentieren, bis zum nächsten Mal.


[Teil 3: Felder und Vektoren] [Teil 5: Ein-/Ausgabe]