Letztes Update: 12.04.2018
Behandelte Befehle: throw, throws, try, catch, Exception, RuntimeException, printStackTrace()

In diesem Kapitel sehen wir uns an, wie man in Java mit Fehlern umgeht. Einen Fehler nennt man beim Programmieren eine Exception (engl. Ausnahme).

21.1 Geprüfte/ungeprüfte Exceptions

Compiler-Fehler

Zunächst mal rufen wir uns ins Gedächtnis zurück, dass unser Java-Code übersetzt wird in ein "maschinenfreundliches" Format, den sogenannten Bytecode. Diese Übersetzung übernimmt der Compiler. Der Compiler findet bereits viele Fehler, die sogenannten Compiler-Fehler. Solange diese Fehler nicht beseitigt sind, können Sie Ihr Programm gar nicht starten. Daher geht es in diesem Kapitel um die anderen, die sogenannten Laufzeit-Fehler, die dann auftreten, wenn das Programm läuft.

Damit wir zunächst mal wissen, was mit Compiler-Fehlern gemeint ist: Beispiele für Fehler, die der Compiler abfängt, sind Syntaxfehler, d.h. Fehler, die aufgrund von falscher Schreibung oder "Grammatik" auftreten. Beispiele sind falsch geschriebene Kommandos (wile statt while), Beispiele für falsche Grammatik sind vergessene Semicolons oder nicht geschlossene Klammern.

Der Compiler kümmert sich aber auch um semantische Fehler, d.h. Code, der zwar syntaktisch stimmt, aber ein inhaltliches Problem hat. Beispiel dafür ist die Typ-Kontrolle:

boolean foo = false;
int num = 10 + foo; // Exception: kann kein Bool addieren

Weiteres Beispiel ist die Überprüfung, ob eine Funktion mit Rückgabetyp auch wirklich immer ein return aufruft:

public int minusAberPositiv(int a, int b) {
  if (a > b) {
    return a + b;
  }
  // Exception: was passiert bei a <= b ?
}

Geprüfte Exceptions

Wie schon angedeutet, wollen wir Fehler behandeln, die während der Programmausführung, also zur Laufzeit auftreten. Nehmen wir folgendes Beispiel:

public class Konto {
  private double guthaben = 0;

  public void abheben(double betrag) {
    guthaben -= betrag;
  }

  public void einzahlen(double betrag) {
    guthaben += betrag;
  }

  public String toString() {
    return "Kontostand: " + guthaben;
  }
}

Ein Beispielprogramm wäre:

public static void main(String[] a) {
  Konto k = new Konto();
  k.einzahlen(100);
  k.abheben(20);
  System.out.println(k);
}
Kontostand: 80.0

Nichts hindert uns jedoch daran, zu Beginn einen negativen Betrag "einzuzahlen":

public static void main(String[] a) {
  Konto k = new Konto();
  k.einzahlen(-100);
  k.abheben(20);
  System.out.println(k);
}
Kontostand: -120.0

Fehler werfen

Um das zu beheben, müssen wir unsere Methode einzahlen mit eine Mechanismus ausstatten, der eine Exception "wirft", falls ein negativer Betrag eingezahlt wird.

public void einzahlen(double betrag) {
  if (betrag < 0) {
    throw new Exception("Keinen negativen Betrag einzahlen: "
      + betrag);
  }
  guthaben += betrag;
}

Es fehlte noch ein Signal, dass innerhalb dieser Methode eine Exception geworfen wird:

public void einzahlen(double betrag) throws Exception {
  if (betrag < 0) {
    throw new Exception("Keinen negativen Betrag einzahlen: "
      + betrag);
  }
  guthaben += betrag;
}

Wozu diese "Markierung" in der ersten Zeile der Methode? Ganz einfach: Jeder, der diese Methode aufruft, weiß jetzt, dass die Methode einen möglichen Fehler auslöst und muss sich entscheiden, wie damit umzugehen ist. Die erste Möglichkeit ist, den Fehler zu "fangen".

Fehler fangen mit try-catch

Nehmen wir an, wir hätten eine Methode, die eine Reihe von Einzahlungen vornimmt. Diese Methode muss die Methode einzahlen aufrufen:

public void vieleEinzahlungen(double[] betraege) {
  for (int i = 0; i < betraege.length; i++) {
    einzahlen(betraege[i]);
  }
}

Der Compiler erlaubt dies nicht. Sie müssen den Fall, dass bei der Einzahlung etwas schiefläuft abfangen mit folgendem Konstrukt:

try {
  CODE
} catch (Exception e) {
  FEHLERFALL-CODE
}

Der "normale" Code wird von einem try eingeklammert. Sobald eine Exception auftritt, wird dieser Code-Block verlassen und es wird in den Fehlerfall-Code gesprungen. Tritt kein Fehler auf, wird der Fehlerfall-Code ignoriert.

In unserem Beispiel wäre das:

public void vieleEinzahlungen(double[] betraege) {
  try {
    for (int i = 0; i < betraege.length; i++) {
      einzahlen(betraege[i]);
    }
  } catch (Exception e) {
    System.out.println("Fehler! " + e.getMessage());
  }
}

