zum Inhalt

Der lise Blog:
Einsichten, Ansichten, Aussichten

Das Einmaleins der Ausnahmebehandlung

09. Oktober 2017 von Avatar of Martin DieblichMartin Dieblich

Lernt man eine Programmiersprache, setzt sich mit „CleanCode“ auseinander oder eignet sich generell neue Fertigkeiten (um Beispiel TDD, aspektorientierte Programmierung, neue Frameworks usw.) an, so wird ein Aspekt für meinen Geschmack immer zu oberflächlich behandelt:
Wie wird richtig mit Ausnahmen (Exceptions) umgegangen?

Wenn dies nicht klar ist, wirkt sich das auf die Art und Weise aus, wie Software entwickelt wird: Es ist ein lästiges Thema, welches dem Kunden oder Nutzer keinen direkten Mehrwert bringt – ein Eingeständnis, dass das System nicht unter Kontrolle ist und noch Fehler hat.

Bei der Verwendung oder Betreuung andere Systeme trifft ;man oft genug auf inkonsistentes oder unvollständiges Verhalten. Wer hat noch nie in seinem Leben eine Fehlermeldung gehabt, die einfach nicht weiterhilft? (Es gibt unzählige Listen lustiger Fehlermeldungen...)

In diesem Beitrag möchte ich ein paar persönliche Tipps vorstellen, welche mit geringem Aufwand umsetzbar sind und zu einem einfachen und konsistenten Arbeiten mit Ausnahmen führen.

Ausnahmebehandlung: Grundlagen

Fast alle modernen Programmiersprachen bieten ein Konzept für Ausnahmebehandlung an. Das zentrale Element dafür sind Ausnahmen (exceptions), welche geworfen (throw) und gefangen (catch) werden. Die Ausnahmebehandlung findet separat vom Produktionscode statt (nämlich im Catch-Block). Anders als beim regulären Code greifen bei Ausnahmen andere Mechanismen (ein Return-Wert wird beim Werfen einer Ausnahme nicht bestimmt).

Da die (technischen) Grundlagen der Ausnahmebehandlung in der gängigen Literatur nahezu lückenlos beschrieben werden, wird dies hier nicht weiter ausgeführt.

Tipp 1: Wirf eine Exception, wenn du nicht weiterweißt

Beim Schreiben einer Methode gibt es entweder der Fall, dass die Methode erfolgreich ausgeführt werden konnte oder eine Ausnahme aufgetreten ist. Jede Funktion bzw. Methode gibt das Versprechen ab, das zu tun, was ihr Name beschreibt (Principle of Least Surprise). Wenn eine Methode nicht das erfüllen kann, was sie verspricht, sollte sie dies deutlich kennzeichnen. Dies in Form von Sonderfall-Rückgabewerten (zum Beispiel NULL) oder Output-Parametern abzubilden, halte ich für unpassend. Solche Sonderfälle werden (wenn überhaupt!) in den Kommentaren einer Funktion beschrieben, die sich sowieso keiner durchliest. Die Aussagekraft von Exceptions ist da deutlich höher.

Daher: Wirf eine Exception, wenn die Methode, die du gerade schreibst, nicht ihre Aufgabe erfüllen kann.

 

public void parseLine(String line)
{
  String[] splitted = line.Split(',');
  if(splitted.Length < 4)
  {
    // Es gibt zu wenig Spalten in der csv-Zeile
    // doof: nix tun (z.B. return)
    // Besser:
    throw new ParsingException("Zu wenige Spalten in Zeile: "+line);
  }
}

 

Tipp 2: Fail Fast bei Übergabeparametern

Sämtliche Übergabeparameter von öffentlichen (public) Funktionen sollten zu Beginn der Funktion geprüft werden. Sind sie nicht so, wie sie für die Funktion benötigt werden, sollte eine Exception geworfen werden.

Hintergrund ist der, dass dadurch jede Funktion einen definierten Ausgangszustand hat und keine vom Entwickler unerwarteten Fehler/Ausnahmen auftreten. Die Prüfung jedes Eingabeparameters klingt zwar aufwändig, verringert jedoch die Fehleranfälligkeit. (Corollar: Als Nebeneffekt lernt man die Anzahl der Übergabeparameter bei eigenen Funktionen gering zu halten.)

 

public double sqrt(double x)
{
  if(x<0)
  {
    throw new ArithmeticException("x muss größer als 0 sein! x="+x);
  }
  ...
}

 

Tipp 3: Verliere keine Informationen

Der Anwender einer Funktion möchte in der Regel wissen, wenn die Ausführung nicht erfolgreich war. Diese Information kann ausgewertet werden, um Maßnahmen dafür zu ergreifen oder, um sie (als Anwender) zu ignorieren.

Daher ist es wichtig, dass alle Informationen in der Exception verfügbar sind, die Aufschluss darüber liefern können, warum sie aufgetreten ist.

  • Eine aussagekräftige Fehlermeldung
  • sämtliche Übergabeparameter
  • relevante lokale Variablen (zum Beispiel Schleifenindizes)
  • relevante Zustandsinformationen des aktuellen Objektes (z.B. Connection-Parameter in eine Klasse, welche Daten über das Internet versendet)

