CRuntimeJavalike - Exception_Jc: Fehlerbehandlungskonzept


 

Traditionelle Fehlerbehandlung in C

Traditionell wird in C, auch in C++ eine Fehlerbehandlung entweder mittels Abfrage der Returnwerte aufgerufener Funktionen durchgeführt, möglicherweise werden dabei auch positive Werte als Rückgabe einer Information (Byteanzahl oder dergleichen) benutzt, negative Werte zeigen einen Fehler an:

 int error = callFunction(something);
 if(error != 0){ .... }

oder es wird eine spezielle (System-) Errorvariable abgefragt, das ist das Konzept der low-level-IO-Funktionen im traditionellen Standard-C:

 int bytes = fread(fileHandle);
 if(errno != 0){ .... }

Fehlerbehandlung mit try-catch-throw in C++ und Java

Im C++, Java und einigen anderen modernen Sprachen gibt es dagegen die try-catch-throw-Methodik:

 try{ callFunction(something); }
 catch(SpecialException exception){ .... }
 ...
 callFunction()
 { if(I_cannot_do_that){ throw new SpecialException("infotext"); }
 }

Der wesentliche Vorteil dieser Methodik ist, dass Fehler über mehrere Subroutinen-Ebenen durchgereicht werden können, ohne dass in diesen Subroutinen (Methoden) auf den Fehlerfall Rücksicht genommen werden muss. Damit wird nicht nur Programmierarbeit (Fleiß) gespart, sondern es können sich auch keine Fehler bei der Fehlerbehandlung einschleichen. Man muss dazu bedenken, dass ein Programmierer oft zunächst nur Augenmerk auf den "Gut-Fall" legt, das Abfangen aller möglichen und unmöglichen Ausnahmezustände dagegen als lästig betrachtet werden.

In Java wird man allerdings darauf hingewiesen, dass es da eine Ausnahmebehandlung gibt, die durchgereicht wird:

 void methodDoSomething throws SpecialException  { callFunction(something);    ...  }

Man braucht sich programmtechnisch zwar nicht um die Exception zu kümmern, aber man muss angeben (der Vorteil ist, man muss es wissen), dass eine solche Exception von inneren Routinen erzeugt werden kann. Ansonsten gibts Compilerfehler, der Java-Compiler weißt also eindeutig darauf hin. In Eclipse hat man es dann mit "Quik fix" sehr leicht, die Korrekturen anbringen (zu lassen). - Bei C++ muss man das "throws" nicht angeben, mit dem Nachteil, dass man überhaupt nicht weiß, dass da eine Exception möglicherweise auftreten kann und sinnvollerweise abgefangen oder beachtet werden könnte.

Situation für Java-like Programmieren in C (++)

Verwenden von try-catch in C ?

Es gibt selbst einige C-Compiler, die das try-catch-Konzept kennnen. CRuntimeJavalike soll allerdings auf allen Plattformen laufen, man kann von Compilern für Spezialprozessoren, die gegebenenfalls auch schon älter sind, dieses Feature nicht erwarten. Das ist ein allgemeines Problem - Nutzung der allerneuesten oder speziellen Features muss aufgewogen werden gegen allgemeine Kompatibilität.

finally ?

Java und auch andere moderen Programmiersprachen kennen das finally-Konstrukt:

doOpenThings();
try{ doSomething(); }
catch(ExceptionX exc){ processExceptionX(); return; }
catch(ExceptionY exc){ processExceptionY(); System.exit(1);}
finally{ doClosingThings(); }

Im Beispiel wird in einer Methode etwas angelegt, geöffnet und dergleichen. Danach folgt ein Block, der gegebenenfalls eine Exception wirft. Diese wird abgefangen, aufgrund der Exception soll im Beispiel ein return erfolgen oder das Programm beendet werden. Allerdings wird vor diesen Beenden-Aktionen noch der finally-Block durchlaufen. Im finally-Block können die Dinge, die angelegt, geöffnet und dergleichen worden sind, wieder freigegeben, geschlossen und dergleichen werden.

In C++ muss man diese "ClosingThings" manuell in jeder Fehlerbehandlung mit einbeziehen, damit wächst die Wahrscheinlichkeit, dass man dabei etwas vergisst. In C++ hat man offensichtlich gemeint, mit den Destruktoren (diese werden in C++ richtig abgearbeitet) hat man genügend viel getan. C++ ist auch die entwicklungsgeschichtlich ältere Programmiersprache.

Festzustellen bleibt, dass es für Javalike-Programmieren ein finally geben sollte.

Ausnahme-Objekte

In Java sieht das throw meist wie folgt aus:

throw new SpecialException(parameter);

Es wird also ein Ausnahme-Objekt neu im Heap angelegt. Dort drinnen stehen Informationen zur Ausnahme. Automatisch übertragen wird dort hinein auch der Stacktrace. Das Ausnahmeobjekt kann dann im catch verwendet werden.

