Fehlerfreie Software? Ein Wunschtraum! Automatisierte Tests sind Standard und dennoch gibt es in der Praxis beim Thema Tests noch einiges aufzuholen. Ist deshalb die „Defensive Programmierung“ ein zusätzlicher Baustein, den es zu befolgen gilt? Ja und Nein. Wenn wir in unseren Trainings hinterfragen, ob die Parameterprüfung am Beginn jeder Methode wirklich sinnvoll ist, gibt es meist lebhafte Diskussionen zur Fehlerbehandlung. Weil überall etwas schief gehen kann, muss es doch sinnvoll sein, überall vorsichtig zu sein. Nein! Es braucht eine differenzierte Betrachtung bei der Fehlerbehandlung.
Differenzierung der Fehlerbehandlung
Um zu mehr Klarheit zu gelangen, sollten wir zunächst versuchen, Fehler in unterschiedliche Kategorien einzuteilen. So wird das Problem in kleinere Probleme zerlegt und wir können darüber nachdenken, welche Form von Fehlerbehandlung in den einzelnen Kategorien Sinn ergibt. Wir identifizieren folgende Kategorien:
- Fehlbedienung durch den Anwender (Bspw. falsche oder fehlende Eingaben)
- Probleme in der Umgebung (Netzwerkprobleme, Timeouts, Datei nicht lesbar, etc.)
- Entwicklerfehler
Sehr wesentlich ist die Abgrenzung der Entwicklerfehler von den anderen beiden Kategorien. Fehler, die wir als Entwickler begehen, äußern sich bspw. in Null Pointer Exceptions, Index Out Of Bounds, etc. Für Sprachen, die auf einer Managed Plattform ausgeführt werden, also bspw. C#, Java, Python, gilt, dass hier die Runtime Entwicklerfehler abfängt und in Form einer Exception meldet. Als Entwickler muss ich nicht mehr zur Laufzeit vor jeder Anweisung prüfen, ob ich auf die verwendete Variable überhaupt zugreifen kann. Der Versuch, auf eine ungültige Speicheradresse zuzugreifen, wird durch die Runtime verhindert, die Ausführung führt nicht mehr zur Katastrophe (erinnerst du dich an MS-DOS?). Somit ergibt es überhaupt keinen Sinn, ständig vor einem Zugriff zu prüfen, ob eine Referenz möglicherweise auf nichts verweist (Null). Desgleichen gilt für Indizes. Beim indizierten Zugriff auf ein Array oder eine Liste kann rein technisch nicht auf ungültige Speicherbereiche zugegriffen werden, da die Runtime vorher mit einer IndexOutOfBounds Exception reagiert. Bitte beachte, dass ich hier gerade nicht von unmanaged Code oder embedded Umgebungen spreche, da mag die Situation anders aussehen.
Wenn also moderne Laufzeitumgebungen verhindern, dass etwas „Schlimmes“ passiert, beim Zugriff auf ungültige Speicheradressen, ist eine reflexartige Parameterprüfung im Sinne der defensiven Programmierung heute nicht mehr notwendig. Es kommt zum Abbruch der Anwendung. Ich sehe im übrigen auch quasi ausschließlich Null Prüfungen. Die Gültigkeit eines Array Index wird eher nicht überprüft. Auch das ist ein Hinweis darauf, dass der Umgang mit Fehlern eher unbewusst und unreflektiert erfolgt. Betrachten wir nun die drei Kategorien von Fehlern etwas genauer.
Fehlbedienung
Wenn Anwender Software verwenden, kommt es dabei zu Bedienfehlern. Fehlerhafte oder fehlende Eingaben sind Beispiele. Als Entwickler müssen wir davon ausgehen, dass Bedienfehler passieren werden und diese nicht überraschenderweise plötzlich auftreten. Entwicklerfehler treten überraschend auf, Bedienfehler sind im Entwurf mit zu bedenken. Es gehört in aller Regel zu den Anforderungen, dass eine Anwendung dem Anwender die Möglichkeit gibt, Fehleingaben zu korrigieren. Folglich müssen wir bei der Planung einer Lösung immer auch den Fehlbedienungsfall mit einplanen. Dies ist insbesondere erforderlich, um einen passenden Ort im Code zu finden, an dem die Fehlermeldung an den Benutzer ausgegeben wird. Hier droht schnell eine Verletzung des Single Responsibility Principle (SRP).
Soll bspw. eine Funktion aus den Kommandozeilenparametern einen Wert extrahieren, müssen wir davon ausgehen, dass es dabei zu Bedienfehlern kommen wird. Es könnte sein, dass im Array nicht ausreichend Werte vorhanden sind oder diese Strings im falschen Format vorliegen. In dem Fall können wir nicht einfach Null zurückgeben oder einen anderen magischen Wert und hoffen, dass es irgendwie gut gehen wird. Nein, eine solche Funktion hat zwei mögliche Resultate: entweder sie liefert den erwarteten Wert aus den Kommandozeilenparametern oder sie signalisiert, dass dies nicht möglich ist. Als Verwender der Funktion möchte ich der Signatur gerne ansehen, dass ich zwei Fälle berücksichtigen muss. Bei folgender Funktion ist das nicht der Fall:
string GetFilename(string[] args) { … }
Die Funktion liefert einen String als Rückgabewert. Woher soll ich als Aufrufer der Funktion erkennen, dass ich zwei Fälle berücksichtigen muss? Welcher spezielle Wert wird ggf. geliefert anstelle des Dateinamens? Und bitte vergesse Null ganz schnell wieder. In allen Sprachen werden gerade große Anstrengungen unternommen, die Verwendung von Null zu reduzieren. Tony Hoare hat Null Referenzen 1965 in ALGOL eingeführt und bezeichnet dies selbst als seinen „billion-dollar mistake“.
Merke: keiner Funktion Null übergeben, kein Null zurückliefen. Statt Null Fehler abzufangen, besser das Wurzelproblem beheben.
Behandlung von Fehlern
Bessere Varianten für Funktionen, die nur manchmal einen Wert liefern, sind in den folgenden Codeschnipseln zu sehen:
Option GetFilename(string[] args) { … }
Hier erhält der Aufrufer optional einen String, ausgedrückt durch den generischen Typ Option<T>. In vielen Sprachen gehört ein Option Typ inzwischen zum Standard. Für .NET gibt es zahlreiche NuGet Pakete für einen solchen Typ. Weitere Details dazu im Beitrag Optional und das IOSP.
(bool, string) GetFilename(string[] args) { … }
Das zweite Beispiel zeigt, wie man die Information darüber, ob ein Dateiname entnommen werden konnte, durch ein Tupel ausdrücken kann. Der Aufrufer erhält hier neben dem Dateinamen einen booleschen Wert, der mit false ausdrückt, dass kein Dateiname entnommen werden konnte.
bool TryGetFilename(string[] args, out string filename) { … }
Der dritte Fall entspricht dem Muster, das Microsoft beim Parsen von Strings in diverse Typen verwendet (bspw. int.TryParse). Die Funktion TryGetFilename liefert true zurück, wenn ein Dateiname aus dem Parameterarray entnommen werden konnte. Der Dateiname wird dann im out Parameter filename abgelegt. Kann kein Dateiname entnommen werden, liefert die Funktion false und der Parameter filename ist inhaltlich nicht weiter definiert. Aufgrund der C# Syntax ist der Wert natürlich von der Funktion mit einem Wert initialisiert worden. Dieser hat aber inhaltlich keine Bedeutung für den Aufrufer, weil ihm durch den Rückgabewert false signalisiert wurde, dass die Entnahme des Dateinamens nicht erfolgen konnte. Auch hier gilt: vergesse Null! Das TryGet… Muster setzt übrigens Call By Reference voraus und scheidet somit bspw. in Java aus.
void GetFilename(string[] args, Action onFilename, Action onNoFilename) { … }
Die vierte Variante arbeitet mit zwei Callbacks. Wenn ein Dateiname aus dem Array entnommen werden kann, wird das erste Callback aufgerufen, mit dem Dateinamen als Parameter. Kann kein Dateiname entnommen werden, wird das zweite Callback ohne Parameter aufgerufen. Auch bei dieser Variante kann der Aufrufer klar erkennen, dass er zwei Fälle berücksichtigen muss.
Für welche Variante man sich als Entwickler entscheidet, muss im Einzelfall im Team diskutiert werden. Jede einzelne hat ihre Vor- und Nachteile.
Eine Bemerkung noch zu Fehlermeldungen: diese sollten keinesfalls wild verteilt über die Anwendung definiert werden. Fehlermeldungen sind Bestandteil der Benutzerschnittstelle und gehören daher genau dorthin. Wenn ein Fehler aufgetreten ist, muss ggf. ein Error Code an die Ui geliefert und dort als Fehlermeldung angezeigt werden. Die Übersetzung eines Error Codes in eine Error Message ebnet gleich auch den Weg zur landessprachlichen Übersetzung einer Anwendung.
Probleme in der Umgebung
Eine weitere Kategorie von erwartbaren Fehlern stellen Probleme in der Umgebung des Softwaresystems dar. Beim Zugriff auf eine Datei wird irgendwann der Moment kommen, wo aus der Datei nicht gelesen werden kann. Sei es, dass sie nicht existiert oder die Berechtigungen des Anwenders nicht ausreichen. Das gleiche gilt für andere Ressourcen. Die Datenbank ist irgendwann nicht erreichbar, der WebService antwortet nicht, etc. Weil das so ist, muss auch diese Kategorie von Fehlern im Vorfeld geplant werden. Es genügt nicht, sich im Code irgendwie durchzuwurschteln, sondern in einem Entwurf muss dargestellt werden, welche Fälle auftreten können und wie mit ihnen umgegangen wird.
Auch hier führen einfache Signaturen nicht weiter. Nehmen wir den einfachen Fall eines Dateizugriffs:
string ReadFile(string filename) { … }
Auch hier ist wieder nicht klar, was passiert, wenn die Datei nicht gelesen werden kann. Rein technisch tritt eine Exception auf. Diese sollte innerhalb der ReadFile Methode abgefangen werden, um dann durch eine geeignete Signatur auszudrücken, dass das Lesen erfolgreich war oder eben nicht. Beispiele für Signaturen sind oben zu finden. Die vier oben dargestellten Varianten eigenen sich auch für diese Kategorie von Fehlern, denn es geht wieder darum auszudrücken, dass nur optional ein Wert geliefert wird.
In manchen Fällen ist es sinnvoll, ein Problem in der Umgebung mit einem eigenen Exception Typ zu signalisieren. Muss ein tiefer Callstack abgebaut werden, weil diverse Methoden tief verschachtelt aufgerufen wurden, kommt man am einfachsten durch das Auslösen einer Ausnahme wieder nach oben. Genau für solche Fälle sind Exceptions und der try / catch Mechanismus in die Programmiersprachen aufgenommen worden. Ferner kann das Exception-Objekt zusätzliche Informationen transportieren. Es gilt hier also wieder zu differenzieren. In einer tiefen Aufrufhierarchie ergibt die Verwendung von try-catch auf der obersten Ebene Sinn. Bei einer flachen Struktur kann auch mit Rückgabewerten gearbeitet werden, da diese dann nur an wenigen Stellen überprüft werden müssen. Ferner kommt es ein wenig auf die verwendete Programmiersprache an. In C# ist es eher unüblich, Exceptions als normalen Kontrollfluss zu verwenden. In Python dagegen ist es eher üblich.
Entwicklerfehler – Error
Zum Umgang mit Entwicklerfehlern empfehle ich, diese über eine Exception in einen globalen Exceptionhandler laufen zu lassen. Exceptions können dabei implizit und explizit auftreten. Implizit dadurch, dass die Runtime ein Problem erkennt und mit einer Exception quittiert, bspw. bei einem Null Zugriff. Das Auftreten eines Laufzeitfehlers wird implizit durch eine Exception signalisiert. Explizit signalisieren wir Fehler dadurch, dass wir als Entwickler einen Programmierfehler erkennen und diesen durch das Auslösen einer Exception explizit mitteilen. Dies kann bspw. eine Parameterprüfung an einer öffentlichen Methode sein. Eine Fehlerbehandlungsroutine ergibt hier meist keinen Sinn. Der Programmablauf ist unterbrochen weil wir als Programmierer einen Fehler begangen haben.
Da bei einem Entwicklerfehler in der Regel nichts anderes getan werden kann, als das Programm zu beenden oder automatisch wiederanlaufen zu lassen, genügt in der Regel das Abfangen aller Exceptions im globalen Handler. Es hilft hier wenig, immer wieder Codebereiche in einen try-Block zu verpacken.
Eine Ausdifferenzierung mag an Modulgrenzen sinnvoll sein. Doch es ist wenig hilfreich, an allen möglichen Stellen Aufrufe in try/catch Statements einzuwickeln, wenn am Ende doch nur eine Logmeldung geschrieben werden kann.
Fazit
Im Vordergrund steht beim Thema Fehlerbehandlung der bewusste Umgang mit den drei Kategorien von Fehlern (Bedienfehler, Probleme in der Umgebung, Entwicklerfehler). Bereits diese Differenzierung führt zu besseren Entscheidungen im Sinne der Werte Wandelbarkeit und Korrektheit. Es erfordert Kommunikation im Team, sich auf eine gemeinsame Linie zu verständigen. Denn auch hier geht es nach der Differenzierung in die drei Kategorien um das Einhalten von Konventionen, die sich das Team gibt. Die wichtigste Empfehlung lautet: entwerfe eine Lösung für die beiden Kategorien Bedienfehler und Probleme in der Umgebung. Diese beiden Themen lassen sich nicht einfach so auf der Ebene von Code lösen, sondern wollen vorher durchdacht werden. Und selbstredend helfen automatisierte Tests dabei, die Zeit im Debugger zu reduzieren um herauszufinden, warum eine Exception ausgelöst wurde.
Clean Code Trainings
Geschlossene Firmenkurse
Wir führen alle Seminare als geschlossene Firmenkurse für Sie durch.
Bei Interesse oder Fragen kontaktieren Sie uns gerne.