Fehlersuche: Guard Clauses – den Code auf Fehler vorbereiten

Fehlersuche im Code ist zeitaufwendig. Kluge Strategien für Exceptions und Guard Clauses helfen, effizient versteckte Übeltäter aufzudecken.

In Pocket speichern vorlesen Druckansicht 50 Kommentare lesen
Zwei Verkehrsampeln vor wolkigem Abendhimmel.

(Bild: monticello/Shutterstock.com)

Lesezeit: 16 Min.
Von
  • Sergej Tihonov
Inhaltsverzeichnis

Der Großteil der Softwareentwicklung besteht darin, in zahlreichen Iterationen neue Funktionen zu schreiben, sie zu testen und schließlich Fehler oder Unstimmigkeiten zu identifizieren und zu beheben. Bei der Fehlersuche ist es fatal, wenn keine Anhaltspunkte vorhanden sind, die auf die Ursache hinweisen, sodass sich die betroffenen Stellen im Code nicht genau eingrenzen lassen.

Je mehr Zeit zwischen dem Entwickeln und dem Auftreten des Fehlers vergangen ist, desto kniffliger gestaltet sich die Fehlersuche. Es erschwert die Suche zusätzlich, wenn während dieser Zeit viele neue Funktionen hinzugekommen sind.

Häufige Test-Iterationen lindern das Problem der verstrichenen Zeit. Sie helfen, den betroffenen Bereich einzuschränken und die Qualität neuer Funktionen zu verbessern. Für die bestehende Codebasis ist eine effizientere Strategie nötig, um Hinweise auf auftretende Fehler zu erhalten.

Die Strategie besteht darin, das Problem als Kombination von Symptom und Ursache zu verstehen und die Distanz dazwischen zu reduzieren. Das Symptom ist das sichtbare Resultat, das von dem gewünschten Ergebnis abweicht. Die Ursache ist die Stelle, die diese Abweichung einleitet. Alle Funktionszeilen zwischen den beiden Punkten ergeben die Distanz. Ein Stacktrace ist hilfreich, um sie darzustellen.

Das folgende Beispiel einer Divide-By-Zero Exception verdeutlicht das Konzept. Es demonstriert den Umgang mit einem Stacktrace und zeigt die versteckten Herausforderungen auf.

public class Stack
{
  public void Execute()
  {
    int x = 10;
    int y = 0;
    Console.WriteLine(x / y);
  }
}

Der Code zeigt zwei Variablen, von denen die zweite die Zahl 0 aufnimmt. Die anschließende Division führt zu einer Divide-By-Zero Exception. Das Beispiel zeigt den Unterschied zwischen Symptom und Ursache: Das Stacktrace der Fehlermeldung enthält eine Zeile und deutet auf die Division hin:

Unhandled exception. System.DivideByZeroException:
Attempted to divide by zero.
at ExceptionFirst.Stack.Execute() in ... /Stack.cs:line 7

Das ist das Symptom: Etwas verhält sich nicht so, wie es soll. Die Ursache des Problems befindet sich eine Zeile darüber: die zweite Variable mit dem Wert 0. Eine Änderung an dieser Stelle behebt den Fehler. Die Distanz zwischen dem Symptom und der Ursache ist eine Zeile Code und beträgt somit eins.

Folgender Code extrahiert die Division in eine eigene Methode:

public class Stack
{
  public void Execute()
  {
    int x = 10;
    int y = 0;
    Console.WriteLine(Divide(x, y));
  }

  private int Divide(int x, int y)
  {
    return x / y;
  }
}

Es handelt sich um ein Refactoring ohne funktionale Änderungen: Das Programm löst weiterhin die Divide-By-Zero Exception aus. Das Stacktrace ist aber um eine Zeile länger geworden:

System.DivideByZeroException: Attempted to divide by zero.
  at Stack.Divide(Int32 x, Int32 y) in ... /Stack.cs:line 12
  at Stack.Execute() in … /Stack.cs:line 7

An erster Stelle steht erneut die Zeile mit der Division: das Symptom. An zweiter Stelle ist die Codezeile mit dem Aufruf der separaten Funktion zu finden. Die Codezeile zeigt nicht die Ursache, aber die richtige Methode. Eine Zeile darüber steht die Variable mit dem Wert 0: die Ursache des Fehlers. Die Distanz zwischen dem Symptom und der Ursache beträgt zwei Zeilen Code. Das Stacktrace ist weiterhin aussagekräftig und zeigt alle Methoden, die beim Auffinden der Fehlerquelle hilfreich sind.

