Sonntag, 23. März 2008

Nur keine Fehler


Als ich anfing die, Computerprogramme zu schreiben, wurde mir gesagt, daß mit das Wichtigste die Fehlerbehandlung sei. Wie genau eine richtige Fehlerbehandlung aussehen würde, erklärte mir aber niemand. Sollte das Programm in der Lage sein, bei einem Stromausfall das Kraftwerk wieder hochzufahren, bei einem Headcrash die Daten der Datenbank wiederherzustellen? Meine erste Idee war, daß man nur alle Eingaben hinreichend genau kontrollieren müßte, um eine zuverlässige Programmausführung zu erreichen.

Später lernte ich dann, wie man in C-Programmen massenweise Returncodes auswertet oder wie man Exceptionhandler benutzt. Etwas, das immer wieder behauptet wurde, war, daß es wichtig ist die Fehlerbehandlung von Anfang an in das Design einzuplanen, um sie richtig zu machen. Leider kenne ich nur ganz wenige Anwendungen mit einem durchgängigen Fehlerbehandlungskonzept. Die Idee von Java, die Entwickler immer wieder zur Fehlerbehandlung zu drängen, hat die Situation in meinen Augen noch verschlimmert.

Da gibt es über den gesamten Code verteilte Fehlerbehandlung, die im wesentlichen nichts tut, als Exceptions zu fangen und wieder zu werfen bis in irgendeinem Exceptionhändler nur noch eine ToDo Kommentar steht. Auch kenne ich eine Anwendung, die Datenbankfehler so gut kapselt, daß der Benutzer nichts davon merkt, wenn die Datenbank während der Bearbeitung verloren geht. Auswahlfelder werden aus irgendwelchen Caches gefüllt und beim Abspeichern landen die Daten im Nirwana. Technisch ist das eine schöne Lösung, aber wahrscheinlich nicht ganz im Sinne des Anwenders.

Typen von Fehlern

Um hier weiterzukommen, müssen wir zuerst die zwei Arten von Fehlern unterscheiden, wie bei der Programmausführung auftreten können. Zunächst gibt es die völlig unerwarteten Fehler. Hierbei kann es sich um Hardwareprobleme handeln oder Programmiererfehler. Java versucht das durch die Unterscheidung von Error und RuntimeException abzubilden. Leider ist derselbe Fehler in einer Situation erwartet und in einer anderen unerwartet.

Nach dem Auftreten eines unerwarteten Fehlers befindet sich das System in einem unerwarteten Zustand. Jetzt weiterzumachen ist gefährlich. Die Annahmen, die bei der Entwicklung gemacht wurden, gelten nicht mehr mit Sicherheit. Variablen können mit inkonsistenten Werten vorbesetzt sein, externe Systeme in einem Zwischenzustand, bei dem unser System die Fortsetzung nicht mehr kennt. Was bleibt ist, aufzuräumen und den Fehler möglichst detailliert zu melden.

Was also tun? Mein erster Ratschlag ist: möglichst wenig. Dies ist vielleicht ein wenig provokativ, aber eine schlechte Fehlerbehandlung zu bereinigen ist schwerer, als eine einfache zu erweitern. Die einfachste Annahme ist, daß keine Fehler zu erwarten sind. Dann brauche ich mich nur um die unerwarteten zu kümmern. Das bedeutet, daß ich nur außen um meine Anwendung einen Exceptionhandler brauche, der eben aufräumt und den Fehler protokolliert. Was "außen" heißt, kann variieren, je nachdem was die isolierbare Einheit ist. In einer einfachen Anwendung, wird es die gesamte Anwendung sein. Unter JEE kann es ein EJB sein, unter dotnet eine Applicationdomain.

Aufräumen

Wenn der Fehler bei einer Verbindung zu externen Ressourcen passiert, wird man zunächst versuchen, einen stabilen Zustand lokal herzustellen, indem man die Ressource schließt. Dies erfolgt durch try/finally oder in C# durch einen using-Block. Kommt es hierbei zu einem Folgefehler, so ist weitermachen nicht mehr sinnvoll und die Exception sollte bis zu dem äußeren Exceptionhandler durchgelassen werden.

Erweiterung der Fehlerbehandlung