Das Neu-Anlegen eines Ausnahmeobjektes ist nicht Pflicht. Man kann auch auf eine vorbereitete Instanz zurückgreifen, für die dann allerdings die Zugriffsrechte (Mutex, von einem anderen Thread eben auch gerade benutzt?) beachten muss. Für ein Java-Programm ist das eine unnötige Erschwernis, bei Echtzeitsteuerungen aber sehr ernsthaft zu erwägen.

C++ kann in seinem throw-Statement nicht nur Referenzen auf Ausnahme-Objekte aufnehmen, sondern alles Mögliche. Hier zeigt sich C++ flexibler aber auch etwas schlecht durchschaubar.

Verwenden von longjmp in C

Das longjmp-Konzept gibt es schon seit der Anfangszeit von C:

 #include <setjmp.h>
 void subroutine(jmp_buf);
 void routine(void)
 { int value;
   jmp_buf jumper;
   value = setjmp(jumper);
   if (value != 0)
   { printf("Longjmp with value %d\n", value);
     exit(value);
   }
   subroutine(jumper);
 }
 void subroutine(jmp_buf jumper)
 { ...
   longjmp(jumper,1);
 }

Das Prinzip ist, das in der Datenstruktur jumper eine Adresse und gewisser Stackinhalt gespeichert ist. Der Aufruf von longjmp führt zum Zurückstellen des Stack auch über mehrere Aufrufebenen hinweg. Es wird die gleiche Stelle angesprungen, die in der Routine setjmp vermerkt worden ist, und es wird damit so getan, als würde man zum zweiten mal aus setjmp zurückkehren. Der Unterschied ist, bei direktem Aufruf von setjmp immer mit dem Rückgabewert 0, sonst mit dem Rückgabewert, der bei longjmp angegeben wurde. Das lässt sich dann zur Verzweigung nutzen.

Mit diesem Konzept kann man in C das throw ersetzen, dass über mehrere Subroutinenebenen bis zum nächsten catch springt. Zu beachten ist allerdings, dass damit Destruktoren der Zwischen-Subroutinen übergangen werden, also untauglich für C++, aber gut tauglich für C: Da von longjmp Subroutinenebenen überspringen werden, erfahren diese Subroutinen auch nichts von dem Fehleraussprung. Wenn in diesen Subroutinen bestimmte Dinge vor dem return als Abspann getan werden müssten, dann werden diese nicht getan, da die Subroutinen vom Fehleraussprung überhaupt nicht berührt werden. In C++ gibt es das Konstruktor/Destruktor-Konzept: Von Daten, die im Stack angelegt werden, werden ohne bewußte Programmierung bei Verlassen der Stackebene der Destruktor aufgerufen. In vielen Fällen ist der Destruktor leer, also unnötig, doch in anderen Fällen muss irgendetwas getan werden, ein Speicherbereich freigegeben, eine Verzeigerung gelöst, ein Filehandle geschlossen und sonst was. Wenn ein Programmierer darauf vertraut, das beim Verlassen einer Subroutinen notwenige Abspannmassnahmen, die er programmiert hat, auch ausgeführt werden, dann hat er sich bei Verwendung von longjmp als Fehleraustritt geirrt. Das kann fatal sein, da der Programmierer gegebenenfalls gar nicht wissen kann, dass weiter drin in einer gerufenen Subroutine ein longjmp ausgeführt wird, das zwar weiter draußen beachtet wird, aber er ist nicht involviert / informiert.

Hier hilft aber ein bewußtes try-finally in den Zwischenebenen, wie in den folgenden Beispielen noch gezeigt wird.

Stacktrace

Passiert in Java ein throw, dann kann die Fehlerstelle im Quellprogramm recht leicht lokalisiert werden, in dem man sich den Stacktrace ausgeben lässt:

try{ doSomething(); }
catch(ExceptionX exc)
{ exc.printStackTrace(System.out);
}

Die Ausgabe dazu sieht beispielsweise wie folgt aus:

java.lang.ArrayIndexOutOfBoundsException: 3
	at vishia.example.common.TestTry.execute(Testtry.java:17)
	at vishia.example.common.TestTry.main(Testtry.java:11)

Es wird bei dieser Ausgabe die Fehlerart und die gerufenen Methoden einschließlich Angabe des Quellfiles und der Zeilennummer angegeben. Damit ist für einen Entwickler ein Fehler oft leicht lokalisierbar. Auch wenn ein Fehler in einem Kundenprojekt passiert, kann diese Ausgabe anstatt auf den Bildschirm in einen logfile geschrieben werden. Angenommen, der Fehler lässt sich kundenverträglich auffangen, wird er im folgenden Release korrigiert, ohne dass die Gesamtfunktionalität wesentlich leidet. Gegebenenfalls muss der Kunde als Workarround lediglich bestimmte Bediensituationen vermeiden.

Dem Stacktrace kann also ein sehr hoher Stellenwert bei der Fehlerbehandlung zugeschrieben werden, insbesondere dann wenn es um Betatests, Nullserien oder Anlagen-Software als Einzelstücke geht. Ein solcher Stacktrace wäre bei C (++)-Anwendungen auch sehr nützlich.