Folgender Code extrahiert das Setzen der Variablen in eigene Methoden. Das simuliert das Übernehmen von Werten aus einer unbekannten Quelle wie Benutzereingaben, API-Rückgabewerte oder Inhalte einer Datenbank.

public class Stack
{
  public void Execute()
  {
    int x = ReadX();
    int y = ReadY();
    Console.WriteLine(Divide(x, y));
  }

  private int Divide(int x, int y)
  {
    return x / y;
  }

  private int ReadX()
  {
    return 10;
  }

  private int ReadY()
  {
    return 0;
  }
}

Erneut gibt es keine funktionale Änderung, und das Verhalten bleibt dasselbe. Es tritt weiterhin die Divide-By-Zero Exception auf. Obwohl sich die Ausführung geändert hat, bleibt das Stacktrace dasselbe wie zuvor. Woran liegt das und welche Folgen hat es für die Fehlerbehebung?

System.DivideByZeroException: Attempted to divide by zero.
  at Stack.Divide(Int32 x, Int32 y) in ... /Stack.cs:line 12
  at Stack.Execute() in ... /Stack.cs:line 7

Das Stacktrace zeigt weiterhin das Symptom an erster Stelle und den Aufruf der Division in der zweiten Zeile. Das Problem an dieser Stelle besteht darin, dass die Methode ReadY, die den Wert 0 zurückliefert, in dem Stacktrace nicht erscheint. Somit enthält das Stacktrace nicht mehr alle Informationen über den Ablauf der Anwendung, was die Fehlersuche erschwert. An dieser Stelle reicht das Stacktrace allein nicht aus, sondern es ist eine detaillierte Analyse des Quellcodes erforderlich. In Feinarbeit muss man das dynamische Verhalten des Systems durch Logging und Debugging nachbilden.

Warum taucht der Aufruf der Methode ReadY nicht im Stacktrace auf? Beim Einsatz eines Frameworks sind die angezeigten Stacktraces extrem lang, auch wenn im eigenen Code kaum eine Zeile ausgeführt wurde. Das ist trügerisch und kann zu der falschen Annahme verleiten, dass das Stracktrace den gesamten Verlauf eines Aufrufs enthält.

Ein Stacktrace protokolliert aber nicht die aufgerufenen Zeilen oder Methoden, sondern zeigt immer nur die aktuell offenen Methoden an. Das sind die Methoden, die aufgerufen wurden und deren Ausführung noch nicht abgeschlossen ist. Weil Methoden ihrerseits andere Methoden aufrufen, ergibt sich ein Stack. Abgeschlossene Methoden werden vom Stack entfernt und es geht mit der obersten Methode auf dem Stack weiter.

Die Methode ReadY fehlt also im Stacktrace, da ihre Ausführung abgeschlossen ist. Sie befindet sich in der Nähe der letzten Stelle im Stacktrace, und die Ursache lässt sich einfach ermitteln. Es ergibt sich eine Distanz von drei Codezeilen. Das Beispiel zeigt im Kleinen, warum es oft schwer ist, die Ursache eines Problems zu finden. Gleichzeitig demonstriert es anschaulich das Stacktrace-Verhalten. Mit diesem Wissen kann man eine Strategie entwickeln, um Stacktraces effektiver zu nutzen.

In einem Fehlerfall sind umfangreiche Informationen hilfreich. Die Menge allein sagt jedoch nichts über die Qualität der Informationen aus. Es ist wichtig, viele relevante Informationen zu sammeln, um die Ursache zu ermitteln. Die Distanz zwischen Symptom und Ursache zu verringern, verbessert die Relevanz des Stacktrace.

Bei einer langen Distanz enthält das Stacktrace viele Informationen und Methodenaufrufe. Selbst wenn alle zum Beheben des Fehlers erforderlichen Informationen vorhanden sind, können sie in der Masse untergehen. Zusätzlich ist die Wahrscheinlichkeit höher, dass mehrere Methoden abgeschlossen und damit nicht mehr im Stacktrace enthalten sind. Damit lässt sich ein Teil der Ausführung nicht mehr nachvollziehen. Somit verringert eine kürzere Distanz den Overhead und erhöht die Wahrscheinlichkeit für hilfreiche Informationen.

Die Distanz lässt sich nur durch das frühzeitige Ausgeben des Stacktrace reduzieren. Wenn eine Exception auftritt, fügt das System die Information automatisch der Ausgabe hinzu.