Mit der Zeit wird man die Ursache und die Auswirkung für bestimmte Fehler verstehen lernen. Dann ist man in der Lage, für diese Fehler eine spezielle Behandlung einzubauen. Hierzu muß zuerst festgestellt werden, wo der Fehler abgefangen werden soll. Eine gute Anwendung hat nur wenige Stellen, wo eine Fehlerbehandlung geschieht. Außerdem darf nur genau dieser spezifische Fehler abgefangen werden. Wenn mehrere Fehlerbehandlungen verwendet werden, hat es sich bewährt, eine entsprechende Vererbungshierarchie von Exceptions aufzubauen.

Beispiel: Batchverarbeitung von Dateien

Ein System verarbeitet alle Dateien, die in einem Verzeichnis liegen. Die Dateien bestehen aus Datensätzen, die einzeln verarbeitet werden. Hier gibt es drei Ebenen der Verarbeitung: Datensatz, Datei und Gesamtverarbeitung. Bei jedem Fehler kann also unterschieden werden, ob der einzelne Datensatz fehlerhaft ist, die gesamte Datei oder ob die Verarbeitung wegen der Schwere des Problems eingestellt werden muß. Es werden also fachliche Exception für Datensatzprobleme und für Dateiprobleme gebraucht, wobei die ersteren von den zweiten erben können. Alle anderen Fehler führen zu einem Anwendungsabbruch.

Umwandlung von Exceptions

Häufig werden Exceptions generell abgefangen und in anwendungsspezifische Exception umgesetzt, die dann neu geworfen werden. Aus meiner Sicht macht das nur unter zwei Umständen Sinn. Der erste ist, wenn ich ein fachliches Problem anhand einer technischen Meldung feststelle. Wenn die Datenbank "Duplicate key" meldet, kann da unter Umständen fachlich behandelt werden. Der andere Grund kann sein, daß ich mich von einer herstellerspezifischen API unabhängig machen will. So abstrahiert das Spring-Framework Datenbankexceptions, so daß der Entwickler einer Datenzugriffsschicht sich nicht um das konkrete Produkt kümmern muß.

Ansonsten sollten Exceptions möglichst in dem Kontext behandelt werden, in dem sie auch verstanden werden. Will sagen, Oracle-Fehler sollten nur in der Datenbankschicht behandlet werden. Wenn dies nicht möglich ist, werden sie zur äußeren Fehlerbehandlungsroutine durchgereicht, die Fehler nicht mehr spezifisch behandelt. Die Datenbankzugriffsschicht kann dann unterschiedliche Methoden anbieten, die z. B. bei einem Fehler infolge bereits vorhandenem Schlüssel einmal einen (unerwarteten) Fehler meldet oder statt Insert transparent ein Update versucht.

Fehler frühzeitig erkennen

Es ist immer besser, wenn die Programmlogik ein Problem erkennt als wenn dies in der Runtime passiert. Wenn eine Nullpointer-Exception auftritt, so ist es recht mühselig, die Ursache zu analysieren. Grund kann sein, daß eine Eingabeschnittstelle einen erwarteten Wert nicht geliefert hat. Wenn dies direkt an der Schnittstelle überprüft wird, kann man gleich eine sinnvolle Fehlermeldung liefern. Ich bin noch nach zwanzig Jahren der Meinung, daß ein Programm wesentlich stabiler und auch einfacher wird, wenn man sich bei der Datenvalidierung mehr Mühe macht. Am besten wäre natürlich, wenn jede Routine ihre Vorbedingungen überprüfen würde, wie das die Sprache Eiffel nahelegt.




Sonntag, 9. März 2008


Haltet euch zurück beim Programmieren

In seinem Blog schreibt Cedric Beust gegen den, wie er es nennt, "Testextremismus". Ich muß soweit zustimmen, daß Extremismus in dem Sinn, daß eine Methodik als die allein seligmachende hingestellt wird, zu nichts Gutem führt. Aber wenn jemand aus dem agilen Lager der Softwareentwicklung das so hinstellt, hat er sicher etwas gründlich mißverstanden. Es ist einer der agilen Grundsätze, nie etwas zu machen, was nicht dem Ziel dient, ein gutes Produkt herzustellen. Nie, nie soll etwas nur um der Methodik wegen getan werden.

