Raus aus der funktionalen Abhängigkeit

Wenn funktionale Abhängigkeiten so problematisch sind, wie können wir sie denn vermeiden? Ich denke, die Antwort ist ganz einfach:
Wir sollten einfach keine funktionalen Abhängigkeiten mehr eingehen.
Funktion c() wie Client sollte sich nicht mehr an eine Funktion s() wie Service binden. Denn wenn sie das tut, wenn sie nicht mehr delegiert, dann kann sie sich endlich selbstbestimmt auf ihre einzige Domänenverantwortlichkeit im Sinne des Single Responsibility Principle (SRP) konzentrieren.
Technisch bieten Funktionen dafür die besten Voraussetzungen. Die Funktion
int Add(int a, int b) { return a+b; }
macht ja schon nur ihr Ding. Sie definiert sich komplett selbst. Ihre syntaktische Spezifikation besteht nur aus einer Signatur:
Func<int,int,int>
Die könnte man mit einer Beschreibung der gewünschten Semantik ("Die Funktion soll zwei Zahlen addieren.") zur Implementation in ein fernes Land geben. Die Entwickler dort müssten nichts weiter berücksichtigen.
Anders ist das bei einer Funktion wie dieser:
string ReportGenerieren(string dateiname) {
  string report = "...";
  ...
  int[] daten = LadeDaten(dateiname);
  foreach(int d in daten) {
    ...
    report += ...;
    ...
  }
  ...
  return report;
}
Deren Spezifikation besteht nicht nur aus:
  • Func<string,string>
  • "Funktion, die den Report wie folgt generiert: ..."
sondern auch noch aus der Signatur der Funktion zum Laden der Daten und deren Semantik:
  • Func<string,int[]>
  • "Funktion, die die soundso Daten in folgender Weise als int-Array liefert: ..."
Entwickler in einem fernen Land müssten nun diese Dienstleistungsfunktion entweder ebenfalls entwickeln oder gestellt bekommen oder zumindest als Attrappe bauen. Damit ist die Funktion ReportGenerieren() nicht mehr selbstbestimmt.
Allgemein ausgedrückt: Bei funktionaler Abhängigkeit besteht die Spezifikation einer Funktion aus 1 + n Signaturen (mit zugehörigen Semantiken). 1 Signatur für die eigentlich zu betrachtende Funktion und n Signaturen für Funktionen, von denen sie funktional abhängig ist.
Das ist doch kein Zustand, finde ich. So kann es nicht weitergehen, wenn wir zu besser evolvierbarer Software kommen wollen.

Aufbrechen funktionaler Abhängigkeiten

Also, wie kommen wir raus aus funktionaler Abhängigkeit? Wir müssen zunächst genauer hinschauen. Funktionale Abhängigkeit besteht aus drei Phasen:
  1. Vorbereitung
  2. Aufruf
  3. Nachbereitung

int c(int a) {
  // vorbereitung
  int x = ... a ...;
  // aufruf
  int y = s(x);
  // nachbereitung
  int z = ... y ...;
  return ... z ...;
}
Jede abhängige Funktion kann man mindestens einmal in diese Phasen zerlegen. Bei mehreren funktionalen Abhängigkeiten können sich Phasen natürlich auch überlagern. Dann mag eine Nachbereitung die Vorbereitung eines weiteren Aufrufs sein.
Sind diese Phasen aber erkannt, kann man jede in eine eigene Funktion verpacken:
int c_vorbereitung(int a) {
  return ... a ...;
}

int c_nachbereitung(int y) {

  int z = ... y ...;
  return ... z ...;
}
Die bisherige abhängige Funktion wird dann zu einer Folge von unabhängigen Funktionen. Die können so zum bisherigen Ganzen zusammengeschaltet werden:
return c_nachbereitung(s(c_vorbereitung(42)));
Das ist jedoch schlecht lesbar, wenn auch vielleicht schön knapp formuliert. Besser finde ich es daher wie folgt:
int x = c_vorbereitung(42);
int y = s(x);
return c_nachbereitung(y);
Oder moderner könnte es in F# auch so aussehen:
42 |> c_vorbereitung |> s |> c_nachbereitung
Da ist doch sonnenklar, wie die Verantwortlichkeit, für die das ursprüngliche c() stand, erfüllt wird, oder? Das kann man lesen wie einen Text: von links nach rechts und von oben nach unten.
Bitte lassen Sie es sich auf der Zunge zergehen: Keine der Funktionen hat nun noch eine funktionale Abhängigkeit mehr. [1]
c_vorbereitung() ruft keine Funktion auf und weiß nichts von s() und c_nachbereitung(). Dito für s() und c_nachbereitung(). Keine Funktion weiß von einer anderen. Das nenne ich "gegenseitige Nichtbeachtung" (engl. mutual oblivion). Und wenn man sich so bemüht zu codieren, dann folgt man dem Principle of Mutual Oblivion (PoMO).
Funktionen sind ein Mittel, um Funktionseinheiten so zu beschreiben, dass sich nicht um Vorher oder Nachher scheren. Es gibt aber auch andere Mittel. Methoden können mit Continuations versehen werden oder Klassen können mit Events ausgestattet werden.
Egal wie das Mittel aber aussieht, die Funktionseinheit beschreibt immer nur sich selbst. Ihr Interface/ihre Signatur ist rein selbstbestimmt. Bei Funktionen fällt das gar nicht so sehr auf. Deren Ergebnisse sind sozusagen anonym. Anders sieht das aus, wenn Continuations ins Spiel kommen:
void c_vorbereitung(int a, Func<int,int> ???) {
  ???(... a ...);
}
Wie sollte die Continuation ??? heißen? Sollte sie vielleicht s genannt werden? Nein! Dann würde c_vorbereitung() wieder eine semantische Abhängigkeit eingehen. Die Prozedur würde eine bestimmte Dienstleistung erwarten, die ihr übergeben wird.
??? muss vielmehr rein auf die Prozedur bezogen sein. Die Continuation könnte neutral result oder continueWith betitelt sein. Oder sie könnte sich auf den produzierten Wert beziehen:
void c_vorbereitung(int a, Func<int,int> on_x) {
  on_x(... a ...);
}
Einen Vorteil hat eine Continuation gegenüber einem Funktionsergebnis sogar: damit können beliebig viele Ergebnisse zurückgeliefert werden, ohne eine Datenstruktur aufbauen zu müssen.