Testen wir jetzt unseren Code mit:

public static void main(String[] a) {
  Konto k = new Konto();
  double[] testnum = { 10, -5, 200 };
  k.vieleEinzahlungen(testnum);
  System.out.println(k);
}

Dann sehen wir, dass der Code bei der zweiten Zahl abbricht bzw. in den Fehlercode-Block läuft, wo eine Ausgabe ("Fehler! ...") stattfindet. Anschließend springt ist die Methode fertig und der Kontostand wird ausgegeben. Wir sehen, dass nur die erste Einzahlung (= 10) geklappt hat:

Fehler! Keinen negativen Betrag einzahlen: -5.0
Kontostand: 10.0

Allgemein sieht die Ausführungsfolge beim Fangen einer Exception wie folgt aus (boo sei eine Methode, die eine BooException wirft):

Wie Sie sehen, wird der Code im try-Block unterhalb der Zeile, in der der Fehler auftritt, nicht mehr ausgeführt. Es wird in den catch-Block gesprungen und dannach ganz normal weiter Code abgearbeitet.

Ein großer Bereich für geprüfte Exceptions sind Ein-/Ausgabe-Operationen zu externen Speichermedien oder über das Netzwerk. Nehmen wir an, Sie möchten eine Datei mit der Klasse Scanner einlesen. Sobald Sie den Konstruktor aufrufen, kann eine FileNotFoundException geworfen werden, die Sie abfangen müssen:

try {
  File file = new File("data.txt");
  Scanner scanner = new Scanner(file);
  while (scanner.hasNextLine()) {
    String line = scanner.nextLine();
    System.out.println("> " + line);
  }
  scanner.close();
} catch (FileNotFoundException ex) {
  ex.printStackTrace();
}

Sie können das in der Java-API sehen:

Werfen und Fangen

Sie fragen sich vielleicht gerade, was das ganze soll mit dem Fangen und Werfen? Bei der Fehlerbehandlung sieht man sich mit zwei Problemen konfrontiert:

  1. Wie kommuniziert man, dass ein Fehler vorliegt (und welcher Art dieser ist)?
  2. Wo behandelt man diesen Fehler?

Für die erste Frage haben wir offensichtlich jetzt eine Lösung. Wohingegen man "früher" (bevor Exceptions erfunden wurden) einfach einen "ungewöhnlichen" Wert zurückgegeben hat, um einen Fehler zu signalisieren, kann man jetzt, mit Hilfe des Exception-Objekts, eine klare Botschaft senden. Ein Beispiel für eine "alte" Lösung:

// Gibt true zurück, falls Einzahlung erfolgreich, sonst false.
public boolean einzahlen(double betrag) {
  if (betrag < 0) {
    return false;
  }
  guthaben += betrag;
  return true;
}

Die zweite Frage ist schwieriger: Wo wird der Fehler behandelt? Stellen Sie sich vor, Sie haben drei Methodenaufrufe:

kundenAktion() ruft auf:
vieleEinzahlungen(double[] betraege) ruft auf:
einzahlen(double betrag)

Die Methode "kundenAktion()" gehört zu einer Reihe von GUI-Aktionen. In welcher Methode würden Sie einen Fehler, der in "einzahlen()" auftritt, behandeln? Die Antwort ist: in der GUI-Methode! Denn dort können Sie z.B. ein Fenster mit einer Fehlermeldung zeigen oder einen Dialog zur Fehlerkorrektur anstoßen.

Aber wie schaffen Sie es, dass erst die Methode kundenAktion() den Fehler behandelt? Ganz einfach: Sie werfen den Fehler immer weiter. Dazu müssen Sie lediglich ein throws in der Methodendefinition hinzufügen. Im Fehlerfall springt Java aus der Methode raus und geht an den Ort zurück, wo die letzte Methode aufgerufen wurde.

Schauen wir uns anhand unserer Konto-Klasse an, wie ein Fehler über mehrere Methoden weitergeworfen wird.

Auf der untersten Ebene wird der Fehler geworfen (Kommando throw) und die Methode wird markiert (Schlüsselwort throws):

public void einzahlen(double betrag) throws Exception {
  if (betrag < 0) {
  throw new Exception("Keinen negativen Betrag einzahlen: "
    + betrag);
  }
  guthaben += betrag;
}

Auf der nächsten Ebene wird der Fehler nicht behandelt, sondern die Methode wird einfach markiert, um zu sagen: "Hier kann auch ein Fehler auftreten!":

public void vieleEinzahlungen(double[] betraege) throws
																		 Exception
{
  for (int i = 0; i < betraege.length; i++) {
    einzahlen(betraege[i]);
  }
}

Erst bei der Methode, die den Fehler wirklich sinnvoll behandeln kann, wird mit try-catch der Fehler abgefangen. Dann ist natürlich keine Markierung mit throws mehr angebracht, da der Fehler nicht weitergeleitet wird.

public void kundenAktion()
{
  ...
  try {
    ...
    vieleEinzahlungen(b);
    ...
  } catch (Exception e) {
    ... // z.B. Fenster mit Klärung
  }
}