Pauschal gilt hier: mehr ist mehr! Bei der Fehleranalyse gibt es selten zu viele Informationen. Nichtsdestotrotz sollten keine 100 MiB Rohdaten oder gar sensible Passwörter in eine Exception geschrieben werden.

In welcher Form diese Informationen der Exception mitgegeben werden (als Member einer Exceptionklasse oder einfach nur stumpf in der Exception-Nachricht), halte ich für zweitrangig. Wichtig ist nur, dass die Informationen am Ende in irgendeiner Form abgerufen werden. Ich persönlich verpacke diese Informationen immer in der Nachricht, sofern andere Methoden nicht einzelne Member der Exception explizit auswerten müssen (zum Beispiel den SQL-Errorcode).

 

public void notifyFriends(List friends)
{
  foreach(Person friend in friends)
  {
    try
    {
       ... irgendwas tun
    }
    catch(Exception e)
    {
      // alles wichtige mitnehmen:
      // - name (Klassenmember)
      // - friend (Schleifenzustand)
      // - friends (Übergabeparameter)
      // - e (Ursache-Exception)
      throw new FriendException("Konnte bei Nutzer "+ _name +
        " den Freund " + friend +
        " aus der Personenliste " + friends +
        " nicht benachrichtigen.", e);
    }
  }
}

 

Tipp 4: Verliere keine Exceptions

Jede Exception ist kostbar. Der Grund dafür ist einfach: Fehlerfälle sind selten, können aber schwere Folgen haben. Wenn ein Fehler auftritt, will man alle wichtigen Informationen dazu nicht verlieren. Wann ich die Ausgabe einer Exception für sinnvoll halte, beschreibe ich in Tipp 6. In allen anderen Fällen empfiehlt sich Exception-Chaining mit Exception-Bubbling.

Mit Exception-Chaining ist gemeint, dass eine Exception gefangen und als Ursache an eine Exception einer höheren Abstraktionsebene angehangen werden soll. Ein Beispiel wäre hier ein Dateilesefehler (zum Beispiel EndOfFile), welcher an eine Import-Exception angehangen wird. So bleibt jede Information (bzw. Exception) erhalten. Die (spätere) Ausgabe einer derartigen Fehlerkette ist sehr nützlich im Hinblick auf die Fehleranalyse, da nahezu alle Informationen zur Reproduktion des Fehlers in der Exception verfügbar sind.
Exception-Chaining sollte in allen halbwegs modernen Programmiersprachen verfügbar sein. Falls nicht, lohnt es sich, dieses einfache Konzept selbst nachzuimplementieren.

Exception-Bubbling beschreibt lediglich, dass Exceptions weiter „nach oben“ geworfen werden sollen. Ich finde, dass diese Technik meistens nur in Kombination mit Exception-Chaining Sinn macht, da man die gerade gefangene Exception mit weiteren Informationen anreichern kann (siehe Tipp 3).

 

public void importUsers(File file)
{
  try
  {
    ... hier passiert irgendwas
  }
  catch(DatabaseException e)
  {
    // wichtig: Die alte Exception mitnehmen
    // und mit neuen Infos anreichern.
    throw new ImportException("Konnte die Nutzer aus der Datei " +
        file + " nicht importieren", e);
  }
}

 

Tipp 5: Nicht zu viele Exception-Klassen!

Wird eine Exception gefangen, gibt es drei

  1. Exception weiterreichen (an den Nutzer, ins Protokoll oder über Exception-Bubbling)
  2. Eine alternative Strategie ausprobieren (zum Beispiel Reconnect)
  3. Ignorieren

Für den ersten Fall benötigt man keine Aufteilung in viele Exception-Klassen, da hier meist nur die Nachricht und die Ursache relevant sind. Zu viele Exceptions führen zu Bloatcode – dazu ein Beispiel aus der realen Welt. Unten steht eine der Methoden eines Java-Interfaces der High-Level-Architecture:

 

void connect(FederateAmbassador federateReference, CallbackModel callbackModel) throws
    ConnectionFailed,
    InvalidLocalSettingsDesignator,
    UnsupportedCallbackModel,
    AlreadyConnected,
    CallNotAllowedFromWithinCallback,
    RTIinternalError;

 

Gut ist, dass der Anwender der API anhand der verschiedenen Exception-Klassen den Fehler sofort verstehen kann – sofern er sich mit dem System einigermaßen auskennt. Aber die Botschaft ist dennoch klar: Mit einem Interface zu arbeiten, welches etliche solcher Funktionen mit 5-7 Exceptions erfordert, ist anstrengend.

Im zweiten Fall ist es wichtig zwischen Exceptions unterscheiden zu können, da unterschiedliche Ursachen zu unterschiedlichen Maßnahmen führen können (PasswordError → Benutzer neu auffordern vs. Timeout → Retry).
Ich muss gestehen, dass ich keine umfangreiche statistische Auswertung habe, aber ich glaube, dass der zweite Fall vergleichsweise selten ist.