Für diejenigen, die den Namen Beust nicht kennen, es handelt sich um einen der Entwickler von TestNG, einer Alternative zu JUnit, die auch dazu beigetragen hat, daß die Entwicklung auch auf der Seite von JUnit wieder Fahrt aufgenommen hat.

Was Cedric Beust behauptet, ist unter anderem Folgendes:

  • Es ist egal, wann Test geschrieben werden, vorher oder nachher.

  • Nur funktionale Tests zählen.

  • Schreibe mehr Code, als für die Tests notwendig ist, wenn dir danach ist.

Auf die ersten beiden Punkte will ich nur kurz eingehen. Der letzte stört mich besonders, da ich zur Zeit in einem Projekt tätig bin, wo diese Arbeitsweise ihre furchtbaren Auswirkungen besonders deutlich zeigt.

Warum Test vorher geschrieben werden sollten

Eine einfache, aber leider zutreffende, Antwort ist, weil nachher meist keine Zeit mehr ist. Wenn sie nicht ein vertraglich festgelegter Lieferbestandteil sind, werden Tests gewöhnlich eingespart, um vermeindlich Aufwand zu reduzieren. Der Code der geliefert werden soll, ist doch schon da. Wenn er beim Abnahmetest akzeptiert wird, ist doch alles gut.

Wenn Tests nicht zuerst geschrieben werden, ist man nicht gezwungen, von Anfang an Testbarkeit beim Design zu berücksichtigen. Ein testbares System ist in der Regel weniger gekoppelt und modulare, also auch in anderer Beziehung besser aufgebaut. Umgekehrt wird es sehr viel aufwendiger, Tests für ein System zu schrieben, daß stark gekoppelt ist.

Der wichtigste Grund aber ist, daß das Schreiben von Tests den Entwickler zwingt, sich vor der Kodierung über die präzisen Anforderungen im klaren zu werden.

Im übrigen sind Modultests natürlich nur ein Mittel zum Zweck. Aber viele Dinge kann man realistisch nur auf dieser Ebene Testen. Wenn ich z.B. ein Interface zu einer von Amazon bereitgestellten API schreiben soll, so sollte das entstehende System sicher auch robust gegen Störungen auf der Seite von Amazon sein. Während des funktionalen Tests kann ich aber nicht mal kurz bei Amazon anrufen, ob sie bitte mal vorübergehend ihr System abstürzen lassen könnten.

Warum nur der notwendige Code geschrieben werden sollte

Ein Grundsatz aus ExtremeProgramming ist YAGNI (YouArentGonnaNeedIt). Das bedeutet: mach nicht mehr, als du tun mußt, um die Anforderung zu erfüllen, denn wenn du meinst, daß du dir in Zukunft Arbeit sparen kannst durch ein paar Zeilen Code mehr heute, bedenke: Erstens kommt es anders und zweitens als du denkst.

In der Regel ist meist nicht genug Zeit vorhanden, so ist es vernünftiger, ein paar zusätzliche Tests zu schreiben und den Code stabiler zu machen.

Es kommt aber noch viel, viel schlimmer. Wenn Code auf Vorrat geschrieben wird, so enthält das System diesen Code, der nie läuft, nie getestet wird. Es ist völlig unklar, in welchem Zustand er sich befindet. Aber bei jeder Codeänderung will er mit berücksichtigt werden. Es entstehen also zusätzliche Pflegekosten.

Man macht eine Abschätzung für eine neue Anforderung und denkt nach Betrachtung des vorhandenen Codes, daß nur ein kleiner Aufruf zu der sowieso vorhandenen Routine X notwendig sei. Dummerweise treten bei der Implementierung dann eigenartige Fehler auf. Erster Gedanke: Ich habe einen Fehler gemacht. Finde aber keinen in meinem Code. Vielleicht habe ich die Routine falsch aufgerufen. Ich suche nach Code, der die Routine benutzt, finde aber kein Beispiel. Okay, war noch nicht verwendet worden. Also versuche ich die fremde Routine zu verstehen und behebe mühsam die dort vorhanden Probleme. Wenn diese Routine nie existiert hätte, hätte ich mir Stunden Sucherei sparen können und direkt eine (vielleicht einfachere) Routine selbst geschrieben.