Die Rückgabe von null zwingt die aufrufende Methode, die Situation aufzulösen. Das Vorgehen beruht auf der Annahme, dass sie das Problem sieht und lösen kann. In der Realität ist das selten der Fall und die aufrufende Methode gibt ebenfalls null zurück. Wer aus Angst vor Unterbrechungen des Programms keine Exception wirft, löst eine null-Kaskade aus, die den Stack abbaut. Bis eine Exception auftritt, fehlen alle relevanten Informationen im Stacktrace, womit es für eine effiziente Fehlersuche unbrauchbar ist.

Die Annahme, beim Entwickeln eine klare Entscheidung treffen zu können, ist häufig falsch. Selten liegen alle notwendigen Informationen vor und noch seltener sind alle korrekt. Informationen und Anforderungen ändern sich mit der Zeit. Selbst eine richtige Entscheidung beim Schreiben des Codes kann sich später durch geänderte Anforderungen als falsch herausstellen.

Die Kunst ist, eine konkrete Annahme zu treffen und sie mit Exceptions zu untermauern. Beim Testen des Programms zeigt sich, ob man die Annahme richtig definiert hat. In dem Fall geht es mit der nächsten Aufgabe weiter. Falls sie falsch ist, löst sie eine der vorbereitete Exceptions aus. Tests zeigen in dem Fall ein konkretes Fehlerszenario mit neuen Informationen auf, die die Kommunikation mit dem Team und den Kunden vereinfacht. Daraus ergibt sich eine verbesserte Annahme, die man wiederum mit Exceptions untermauert. Der Informationsgewinn durch eine schnelle Unterbrechung des Programms rechtfertigt die kurzfristige Instabilität und steigert langfristig die Stabilität.

Der Fachbegriff für das Konzept der Umsetzung von Annahmen lautet Guard Clauses. Das sind einfache Bedingungen für jeweils genau einen Aspekt. Ist eine solche Bedingung verletzt, wirft das Programm eine Exception aus:

public double GetAmount(Person person)
{
  if (person == null)
    throw new ArgumentException();

  /* do something */
}

Üblicherweise definiert man Guard Clauses als Vorbedingungen am Anfang einer Methode. Das trennt die Ausnahmen von der Ausführungslogik. Doch nicht alle Guard Clauses sind Vorbedingungen. Nach Bedarf können sie auch inmitten oder am Ende einer Methode auftauchen, um den beabsichtigten Zustand zu garantieren.

Eine Variante von Guard Clauses ist das Validieren von Benutzereingaben. Wenn die Bedingungen für ein Feld nicht passen, unterbricht das Programm die Ausführung und zeigt einen Fehler an. Benutzereingaben zu validieren, ist ein wichtiger Bestandteil jeder Anwendung. Fehlermeldungen helfen Usern dabei, die Eingabe zu korrigieren, und das Validieren verhindert, dass defekte Daten im System landen.

Guard Clauses sichern die Eingabe jeder einzelnen Methode in der Anwendung. Die Zielgruppe der auftretenden Fehlermeldungen sind vorrangig Personen, die das System betreuen. Wenn ein Fehler auftritt, erhalten sie ein Stacktrace mit einer kurzen Distanz zwischen Symptom und Ursache. Es enthält den vollen Stack bis zum Aufruf, der den Fehler ausgelöst hat. Zusätzlich können Entwicklerinnen und Entwickler die Exception-Meldung um zusätzliche Informationen zum Kontext anreichern.

Bei der Auswahl der richtigen Exception für die Erstellung der Guard Clause ist zwischen zwei Arten von Exceptions zu unterscheiden. Checked-Exceptions treten auf, wenn der aufrufende Code sie behandeln muss. Das ist hilfreich, um gezielt den Programmfluss zu unterbrechen und darauf mit vordefinierter Logik zu reagieren. Somit handelt es sich um eine geplante Unterbrechung.

Für reine Annahmen passen dagegen Runtime-Exceptions, die die aufrufende Stelle nicht dazu zwingen, sie zu behandeln. Die Informationen aus der gescheiterten Guard Clause fängt das Programm an einer zentralen Stelle auf und schreibt sie ins Log. Anschließend versetzt sie das System in einen neutralen Zustand.

Für das Konzept von Guard Clauses wäre es hinderlich, wenn die aufrufende Methode die geworfene Runtime-Exception verarbeitet. Das würde zum Verlust von Informationen führen und kritische Fehler verschleiern. Handelt es sich bei dem Fehler um ein Szenario, welches geplant auftreten kann, dann sind die Guard Clauses die falsche Wahl an dieser Stelle. Hier muss die Überprüfung mit einer Checked-Exception erfolgen.