Eigene geprüfte Exception schreiben

Exceptions sind Objekte, die z.B. eine Nachricht enthalten (siehe Code oben). In Java gibt es eine Hierarchie von Fehlerklassen, an deren Spitze die Klasse Throwable (engl. werfbar) steht.

Uns interessiert hauptsächlich die Java-Klasse Exception. Sie ist die Oberklasse von geprüften Exceptions abgesehen von den Klassen unter RuntimeException (siehe nächsten Abschnitt). Die Unterklassen von Exception drücken mit Ihrem Namen bereits das behandelte Problemen aus, z.B. FileNotFoundException.

Wenn Sie ein größeres Projekt schreiben, definieren Sie sich Ihre eigenen Fehlerklassen, indem Sie eine Unterklasse von Exception erstellen. Für das Beispiel oben zum Beispiel die Klasse NegativeEinzahlungException.

public class NegativeEinzahlungException extends Exception {

public NegativeEinzahlungException() {
}

public NegativeEinzahlungException(String message) {
  super(message);
}

}

Die Definition leitet nur die Information an die Oberklasse Exception weiter. Ihr Beitrag ist lediglich der Name der Klasse. Jetzt können Sie die Klasse in Ihrem Projekt einsetzen:

public void einzahlen(double betrag) throws NegativeEinzahlungException {
  if (betrag < 0) {
    throw new NegativeEinzahlungException("Keinen negativen Betrag einzahlen: "
      + betrag);
  }
  guthaben += betrag;
}

Beim Fangen des Fehlers wird der Name der Exception berücksichtigt:

public void vieleEinzahlungen(double[] betraege) {
  try {
    for (int i = 0; i < betraege.length; i++) {
      einzahlen(betraege[i]);
    }
  } catch (NegativeEinzahlungException e) {
    System.out.println("Fehler! " + e.getMessage());
  }
}

Exception-Hierarchie beim Fangen

Wenn Sie eine Methode aufrufen, die eine FileNotFoundException wirft, können Sie beim Fangen auch eine Oberklasse verwenden. Das ist nützlich, wenn Sie mehrere Methoden in try-Block haben, die Exceptions werfen.

Nehmen wir an, Sie rufen die Methode someFileMethod auf, die eine FileNotFoundException wirft. Dann können Sie die Exception wie folgt abfangen:

try {
  someFileMethod("bla.txt");
} catch (FileNotFoundException e) {
  System.out.println("Fehler: " + e);
}

Sie können aber auch die Oberklasse IOException fangen:

try {
  someFileMethod("bla.txt");
} catch (IOException e) {
  System.out.println("Fehler: " + e);
}

Das hätte hier die gleichen Konsequenzen. Als Daumenregel sagt man, man möge die spezifischste Exception fangen, in diesem Fall also FileNotFoundException.

Wenn Sie zwei Methoden aufrufen, die beiden Exceptions werfen, können Sie aber eine allgemeinere Exception fangen (die Klasse Exception z.B. geht immer):

try {
  vieleEinzahlungen(fooArray);
  someFileMethod("bla.txt");
} catch (Exception e) {
  System.out.println("Fehler: " + e);
}

Alternativ können Sie auch beide Exceptions separat abfangen, indem Sie einen weiteren catch-Block anhängen.

try {
  vieleEinzahlungen(fooArray);
  someFileMethod("bla.txt");
} catch (IOException e) {
  System.out.println("Schreib-/Lesefehler: " + e);
} catch (NegativeEinzahlungException e) {
  System.out.println("Problem mit der Einzahlung: " + e);
}

Übungsaufgaben

(a) Eigene geprüfte Exception

Schreiben Sie eine eigene Exception namens MyException wie oben beschrieben.

(b) Exception werfen

Schreiben Sie eine Klasse Konto mit Instanzvariable geld. Die Methode abheben bekommt eine Zahl (double) und soll eine Exception names KontoUeberzogenException werfen, wenn man mehr Geld abheben will, als auf dem Konto ist.

Schreiben Sie die Exception und die Methode und konstuieren Sie einen Test, wo die Exception ausgelöst wird.

(c) Exception durchreichen

Wir setzen voraus, dass Sie die Klasse MyException geschrieben haben. Im gleichen Projekt schreiben Sie die nächste Klasse. Diese Klasse enthält drei Methoden:

public class ExceptionDemo {

  public void topLevel() {
    midLevel();
  }

  public void midLevel() {
    lowLevel(0);
  }

  public void lowLevel(int x) throws MyException {
    if (x == 0) {
      throw new MyException("Du Null!");
    }
    System.out.println("Alles gut.");
  }

  public static void main(String[] args) {
    ExceptionDemo d = new ExceptionDemo();
    d.topLevel();
  }
}

Die low-level Methode wirft eine Exception, wenn sie mit einer Null aufgerufen wird. Diese Exception soll aber erst in topLevel() gefangen werden. Ergänzen Sie den Code entsprechend.

Ungeprüfte Exceptions

Die im vorigen Abschnitt beschriebene Methode der Fehlerverarbeitung ist zwar sehr "sauber", führt aber zu einer Menge Code. Für eine Reihe von Fällen wäre es "Overkill", diesen Mechanismus zu verwenden.

