[Teil 4: Ausnahmen] [Teil 6: Praktische Standardklassen]

Einstieg in Java
Teil 5: Ein-/Ausgabe

Von Enno Runne und Hendrik C.R. Lock

Java's Ein-/Ausgabe ist so reich ausgestattet, dass man zuerst von einer Fülle neuer Möglichkeiten erschlagen wird. Diese Fülle zahlt sich aber durch Flexibiltät und Sicherheit in der Benutzung aus.

Eingabe und Ausgabe - Ströme von Daten

Die Ein- und Ausgabemöglichkeiten in Java werden durch die reichhaltige Standardbibliothek java.io festgelegt. Ein- und Ausgabe sind im Gegensatz zu C typsicher, d.h. das Typsystem überprüft, ob die richtigen Operationen auf den richtigen Daten ausgeführt werden. Außerdem werden Fehlersituationen durch ein Reihe fein abgestimmter Ausnahmeklassen behandelt (siehe Internet Online Januar 1997).

Die Ein- und die Ausgabe wird in Java durch Ströme dargestellt, in denen die Daten in fester Reihenfolge zuerst abgelegt und danach abgeholt werden. Ein solcher Strom bindet eine Datei, die Konsole oder einen Puffer im Hauptspeicher an. Ein einfacher Strom realisert z.B. eine byte-weise Zeichenübertragung.

Das folgende Programmbeispiel liest Zeichen von der Konsole ein und gibt sie gleich wieder aus.

import java.io.*;

public class Echo1
{
  public static void main(String[] args)
    throws IOException
  {
    System.out.println( "ein: " + System.in.getClass() );
    System.out.println( "aus: " + System.out.getClass() );
    // lese von der Standardeingabe ein
    byte eingabe[] = new byte[128];
    System.in.read(eingabe);
    // schreibe auf Standardausgabe
    System.out.println(eingabe);
  }
}
Zu Beginn binden wir die Bibliothek java.io komplett ein. Alle Klassen, die zur Ein- und Ausgabe benötigt werden, sind nun bekannt. Dadurch werden auch die Objekte System.in und System.out angelegt, über die die E/A-Operationen read(byte[]) und print(String) aufgerufen werden. Weil die Lesefunktion read() eine Ausnahme auslösen kann, wird dies bei der Hauptfunktion angegeben. Die Ausführung ergibt folgendes:
> ein class java.io.BufferedInputStream
> aus class java.io.PrintStream
> hallo welt
> [B@ee300940
System.in ist also ein Strom der Klasse BufferedInputStream, und System.out ein Strom der Klasse PrintStream. Die Operation read() liest bis zum Zeilenende ein, d.h. "hallo welt". Aber zu unserer Überraschung ist die Ausgabe "[B@ee300940". Das liegt daran, dass read(byte[]) eine ziemlich primitive Opration ist, die nur ein Feld von Bytes einliest, wohingegen System.out.println() auf einem aus Zeichen bestehenden Feld arbeitet. Der Effekt wird also durch einen Darstellungsunterschied zwischen Bytes und Zeichen verursacht. Die Konvertierung des Bytefeldes in eine Zeichenkette behebt das Problem schlagartig.
    String kette = new String(eingabe,0);
    System.out.println(kette);

Wie das Beispiel zeigt, haben Ströme bestimmte Eigenschaften bezüglich der Darstellung und Verarbeitung ihrer Daten. Dadurch erlauben die vorgegebenen Stromtypen E/A auf unterschiedlichen Niveaus. Die zentrale Idee der E/A Programmierung in Java ist es, Ströme aus mehreren Niveaus je nach Bedarf zusammenschalten, um vom Format der E/A zum benötigten Format zu gelangen (siehe Schaubild 1). Einige wichtige Ströme werden im Folgenden vorgestellt.

Die einfachsten Ströme gehören zur Klasse InputStream, sie definiert Basismethoden für primitive byte-weise E/A. Deren wichtigste Methode ist int read(byte b[]), die an alle Eingabeströme vererbt wird. Die Unterklassen (siehe Schaubild 2) definieren Methoden mit spezielleren Eigenschaften. So wird z.B. ein FileInputStream zum Lesen aus Dateien verwendet. Analog gibt es zu jeder xxxInputStream-Klasse eine xxxOutputStream-Klasse mit denselben Eigenschaften.

Die wichtigsten Ströme für die Kommunikation mit der Konsole, die wir ja bereits mehrfach verwendet haben, sind in Tabelle 1 zusammengefasst. Die Operation println(String) verhält sich dabei wie print(String), außer dass zusätzlich ein Zeilenende ausgegeben wird. Tabelle 2 fasst Dateioperationen zusammen.

Layout: Schaubild 1 und 2

Layout: Tabelle 1 (bitte farbig aufpeppen)


Standardeingabe
Standardausgabe
Standardfehlerausgabe
System.in
System.out
System.err
BufferedInputStream
PrintStream
PrintStream
int read(byte[])
print(String)
print(String)

Layout: Tabelle 2 (bitte farbig aufpeppen)


Dateieingabe
Dateiausgabe
FileInputStream
FileOutputStream
int read(byte[])
write(byte[],int,int)

Schaubild 1 zeigt, wie einfachere Ströme in Ströme mit komplexeren Eigenschaften eingespeist werden. So erkennt z.B. die Klasse DataInputStream das Zeilenende in Eingaben und kann so ganze Zeilen als String an das Programm zurückliefern. Wir nutzen dies im folgenden Beispiel, um gepuffert von einer Datei zu lesen und die Daten zeilenweise auszulesen.

  FileInputStream fin = new FileInputStream("test.dat");
  BufferedInputStream bin = new BufferedInputStream(fin, 1024);
  DataInputStream din = new DataInputStream(bin);
  String zeile = din.readLine();
In diesem Programmfragment werden Ströme per Konstruktor geöffnet und dann als Argument dem nächsten Stromkonstruktor übergeben. Durch diese Form der Aneinanderreihung von Strömen ist es also möglich, die Funktionalität verschiedener Stromklassen zusammenzuführen. Insbesondere haben wir durch die Puffererung den Einlesevorgang beschleunigt, die verwendete Puffergröße 1024 im Konstruktor ist dabei optional.

Anwendung Adresskartei

Wir erweitern nun unsere Adressenverwaltung so, dass ein Benutzer eine Adresse über die Standardeingabe (Tastatur) eingeben kann. Dabei lesen wir die Felder unserer Adresse als String ein, was wir über einen DataInputStream organisieren.
import java.io.*;

//... diese Methode 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();
  }
