Fehlersuche: Guard Clauses – den Code auf Fehler vorbereiten

Seite 2: Definition einer Strategie

Inhaltsverzeichnis

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.