Wann können Fehler auftreten. Zum Beispiel, wenn man durch Null teilen will (a und b seien Variablen vom Typ double):

double x = a / b;
Oder wenn Sie auf einen Array zugreifen, könnte der Index außerhalb der Grenzen des Arrays sein (namen sei ein Array von Strings, index eine Integer-Variable):
String foo = namen[index];

Oder wenn Sie eine Methode aufrufen, könnte die Variable statt eines Objekts null enthalten (foo sei eine Variable vom Typ String):

int size = foo.length();

In alle Fällen werden von Java Exceptions geworfen, aber das erstaunliche ist: Sie müssen kein try-catch anwenden. Der Grund: diese Exceptions sind ungeprüfte Exceptions und ungeprüfte Exceptions müssen nicht gefangen werden.

Rein technisch sind die ungeprüfte Exceptions Unterklassen der Klasse RuntimeException. Eine "Runtime-Exception" ist also eine ungeprüfte Exception, wobei der Name eine klare Fehlbenennung ist, denn alle hier diskutierten Fehler, geprüft oder ungeprüft, sind "Laufzeit-Fehler". Die andere Sorte Fehler, Compiler-Fehler, findet hier ja keine Beachtung, sie müssen schließlich nicht im Code berücksichtigt werden.

Wichtig: Da ungeprüfte Exceptions in der Regel nicht gefangen werden, wird im Fehlerfall das Programm beendet. Im Gegensatz dazu läuft bei einer geprüften Exception, die entsprechend gefangen werden muss, das Programm normalerweise weiter.

Ungeprüfte Exception schreiben

Wenn Sie eine eigene Exception lieber ungeprüft lassen wollen, dann erstellen Sie eine Unterklasse der Klasse RuntimeException.

Im obigen Beispiel würde man das so definieren:

public class NegativeEinzahlungException extends RuntimeException {

  public NegativeEinzahlungException() {
  }

  public NegativeEinzahlungException(String message) {
    super(message);
  }

}

Der Unterschied zu vorher: Alle Methoden, die eine solche Exception werfen würden, müssen nicht markiert werden, selbst wenn sie eine NegativeEinzahlungException werfen:

public void einzahlen(double betrag) {
  if (betrag < 0) {
    throw new NegativeEinzahlungException("Keinen negativen Betrag einzahlen: "
      + betrag);
  }
  guthaben += betrag;
}

Und natürlich müssen Methoden, die solche Methoden mit Fehlerpotential aufrufen, nicht mit try-catch der Status der Methode testen

public void vieleEinzahlungen(double[] betraege) {
  for (int i = 0; i < betraege.length; i++) {
    einzahlen(betraege[i]);
  }
}

Bestehende Exceptions nutzen

Wenn es bereits eine Exception-Klasse gibt, die von der Bedeutung her auf Ihren Fehlerfall passt, dann sollten Sie einfach die bestehende Klasse benutzen.

Sehen Sie sich dazu in der Java-API die Unterklassen von RuntimeException an:

Nehmen wir an, Sie haben eine Klasse Party, wo Sie alle Teilnehmer in einer Liste gespeichert haben. Sie erlauben das Hinzufügen von neuen Teilnehmern mit der Methode meldeAn:

public class Party {

  private ArrayList<String> teilnehmer = new ArrayList<>();

  public void meldeAn(String name) {
    teilnehmer.add(name);
  }
}

Jetzt möchten Sie vermeiden, dass unsinniger Input als Anmeldung durchgeht, z.B. ein leerer String "" oder auch ein String nur aus Leerzeichen " ". Sie möchten eine Exception werfen, allerdings soll es sich um eine RuntimeException handeln, damit Ihre Methode nicht zu unhandlich wird.

In der Java-API finden Sie die Exception IllegalArgumentException. Diese Exception ist genau für solche Fälle geschaffen, dass Sie ungeeignete Eingabewerte abfangen. Da es sich bereits um eine ungeprüfte Exception handelt, können Sie sie einfach verwenden, ohne eine eigene Klasse zu schreiben:

public class Party {

  private ArrayList<String> teilnehmer = new ArrayList<>();

  public void meldeAn(String name) {
    if (name.trim().length() == 0) {
      throw new IllegalArgumentException("Anmeldung mit leerem Namen.");
    }
    teilnehmer.add(name);
  }
}

Die Methode trim() entfernt alle Leerzeichen zu Beginn und Ende eines Strings (z.B. aus " hallo welt   " wird "hallo welt"). Da IllegalArgumentException eine Unterklasse von RuntimeException ist, muss die Exception weder behandelt noch weitergeworfen werden.

Wichtige Anwendungsregeln

Nie den catch-Block leer lassen

In der Java-Bibliothek gibt es viele Methoden, die Exceptions werfen. Ein Programmieranfänger mag versucht sein, den entsprechenden Methodenaufruf einfach mit einem try-catch zu umgeben und den catch-Block leer zu lassen:

...
try {
  doSomething();
} catch (SomeException e) {
}