Guard Clauses nehmen mehrere Codezeilen ein. Für bessere Lesbarkeit ist es empfehlenswert, sie in eine eigene Klasse zu extrahieren, damit der Einsatz nur eine Codezeile benötigt. Die einzelnen Methoden kapseln die Logik und können ausführliche Nachrichten mit allen verfügbaren Informationen enthalten. Der Einsatz erfolgt über injizierte Serviceklassen oder mit statischen Methodenaufrufen. Letzteres hat bei Guard Clauses keine Nachteile gegenüber dem Injizieren, da folgende Richtlinien gelten:

  • Guard Clauses werden in der Produktion nicht abgeschaltet.
  • Guard Clauses werden beim Testen nicht umgangen oder gemockt.
  • Guard Clauses sind fester Bestandteil der Methode, in der sie zum Einsatz kommen.

Beim Erstellen von Guard-Clauses-Methoden gilt es, ein paar Grundlagen zu beachten:

  • Eine Methode bildet genau eine Guard Clause ab.
  • Die Methode hat keine Seiteneffekte.
  • Die Methode ist beim regulären Programmablauf untätig.
  • Die Methode unterbricht im Fehlerfall die Ausführung mit einer Runtime Exception.
  • Die Fehlermeldung sollte ausführlich sein und einen Hinweis zum Beheben des Problems enthalten.

Der einheitliche Einsatz von Guard Clauses hat abgesehen von der strukturierten Fehlersuche einige positive Effekte. Unter anderem zeigen sie den Code Smell Primitive Obsession auf. Dabei verwendet Code wiederholt ein oder mehrere primitive Typen, die einen bestimmten Kontext repräsentieren, und gibt die primitiven Typen weiter an andere Methoden.

Da Guard Clauses eingehende Parameter prüfen, müssen sie sicherstellen, dass der primitive Typ den passenden Wert repräsentiert. Für einen String, der eine E-Mail-Adresse darstellt, ist eine isEmail-Guard-Clause erforderlich. Da die Methode den Wert als String weiterreicht, benötigt die aufgerufene Funktion dieselbe Guard Clause. Diese Code-Duplizierung fällt dadurch deutlich auf. Folgender Code reicht mehrere primitive Werte gemeinsam weiter und überprüft sie jeweils neu:

public void Prepare(int amount)
{
  if (amount is < 1 or > 100)
    throw new ArgumentException("Invalid amount");

    /* do something */
  
  Calculate(amount);
}

public void Calculate(int amount)
{
  if (amount is < 1 or > 100)
    throw new ArgumentException("Invalid amount");

  /* do something */
}

Primitive Obsession und Code-Duplizierung lassen sich mit dem Value-Object-Pattern auflösen: eine Klasse repräsentiert als Value Object einen eigenen Typen und speichert die erforderlichen Informationen intern ab. Außerdem sorgen Prüfungen beim Erstellen dafür, dass die Werte gültig sind. Das kann durch eine Guard Clause oder andere Logik im Konstruktor der Klasse geschehen.

Folgender Code zeigt die gleichen Methoden mit Value Objects. Weil die Klasse die Werte überprüft, sind die Guard Clauses in den Methoden nicht mehr erforderlich.

public record ItemAmount(int Amount)
{
  public int Amount { get; }
    = Amount is < 1 or > 100
      ? throw new ArgumentException("Invalid amount")
      : Amount;
}

public void Prepare(ItemAmount amount)
{
  /* do something */
  Calculate(amount);
}

public void Calculate(ItemAmount amount)
{
  /* do something */
}

Die Entscheidung, ob man einen primitiven Typ direkt verwendet oder ein Value Object erstellt, hängt von der konkreten Anwendung ab: Wenn ein Wert häufig gebraucht wird oder Verwechslungsgefahr besteht, sind Value Objects zu empfehlen. Tritt ein Wert selten auf, lohnen sie sich nicht. Zusätzliche Klassen erhöhen die Komplexität der Anwendung und es wird zunehmend schwerer, sprechende Klassennamen zu wählen. Der Wechsel von primitiven Typen zu Value Objects ist ein einfaches Refactoring, das jederzeit erfolgen kann.

Folgender Code zeigt, wie Guard Clauses das Konzept Early Return begünstigen, also die Methode sofort zu verlassen, sobald der erforderliche Teil der Logik ausgeführt ist. Ziel von Early Return ist, die Verschachtelung in den Methoden zu reduzieren.