Die dritte Variante – das Ignorieren von Fehlern – sollte man nicht tun. In der Regel ist das Weiterreichen (Fall 1) eine brauchbare Alternative.

Daher: Wenn man die Exception nicht explizit unterscheiden muss, sollte eine Exception-Klasse pro Abstraktionsebene ausreichen.

Tipp 6: Exceptions müssen vollständig ausgegeben werden

Um wieder zum Beispiel vom Anfang zurückzukommen: Nichts ist schlimmer als eine nichtssagende Fehlermeldung. Daher sollten Fehlermeldungen immer komplett ausgegeben werden: Typ (Klasse)

  • Der Typ der Exception hilft dem Anwender/Entwickler beim groben Einordnen des Fehlers (z.B. Importfehler).
  • Die Fehlermeldung ist das, was der Nutzer durchliest. Sie dient dazu, den Fehler zu verstehen (z.B. invalide Datei).
  • Die Ursache ist in der Regel eine lange Kette anderer Ausnahmen, welche ebenso vollständig ausgegeben werden müssen. Sie sind nötig, wenn der Fehler nicht anhand der Fehlermeldung verstanden werden kann (z.B. Zeile 17 enthält ungültige Zeichen).

Die Form der Ausgabe einer Exception variiert von Anwendungsfall zu Anwendungsfall (zum Beispiel MessageBox oder Protokollierung). Mit vollständigen aussagekräftigen Fehlermeldungen kann entweder der Nutzer selbst etwas anfangen oder aber spätestens der Entwickler. Wichtig ist jedoch, dass die Informationen spätestens für den Entwickler vollständig verfügbar sein müssen.

 

Zu guter Letzt: Schreibe keinen Bloat-Code

Exception-Handling kann viel Overhead im Code verursachen. Als Ursache sehe ich hier ein unterschiedliches Verständnis beim Umgang mit Ausnahmen in einem Entwicklungsteam oder Unsicherheit, wie damit umgegangen werden soll. Mit diesem Beitrag möchte ich Entwicklungsteams eine gute Grundlage zur Diskussion bieten.

Die Tipps 3 und 4 sind nicht so zu verstehen, dass man in jede Methode einen Try-catch-Block einbauen muss. Wichtig ist hier die Frage: Verliere ich wirklich Informationen? Dazu ein Beispiel:

 

public class CustomerFile
{
  private readonly File _sourceFile;

  public CustomerFile(File sourceFile)
  {
    _sourceFile = sourceFile;
  }

  private String getName()
  {
    // ... irgendeine Magie passiert hier
    catch(IOException e)
    {
    throw new CustomerParserException("Name konnte nicht gelesen
      werden! Datei="+_sourceFile, e);
    }
  }

  private String getEmail(){ /* In diesem Beispiel egal */ }

  public Person getCustomer()
  {
    // würde es hier Sinn machen die Exception neu zu fangen?
    return new Person(getName(), getEmail());
  }
}

 

In der Funktion getCustomer() die Exception zu fangen, zu verpacken und neu zu werfen würde keinerlei Mehrwert bei der Fehleranalyse durch den Anwender oder Entwickler bieten. Daher kann der zusätzliche Try-Catch-Code weggelassen werden.

Fazit: Korrektes Grenzverhalten ist einfach, perfektes Grenzverhalten ist schwer

Der korrekte Umgang mit Grenzverhalten ist erst einmal nicht schwer. Die hier vorgestellten Regeln sollen eine Hilfe sein, einen Einstieg in den korrekten Umgang mit Ausnahmen zu finden.

Ich halte dies für sehr wichtig, da Fehlerbehebung ein Kerngeschäft im Lebensyzklus von Software ist. Aussagekräftige Fehlermeldungen (durch korrekt gehandhabte Exceptions) können die Ursachensuche immens beschleunigen und im besten Falle dem Nutzer sogar selber Fehlerbehebungen oder Workarounds darlegen.

Das Meistern des Umgangs mit Ausnahmen ist jedoch schwierig: Welche Informationen sind wichtig? Wann sollte ich eine Exception fangen, wann einfach durchlassen? Ist es für die API sinnvoll, zwischen verschiedenen Exceptions zu unterscheiden oder nicht? Wie viel gebe ich dem Nutzer von der Fehlermeldung preis? Wann und wo sollen Fehlermeldungen unterdrückt werden? Der Unterschied zwischen ausführlicher Fehlerbehandlung und Bloatcode ist eine Gradwanderung, die von Sprache zu Sprache unterschiedlich ist. Für diese Gradwanderung ist die Rücksprache mit Kollegen sehr zu empfehlen.

Neuer Kommentar

0 Kommentare

Rufen Sie uns an: 0221 222 812 - 12
Kommen Sie vorbei!
lise GmbH
Butzweilerhof-Allee 2
50829 Köln