Strategien und Techniken zur Fehlerprävention

Seite 2: Lesbarkeit, Typsystem, Nullreferenzen

Inhaltsverzeichnis

Was können qualitätsbewusste Entwickler also tun, um von vornherein die Code-Komplexität zu reduzieren? Hierzu können sie sich die Frage stellen, womit sie beim Implementieren die meiste Zeit verbringen. Im Normalfall dürfte das das Lesen (und nicht etwa das Schreiben) von Quelltext sein. Das bedeutet, dass Entwickler ihre kognitive Belastung am besten entlasten können, wenn sie Code so schreiben, dass er möglichst leicht lesbar ist (für ihn selbst und für die Kollegen im Team). Diese Erkenntnis ist Kernthese der "Clean Code"-Bewegung. Tatsächlich sind alle (teils provokanten) Thesen aus Robert C. Martins Buch zu Clean Code [4] darauf ausgerichtet, die Lesbarkeit und Verständlichkeit von Quelltext zu erhöhen.

Es ist alles andere als neu, dass kurze Klassen und Methoden besser verständlich und deswegen weniger fehleranfällig sind als lange oder tief verschachtelte. Hierfür gibt es klassische Metriken wie McCabes zyklomatische Komplexität, die sich auch im Rahmen einer statischen Codeanalyse automatisch prüfen lässt. Aber dann ist es bereits wieder zu spät für die Vorsorge. Es ist auch nicht neu, dass sprechende Benennungen helfen, Code verständlicher zu machen. Die Innovation bei Clean Code liegt eher in der Forderung nach kompromissloser Entwicklerdisziplin, den eigenen Code sauber zu halten.

Ein einfacher Selbstversuch mit Clean Code kann eindrücklich aufzeigen, wie sehr es entlastet, an lesbarem Code zu arbeiten. Er verdeutlicht, dass es tatsächlich viel Disziplin erfordert, Code von vornherein lesbar zu gestalten. Und er zeigt, dass sich die Mühe auszahlt, die man beispielsweise in die möglichst passende Benennung seiner Klassen und Methoden steckt – nicht zuletzt stellt man fest, dass die Wahl sprechender Namen direkt das gesamte Systemdesign verbessern kann: Wenn Entwickler eine Klasse einführen möchten, aber keinen guten Namen für sie finden, ist das im Normalfall kein Zeichen für fehlende Kreativität, sondern eines dafür, dass am Domänenmodell etwas noch nicht ganz stimmt.

Mit der Philosophie von "Clean Code" wurde also ein wichtiger Baustein für Fehlerprävention identifiziert. Die Lesbarkeit oder äußere Form von Quelltext ist aber noch lange nicht alles, was zu dessen Komplexität beiträgt. Mindestens ebenso wichtig ist, auf welche Art und Weise der Code das tut, was er tun soll.

Ein oft unterschätztes Hilfsmittel ist das Typsystem: Es ist ja gerade Aufgabe eines statischen Typsystems, Fehler bereits für den Compiler auffindbar zu machen, die sonst erst zur Laufzeit auffallen würden, sobald man den fehlerhaften Programmzweig durchläuft. In diesem Sinne ist das Typsystem einer Programmiersprache ihr wichtigstes Bordmittel zur Fehlervermeidung.

Leider hat das seinen Preis: Es zwingt einen dazu, an vielen Stellen die Typen explizit hinzuschreiben, was unbequem sein und auch Boilerplate-Code erzeugen kann (also Code, der nur für den Compiler zu schreiben ist, für den Entwickler aber überflüssigen Ballast darstellt). Das lässt sich zwar je nach Programmiersprache mehr oder weniger gut mit Typinferenz abmildern, aber im Normalfall nicht ganz verhindern. Damit es sich im Sinn einer Effizienzsteigerung beim Entwickeln lohnt, ein Typsystem einzusetzen, muss es genügend mächtig sein, dass es nicht nur triviale Fehler findet. Es muss einen in die Lage versetzen, signifikant Anwendungslogik mit domänenspezifischen Typen zu unterstützen, und zwar so, dass tatsächlich unzulässige Zustände im zu lösenden Problemraum zu Compiler-Fehlern führen.

Ein einfaches Beispiel für Situationen, in denen es sich fast immer lohnt, auf das Typsystem zurückzugreifen, sind Sonderfallbehandlungen. Und der wohl häufigste anzutreffende in der Entwicklung dürfte der null-Sonderfall sein, von dem Tony Hoare, der "Erfinder" der Nullreferenz, inzwischen selbst sagt, es sei sein "billion-dollar mistake" gewesen.

Es dürfte heute wohl kaum einen Entwickler geben, der sich nicht schon mit Nullreferenzen und den zugehörigen Laufzeitfehlern herumgeschlagen hat, ob sie nun NullPointerException heißen oder NullReferenceException. Das Grundproblem ist, dass Datentypen in den gängigen Programmiersprachen so gut wie immer zusätzlich zu ihrem "normalen" Wertebereich noch einen Nullwert zur Verfügung stellen. Bei Verwendung also von Variablen solcher Datentypen muss sich der Entwickler grundsätzlich immer darum kümmern, beide Fälle zu behandeln.

