18. Feb 2019
Lesedauer 8 Min.
Try, catch und wie es nicht geht
Exceptions, Teil 3
Die wenigen Sprachmittel von C# zur Ausnahmebehandlung sind ausgefeilt. Beim Einsatz ist aber Vorsicht geboten.

Was das Erzeugen und Behandeln von Fehlern angeht, herrschen die unterschiedlichsten Meinungen in der Entwicklerwelt. Die vorangegangene Episode von Davids Deep Dive hat gezeigt, dass es hierfür aber nur einen richtigen Weg geben kann: das in .NET eingebaute Ausnahmekonzept [1]. Nur dieses erfüllt die fünf Regeln der Ausnahmebehandlung, die für ein reibungsloses Fehlermanagement zu befolgen sind.Der aktuelle Teil von Davids Deep Dive geht weg von der Theorie und befasst sich damit, wie mit Fehlern im Code umgegangen wird. Neben den notwendigen Grundlagen der Fehlerbehandlung soll es aber besonders um die Anti-Pattern der Fehlerbehandlung in C# gehen. Denn auch wenn das Sprachkonstrukt try/catch/finally nicht besonders kompliziert ist, kann es falsch angewendet werden und dabei die Ausnahmesituation teilweise sogar verschlimmern.
Try, catch und finally
Zunächst sollen die Grundlagen der Ausnahmen in C# erläutert werden; erfahrene Entwickler können diesen Abschnitt ohne Weiteres überspringen. Im Prinzip kann nahezu jede Zeile Quellcode in C# eine Ausnahme auslösen, egal ob es eine simple Division ist (die zu einer DivideByZeroException führt) oder eine korrupte Verbindung zu einem SQL-Datenbankserver (SqlConnectionException). Folglich sollte der Quellcode immer in dem Bewusstsein geschrieben werden, dass eine gerade geschriebene Zeile Quellcode nicht erfolgreich ausgeführt werden kann.Tritt eine solche Ausnahme während des Programmablaufs auf, wird die Ausführung der aktuellen Methode auf der Stelle unterbrochen und die aufgetretene Ausnahme an die Stelle „weitergeleitet“, an der die fehlerhafte Methode aufgerufen wurde. Dort wird diese dann erneut ausgelöst, womit auch die aufrufende Methode unmittelbar unterbrochen wird. Dieser Prozess setzt sich so lange fort, bis die Ausnahme an der Startmethode (zum Beispiel Main()) angekommen ist. Dort sorgt der Fehler dann für das Terminieren der AppDomain und damit der laufenden Anwendung. Dies ist der übliche Kontrollfluss einer Ausnahme. Soll dieser Kontrollfluss „gesteuert“ werden, kann dies mithilfe des try/catch/finally-Statements geschehen.In Listing 1 wird ein Stream mithilfe der Methode File.OpenRead() aufgerufen, bei der mehrere Ausnahmen auftreten können, weil die Datei nicht vorhanden oder gesperrt ist, weil keine Dateiberechtigung vorhanden ist, weil sie bereits geöffnet ist und so weiter. Ist der Aufruf von OpenRead() jedoch innerhalb eines try-Blocks enthalten, so sorgt die auftretende Ausnahme nicht für einen direkten Abbruch der Methode, sondern es wird zunächst der Inhalt des catch-Blocks ausgeführt, in dem der Fehler behandelt werden kann, Protokollinformationen geschrieben werden können und so weiter. Der optional vorhandene finally-Block wird sowohl im Ausnahme- als auch im Erfolgsfall ausgeführt.Listing 1: Grundlagen von try/catch/finally
Stream strm = null; <br/><span class="hljs-keyword">try</span> <br/>{ <br/> stream = File.OpenRead(<span class="hljs-string">"data.csv"</span>); <br/> // ... <br/>} <br/><span class="hljs-keyword">catch</span> <br/>{ <br/> Console.WriteLine(<span class="hljs-string">"Fehler"</span>); <br/>} <br/>finally <br/>{ <br/> stream?.Close(); <br/>}
Der catch-Block
Der catch-Block kann auf mehrere unterschiedliche Arten beginnen:- catch { … }
- catch(ExceptionTyp) {…}
- catch(ExceptionTyp e) { … }
catch(FileNotFoundException) { }
catch(UnauthorizedAccessException) { }
catch(Exception) { }
Da der Fokus dieser Deep-Dive-Episode nicht auf den Grundlagen liegen soll, wollen wir es bei diesen allgemeinen Bemerkungen belassen und auf eine weitere Beschreibung verzichten; stattdessen sei auf die Ausnahmebehandlung im C#-Programmierhandluch von Microsoft verwiesen [2].
Anti-Pattern 1: Leerer catch-Block
Gäbe es eine Hitliste der Exception-Antimuster, wäre die unangefochtene Nummer eins wohl der leere catch-Block. Bleibt ein solcher Block komplett leer, so wird die aufgetretene Ausnahme einfach verschluckt und die Anwendung setzt ihre Ausführung fort. Leider ist dies besonders bei Einsteigern einer der häufigsten Fehler im Umgang mit Ausnahmen, daher muss auch dieser Artikel hier davor warnen: Ausnahmen treten nicht ohne Grund auf, daher sollte es niemals einen leeren catch-Block innerhalb einer Anwendung geben; vielmehr sollte die Ausnahme erst gar nicht entstehen. Daher sollte in jeder Codierrichtlinie die Verwendung von leeren catch-Blöcken verboten werden und in Tools wie ReSharper sollte eine entsprechende Verletzung zu einem fehlerhaften Build führen. Zu finden ist diese Einstellung in dem Programm unter Options | Code Inspection | Inspection Severity | Empty general catch clause(Bild 1).
Das ToolReSharper kann leere catch-Blöcke verbieten(Bild 1)
Autor
Nur in absoluten Ausnahmefällen, in denen beispielsweise ein API trotz Erfolg eine Ausnahme auslöst, ist ein leerer catch-Block erlaubt, sollte dann aber trotzdem um einen entsprechenden Kommentar erweitert werden, warum und wieso an dieser Stelle seine Verwendung nicht verhindert werden kann.
Anti-Pattern 2: Alles „catchen“
Der Verzicht auf try/catch/finally ist natürlich ein No-Go, aber der massive Einsatz ist auch nicht viel besser. Von Zeit zu Zeit sehe ich in Kundenprojekten besonders eifrige Entwickler, die nahezu alle Quellcodezeilen mit einem try-Block umschließen, um jede aufkommende Ausnahme zu erwischen. Das macht den Code sehr unleserlich und bietet darüber hinaus meist keine Stabilität innerhalb der Software, denn der Einsatz von try/catch ist nur dann sinnvoll, wenn die aufgetretene Ausnahme auch tatsächlich behandelt werden kann oder sonstige Maßnahmen wie eine Protokollierung nötig sind. Wenn beides nicht erforderlich ist, muss auch kein try/catch eingesetzt werden, denn die aufgetretene Ausnahme wird ganz von allein zur aufrufenden Methode weitergeleitet.Neben der Unsinnigkeit und der schlechten Lesbarkeit des Codes gilt es aber noch etwas viel Wichtigeres zu beachten: Quellcode, der innerhalb eines try-Blocks steht, wird nicht durch den Just-in-time-Compiler der Common Language Runtime optimiert [3]. Folglich führt ein massiver Einsatz von try/catch zu einer verminderten Anwendungsgeschwindigkeit.Anti-Pattern 3: Alles ins Log schreiben
Zusammen mit dem „Catching everything“-Anti-Pattern ist oft das unnötige und massive Protokollieren von Ausnahmeinformationen zu sehen – was meist in bester Absicht geschieht. Dabei wird ein sehr großer Teil des Quellcodes mit try/catch-Blöcken zugepflastert, um in Letzterem die Ausnahmeinformationen in eine Log-Datei zu schreiben. Wird nun aber eine Methode C() von einer Methode B() aufgerufen, die wiederum von einer Methode A() aufgerufen wird, so wird die Ausnahme in jeder Methode in der Log-Datei protokolliert, das heißt, die Exception wird drei Mal mit unterschiedlichen Stack-Traces in die Log-Datei geschrieben. Je nachdem, wie lang die Aufrufkette der Methoden ist, erschwert dies die Auswertung der Log-Datei massiv. Dabei sollte eine Ausnahme nur in folgenden Fällen protokolliert werden:- Die Ausnahme wurde behandelt (Log-Stufe Warning).
- Die Ausnahme verlässt die Anwendungsgrenze, zum Beispiel weil dem Nutzer ein Fehlerdialog angezeigt wird (Log-Stufe Error).
- Die Anwendung stürzt wegen einer unbehandelten Ausnahme ab (Log-Stufe Fatal).
class Program
{
static void Main()
{
AppDomain.CurrentDomain.UnhandledException
+= LogUnhandledException;
App.Run();
}
private static void LogUnhandledException(
object sender, UnhandledExceptionEventArgs e)
{
File.WriteAllText("log.txt", e.ToString());
}
}
Anti-Pattern 4: Ausnahme neu erzeugen
Wenn eine Ausnahme in einem catch-Block abgefangen wurde, lassen sich dort geeignete Maßnahmen einleiten. Manchmal kann eine Gegenmaßnahme die Ausnahme kompensieren; oft aber muss am Ende die Ausnahme an den Aufrufer der Methode weitergegeben werden. Dazu bietet C# mehrere Möglichkeiten, wie das folgende Codebeispiel zeigt:catch (ArgumentNullException e)
{
throw e; // Falsch!
}
catch (ArgumentOutOfRangeException e)
{
throw; // Richtig!
}
Die als richtig gekennzeichnete Weiterleitung (throw;) gibt das unveränderte Ausnahmeobjekt an den Aufrufer weiter. Die als falsch gekennzeichnete Variante leitet die Ausnahme zwar ebenfalls weiter, jedoch wird der Call-Stack der Ausnahme verändert, denn nun zeigt dieser nicht mehr auf die Stelle, an der die Ausnahme ursprünglich ausgelöst wurde, sondern auf die Stelle throw e; – das Auffinden des Fehlers wird also wesentlich schwieriger.Da dieses Verhalten in der Praxis so gut wie nie gewünscht wird, zeigen Tools wie ReSharper oder CodeRush dieses als Warnung an, nicht jedoch als Fehler. Da besonders Einsteiger diesen Fehler oft machen – und es bei Unachtsamkeit auch einem erfahrenen Entwickler passieren kann –, rate ich hier ebenfalls, durch ReSharper oder CodeRush den Build fehlschlagen zu lassen.
Anti-Pattern 5: Null-Referenz in finally
Der finally-Block ist für Arbeitsschritte gedacht, die auf jeden Fall erfolgen sollen, sowohl im Erfolgs- als auch im Misserfolgsfall, etwa Aufräumarbeiten. Dabei ist aber zu berücksichtigen, wo genau eine Ausnahme aufgetreten sein kann. In Listing 1 wird ein FileStream-Objekt durch Aufruf der Methode File.OpenRead() erzeugt und anschließend der Referenz stream zugewiesen. Sollte die Ausnahme beim Aufruf dieser Methode ausgelöst werden, so ist die Referenz natürlich leer, hat also den Wert null. Wenn der Stream nun im finally-Block geschlossen werden soll, so muss vor dem Zugriff überprüft werden, ob die Referenz tatsächlich nicht null ist, sondern ihr ein Objekt zugewiesen wurde. Seit C# 6.0 gibt es dazu mit dem Null-Conditional-Operator [4] eine sehr elegante Lösung, ohne eine Verzweigung einbauen zu müssen.Anti-Pattern 6: Unvollständige eigene Exceptions
Wenn Sie eine sorgfältige Ausnahmebehandlung umsetzen möchten, werden Sie nicht um die Implementierung von eigenen Ausnahmen herumkommen. Wichtig ist dabei, dass eigene Ausnahmen einige sehr wichtige Informationen an die Basis-Exception über verschiedene Konstruktoren weiterleiten und darüber hinaus versioniert serialisierbar sind, damit Ausnahmen sich auch über Prozessgrenzen hinweg übertragen lassen. Daher sollte eine Ausnahme folgende Eigenschaften haben:- den Konstruktor ctor()
- den Konstruktor ctor(string msg) : base(msg)
- den Konstruktor ctor(string msg, Exception ie) : base(msg, ie)
- einen Serialisierungskonstruktor
- ein [Serializable]-Attribut
Fazit
Das Sprachmittel try/catch/finally in C# ist schnell erklärt und jeder Entwickler sollte es beherrschen. Das Problem ist jedoch, dass es in zahlreichen Antimustern verwendet werden kann. Diese Episode von Davids Deep Dive hat die wichtigsten davon gezeigt, die es zu vermeiden gilt.Eines wurde hier aber noch nicht berücksichtigt. Dieses Anti-Pattern wirkt sich sehr schlecht auf die Architektur einer Anwendung aus, da es durch eine Ausnahme zu einer Verletzung eines Kontrakts und des Geheimnisprinzips führt. Da dies jedoch eine ganze Episode füllen würde, wird es auf die nächste Ausgabe verschoben. Bis dahin wünsche ich Ihnen viel Spaß bei der Behandlung von Ausnahmen!Fussnoten
- David Tielke, Was is’n das genau? Exceptions, Teil 2, dotnetpro 2/2019, Seite 36 ff., http://www.dotnetpro.de/A1902DDD
- Ausnahmen und Ausnahmebehandlung (C#-Programmierhandbuch), http://www.dotnetpro.de/SL1903DDD1
- Peter Ritchie, Performance implications of try/catch/finally, http://www.dotnetpro.de/SL1903DDD2
- David Tielke, Webcast „C# 6.0 – Teil 5: Null-Conditional-Operator“, http://www.dotnetpro.de/SL1903DDD3