Häufig läßt sich eine Anforderung viel einfacher Einbauen, wenn man die Anwendung zuerst ein wenig umbaut (Refactoring). Der Aufwand hierfür ist in etwa proportional zu der Menge betroffenen Codes. Auch hier stört jeder auf Verdacht eingebaute Code.

Wider Codezombies

Mit jeder Zeile Code, die man ohne die Funktionalität zu beeinträchtigen entfernt, wird die Anwendung besser (pflegbar). Toter Code muß, sobald er als solcher erkannt wird, entfernt werden. Bei Code, der auf Verdacht eingefügt wurde, aber derzeit keine Funktion hat, handelt es sich um einen Codezombie. Meine Erfahrung lehrt, daß man gegen solche unangenehmen Geschöpfe immer Weihwasser und Holzpflock bereit haben sollte. Also auskommentieren, compilieren und Tests laufen lassen. Wenn kein Fehler auftritt, sofort löschen.

Es gibt Leute, die in der Tat etwas extrem sind. Diese messen die Testabdeckung und entfernen alles was nicht von Tests erreicht wird automatisch. Netter Gedanke, aber leider wohl selten durchsetzbar.

Sonntag, 2. März 2008

Sollten wir nicht etwas dynamischer sein?

Sogenannte "dynamische Sprache" sind das heiße Thema in diesen Tagen. Insbesondere Skriptsprachen wie Ruby und Python finden großes Interesse. Neuerdings lassen sie sich gut mit der Java-Welt kombinieren, wo es JRuby, Jython und speziell nur für die JVM entwickelt Groovy gibt. Ähnliches gilt für die dotnet-Welt, wo es z.B. IronPython gibt.

Die Ideen dieser Sprachen gehen auf Lisp, Smalltalk und teilweise auf funktionale Sprachen zurück (Lisp und Smalltalk haben funktionale Elemente). Somit handelt es sich hier nicht um etwas neues. Die erste Lisp-Spezifikation stammt aus meinem Geburtsjahr.

Der Begriff "dynamisch" ist nicht sonderlich scharf definiert. Eine der wichtigsten Eigenschaften dieser Sprachen ist, daß das Programm auch als Daten aufgefaßt wird, die zur Laufzeit erweitert oder geändert werden können. Daneben haben die meisten dynamischen Sprachen ein wesentlich einfacheres Typsystem.

Ich will hier heute auf einige Aspekte eingehen, die für oder auch gegen die Verwendung dieser Sprachen sprechen.

Performance

Die erste Frage, die immer von skeptischen Zeitgenossen kommt, wenn in der Softwareentwicklung ein neuer Trend aufkommt, ist die nach der Performance. Diese Frage läßt sich leicht stellen, ohne von der Sache sonderlich viel zu verstehen. Sie ist aber so absolut gestellt äußerst naiv. Nicht, daß Performance nie eine Rolle spielt und daß die Rechner bis zur Inbetriebnahme des Softwaresystems bereits so viel schneller sein würden, daß alle Performanceprobleme dadurch ausgeglichen würden. Aber es kommt darauf an, was man entwickeln will. Neulich las ich irgendwo, Ruby wäre 60 mal langsamer als Java. Aber bei welchem Problem? Niemand wird wohl ernsthaft auf die Idee kommen, ein Fast Fourier Transformation in Ruby zu implementieren. Für große numerische Probleme ist wohl immer noch die in Fortran geschriebene CERN-Bibliothek die beste Lösung.

Häufig sind die Zeiten, die im Programm verbracht werden, vernachläßigbar gegenüber Zeiten, die für Datenbankzugriffe, Netzwerkoperationen etc. gebraucht werden. Dann ist auch mit einer weniger effizienten Sprache ein genauso zufriedenstellendes Antwortzeitverhalten hinzubekommen. Allerdings kommt ein Aspekt hinzu, wenn sehr viele parallele Zugriffe erfolgen. Wenn die verwendete Sprache mehr CPU-Zyklen für dieselbe Aufgabe braucht, kann ich weniger Anfragen auf einer Maschine gleichzeitig verarbeiten. Ich muß dann mehr Hardware planen. Die zusätzlichen Hardwarekosten sollten aber kaum eine Rolle spielen. Eher ist schon die benötigte Energie ein Kostenfaktor.

Im übrigen wird der Support von dynamischen Sprachen durch die JVM bzw. CLR zunehmend besser. Dadurch werden die Sprachen auch in diesen Umgebungen immer effizienter.