Das führt natürlich dazu, dass Sie im Fehlerfall nicht davon erfahren, dass ein Fehler an genau dieser Stelle auftrat, so dass die Fehlersuche sehr aufwändig werden kann.

Die Minimallösung für solche Standardsituationen ist:

...
try {
  doSomething();
} catch (SomeException e) {
  e.printStackTrace();
}

Mit der Methode printStackTrace() wird ausgegeben, welche Methoden bis zum Aufkommen der Exception aufgerufen wurden, inklusive der Zeile und Meldung des aktuellen Fehlers.

Wann geprüft, wann ungeprüft?

Wenn Sie eigene Fehlerklassen schreiben, müssen Sie entscheiden, ob Sie eine geprüfte oder ungeprüfte Exception verwenden.

Eine Daumenregel dazu besagt:

  • Geprüfte Exceptions verwenden, wenn ein Fehler auf äußere Umstände zurückgeht, z.B. dass eine Datei nicht am richtigen Ort ist, die Netzwerkleitung nicht funktioniert oder der Benutzer eine falsche Eingabe gemacht hat. Diese Fehler kann der Programmierer nie beseitigen, also lohnt es sich, im Code entsprechende Abfangmechanismen zu implementieren, so dass das Programm im Fehlerfall weiterlaufen kann.
  • Ungeprüfte Exception verwenden, wenn ein Programmierfehler vorliegt, z.B. ein Zugriff auf nicht vorhandene Elemente eines Arrays (oder einer Liste), der Aufruf einer Funktion mit illegalen Parameterwerten oder falsches Casten von Typen. Diese Fehler sollten nach und nach aus dem Programm entfernt werden, daher ist es gut, wenn die Exceptions nicht aufgefangen werden, sondern das Programm stoppt.

Übungsaufgabe

(a) Todo-Liste

Schreiben Sie Backend und GUI für die Verwaltung einer Todo-Liste. Jeder Todo-Eintrag ist ein "Task" und hat einen Titel und eine Priorität zwischen 1 und 3:

(In der GUI wird mit Absicht kein Slider für die Priorität benutzt.)

Ihr Backend besteht aus den zwei Klassen Task mit den Eigenschaften title (String) und priority (int) und TodoList mit der Eigenschaft tasks (Liste von Task-Objekten). Die Klassen sollen in einem Package mit Namen de.hsa.todo.model sitzen. Die Klasse TodoList hat die Methoden addTask(String, int) und printAll().

Die GUI wird in einer JavaFX-Klasse TodoListUI realisiert, in einem separaten Package mit Namen de.hsa.todo.view. Bei "Add" wird ein neuer Task hinzugefügt, bei "Print" werden alle Tasks auf der Konsole ausgegeben, bei "Close" schließt das Fenster.

Anmerkung: Die Trennung von Backend und GUI in zwei Packages folgt der Praxis des Model-View-Controller-Konzepts, das wir im nächsten Kapitel noch kennen lernen. Die Backend-Klassen nennt man auch ein Modell, die GUI-Klasse vereint sowohl View als auch Controller.

Hinweis: Mit Integer.parseInt() können Sie einen String in eine ganze Zahl umwandeln. Für den Fall, dass es sich bei dem String nicht um eine Zahl handelt, wird eine NumberFormatException geworfen (ist diese geprüft oder ungeprüft? Schauen Sie in die API!).

Wenn der Benutzer auf "Add" drückt, können drei "Fehler" passieren:

  1. Der Titel ist leer
  2. Die Priorität ist keine Zahl
  3. Die Priorität ist eine Zahl aber nicht zwischen 1 und 3

Sie sollen diese Fehler mit Exception-Handling abfangen. Überlegen Sie sich, wo die Exception geworfen wird (sollte nicht im UI-Code passieren). Überlegen Sie auch, ob Sie eine eigene Exception schreiben oder ob Sie eine vorhandene nehmen.

Geben Sie im einfachsten Fall die Fehlermeldungen auf der Konsole aus:

Titel ist leer.
Priorität muss Zahl sein.
Priorität muss 1, 2 oder 3 sein.

Wenn Sie möchten, können Sie eine Meldungsleiste hinzufügen, die nur eingeblendet wird (FadeIn und FadeOut), wenn eine Meldung kommt, z.B. nachdem ein Task hinzugefügt wurde:

Fügen Sie dazu ein Label ein und fügen Sie es dem zentralen Panel hinzu (unter Umständen innerhalb einer HBox, damit Sie das Label zentrieren können). Setzen Sie die Opaqueheit auf 0. Erzeugen Sie dann zwei FadeTransitions, die Sie mit SequentialTransition kombinieren (siehe voriges Kapitel). Um eine Meldung abzufeuern, setzen Sie zunächst den Text im Label entsprechend und rufen dann play() auf der SequentialTransition auf.

(b) Todo-Liste 2

Fügen Sie Ihrer Klasse TodoList die Methoden load() und save() hinzu. Beide Methoden bekommen einen String mit dem Dateipfad und speichern bzw. laden die Tasks als CSV. In der GUI haben Sie dafür spezielle Buttons:

