Spezifikation von Schnittstellen abseits des Happy-Path
Unter Happy-Path versteht man den beziehungsweise vielmehr die Ausführungspfade einer Funktion, die im Normalfall
durchlaufen werden, das heißt wenn keine Fehler auftreten. Die Definition ist unscharf und hängt davon ab, was man unter normal
versteht. Für diesen Artikel lasse ich diese Grauzone allerdings beiseite. Denn es geht mir primär um die Schnittstellenspezifikation der Fälle, die deutlich abseits des Normalpfads liegen.
Beispiel
Im ersten Beispiel ist das aus meiner Sicht sehr einfach. Die folgende Funktion orientiert sich an der API-Dokumentation zu der statischen Methode java.util.Arrays.fill
aus der Standardbibliothek von Java 6. Dabei handelt es sich um eine einfache Hilfsfunktion, die die Elemente eines Arrays mit dem übergebenen Wert überschreibt – und zwar eingeschränkt auf das halb offene Intervall [fromIndex,toIndex).
void fill(char[] array, int fromIndex, int toIndex, char value) {
if (fromIndex > toIndex) {
throw new IllegalArgumentException("fromIndex > toIndex");
}
for (int i = fromIndex; i != toIndex; ++i) {
array[i] = value;
}
}
Im Normalfall
ist fromIndex <= toIndex
, die Schleife wird entsprechend häufig durchlaufen und die Funktion kehrt am Ende des Funktionsrumpfes ohne Ausnahme zum Aufrufer zurück. Die IllegalArgumentException
gehört dagegen aus meiner Sicht nicht zum Happy-Path, genauso wenig wie die NullPointerException
und ArrayIndexOutOfBoundsException
, die innerhalb des Schleifenrumpfes geworfen werden können.
Das ist genügend Information um die Methode mit Javadoc-Kommentaren zu spezifizieren. Wenn ich den Happy-Path mal außen vor lasse, dann sieht das möglicherweise so aus:
/**
* Assigns the specified ... [happy path].
*
* @throws IllegalArgumentException if <tt>fromIndex > toIndex</tt>
* @throws ArrayIndexOutOfBoundsException if <tt>fromIndex < 0</tt>
* or <tt>toIndex > array.length</tt>
* @throws NullPointerException if <tt>array</tt> is <tt>null</tt>
*/
void fill(char[] array, int fromIndex, int toIndex, char value);
Die Dokumentation zu der entsprechenden Methode aus der Java-API sieht im Wesentlichen genauso aus. Also ist so weit alles in Ordnung, oder?
Nein, zumindest aus meiner Sicht ist das nicht so einfach wie es aussieht. Es gibt gleich mehrere Punkte, die ich an dieser Spezifikation zu bemängeln habe. Und die Wichtigsten werde ich im Folgenden thematisieren.
Reihenfolge
Die obige Spezifikation enthält unterschiedliche Ausnahmetypen, die mit unterschiedlichen Bedingungen verknüpft sind. Betrachtet man diese Bedingungen genauer, so wird man feststellen, dass sie fast vollständig unabhängig voneinander sind. Jeweils zwei der Bedingungen treffen bei geeigneter Wahl der Argumente gleichzeitig zu, und in bestimmten Fällen sind sogar alle drei Bedingungen erfüllt – beispielsweise für den Aufruf fill(null, -1, -2)
.
Die Funktion kann allerdings nur eine Ausnahme werfen. Nur welche ist es, wenn mehrere Ausnahmebedingungen erfüllt sind? Mit der Reihenfolge, in der die Fälle spezifiziert sind, hat es zumindest in meinem Beispiel nichts zu tun. Das ließe sich allerdings korrigieren, indem man die NullPointerException
vor der ArrayIndexOutOfBoundsException
angibt. Aber ist das Problem der Reihenfolge wirklich so einfach zu lösen?
Statt auf die Frage direkt einzugehen, zeige ich zunächst eine alternative Implementierung der Funktion. Denn schließlich gibt es an meiner ersten Implementierung berechtigte Kritikpunkte. Insbesondere gefällt mir daran nicht, dass die ArrayIndexOutOfBoundsException
implizit ausgelöst wird. Das ändere ich im folgenden Beispiel:
void fill(char[] array, int fromIndex, int toIndex, char value) {
if (fromIndex > toIndex) {
throw new IllegalArgumentException("fromIndex > toIndex");
}
if (fromIndex < 0 || toIndex > array.length) {
throw new ArrayIndexOutOfBoundsException("out of range");
}
for (int i = fromIndex; i != toIndex; ++i) {
array[i] = value;
}
}
Dieser Missstand ist nun beseitigt. Allerdings habe ich dadurch auch ein neues Problem geschaffen: Diese Verschlimmbesserung hat die Reihenfolge geändert, in der die Ausnahmen auftreten können. Und als wäre das noch nicht genug: Es gibt nun auch keine klare Reihenfolge mehr. Denn die ArrayIndexOutOfBoundsException
kann nun sowohl vor als auch nach der NullPointerException
auftreten.
Natürlich lässt sich auch dieses Problem lösen. Eine einfache Anpassung besteht aus einer zusätzlichen Prüfung auf null
ganz am Anfang der Methode. Das hat auch den Vorteil, dass die Reihenfolge der Ausnahmen dadurch intuitiver wird. Ich will nicht weiter darauf eingehen, wie sich dieses Vorgehen auf andere Beispiele übertragen lässt. Stattdessen widme ich mich meinem nächsten Kritikpunkt, und nehme dafür an, dass die gerade angesprochene Änderung am Code vorgenommen ist.
Aufrufer
Die Spezifikationslücken sind geschlossen. Jetzt ist an der Zeit sich anzuschauen, wie der Aufrufer die Funktion verwenden kann – natürlich abseits des Happy-Path.
Das folgende Beispiel wirkt möglicherweise ein wenig konstruiert. Aus meiner Sicht ist es dennoch geeignet, um meinen Standpunkt darzustellen. Ich orientiere ich wiederum an der Standardbibliothek von Java 6. Dieses Mal betrachte ich eine Variante der Methode java.util.Arrays.copyOf
. Im Unterschied zur ursprünglichen Methode erwartet meine Version ein weiteres Argument, mit dem die zusätzlichen Elemente initialisiert werden.
char[] copyOf(char[] array, int newLength, char defaultValue) {
char[] result = new char[newLength];
try {
fill(result, array.length, newLength, defaultValue);
System.arraycopy(array, 0, result, 0, array.length);
} catch (IllegalArgumentException e) {
System.arraycopy(array, 0, result, 0, newLength);
}
return result;
}
Ich behaupte, dass diese Implementierung korrekt ist. Dennoch wird sie vermutlich bei vielen Entwicklern ein Kopfschütteln auslösen. Aber woran liegt das? Es gibt bestimmt ein Reihe von Gründen. Und dazu gehört sicherlich auch, dass die Eigenschaft der geworfenen Ausnahme überhaupt genutzt wird.
Moment mal! Soll das etwa bedeuten, dass die Umstände der geworfenen Ausnahme zwar genau spezifiziert sind, sie aber trotzdem nicht ausgenutzt werden sollten? Wenn das so ist, warum sich überhaupt die Mühe machen, die Ausnahmefälle sorgfältig zu betrachten?
Meine Erklärung ist einfach: Der Entwickler von fill
hatte zu keinem Zeitpunkt die Absicht, dem Aufrufer die Möglichkeit zu geben, die Fälle anhand der Ausnahmen zu unterscheiden. Umgekehrt: Die Methode besitzt eine starke Vorbedingung, die in korrektem Code nie verletzt werden sollte. Wird sie dennoch verletzt, so ist die Implementierung umsichtig und wirft eine Ausnahme. Dieser Umstand wird außerdem dokumentiert, obwohl er kein Bestandteil des eigentlichen Vertrags zwischen Aufrufer und Aufgerufenem ist.
Diese Vorgehensweise ist eine Ausprägung der defensiven Programmierung
. Die Spezifikation erweckt bisher aber noch einen anderen Eindruck. Daher würde ich mindestens eine Änderung vornehmen: Anstatt die unterschiedlichen Ausnahmetypen aufzulisten würde ich lediglich angegeben, dass eine aussagekräftige RuntimeException
– beziehungsweise eine davon abgeleitete Ausnahme – geworfen wird, wenn eine der angegebenen Vorbedingungen verletzt ist. Dadurch verschwindet das Problem der Reihenfolge gleicht mit, und es wird offensichtlich, dass die Ausnahme nicht gezielt behandelt werden sollte.
/**
* Assigns the specified ... [happy path].
*
* @throws RuntimeException if any precondition is violated:
* <c>array != null</c>,
* <c>fromIndex <= toIndex</c>,
* <c>fromIndex >= 0</c>,
* <c>toIndex <= array.length</c>
*/
void fill(char[] array, int fromIndex, int toIndex, char value);
Das sieht aus meiner Sicht ein wenig besser aus. Allerdings werden ohne weitere Information viele Entwickler zunächst irritiert sein, wenn sie bisher nur anderes gesehen haben und zum ersten Mal auf eine solche Spezifikation treffen. Ich empfehle daher, solche Vorgehensweisen nach Möglichkeit auf komplette Bibliotheken oder Systeme anzuwenden und diese explizit zu festzuschreiben. Alternativ kann man es auch zu einer festgelegten Konvention machen und auf die Dokumentation im Einzelfall verzichten.
Illusion
Es ist nun an der Zeit, auf eine Sache deutlich hinzuweisen: Ich habe in diesem Artikel bisher nur ein einziges Beispiel betrachtet, und der enthaltene Code ist sehr einfach und nicht repräsentativ. An diesem Beispiel sind zwei wichtige Punkte nicht direkt ersichtlich, die man unbedingt berücksichtigen sollte: Erstens sind die Vorbedingungen vieler Methoden so einschränkend, dass sie überhaupt nur von einem winzigen Bruchteil aller möglichen Eingaben erfüllt werden. Und können die aufgerufenen Methoden im Allgemeinen nur einen winzigen Bruchteil aller fehlerhaften Eingaben überhaupt erkennen. Das bedeutet insbesondere auch, dass das Verhalten vieler Methoden nur schwierig vollständig spezifiziert werden kann.
void sort(String[] array, Comparator<String> comparator);
Ein Beispiel sagt bekanntlich mehr als tausend Worte, und ich orientiere mich dafür wiederum an einer Methode der Klasse java.util.Arrays
. Auf den ersten Blick sieht diese Methode harmlos aus. Doch was passiert bei dem folgenden Aufruf:
String[] array = { ... };
sort(array, new Comparator<String>() {
@Override
public int compare(String lhs, String rhs) {
return -1; // NOTE: always returns same value
}
});
Offensichtlich erfüllt das übergebene Comparator-Objekt nicht die Eigenschaften einer Ordnungsrelation. Aber kann die aufgerufene Methode das überhaupt erkennen? Klar, in solchen Spezialfällen ist das einfach. Aber im Allgemeinen ist es schlicht und einfach bei der Zeitkomplexität der Sortierfunktion unmöglich – und das selbst wenn man voraussetzen könnte, dass die Vergleichsfunktion deterministisch ist.
Es ist auch völlig unklar, was die Methode bei diesem Aufruf überhaupt macht. Lässt sich aus diesem Beispiel aber auch eine allgemeine Regel ableiten? Natürlich handelt es sich wiederum nur um ein einziges Beispiel. Aber der wichtige Unterschied zu dem ersten Beispiel ist, das statt trivialen Argumenten komplexe Objekte übergeben werden. Dadurch entstehen die zusätzlichen Freiheitsgrade, und es ist naheliegend das auf andere Methoden mit Objekt-Parametern zu übertragen.
Vorgehensweise
Meiner dritter und womöglich wichtigster Kritikpunkt betrifft das grundsätzliche Vorgehen, das ich oben geschildert habe. Eigentlich habe ich bereits ganz am Anfang einen riesigen Aufschrei erwartet. Nochmals zur Wiederholung: Ich habe zunächst die Implementierung der Methode fill
geschrieben und anschließend das Verhalten betrachtet und als Spezifikation übernommen.
Das ist natürlich vollkommener Unsinn gewesen. Das Vorgehen sollte genau umgekehrt sein: Zunächst sollte man sich überlegen, welche Funktionalität der Aufrufer benötigt. Daraus wird die Spezifikation abgeleitet und schließlich basierend auf Letzterer die Implementierung erstellt. In der Regel gibt es dann zwischen Spezifikation und Implementierung noch zwei oder drei Iterationen, weil während der Implementierung sowohl Spezialfälle als auch Verallgemeinerungen ans Licht treten, die vorher im Verborgenen lagen. Nichtsdestotrotz gilt grundsätzlich, dass die Schnittstelle anhand der Anforderungen des Aufrufers festgemacht wird – und nicht anhand der Möglichkeiten der Implementierung.
Wäre diese Richtlinie oben beachtet worden, dann wären die Ausnahmen vermutlich erst gar nicht in der Spezifikation gelandet. Genau lässt sich das natürlich nicht feststellen, da es initial keinen Aufrufer gab. Aber es ist zumindest sehr zweifelhaft, dass ein Aufrufer die angegeben Ausnahmen unter den entsprechenden Bedingungen benötigt.
Fazit
Mal abgesehen vom letzten Punkt, der für sich alleine steht: Es gibt ein paar wichtige Dinge, die man im Rahmen der Spezifikation bei der Betrachtung der Fehlersituationen beachten sollte. Dazu gehört auf jeden Fall, dass man sich überhaupt darüber im Klaren wird, was und was nicht Bestandteil der Vorbedingungen ist, dass man das Verhalten bei verletzten Vorbedingungen nicht überspezifiziert, und dass man trotzdem die Defensive Programmierung
beherzigt. In späteren Beiträgen werde ich auf diesem Thema aufbauen und diskutieren, wie das in konkreten Fällen sinnvoll umgesetzt wird.