Realisierung in CRuntimeJavalike

Folgend wird zunächst ein Einsatzbeispiel gezeigt:

int testExceptionLongjmp(STACKTRCP)
{ int return_Jc;
  STACKTRC_NAME("testExceptionLongjmp")
  TRY
  { testExceptionThrows(STACKTRC);
  }_endTRY
  CATCH(RuntimeException_Jc, exc)
  { printStackTrace_Exception_Jc(exc, &System_out);
    RETURN(1);
  }
  FINALLY
  { printf("finally!!!\n");
  }
  FINISH_TRY_RETURN
  return 0;
}


void testExceptionThrows(STACKTRCP)
{ STACKTRC_NAME("testExceptionThrows")
  void* ptr = malloc(12);
  TRY
  { testExceptionThrower(STACKTRC);
  }_endTRY
  FINALLY
  { free(ptr);
  }
  FINISH_TRY
}


void testExceptionThrower(STACKTRCP)
{ STACKTRC_NAME("testExceptionThrower")
  THROW(RuntimeException_Jc, "error-message", 0);
}

Zunächst fällt auf, dass TRY, CATCH, FINALLY, THROW groß geschrieben sind. Es handelt sich um Makros. Abhängig davon, ob es sich um C oder C++ handelt, wird im Kern der Makros auch der longjmp verwendet, oder try-catch bei C++ wegen der Destruktoren.

Es sind auch noch Hilfskonstruktionen _endTRY und FINISH_TRY notwendig. Ersteres schließt nur den eigentlichen TRY-Block ab, letzteres klammert die gesamte TRY-Folge. Weiterhin fällt ein großgeschriebenes RETURN auf, gepaart mit einem FINISH_TRY_RETURN.

Wesentlich ist hierbei der Einsatz des Stacktrace. Dazu muss lediglich jede Methode als zusätzlichen (letzten) Parameter STACKTRCP bekommen und mit STACKTRC gerufen werden. Die Möglichkeit der Angabe des Namens der Methode in STACKTRC_NAME() am Anfang jeder Methode ist notwendig.

Ansonsten sollte die Konstruktion recht übersichtlich und Java-ähnlich aussehen. Die Frage ist, was passiert im Inneren? Das ist jedenfalls auch überschaubar, wie folgend gezeigt wird.

Stacktrace-Realisierung

Alle folgend gezeigten grau hinterlegten Definitionen befinden sich in Exception_Jc.h

Für den Stacktrace gibt es eine Datenstruktur, die im Stack jeder Methode angelegt wird. Sie ist nicht sehr umfangreich, dürfte also keinesfalls den Stackbedarf insgesamt sprengen:

typedef struct Stacktrace_Jc_t
{ struct Stacktrace_Jc_t* previous;
  const char* name;
  const char* source;
  int line;
  TryBuffer_Jc* tryBuffer;
}Stacktrace_Jc;

Das Makro STACKTRCP ist wie folgt definiert:

#define STACKTRCP Stacktrace_Jc* stacktracePrev

Es wäre also fast auch direkt angebbar, sähe nur nicht so übersichtlich aus.

Wesentlich ist das Makro STACKTRC_NAME(). Dieses muss am Anfang einer Methode angegeben werden und legt den Speicher im Stack für den Stacktrace an:

#define STACKTRC_NAME(NAME) Stacktrace_Jc stacktrace = {stacktracePrev, NAME, null, 0, null};

Das ist auch nur eine Zeile. Zur Runtime passiert hier nicht viel. Der Platz im Stack wird automatisch beim Aufruf des Anweisungsblockes zusammen mit dem notwendigen Platz anderen Stack-lokalen Variablen angelegt, benötigt also keine zusätzliche Rechenzeit. Der Platz wird meist mit 0 initialisiert, das sind wenige Maschinenbefehle. Der stacktracePrev wird aus dem aktuellen Parameter übertragen, zwei Registerzugriffe im Stack. Die Zeichenkette ist konstant und liegt nicht im Stack sondern wird beim Compilieren im Konstantenbereich fest angeordnet. Es geschieht hier auch keinerlei strcpy(). Lediglich die Adresse der Zeichenkette wird im Stack als name abgelegt. Das ist ein imediate-load-Maschinenbefehl und ein Register-write in den Stack. Manche Prozessoren können das sogar mit einem Maschinenbefehl.

Das Makro STACKTRC ist wieder sehr einfach, es überträgt lediglich die Adresse des eigenen Stacktrace an die Folge-Routine:

#define STACKTRC (&stacktrace)

Zur Laufzeit im Nichtfehlerfall passiert also nichts zeitfressendes und nichts speicherplatzfressendes.

Der Stacktrace wird erst in den Exception-Buffer (TryBuffer_Jc) einer vorigen Subroutinenebene übertragen, wenn eine Exception geworfen wird. Das ist etwas aufwändiger, je nach Schachtelungstiefe eine while-Schleife, die Zeiger bewegt. Folgender Codeabschnitt ist in der Methode throw_Jc enthalten:

while(...) TODO