Aus der GUI heraus spezifizieren Sie den Dateinamen, z.B. "./todo.csv". Punkt und Schrägstrich benötigt evtl. Ihr Betriebssystem.

21.2 Dateien lesen/schreiben

I/O steht in der Informatik für Input/Output und meint das Lesen von Dateien, Internet, Tastatur (Input) oder das Schreiben auf Dateien, Internet, Konsole, Grafikbereich (Output). In diesem Abschnitt konzentrieren wir uns auf Dateien I/O und Tastaturinput.

Auch in Java müssen wir immer wieder Daten auf die Festplatte schreiben. Leider kennt Java nicht die Processing-Funktionen loadStrings() und saveStrings(), die wir in Kap. 17 kennen gelernt haben.

Stattdessen stellt Java die zwei Klassen Scanner zum Lesen und PrintWriter zum Schreiben bereit.

Dateien und Pfade

Klasse File

Bevor wir zum Lesen/Schreiben kommen, sollten wir uns die Klasse File aus dem Paket java.io anschauen. Bislang haben wir unsere Dateinnamen und Pfade mit einem einfachen String repräsentiert. Das bringt so banale Probleme mit sich wie die Frage nach dem Schrägstrich (vorwärts wie unter Linux/Mac oder rückwärts wie unter Windows?). Stattdessen sollen Pfade als File-Objekte repräsentiert werden.

Im einfachsten Fall erzeugen wir einfach ein File-Objekt, indem wir den Dateipfad als String übergeben:

File file1 = new File("C:/Foo/Baa/meineEinkaufsliste.txt"); // absolut
File file2 = new File("freunde.txt"); // relativ

Bei file1sehen Sie einen absoluten Pfad, weil er von der "Wurzel" des Dateisystems (hier wäre das C:) bis zum Ziel alles auflistet. Ein relativer Pfad wie bei file2 geht von der aktuellen Position des Systems aus, das ist bei NetBeans das aktuelle Projektverzeichnis. Wenn Ihr Netbeans-Projekt "Foo" heißt, dann ist das Projektverzeichnis eben das Verzeichnis "Foo" (darunter befinden sich dann src und weitere Verzeichnisse).

Weitere Beispiele für relative Pfade sind:

File file3 = new File("trash/feinde.txt"); // in einem Unterverz.
File file4 = new File("../chefs.txt"); // im übergeordneten Verz.

Pfade zusammensetzen

Häufig liegen Dateien nicht genau im Projektverzeichnis, sondern z.B. im Home-Verzeichnis des Benutzers oder ganz woanders. Sie können Pfade als String zusammensetzen:

String pfad = "C:/users/kipp";
String dateiname = "foo.txt";
File file = new File(pfad + "/" + dateiname);
System.out.println("f: " + file);

Dabei sollten Sie darauf achten, immer den Forward-Slash (/) zu verwenden, und nicht den Backward-Slash(\). Der Forward-Slash funktioniert unter allen Betriebssystemen (Win, Mac, Linux), der Backward-Slash nur unter Windows.

Man sollte allgemein versuchen, den Slash zu vermeiden. Die Klasse File bietet einen Konstruktor an, wo Sie Pfad und Dateiname als Parameter übergeben und des komplette Pfade dann von Java gebaut wird. Der obige Code sieht dann so aus:

String pfad = "C:/users/kipp";
String dateiname = "foo.txt";
File file = new File(pfad, dateiname);
System.out.println("f: " + file);

Java kann Ihnen außerdem zwei Verzeichnisse als absolute Pfade liefern mit der Methode System.getProperty().

Mit System.getProperty("user.dir") bekommen Sie das aktuelle Projektverzeichnis (also da, wo Ihr Code liegt). Mit System.getProperty("user.home") bekommen Sie das Home-Verzeichnis des aktuellen Users (also auf dem Mac z.B. "C:/users/schmidt").

String user = System.getProperty("user.dir");
String home = System.getProperty("user.home");
System.out.println(user);
System.out.println(home);

Sie können dies benutzen, um Ihren gewünschten Pfad zusammenzubauen.

Methoden von File

Einen Pfad in ein File-Objekt "einzupacken" ist für viele Klassen und Methoden notwendig, damit diese den Pfad nutzen können. Die Klasse File ist aber auch für sich genommen nützlich. So können Sie das Objekt befragen, z.B. ob die Datei existiert, ob es sich um Datei oder Verzeichnis handelt etc.

File file = new File("C:\Foo\Baa\meineEinkaufsliste.txt");

// Jetzt die Befragung:

if (file.exists()) {
  System.out.println("Ich bin!");
}

if (file.isFile()) {
  System.out.println("Ich bin eine Datei!");
}

if (file.isDirectory()) {
  System.out.println("Ich bin ein Verzeichnis!");
}

Mit toString() können Sie auch jederzeit wieder den Pfad ansehen oder ausgeben. Bei System.out.print wird toString() wie gewohnt automatisch aufgerufen.

File file = new File("C:\Foo\Baa\meineEinkaufsliste.txt");

System.out.println("... und der Pfad ist ... " + file);