public double GetAmount(Person person) {
  var result = 0.0;
  if (person.IsDead())
    result = DeadAmount();
  else {
    if (person.IsRetired())
      result = RetiredAmount();
    else {
      if (person.IsChild())
        result = ChildAmount();
      else
        result = WorkerAmount();
    }
  }
  return result;
}

public double GetAmount(Person person)
{
  Guard.NotNull(person);

  if (person.IsDead()) return DeadAmount();
  if (person.IsRetired()) return RetiredAmount();
  if (person.IsChild()) return ChildAmount();

  return WorkerAmount();
}

Guard Clauses folgen immer diesem Konzept. Der Ausstieg ist die im Fehlerfall geworfene Exception. Da Guard Clauses einen Teil der Überprüfungen an den Anfang der Methode verschieben, reduzieren sie die Verschachtelung in der nachfolgenden Logik. Die flache Codelogik begünstigt Early Returns. Folgende Sortierung der Logik innerhalb der Methode verbessert die Lesbarkeit und Erweiterbarkeit:

  1. Überprüfungen
  2. Sonderfälle
  3. Standardlogik / Happy Path

Wer Methoden erweitert, ergänzt sie meist durch Sonderfälle. Durch das Trennen von der Standardlogik ist später klar erkennbar, an welcher Stelle der nächste Sonderfall seinen Platz finden kann.

Guard Clauses in ein Bestandsprojekt einzuführen, ist nicht trivial. Folgende Schritte erleichtern die Umsetzung und die Kommunikation innerhalb des Projekts:

Zunächst stellt man einen globalen Exception Handler bereit. In allen großen Frameworks ist einer vorhanden, sodass dabei nur noch die Konfiguration zu überprüfen ist. Wer selbst einen Handler implementiert, muss sicherstellen, dass die Anwendung alle gefangenen Exceptions loggt. Während des Entwicklungsprozesses muss die Anwendung die Exception anzeigen. In Produktion lässt sich eine Fehlerseite oder eine Umleitung auf die Startseite einrichten.

Anschließend folgen die Klassen für die einzelnen Guard-Clause-Methoden. Entwicklungsteams sollten mindestens drei Methoden vorab erstellen, die als Referenzen dienen. Folgende Methoden bieten sich für den Start an:

  • isNotNull
  • isNotEmpty / isNotBlank
  • isPositive / isPositiveOrZero

Alle weiteren Methoden erstellt das Team bei Bedarf, sodass die Klasse mit der Zeit organisch wächst.

Als Drittes stellt eine Person aus dem Team das Konzept und die vorbereitete Klasse anhand eines Beispiels vor. Dabei baut sie Guard Clauses in einige bestehende Methoden ein. Die Demonstration betont die Vorteile und gibt Hinweise auf die Logik. Die Guard-Clauses-Klasse sollte das Team bei der Vorstellung um mindestens eine Methode erweitern. Das hilft, die Regeln für die Erweiterung der Klasse zu definieren, und bietet Raum für Fragen. In den ersten Wochen nach der Einführung sollten alle in Code Reviews auf den Einsatz achten.

Als Viertes gilt es, Verständnis dafür zu schaffen, dass Guard Clauses kurzfristig die Stabilität eines Systems verringern. Denn sie decken zahlreiche Fehler auf, die vorher verschleiert blieben. Durch die Umstellung lehnt die Anwendung beispielsweise ungültige Werte ab und quittiert sie mit einer Fehlermeldung. Langfristig stabilisiert die Umstellung das System, weil sie eine schnelle Korrektur ermöglicht.

Guard Clauses bieten ein einfaches Konzept mit großem Potenzial. Es hilft dabei, Fehler aufzuspüren und zu beheben. Außerdem ist es eine Grundlage für weiterführende Konzepte. Dabei ist es leicht verständlich und einfach einzuführen.

Teams sollten sich von den anfänglich häufigeren Fehlermeldungen und Programmabbrüchen nicht abschrecken lassen. Sie sind ein Zeichen dafür, dass das Konzept funktioniert und Fehler aufspürt, die zuvor unentdeckt darauf warteten, im Produktivbetrieb größeren Schaden anzurichten.

Sergej Tihonov
ist selbstständiger Softwareentwickler. Seit über 10 Jahren widmet er sich dem Entwickeln von komplexen Softwaresystemen und Prozessautomatisierung. Seine Arbeitsschwerpunkte liegen im Bereich der Softwarearchitektur und der Projekte Stabilisierung.

(rme)