Strategien und Techniken zur Fehlerprävention

Seite 3: Zweierlei Komplexität

Inhaltsverzeichnis

Eine andere wesentliche Erscheinungsform überflüssiger Komplexität – bei der der Compiler allerdings nichts hilft – ist Komplexität durch Redundanz. Es gibt viele Arten von Redundanz, aber die meisten entstehen beim Programmieren durch Copy & Paste. Code wird kopiert, weil es bequem ist und schnell geht. Was allerdings oft vernachlässigt wird, ist, wie sich das auf die Wartbarkeit auswirkt. Eine eigentlich einfache Änderung kann bei zu viel kopiertem Quelltext zu enormem Aufwand führen: Plötzlich ist die Änderung an zig Stellen einzupflegen, und Entwickler wissen hinterher nicht einmal mit Sicherheit, ob er an alle solchen Stellen gedacht hat. Und genau auf diese Weise entsteht aus Redundanz dann Komplexität – wieder muss man beim Entwickeln Seiteneffekte im Blick behalten ("Wo muss ich das noch überall ändern?"), was das Arbeitsgedächtnis unnötig belastet.

Die Regel, Code nicht zu kopieren und stattdessen zu abstrahieren, sobald er an zwei oder mehr Stellen benötigt wird, hört sich allerdings leider einfacher einhaltbar an, als sie es in der Praxis zu sein scheint. Oft unterscheiden sich Codestellen in ein paar kleinen Details, sodass man nicht mehr einfach nur eine Unterfunktion ausgliedern kann. Im nächsten Codebeispiel sehen die Leser als (etwas konstruiertes) Beispiel die Ergebnisverarbeitung zweier solchermaßen "ähnlicher" Datenbankaufrufe.

def getNumPersons(query: PreparedStatement): Long = {
val results = query.executeQuery
try {
results.next
return results.getLong("numPersons")
} finally {
results.close
}
}

def getNumPersonsNamed(
name: String, query: PreparedStatement): Int = {
query.setString(1, name)
val results = query.executeQuery
try {
results.next
return results.getInt("numNamedPersons")
} finally {
results.close
}
}

Das sieht nach viel dupliziertem Code aus, aber was tun? Im hektischen Arbeitsalltag erlebt man oft, dass an dieser Stelle aufgegeben wird: zu kompliziert, zu unbequem. Je nach Programmierparadigma gibt es aber zwei einfache Lösungsansätze: das Template Method Pattern (objektorientiert) und Higher-Order Functions (funktional). Der erste Ansatz erfordert eine abstrakte Basisklasse, in der der kopierte Code als Schablone angelegt ist. Die variierenden Codestellen bleiben abstrakt:

abstract class NumPersons[T] {
final def get(query: PreparedStatement): T = {
initializeQueryParameters
val results = query.executeQuery
try {
results.next
return valueOf(results)
} finally {
results.close
}
}


// abstract
def initQueryParams(query: PreparedStatement): Unit

// abstract
def valueOf(results: ResultSet): T
}

class NumAllPersons extends NumPersons[Long] {
def initQueryParams(query: PreparedStatement) = {
/* nothing to do */
}
def valueOf(results: ResultSet): Long =
results.getLong("numPersons")
}

class NumNamedPersons(name: String)
extends NumPersons[Int] {

def initQueryParams(query: PreparedStatement) =
query.setString(1, name)
def valueOf(results: ResultSet): Int =
results.getInt("numNamedPersons")
}

"Higher-Order Functions" erfordern statt der Vererbungshierarchie, dass die aufgerufene "kopierte" Funktion die variierenden Stellen als Funktionen mit hineingereicht bekommt, die sie dann passend aufruft:

def getNumAllPersons(query: PreparedStatement): Long =
getNumPersons(query,
_ => (),
_.getLong("numPersons"))

def getNumPersonsNamed(
name: String, query: PreparedStatement): Int =
getNumPersons(query,
_.setString(1, name),
_.getInt("numNamedPersons"))