Ansonsten können Sie noch andere tolle Dinge mit der Klasse File anstellen, z.B. Dateien löschen, den Inhalt von Verzeichnissen untersuchen, Verzeichnisse erzeugen uvm.

Schauen Sie in die API von File, um mehr zu erfahren.

Datei lesen mit Scanner

Die Klasse Scanner aus dem Paket java.util wurde speziell zum Einlesen von verschiedenen Datentypen (Zahlen, Texten etc.) gemacht. Wir beschäftigen uns hier mit Text in Form von Strings.

Ob ein Scanner seine Daten aus einer Datei oder aus dem Internet oder von der Tastatur des Users liest, ist übrigens egal. Der Scanner ist sehr allgemein gehalten. Zunächst mal gehen wir aber davon aus, dass wir eine Datei einlesen wollten.

Zunächst erzeugt man ein Scanner-Objekt, indem man ihm ein File-Objekt zum öffnen übergibt.

File file = new File("secretStuff.txt");
Scanner scanner = new Scanner(file);

Das Scanner-Objekt funktioniert wie ein Cursor, den wir durch die Datei bewegen. Der Cursor springt zum Beispiel zeilenweise durch den Text und gibt dabei die übersprungene Zeile zurück.

Das Scanner-Objekt hat dafür die Methoden hasNextLine() (ist true, wenn es noch eine Zeile zum Überspringen gibt) und nextLine() (überspringt die nächste Zeile).

Die Methoden lassen sich wie folgt in eine While-Schleife einbauen:

while (scanner.hasNextLine()) {
  String line = scanner.nextLine();
  System.out.println("> " + line);
}

Nach Verwendung des Scanners, sollte der "Datenstrom" geschlossen werden (engl. close), damit gebundene Ressourcen freigegeben werden:

scanner.close();

Der Code wird in dieser Form nicht laufen, da der Compiler etwas über eine "Exception" erzählt und sich weigert, den Code zu compilieren. Schuld ist die Zeile new Scanner(file). Hier könnte es sein, dass das übergebene File nicht existiert - in Java-Speak sagt man, dass eine FileNotFoundException geworfen werden könnte. Wir behandeln die Behandlung von Exceptions erst später, deshalb müssen Sie sich mit der Info begnügen, dass Sie folgendes try-catch-Konstrukt um den Code herumbauen müssen:

try {
  File file = new File("secretStuff.txt");
  Scanner scanner = new Scanner(file);
  while (scanner.hasNextLine()) {
    String line = scanner.nextLine();
    System.out.println("> " + line);
  }
  scanner.close();
} catch (FileNotFoundException ex) {
  ex.printStackTrace();
}

Bevor wir uns mit dem Schreiben von Daten beschäftigen, sehen wir uns eine andere nützliche Verwendung von Scanner an.

Tastaturinput mit Scanner

Ein Scanner-Objekt kann auch benutzt werden, um Tastatureingaben von Benutzern einzulesen. Anstatt eine Datei als Datenstrom anzugeben, verwendet man den Kanal System.in (wird auch häufig standard input genannt). Im Code sieht das so aus:

Scanner scanner = new Scanner(System.in);

Wenn man jetzt nach der nextLine() fragt, wartet Java so lange, bis der Benutzer die Enter-Taste drückt und der davor eingegebene Text wird zurückgegeben. Denken Sie immer an das Schließen des Scanners mit close().

Scanner scanner = new Scanner(System.in);
System.out.print("Write something: ");
String input = scanner.nextLine();
System.out.println("You wrote: " + input);
scanner.close();

In dem obigen Beispiel fangen Sie genau eine Eingabe ab. Häufig möchten Sie aber viele Eingaben verarbeiten - bis zum Beispiel der Benutzer "exit" eintippt. Hier bietet sich eine While-Schleife an, die die Methode hasNextLine() von Scanner benutzt, um herauszufinden, ob eine neue Zeile zur Verfügung steht.

Scanner scanner = new Scanner(System.in);

boolean exit = false; // Zum Beenden

System.out.print("Say something: ");
while (!exit && scanner.hasNextLine()) {

  String line = scanner.nextLine();
  if (line.toLowerCase().startsWith("exit")) {
    exit = true;
  } else {
    System.out.println("Did you say this: " + line);
    System.out.print("Say something: ");
  }
}
System.out.println("Bye bye");
scanner.close();

Sie sehen, dass wir hier noch auf das Wort "exit" testen und dann aus der While-Schleife herausspringen, sollte es auftauchen.

Zu beachten ist hier, dass hasNextLine() bereits wartet (bis ein Enter erfolgt). Deshalb darf man z.B. die Reihenfolge in der While-Bedingung nicht umdrehen.

Datei schreiben mit PrintWriter

Zum Schreiben stellt Java die Klasse PrintWriter zur Verfügung. Sie erstellen ein Objekt dieses Typs und übergeben ihm den Dateipfad der Zieldatei.

File file = new File("meinzeug.txt");
PrintWriter writer = new PrintWriter(file);

Anschließend können wir die Methode println() verwenden, wie wir sie in Processing und bei System.out verwendet haben.

writer.println("Agent 001");
writer.println("Agent 003");
writer.println("Agent 005");