Typsicherheit

Ein gern gebrachtes Argument von Javafans. Durch die Verwendung einer stark typisierten Sprache werden angeblich viele Fehler bereits vom Compiler gefunden. Leider sieht die Wirklichkeit nicht ganz so rosig aus. Wie häufig werden Downcasts durchgeführt, die vom Compiler nicht überprüft werden und zur Laufzeit dann zu einer InvalidCastException führen. Durch die konsequente Verwendung von Generics kann man das Problem zwar deutlich entschärfen, ganz lösen wird man es jedoch nicht. Allerdings bezahlen wir für diese löchrige Sicherheit noch einen ziemlich hohen Preis, der sich in Entwurfsmustern zeigt, die nur dazu da sind, die fehlende Flexibilität wiederherzustellen. Genaueres zu dieser Problematik ein andermal.

Meiner Erfahrung nach ist ein größeres Fehlerpotential in Ruby oder Python der Umstand, daß Variablen nicht explizit deklariert werden, sondern bei der ersten Verwendung entstehen. So führen Tippfehler zu hartneckigen Fehlern.

Refactoring

Ein neuerdings häufig aufgebrachtes Argument ist das Fehlen guter Refactoring-Werkzeuge für dynamische Sprachen. Dabei wird natürlich immer vergessen, daß das erste derartige Werkzeug der Refactoring Browser für Smalltalk war. Smalltalk ist nun definitiv nicht typisiert. Bei einigen Refactorings erscheint der notwendige Aufwand höher für eine nichttypisierte Sprache. Andere sollten aber unproblematisch oder sogar einfacher sein. "Extract Method" dürfte genauso funktionieren. Umbenennen einer Klasse auch, wobei wesentlich weniger Referenzen zu ändern sind, da die Instanzen nicht explizit mit Typ deklariert werden müssen. Interessanter ist das Umbenennen einer Methode. Da in einer nichttypisierten Sprache Methoden mit einem bestimmten Namen immer akzeptiert werden, egal von welchem Typ der Aufrufer ist, sollten wohl vernünftigerweise auch alle gleichnamigen Methoden in anderen Klassen geändert werden, wenn das Programm nachher noch genauso funktionieren soll wie vorher.

Es ist also damit zu rechnen, daß die Unterstützung durch Refactoring-Werkzeuge in der Zukunft für dynamische Sprachen genauso gut sein wird wie derzeit für Java. Ansätze finden sich z.B. schon in IntelliJs Unterstützung von Ruby for Rails.

Was die Sicherheit betrifft, gibt es kein 100% sicheres Refactoring. Auch im Java-Fall kann man über in Konfigurationsdateien verborgenen Referenzen stolpern oder die Nutzung von Reflection.

Reflection

Manchmal trifft man auf Code, der in Java oder in C# geschrieben ist und der einen extensiven Gebrauch von Reflection macht. Meist geschieht das, um dynamische Eigenschaften zu emulieren. Dabei gehen in der Regel alle Vorteile von typisierten Sprachen endgültig verloren. Mit Reflection kann ich auch mit Java/C# Ducktyping realisieren, d.h. auf Verdacht eine Methode auf einem mir unbekannten Objekt aufrufen und wenn kein Fehler auftritt davon ausgehen, daß es wohl den erwarteten Typ besitzt.

Derartige Programme sind sicher nicht effizienter als äquivalente in dynamischen Sprachen geschriebenen. Hier werden die entsprechenden Funktionen ad hoc neu erfunden, während sie in dafür gemachten Sprachen optimiert sind. Der wichtigste Punkt aber ist, daß derartige Programme meist extrem schlecht lesbar und damit wartbar sind.

Wenn man meint, eine solche Flexibilität zu benötigen, so sollte man doch ernsthaft darüber nachdenken, gleich das dafür gemachte Werkzeug zu verwenden.

Leute

Das größte Problem dürfte derzeit sein, geeignete Entwickler zu finden, die an das hinter den dynamischen Sprachen stehende gedankliche Modell gewohnt sind. Gerade bei kleineren Teams ist es sicher auch schwierig, verschiedene Sprachen zu integrieren, da dann das Wissen über einzelne Elemente nur bei sehr wenigen Mitarbeitern liegt.