Unsere Klasse Adresse erweitern wir um die Methode einlesen(). Zunächst geben wir mit throws an, dass alle Ausnahmen, die durch fehlerhafte Eingaben entstehen, an die aufrufende Methode weitergeleitet werden sollen. An dieser Stelle lässt sich später eine Fehlerbehandlung einfügen.

Systemseitig geschieht nun das Folgende: alle Eingaben der Tastatur werden an das Objekt eingabeStrom der Klasse DataInputStream weitergegeben, das sie aufsammelt. Eine komplette Eingabezeile wird dann mir der Methode readLine() abgerufen. Weitere Aufrufe geben dann jeweils die nächste Eingabezeile zurück. Hierbei ist bemerkenswert, dass DataInputStream maschinenunabhängig arbeitet: bei DOS-Systemen wird CR und LF erwartet, während unter Unix das Zeichen CR ausreicht.

Die neue Methode liest allerdings noch kein Alter ein. Hier treffen wir zunächst auf eine technische Hürde. Wenn wir uns die Methoden von DataInputStream ansehen, finden wir dort eine Methode

public final int readInt()
Sie liefert uns scheinbar einen eingelesenen int-Wert. Weil aber DataInputStream auch zum Lesen von Dateien in einem systemunabhängigen Format dient, erwartet diese Methode eine Darstellung des Wertes durch genau vier Zeichen, aus denen dann der int-Wert berechnet wird. Textuelle Ziffernfolgen besitzen aber eine andere Darstellung, und somit würden wir nicht das gewünschte Ergebnis erhalten.

Wir kommen aber über einen Umweg ans Ziel: Wir lesen das Alter ebenfalls als String und verwandeln anschließend die eingegebenen Zeichen in einen Wert. Dazu bietet die Klasse Integer die Methode static int parseInt(String) an. Integer ist eine Klasse, die den Basistyp int auf die Objektebene hievt (denn numerische Werte sind keine Objekte).

    System.out.println("Alter: ");
    String stringAlter = eingabeStrom.readLine();
    setzeAlter(Integer.parseInt(stringAlter));
Nach dem Einlesen einer Zeile wird die dazugehörige Zeichenkette in einen numerischen Wert umgewandelt. Der Aufruf der Methode parseInt() wird hier direkt mit dem Klassennamen Integer qualifiziert, da es sich um eine statisch gebundene Methode handelt, d.h. um eine einzige Methode, die alle Objekte dieser Klasse gemeinsam benutzen.

Jetzt kann es aber geschehen, dass ein Benutzer eine Eingabe macht, die keine Zahl darstellt. In diesem Fall löst parseInt eine NumberFormatException aus. Weiterhin könnte unsere Methode setzeAlter die Ausnahme InvalidAgeException veranlassen. Wir modifizieren nun die Einlesemethode so, dass ein Benutzer bei fehlerhaften Eingaben die Eingabe wiederholt. Die vollständige Methode sieht dann so aus:

class Adresse 
{
  // ...
  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();
	try {
          setzeAlter(Integer.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);
    }
}
Die do-while-Schleife wird mindestens einmal ausgeführt und solange wiederholt, bis fehler==false. Mittels den try-catch-Konstruktionen erhalten wir eine rudimentäre Fehlerbehandlung. Natürlich können bei der Eingabe auch andere Ausnahmen entstehen, die allesamt Spezialfälle der IOException sind. Solche Ausnahmen behandeln wir in der Methode einlesen nicht, sondern geben sie mit throws weiter.

Abschluss

Soweit haben wir Wege im Dickicht der vielfältigen Ein/Ausgabe Möglichkeiten von Java gebahnt. Wir haben gesehen, wie sich vordefinierte Ströme so zusammenschalten lassen, dass eine gewünschte Funktionalität erreicht wird. Dieses Konzept der Schachtelung ist im ersten Moment sicherlich sehr verwirrend, aber es eine einfache Möglichkeit höchste Flexibilität zu erreichen. Dieses Konzept ist als Entwurfsmuster Dekorierer bekannt (siehe Schaubild 3).

Ein Beispiel war das gepufferte Einlesen von Zeilen aus einer Datei, das wir durch Verkettung von drei Strömen erreichten. Weiterhin wurde gezeigt, wie sich eine Ziffernfolge in einen numerischen (int) Wert umwandeln lässt.

Noch sind die eingelesen Adressdaten flüchtig, aber in der nächsten Ausgabe werden sie auf Dateien gebannt. Bis dahin wünschen wir euch viel Spaß mit Java.


[Teil 4: Ausnahmen] [Teil 6: Praktische Standardklassen]