Bei der Arbeit am Paper „Evolution of Functional UI Paradigms“, das
beim FUNARCH-Workshop der ICFP
2025
veröffentlicht wurde, kam mir ein Gedanke zu puren Funktionen, den ich
hier gern separat kurz erläutern möchte. Als Argument für pure
Funktionen führen wir funktional Programmierenden gern die bessere
Testbarkeit ins Feld: Pure Funktionen erfordern keine komplizierten
Test-Setups und Mocks, sind deterministisch, parallelisierbar etc. In
der Tendenz ist das sicherlich richtig. Pure Funktionen sind oft
besser testbar. Notwendig ist dieser Zusammenhang allerdings weder in
die eine noch in die andere Richtung. Es gibt pure Funktionen, die
sind schlecht testbar und es gibt auch nicht-pure Funktionen, die gut
testbar sind. Die Purity selbst kann also der Stoff nicht sein, der es
erlaubt, Programmstücke ordentlich zu testen. Was aber ist dann dieser
Stoff?
Bevor ich zum Versuch einer Antwort auf diese Frage komme, will ich
kurz ausführen, weshalb der Zusammenhang zwischen Purity und
Testbarkeit nicht zwingend ist.
Es gibt nicht-pure Funktionen, die gut testbar sind
Eine nicht-pure Funktion ist eine solche, deren Funktionsweise sich
nicht allein durch eine Beschreibung des Zusammenhangs der Eingabe-
und Ausgabewerte feststellen lässt. Das kann beispielsweise der Fall
sein, wenn die Ein- und Ausgaben gar keine reinen (mathematischen)
Werte sind, sondern veränderliche Objekte, also Speicherorte, an die
man unterschiedliche Werte legen kann.
public class Counter {
private int value;
public Counter() {
this.value = 0;
}
public int getValue() {
return value;
}
public void inc() {
this.value = this.value + 1;
}
}
...
Counter counter = new Counter();
counter.inc();
assert counter.getValue() == 1;
Die Variable counter im unteren Teil dieses Code-Blocks bezeichnet
einen Counter und dieser ist ein Speicherort mit veränderlichen
int-Werten. Die Methode inc, die einen solchen Speicherort
(implizit this) als Argument bekommt, ist definitiv nicht pur. Dennoch ist
sie einfach zu testen. Wir erwarten, dass inc den Zähler
inkrementiert und genau das können wir mit drei simplen Zeilen
Test-Code überprüfen. Softwarearchitektonisch gibt es mit
veränderlichem Zustand noch andere Probleme, aber die Testbarkeit ist
zumindest in diesem einfachen Beispiel unproblematisch.
Es gibt pure Funktionen, die schlecht testbar sind
Pure Funktionen sind andererseits nicht notwendigerweise gut
testbar. Wir Active-Groupies programmieren viel UI-Code mit der
Library reacl-c. Dieser
Code setzt sich fast ausschließlich aus puren Funktionen zusammen und
trotzdem gibt es große Teile unseres GUI-Codes, der nicht
automatisiert getestet wird. Das liegt nicht dran, dass wir faul sind
(Tests zu schreiben erleichtert ja die Programmierarbeit), sondern
dass GUI-Code oft inhärent schlecht testbar ist. Betrachten wir
folgendes Beispiel in ClojureScript.
(defn view [temp]
(dom/div
{:style (when (too-hot? temp)
{:background "red"})}
(temperature->string temp))
(assert-equal (view 22) (dom/div "22")
(assert-equal (view 183)
(dom/div {:style {:background "red"}} "183"))
Die Funktion view ist eine Abbildung von einem Domänenwert
„Temperatur“ zu einer UI-Repräsentation. Der Temperaturwert soll als
Zahl in der UI auftauchen. Zusätzlich sollen „zu hohe“ Temperaturen
(was auch immer das konkret bedeutet) prominenter betont werden. Hier
haben wir uns dazu entschieden, diese „Betonung“ mithilfe eines roten Hintergrunds
umzusetzen.
Nun ist view ja eine pure Funktion – es finden keine Veränderungen
und keine Effekte statt – und wir können ja auch einfache Tests für
diese Funktion schreiben. Das zeigt der untere Teil des
Code-Blocks. Diese Tests sind allerdings schlecht, denn sie prüfen
einen Aspekt der Implementierung ab: Die Tests fordern den roten Hintergrund
ein und erlauben keine abweichenden Implementierungen
derselben Idee von „Betonung“ zu hoher Temperaturen. Falls wir uns
später entscheiden sollten, dass eine schönere Implementierung ein
roter Rand oder ein kleines rotes Ausrufezeichen sein könnten, dann
müssten wir sowohl die Implementierung als auch die Tests
anpassen. Solche Tests, die ständig an die Implementierung angepasst
werden müssen, sind fast wertlos.
Gute Tests prüfen, ob eine Implementierung ihre Spezifikation einhält
und genau das ist bei diesem Beispiel sehr schwierig. In natürlicher
Sprache könnten wir die Spezifikation für view so formulieren: Zeig
die Temperatur als Text an und hebe zu hohe Temperaturen hervor. Wie
kann diese Spezifikation aber so formalisiert werden, dass sie auch
von automatisierten Tests geprüft werden kann?
Der Stoff, der die Testbarkeit garantiert
Diese Frage kann ich hier nicht beantworten. Im Gegenteil: Ich
behaupte, dass manche Aspekte, die ein Computerprogramm behandelt,
schlicht nicht formalisierbar und damit nicht automatisiert testbar
sind. Mit diesem Artikel wollte ich bisher nur zeigen, dass pure
Funktionen noch kein Garant für gute Testbarkeit sind. Ich behaupte
stattdessen, dass dieses Verhältnis vermittelt ist durch etwas
anderes. Das Beispiel mit der Temperaturanzeige wirft ein Licht
darauf, was dieses Andere ist: Präzise und simple Spezifikationen.
Zunächst muss klargestellt werden, dass jedes Programmstück auf ein
Ziel hin programmiert wird, das selbst nicht im Code aufgeht. Anders
gesagt: Code ist nie Selbstzweck. Wenn man sich Mühe gibt, verhandelt
man diese Ziele nicht bloß in endlosen Meetings, sondern schreibt sie
auf. Das Ergebnis sind Spezifikationen. Diese Spezifikationen sind
umso wertvoller, je einfacher (und dennoch ausreichend) und präzise
sie sind.
Pure Funktionen starten in der Pole-Position, was die Einfachheit
ihrer Spezifikationen betrifft. Die Abwesenheit von Veränderungen und
Seiteneffekten ergibt, dass eine pure Funktion eben einzig und allein
über den Zusammenhang von Ein- und Ausgabe charakterisiert werden
kann. Bei nicht-puren Funktionen gibt es mehr Freiraum für
Unfug. Diesen Unfug muss man als programmierende Person mit viel
Disziplin im Zaum halten. Das Counter-Beispiel oben zeigt, dass das
auch gelingen kann. Dieser Zähler hat eine sehr einfache und präzise
Spezifikation und es fällt uns deshalb auch leicht, einen ordentlichen
Test zu formulieren.
Andererseits zeigt das Temperatur-Beispiel auch, dass die Testbarkeit
eben trotz puren Funktionen schief gehen kann und zwar dann, wenn
beispielsweise die Spezifikation unpräzise ist. Ebenso ließen sich
Beispiele finden mit präzisen aber komplizierten Spezifikationen. Auch
dort ist es um die Testbarkeit nicht gut bestellt – Purity hin oder
her.
In unserem FUNARCH-Paper beschreiben wir einen Weg, um mit diesen
unpräzisen Spezifikation umzugehen. Anstatt aufzugeben und einfach gar
keine UI-Tests zu schreiben, schlagen wir vor, dem Problem mit
klassischer Softwarearchitektur-Handwerkskunst zu begegnen und
Verantwortlichkeiten zu trennen. Konkret schlagen wir das funktionale
Model-View-ViewModel-Pattern vor: Aspekte des UI-Codes, die präzise
spezifizierbar sind, kommen in ein extra Modell (bestehend aus
unveränderlichen Daten und puren Funktionen) und werden dadurch
testbar. Die genuin schwierig präzisierbaren Aspekte des
User-Interfaces werden damit in einen minimalen und dank des
reacl-c-Programmiermodells lose gekoppelten Bereich der Anwendung
gedrückt, was den manuellen und damit kostspieligen Testaufwand
minimiert.