ERR.ART
Korrektur clv03jul89
Entwurf   clv01mar88

Titel:

Behandlung von Ausnahmesituationen in Forth83

Stichworte:

Fehlerbehandlung
Ausnahmebehandlung
exception handling
volksFORTH83
returnstack

Kurzfassung: Ausgehend von einer Analyse des Bedürfnisses nach
programmspezifische Fehlerbehandlung (die insbesondere zum erweiterten
Begriff des 'Exception handling' führt) werden Konzepte aus
verschiedenen Programmiersprachen sowie zu Forth83 vorgeschlagene
Konzepte auf ihre Brauchbarkeit hin diskutiert. Es folgt eine unter
ultraFORTH83 rev 3.8 auf einem C16 entwickelte Lösung des Autors, die
abhängig von der Ausführungsebene eine spezielle mittels FAILS..THEN
installierte Behandlung der Worte ABORT" und ERROR" gestattet.

* Die derzeitige Fehlerbehandlung in Forth: ABORT"

Im 83er-Standard ist das zentrale Wort zur Fehlerbehandlung ABORT". Es
gibt den folgenden String als Fehlernachricht aus, versetzt das System
in einen (einigermaßen) definierten Zustand und ruft das
Top-Level-Wort QUIT auf, das Eingaben von der Tastatur entgegennimmt
und verarbeitet. Jedes laufende Programm wird also ohne Rücksicht auf
Verluste gestoppt und gewissermaßen Forth neu gestartet. Eine ähnliche
Wirkung haben die Worte ABORT und QUIT.

Im ultraFORTH83/volksFORTH83 gibt es ein Wort ERROR", das sich von
ABORT" nur dadurch unterscheidet, daß der Datenstack nicht gelöscht
wird. Desweiteren enthält dieses Forth eine User-Variable
ERRORHANDLER, die es ermöglicht, ein anderes Verhalten von ABORT" und
ERROR" zu installieren.

* Was soll eine Fehlerbehandlung können

Diese Art der Fehlerbehandlung funktioniert zwar meistens recht gut,
wirft aber einige Probleme auf. Im folgenden wird versucht, folgende
Stichworte zu diskutieren:

    - Reservierte Ressourcen schließen
    - Das Level, auf dem die Behandlung erfolgt
    - Informationen über den Fehlerzustand erhalten.
    - Übersichtliche Behandlung selten auftretender Ereignisse
    - Fehler auch während der Fehlerbehandlung (ohne Endlosschleifen)

Hierbei fließen jeweils die Erfahrungen des Autors mit MS-DOS, Pascal,
Modula-2, Fortran und TLC-Lisp mit ein.

* Schließen von Ressourcen

Das Wort ABORT" (so es im Quelltext vorliegt) zeigt bereits daß im
Fehlerfalle gewisse Systemressourcen wieder freigegeben werden müssen.
Zumindest dürfte in jedem System der Return-stack entleert werden, oft
auch der Datenstack, vielleicht werden sogar gewisse Systemvektoren
restauriert (insb. für die Standard-Ein/Ausgabe scheint das geraten).
Falls das Programm gewisse weitere Ressourcen reserviert hat, werden
sie nicht wieder frei gegeben. Dies könnte ein geöffnetes File sein,
das nicht geschlossen wird; ein Semaphor, das gelockt bleibt; ein
menüartiger Bildschirm, der weiter in allen Farben des Spektrums
blinkt; eine hoffnungslos verdrehte Schnittstelle etc.. Am
auffälligsten ist eine z.B. auf den Drucker umgeleitete
Standardausgabe, wenn sie von ABORT" nicht restauriert wird.