Bei einem Newsletter als Beispiel, der an alle Personen geschickt werden soll, die über ein Webformular neben ihrem Namen ihre E-Mail-Adresse angegeben haben, sieht die Datenstruktur (etwa in Scala) naiv wie im folgenden Listing aus, und das dazugehörige Versenden des Newsletters erfordert eine Abfrage auf null.

case class Person(name: String, email: String)

trait Newsletter {
val persons: List[Person]

def sendNewsletterTo(email: String): Unit

def sendNewsletter: Unit = {
for (person <- persons)
if (person.email != null)
sendNewsletterTo(person.email)
}
}

object Main extends App {
val newsletter = new Newsletter {
val persons = List(
Person("Bill", "bill@somewhere.de"),
Person("Bob", null))

def sendNewsletterTo(email: String) =
println(email)
}

newsletter.sendNewsletter
}

Das Beispiel ist noch einfach genug, dass es keine Probleme bereitet, an die Abfrage auf null zu denken. In komplexen Programmen entstehen aber häufig Fehler genau deshalb, weil man so eine Abfrage vergessen hat. Deswegen gehen viele Entwickler dazu über, in ihren Programmen überall sicherheitshalber Nullprüfungen vorzunehmen, ob sie notwendig sind oder nicht. Dieser defensive Programmierstil verhindert zwar dann genau diese Fehlerquelle, allerdings werden dadurch die Programme deutlich unübersichtlicher und unlesbarer als nötig, was wiederum eine eigene Fehlerquelle ist.

Die Alternative ist hier, die Option der E-Mail-Adresse mit dem Typsystem zu adressieren. Dafür gibt es ein allgemeines Rezept in Form des Option-Datentyps. So ein Summentyp kann entweder leer sein (hat also den Untertyp None) oder enthält wie im folgenden Beispiel einen String (also den Untertyp Some[String]).

case class Person(name: String, email: Option[String])

trait Newsletter {
val persons: List[Person]

def sendNewsletterTo(email: String): Unit

def sendNewsletter: Unit = {
for (person <- persons)
person.email foreach sendNewsletterTo
}
}

object Main extends App {
val newsletter = new Newsletter {
val persons = List(
Person("Bill", Some("bill@somewhere.de")),
Person("Bob", None))

def sendNewsletterTo(email: String) =
println(email)
}

newsletter.sendNewsletter
}

Der Trick ist hier, dass die Option den Entwickler zwingt, an die Sonderbehandlung für den Fall einer nicht vorhandenen E-Mail-Adresse zu denken.

Wer Entwickler allerdings zu etwas zwingt, sollte tunlichst dafür sorgen, dass es trotzdem bequem bleibt, den None-Sonderfall zu behandeln. Und das ist es glücklicherweise in Sprachen wie Scala und F#, die einen Option-Datentyp enthalten, der sich dank Funktionen höherer Ordnung (wie map und flat-Map) angenehm in seine Umgebung integriert. In Java war das leider lange nicht der Fall (zumindest vor Java 8), was wohl jeder schon erlebt hat, der beispielsweise mit Optional aus der Guava-Bibliothek arbeitet.

Aber selbst in Java gab es schon frühereinen Ausweg, den Spezialfall mit dem Typsystem abzufangen, ohne sich zu verrenken. Ganz klassisch objektorientiert können Entwickler die Personen per Subclassing in Personen mit und ohne E-Mail-Adresse unterteilen. Das Versenden der Newsletter-E-Mail erledigt dann die jeweilige Unterklasse via Dynamic Dispatch (s. folgendes Listing).

sealed trait Person {
def name: String
def sendNewsletter(newsletter: Newsletter): Unit
}

case class PersonWithEmail(name: String, email: String)
extends Person {

def sendNewsletter(newsletter: Newsletter) =
newsletter.sendNewsletterTo(email)
}

case class PersonWithoutEmail(name: String)
extends Person {

def sendNewsletter(newsletter: Newsletter) = {
/* nothing to do */
}
}

trait Newsletter {
val persons: List[Person]

def sendNewsletterTo(email: String): Unit

def sendNewsletter: Unit =
for (person <- persons)
person.sendNewsletter(this)
}

object Main extends App {
val newsletter = new Newsletter {
val persons = List(
PersonWithEmail("Bill", "bill@somewhere.de"),
PersonWithoutEmail("Bob"))
def sendNewsletterTo(email: String) =
println(email)
}

newsletter.sendNewsletter
}

Dadurch lassen sich nicht nur Nullprüfungen mit dem Typsystem abfangen, sondern auch viele andere Sonderfälle. Das ist für die meisten nichts Neues. In der Praxis ist allerdings leider häufig zu beobachten, dass stattdessen if-Abfragen verwendet werden, die zu später entdeckten Laufzeitfehlern und aufwendiger Fehleranalyse führen, wo schon der Compiler einen Fehler hätte melden können.