def getNumPersons[T](query: PreparedStatement,
initQueryParams: PreparedStatement => Unit,
valueOf: ResultSet => T): T = {
initQueryParams(query)
try {
results.next
return valueOf(results)
} finally {
results.close
}
}

Der Ansatz funktioniert nur gut, wenn die Sprache Lambda-Ausdrücke gut unterstützt, was zum Glück bei den meisten modernen Sprachen der Fall ist. Aber egal wie man es genau macht, in jedem Fall ist es alles andere als ein Ding der Unmöglichkeit, die Quelltext-Kopiererei zu vermeiden.

Zu guter Letzt noch ein weiterer Aspekt, der starken Einfluss auf die Komplexität beim Entwickeln hat: der veränderliche Zustand. Das zustandsorientierte Programmieren stammt aus einer Zeit, in der es absolut notwendig war, in einzelnen Speicherzellen zu denken und diese direkt zu ändern. Das hat sich inzwischen allerdings stark gewandelt. In der Regel ist Speicherplatz inzwischen nicht mehr das Hauptproblem. An seine Stelle treten langsam, aber sicher Themen wie die Parallelisierung. Die Sicht auf das Programmieren ist aber immer noch sehr bestimmt von Variablen, die ständig zu ändern sind.

Veränderlicher Zustand erzeugt aber Komplexität. Dem menschlichen Gehirn fällt es im Allgemeinen schwer, von außen nachzuvollziehen, in welchem Zustand sich ein System befindet. Das merkt man insbesondere dann, wenn man Objekte mit komplexem inneren Zustand systematisch testen möchte: Ein paar einfache Beispiele und Spezialfälle reichen plötzlich als Testfälle bei weitem nicht mehr aus. Stattdessen ist aufwendig der gesamte Zustandsgraph des Objekts abzudecken. Viel einfacher dagegen ist das Testen von Funktionen, die keine Seiteneffekte haben: Sie liefern für gleiche Eingabe- immer gleiche Ausgabewerte. So fällt auch das Nachvollziehen solcher Funktionen deutlich leichter.

Es ist von Vorteil, möglichst weite Teile des Quelltexts zustandsfrei zu halten, um die Komplexität zu senken. Das ist in der Theorie allerdings leichter gesagt, als in der Praxis getan. Ein Zustand ist in der Programmierung überall zwangsläufig erforderlich, wo Ein- und Ausgabe passiert: etwa bei Events aus einer Bedienoberfläche oder beim Zugriff auf Datenbanken.

Zum Glück schaffen hier moderne Programmierkonzepte Abhilfe. Bei Events kann der Entwickler heute ohne Callbacks auskommen und veränderlichen Zustand vermeiden, wenn er reaktiv arbeitet, wie es beispielsweise in der .NET-Welt via Rx ("Reactive Extensions") möglich ist. Auch Datenbanken gehen zunehmend in eine "ereignisorientierte" Richtung, wo Daten historisiert werden, statt direkt Änderungen daran vorzunehmen.

Das Grundprinzip ist dabei immer, Daten an einem Zeitstrahl anzuordnen, statt sie zu ändern. Eine Änderung ist in diesem Sinn ein Ereignis auf dem Zeitstrahl, ab dem dann andere Fakten vorhanden sind als vorher. Die vorherigen Fakten sind aber unverändert vorhanden und abfragbar. Datenstrukturen, die diesen Mechanismus effizient unterstützen, nennt man deshalb auch persistente Datenstrukturen. Ihre Speichereffizienz ist besser, als man auf den ersten Blick vielleicht meinen könnte, insbesondere auch im Hinblick auf Szenarien mit Parallelität, wo die Alternative direkter Änderungen schnell dazu führt, dass man viele defensive Kopien vorhalten muss. Und die Komplexität, die sonst durch unkontrolliert veränderlichen Zustand entsteht, kann man dank dieser Datenstrukturen so gut wie vollständig loswerden.