In diesem Fall wird schon die Ausgabe der ABORT"-Meldung (auf den
Drucker) fehlschlagen, insb. wenn die gewünschte Fehlermeldung
"Drucker ausgeschaltet" heißen mag. Dieser Effekt wird in jedem
intelligenten Forth-System natürlich abgefangen, unter MS-DOS läßt er
sich allerdings noch sehr hübsch beobachten. Die gelockten Semaphore
machen sich allerdings - in seltenen Fällen - auch unter volksFORTH
bemerkbar. Völlig hoffnungslos wird der Fall, wenn eine größere
Stand-Alone-Anwendung (z.B. ein fürchterlich kompliziertes
Menüprogramm) grade sämtliche Systemvektoren erfolgreich verbogen hat
und nun durch einem jämmerlich kleinen Fehler (vielleicht einen
offengelassener Diskettenschacht) jäh in die Forth-Hauptschleife
geschleudert wird.

* Auf welcher Programmebene soll der Fehler behandelt werden?

In einem Fall wie letzterem wäre es sogar denkbar, den Fehler noch
innerhalb der Systemroutinem (in diesem Fall in der Block-Lese-Routine
des Betriebssystem) zu beseitigen (z.B. den Benutzer aufzufordern,
doch bitte den Schacht zu schließen) und anschließend fortzufahren,
ohne daß das darüberliegende Programm etwas bemerkt. Derartiges kann
sogar MS-DOS. Alle Fehler die in Zusammenhang mit Diskettenlaufwerkern
stehen werden noch innerhalb des Betriebssystems mit einer Meldung der
Form:

allgemeiner Fehler. Kaffee in Laufwerk A:
(A)bbruch, (W)iederholen, (I)gnorieren ?

beantwortet. Der Benutzer kann sich nun für eine der Alternativen
entscheiden. Tippt er 'W', so versucht das System denselben Zugriff
nochmal. Dies ist bei einem offen gelassenen Schacht nützlich, gegen
Kaffee hilft es natürlich nicht. 'A' terminiert das laufende Programm
und springt zurück ins Betriebssystem (in etwa wie unser QUIT). Dies
funktioniert meistens, es sei denn das Betriebssystem möchte selbst
Teile seiner selbst von der Diskette lesen: Wir bekommen die beliebte
Endlosschleife, bis wir eine saubere Diskette eingelegt haben. Die
Alternative 'I' ist die hübscheste. Das System vergißt die Operation
und kehrt ins rufende Programm zurück. Dieses arbeitet brav weiter,
bis es sich an irgendwelchenden Zufallsergebnissen den Magen verdirbt.
Um zum Beispiel des Menü-Programms mit offenen Kassettenschacht noch
ein Wort zu verlieren: Beim 'W'iederholen ist natürlich trotz allem
der Bildschirmaufbau im Eimer. Bis hierher läßt sich erstmal
formulieren, daß die Fehlerbehandlung lieber auf der Ebene des
Anwenderprogramms erfolgen sollte.

* Warum eigentlich nur Fehler mit Methoden der Fehlerbehandlung behandeln?