Hier ist besonders wichtig, den Datenstrom zu schließen, weil es sonst passieren kann, dass die Datei nicht korrekt gespeichert wird. Dann sind manchmal alle Daten verloren.

writer.close();

Genauso wie bei Scanner muss auch hier eine Exception gefangen werden, wenn das PrintWriter-Objekt hergestellt wird.

try {
  File file = new File("C:/Users/Bond/agenten.txt");
  PrintWriter writer = new PrintWriter(file);
  writer.println("Agent 001");
  writer.println("Agent 003");
  writer.println("Agent 005");
  writer.close();
} catch (FileNotFoundException ex) {
  ex.printStackTrace();
}

Übungsaufgaben

(a) Text lesen

Erstellen Sie ein Projekt mit einer einzigen Klasse TextReader Legen Sie im Projektverzeichnis eine Textdatei an mit ein paar Zeilen Text, z.B.

Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
sed diam voluptua.

Schreiben Sie die Methode readFile, die einen Dateipfad als String bekommt und den (Text-)Inhalt dieser Datei einliest und als Liste von Strings zurückgibt.

Testen Sie die Methode mit Ihrer Textdatei in der statischen main. Geben Sie den Inhalt der Liste zum Testen aus.

(b) Protokollprogramm (Text schreiben)

Schreiben Sie ein Programm, dass Text von der Konsole einliest und direkt in eine Datei schreibt.

Auf der Konsole erscheint ein "Prompt":

input>

Der Benutzer kann Text schreiben und mit ENTER die Zeilen bestätigen:

input> Hallo, Welt
input> Mein Name ist Hase

Mit einem Schlüsselwort kann die Eingabe beendet werden und das Programm zeigt an, wohin der Text gespeichert wurde. Das Schlüsselwort selbst soll nicht mit gespeichert werden.

input> Hallo, Welt
input> Mein Name ist Hase
input> QUIT
Wrote text to: protokoll.txt

Schreiben Sie eine Klasse NoteWriter mit einer Methode start(). Die Methode bekommt den Dateinamen des gewünschten Textfiles als String übergeben. Verwenden Sie die oben besprochnen Klassen Scanner und PrintWriter.

Testen Sie Ihr Programm mit z.B. folgendem Code in der main:

public static void main(String[] args) {
  NoteWriter noteWriter = new NoteWriter();
  noteWriter.start("protokoll.txt");
}

Sehen Sie sich beim Testen immer den Text in der Textdatei mit einem Editor an.

(c) Einfacher Chatbot

Schreiben Sie einen einfachen Chatbot, wo der Benutzer Text in der Konsole eingibt und nach dem Betätigen von ENTER eine (Text-)Antwort erhält.

Erstellen Sie dazu eine Klasse Chatbot mit der Methode start(). In der Methode start() lesen Sie mit einer while-Schleife den Input von der Konsole (mit Scanner). Kopieren Sie ruhig den Code aus dem obigen Abschnitt.

Aufgabe ist, auf die folgenden Inputs wie folgt zu reagieren:

  • Input "hallo": Output "Guten Tag, ich bin ein Chatbot."
  • Input "wie gehts": Output "Super!"
  • Input "tschüs": Programm endet
  • Sonstiger Input: Output "Das habe ich leider nicht verstanden."

Gutes Design wäre, die Verarbeitung des Inputs und die Generierung des Outputs in eine eigene Methode (z.B. parseInput) zu packen. Die Methode bekommt eine String (Eingabe), liefert einen String zurück (Ausgabe) und wird in start() verwendet.

(d) Intelligenter Chatbot

Versuchen Sie, Ihren Chatbot intelligenter zu machen. Dazu müssen Sie versuchen, auf bestimmte Schlüsselwörter (oder auch Interpunktion) in der Benutzereingabe zu reagieren. Schauen Sie sich dazu nochmal die Möglichkeiten an, einen String zu untersuchen, entweder in Kap. 12 (Dateien und Text) oder direkt in der Java API für die Klasse String.

Einige Vorschläge:

  • Unterscheiden Sie zwischen Fragen und Aussagen und reagieren Sie, wenn Sie jeweils die Eingabe nicht verstehen, mit "Das habe ich nicht verstanden." oder "Die Frage habe ich nicht verstanden."
  • Reagieren Sie auf den Textschnipsel "was ist" und antworten Sie, indem Sie das Folgewort wieder in den Output schreiben. Beispiel: "was ist Informatik" => Antwort "Informatik ist ein gutes Thema."
  • Fragen Sie den Benutzer nach dem Namen und versuchen Sie, den Namen aus der Antwort zu extrahieren und ihn in einer Instanzvariablen abzulegen, so dass Sie ihn immer wieder mal (30% Wahrscheinlichkeit) an die Antwort hängen können ("Informatik ist ein gutes Thema, Martin.").

Sie können hier beliebig tief einsteigen in die Themen Computerlinguistik und Künstliche Intelligenz. Wenn Sie sich für die Geschichte der Chatbots interessieren, schauen Sie nach den Stichworten "Eliza", "Turing Test" und "Loebner Award".