Vorteile der Vermeidung funktionaler Abhängigkeiten

Inwiefern dient das PoMO aber nun der Vermeidung der durch funktionale Abhängigkeit aufgeworfenen Probleme?
ad Problem #1: Es gibt keine Abhängigkeit, also muss auch keine Bereitstellung erfolgen. Die Frage nach statischer oder dynamischer Abhängigkeit stellt sich nicht einmal. Um c_vorbereitung() oder c_nachbereitung() zu testen, sind keine Attrappen nötig. Von Inversion of Control (IoC) oder Dependency Injection (DI) ganz zu schweigen.
ad Problem #2: Es gibt keine Abhängigkeit, also gibt es auch keine topologische Kopplung. Ob s() auf dem einen oder auf dem anderen Interface definiert ist, interessiert c_vorbereitung() und c_nachbereitung() nicht.
ad Problem #3: Das Wachstum von Funktionen wird ganz natürlich begrenzt. Wann immer eine Funktion eine Dienstleistung benötigt, muss sie beendet werden. Sie bereitet sozusagen nur vor. Für die Nachbereitung ist eine weitere Funktion zuständig. Überlegen Sie, wie lang Funktionen dann noch werden, wenn sie nur noch von einer Delegation bis zur nächsten reichen dürfen. Ich würde sagen, dass es kaum mehr als 10-20 Zeilen sein können, die Sie guten Herzens ohne Verletzung eines anderen Prinzips anhäufen können.
ad Problem #4: Ohne funktionale Abhängigkeit gibt es zumindest keine direkte syntaktische Abhängigkeit. Wenn sich die Form des Output von s() ändert, muss c_nachbereitung() nicht zwangsläufig nachgeführt werden. Es könnte auch eine Transformation zwischen s() und c_nachbereitung() eingeschoben werden:
...
Tuple t = s(x);
int y1 = t.Item1;
return c_nachbereitung(y1);
Das mag nicht immer möglich sein, doch zumindest sinkt das Risiko, dass sich Änderungen an s() ausbreiten.
Dito für eine Änderung der Form der Parameter von s(). Auch hier könnte eine Transformation zwischen c_vorbereitung() und s() die Veränderung abpuffern.
ad Problem #5: Semantische Änderungen, also logische Kopplungen, haben am ehesten weiterhin eine Auswirkung auf Vorbereitung bzw. Nachbereitung. Doch auch hier ist Pufferung möglich, z.B.
Vorher:
int[] sortierteDaten = s(...);
return c_nachbereitung(sortierteDaten);
Nachher:
int[] unsortierteDaten = s(...);
int[] sortierteDaten = unsortierteDaten.Sort();
return c_nachbereitung(sortierteDaten);

Fazit

Die Auflösung funktionaler Abhängigkeit ist immer möglich. Das ist kein technisches Hexenwerk.
Das mag hier und da (zunächst) mühsam sein - doch der Gewinn ist groß. Und wenn man ein bisschen Übung darin hat, dann kann man quasi nicht mehr anders denken. Glauben Sie mir :-) Ich mache das jetzt seit vier Jahren so und es geht inzwischen mühelos.
Sollten funktionale Abhängigkeiten immer aufgelöst werden? Nein. Das wäre Quatsch. Man würde auch in einen infiniten Regress hineinlaufen. Die Frage ist also, wann auflösen und wann nicht? Doch davon ein anderes Mal.
Für heute ist mir nur wichtig, eine Lösung der aufgeworfenen Probleme aufgezeigt zu haben. Die sind aber natürlich gegen andere Werte abzuwägen. Evolvierbarkeit als eine Form von Flexibilität hat ja immer den Gegenspieler Effizienz.

Endnoten

[1] Falls Sie einwenden wollen, dass doch nun irgendwie auf einer höheren Ebene eine Funktion von drei anderen abhängig geworden sei, dann halten Sie den Einwand bitte noch ein bisschen zurück. Sie haben ja Recht. Doch darauf möchte ich in einem späteren Blogartikel eingehen.
Nehmen Sie für heute einfach hin, dass das, was c() mit seiner funktionalen Abhängigkeit war, aufgelöst wurde in drei unabhängige Funktionen.

wallpaper-1019588
Muskel-Leggings
wallpaper-1019588
[Comic] Bitter Root [2]
wallpaper-1019588
Fütterungstabelle für Hunde
wallpaper-1019588
Fütterungstabelle für Hunde