Um diesem hübschen Wortspiel Sinn zu geben, mag ein anderes Beispiel
herhalten. Ein Programm lese Daten von einem File, verarbeite sie und
schreibe das Ergebnis auf ein anderes File. Es muß somit (wenn es
strukturiert sein möchte) vor dem Lesen jedes einzelnen Zeichens das
Betriebssystem befragen, ob das File vielleicht schon erschöpft ist.
Das ergibt beispielsweise in Pascal endlose Konstrukte des
Strickmusters:

   WHILE not eof(input) DO
   WHILE not eoLn(input) DO
   BEGIN
      READ (input,char);
      IF char=...THEN
	 ....
	 IF or(eof(input),eoLn(input)
	 THEN schweinkram;
	 ELSE  read (input,nochn_char);
	       ....
	 ENDIF;
	 ....
      ENDIF;
   END;

Man verzeihe es mir, wenn ich die Pascal-Syntax nicht mehr so
besonders gut kann. Das Strickmuster sollte eigentlich etwas anderes
zeigen: In Pascal erfordert jeder Zugriff eines Programms (so es sich
strukturiert nenn will) das Abfragen auf End-Of-File und End-Of-Line.
Letzteres ist nötig, da der Standard leider (?) nicht vorschreibt, wie
EOLn am Fileende aussieht. Soll innerhalb der großen WHILE-Schleife
ein weiteres Zeichen gelesen werden, so muß beides erneut geprüft
werden, daß System ärgert sich mit ständigem Abgefrage herum, der
Programmierer mit der Definition der Routine 'schweinkram', die ihren
Namen i.a. zu Recht trägt.

Viel einfacher haben es da Sprachen, die keinerlei Anspruch auf
strukturiertes Programmieren erheben. Das obige Kuddelmuddel ließe
sich in Fortran etwa so umgehen:

  10  READ (input,char,end=100,err=200)
      IF char.eq..... THEN
	 READ (input,nochnchar,err=200)
      ENDIF
      GOTO 10
      ...
 100  ...<fileEnde behandeln>...
 200  ...<sonstige Fehler behandeln>...

Die 'end=' und 'err=' Sequenzen sind verkappte GOTOs. Hier wird
(völlig unstrukturiert) der Programmfluß im Fehlerfall unterbrochen
und an den durch 100 und 200 gekennzeichneten Stellen fortgesetzt. Ein
ähnliches GOTO-Konstrukt bietet auch Pascal an. Jedes gute Lehrbuch
bittet aber darum es möglichst nie zu benutzen. Als einzigen
tolerierbaren Zweck wird meist die Fehlerbehandlung angegeben. Ein
sehr treffender Beleg dafür, daß grade dem Vater der strukturierten
Programmierung, Herrn Wirth, die Fehlerbehandlung Kopfzerbrechen
bereitet.

Was sollte das nun belegen? Es soll zeigen, daß erstens auch völlig
normale Vorgänge (Ende eines Files) fehlerbehandelt werden wollen. In
diesem Fall spricht man von 'Ausnahmebehandlung', da dieser
Programmierstil nur für selten auftretende Fälle sinnvoll ist. Es soll
zweitens zeigen, daß eine Erhöhung der Performance und Wartbarkeit aus
sinnvoller Ausnahmebehandlung entspringt. Denn sicher spart das obige
Fortran-beispiel einigen Code. Dies spart auch Zeit, da an weniger
Stellen auf Fehler geprüft werden muß. Und ich kann es besser lesen.
(Zur Frage der Lesbarkeit steht mir als Nicht-Informatiker kein
allgemeines Urteil zu. Die 'ich'-Form spart mir hier sicher wieder
einigen Streit mit Helge und Gerd.)

* Ist Ausnahmebehandlung auf ganz tiefer Ebene verzichtbar ?

Manchmal muß aber auch eine Ausnahmebehandlung auf ganz tiefer Ebene
erfolgen. Als Beispiel sei hier die Ausgabe von Informationen im
Fehlerfall genannt. Häufig möchte der Benutzer im Fehlerfall wissen,
wo der Fehler aufgetreten ist, wie bestimmte Variablen aussahen, etc.
Im volksFORTH gibt es ein Wort UNRAVEL, das die Aufrufhierarchie
ausgibt. Dies könnte etwa so aussehen:

FEHLER divide by Zero AUFGETRETEN.
   Der Fehler geschah in Wort: 0/
	       aufgerufen von: MEINWORT
	       aufgerufen von: TEST
	       aufgerufen von: EDIT
	       aufgerufen von: L

Derartige POST-MORTEM-DUMPS ermöglichen i.a. ein schnelles
Lokalisieren des Fehlers. Sie enthalten des öfteren nicht nur die
Aufrufhierarchie sondern diverse Register- und Variablen-Inhalte zum
Zeitpunkt des Fehlers (am besten noch aus allen Unterprogrammen...),
sodaß sie manchmal den Benutzer eher in hunderten von Seiten Papier
ersticken, als ihm bei der Fehlersuche zu helfen. Aber selbst dagegen
sind Kräuter gewachsen. Logitech's Modula-2-Compiler teilt dem
Benutzer außer einer sehr knappen Fehlerbeschreibung nichts mit und
schreibt ersatzweise den kompletten Systemzustand auf Diskette, wo man
ihn anschließend mit einem Post-Mortem-Debug-Programm umgraben kann.
Es frage mich bitte niemand, was passiert, wenn die Diskette voll war.
Ruft der Fehler dann einen erneuten Post-Mortem-Dump hervor?

Um wieder zum Faden zurückzukehren: Zumindest zum Post-Mortem-Debuggen
(=Entlausen aus einem toten Programm. Übertragen: Infos über den
Fehlerzustand erhalten) ist es nötig Fehler auf niedrigster Ebene zu
behandeln.

* Mein Wunsch zur Fehlerbehandlung: Call-with-current-Continuation (CallCC)

Die schönste, allgemeinste Art der Fehlerbehandlung, die ich kenne,
ist das CATCH-THROW-Konstrukt aus TLC-Lisp (von T.Allen). Ich habe mir
sagen lassen, daß sie unter Zuhilfenahme des
Call-with-current-Continuation-Konzepts implementiert ist. Daher
gefällt mir dieses auch sehr gut. Da ich CallCC leider nicht kenne
beschränke ich mich jetzt aber wieder auf CATCH-THROW. Es wird in
folgender Form benutzt:

(CATCH name ((expression)
	     (exceptionhandler)))

Die Bedeutung ist folgende: Wenn während der Evaluierung (=Ausführung
auf LISPisch) von EXPRESSION eine Ausnahme mit dem Namen NAME
auftreten sollte, so möge bitte sofort EXCEPTIONHANDLER evaluiert
werden. Ansonsten ist der obige Ausdruck identisch mit:

   (expression)

Eine Ausnahme tritt dadurch auf, daß eine Funktion (=wort auf
LISPisch) innerhalb von expression die Funktion

   (THROW name)

aufruft.

Eigentlich ist es verabscheuungswürdig, von Lisp aus Files zu lesen,
da dies dem funktionalen Programmieren zuwiderläuft. Um aber trotzdem
das obige File-Lese-Beispiel nochmal zu strapazieren:

(CATCH end-of-file
 (CATCH end-of-line
  (COND
   ((eq (setq char READCHAR))
    (dosomething READCHAR)
   )
   (T
    ( ... )
   )
  )
  ( ...end-of-line-handler..)
 )
 ( ...end-of-file-handler..)
)

Dies sieht nicht nur wundervoll aus mit den vielen Klammern, sondern
hat auch eine Wirkung: wenn READCHAR irgendwann (THROW end-of-line)
oder (THROW end-of-file) evaluiert, wird einer unsrer HANDLER
aufgerufen. Ob READCHAR das tut und ob es in Lisp überhaupt eine
Funktion diesen Namens gibt, entzieht sich leider meiner Kenntnis.
Selbstverständlich können solche handler geschachtelt werden. Um
DOSOMETHING könnte z.B. ein noch spezielleren handler heruminstalliert
werden. Eine Ausführung von (THROW name) aktiviert jeweils den
nächstäußeren (höhergelegenen) handler. Wenn dieser das nötige getan
hat, kann er beispielsweise erneut (THROW name) evaluieren, um
wiederum den ihm nächstäußeren handler zu aktivieren. Das Spiel läßt
sich weitertreiben, bis schließlich der alleräußerste (von der
LISP-Interpreter-Schleife) installierte handler aufgerufen wird, der
(ähnlich unserem ABORT") wieder Eingaben von der Tastatur verarbeitet.
Selbstverständlich kann jeder handler in der Schlange auch etwas
anderes unternehmen, beispielsweise die gescheiterte Aktion
wiederholen.

* Zurück zu Forth

Aus dem gesagten seien noch einmal die
Kernpunkte zusammengefaßt:

- Fehlerbehandlung soll auf jeder beliebigen Programmebene möchlich
  sein
- insbesondere auch auf tiefster Ebene
- Die Fehlerbehandlungsroutinen sollen geschachtelt werden können
- Fehlerbehandlar sollen Möglichkeiten erhalten, nach Bedarf die
  fehlerverursachende Routine erneut zu probieren oder die Ausführung
  dem nächsthöheren Fehlerbehandler weiterzugeben.
- Das ganze soll so einfach zu benutzen sein, daß Routinen für selten
  auftretende Ereignisse einfach zu formulieren sind.
- Diese Fehlerbehandlung soll durch das Standardwort ABORT" aktiviert
  werden.

Nun wird's konkret: Wo sollen die Informationen über die installierten
Fehlerbehandlungsroutinen abgelegt werden? Auf dem Returnstack, da
sich hier am einfachsten eine der jeweiligen Ausführungsebene (Wort)
angelehnte Datenstruktur bilden läßt.

Wie soll die Syntax aussehen? Es soll einfach sein, daher
Kontrollstrukturen. Beispielsweise:

: name ..<clause1>..
   FAILS ...errorhandler... THEN
       ..<clause2>.. ;

Die Bedeutung: Es wird ein Wort NAME definiert. Bei Ausführung führt
NAME erst <clause1> aus, installiert anschließend einen ERRORHANDLER,
führt dann <clause2> aus und deinstalliert ERRORHANDLER nach Verlassen
des Wortes. Sollte innerhalb von <clause2> ein ABORT" ausgeführt
werden, so wird ERRORHANDLER ausgeführt.

Aktivieren des äußeren ERRORHANDLERS: Sollte innerhalb von
ERRORHANDLER das Wort THROW ausgeführt werden, so wird die Ausführung
von ERRORHANDLER beendet und die nächstäußere Fehlerbehandlungsroutine
aktiviert.

Wiederholen der fehlerverursachenden <clause2>: Hier wird's kritisch.
Im Gegensatz zu praktisch allen anderen Sprachen liegen die Parameter
auf dem Datenstack. Vor einer Wiederholung müssen Daten- und
Returnstack repariert werden. Der Returnstack läßt sich problemlos so
manipulieren, daß er bei Aufruf von ERRORHANDLER bereits wieder im
gewünschten Zustand ist. Falls ein definierter Zustand des Datenstacks
gewümscht wird, muß allerdings ein spezielles Konstrukt:

     nn #FAILS ..errorhandler.. RETRY

benutzt werden. Die NN obersten Stackwerte, sowie der Stackpointer
werden gesichert. Falls ERRORHANDLER aufgerufen werden sollte, wird
der Stack vorher soweit restauriert. Es gilt natürlich aufzupassen,
daß sie bis zur Ausführung von RETRY auch dableiben.

Noch ein weiteres Konstrukt wurde eingeführt:

: name ..<clause1>..
   EXITS ..errorhandler.. throw THEN
   ..<clause2>. ;

Es trägt der Idee Rechnung, daß ERRORHANDLER des öfteren nur
installiert wird, um eine Ressource zu schließen. Die Bedeutung: Es
wird ein Wort NAME definiert. Bei Ausführung führt NAME erst <clause1>
aus, installiert anschließend einen ERRORHANDLER, führt dann <clause2>
aus. ERRORHANDLER wird im Falle eines Fehlers oder nach Verlassen des
Wortes NAME ausgeführt. Es lassen sich also so schöne Konstrukte
bilden wie:

: machWas  ...
   ..<öffne-Gerät1>..
   EXITS ..<schließe-Gerät1>.. throw THEN
   ..<öffne-Gerät2>..
   EXITS ..<schließe-Gerät2>.. throw THEN
   .... ;

Es werden auf jeden Fall die geöffneten Geräte wieder geschlossen, ob
nun ein Fehler auftritt oder nicht.

* Was gibt es noch für Ansätze in Forth

Der vorgestellte Ansatz bringt nichts prinzipiell neues. In
///////Schliesieck läßt sich eine Methode nachlesen, die sicherlich
schneller und einfacher implementiert ist, allerdings verändert sie
nicht das Verhalten von ABORT", sondern muß mit einem gesonderten Wort
aufgerufen werden. Auch sichert sie lediglich den Stackpointer,
eventuelle Parameter müssen also 'zu Fuß' gesichert werden.
//////weiter.... wenn m

 chöner: statt THROW ein EXIT nehmen. Was passiert bei exit? Bitte
nicht IF..ELSE..RETRY Bitte kein FAILS...THEN ohne throw oder so

Wie rauskriegen, welcher Fehler passiert ist?
