Follow my new blog

Mittwoch, 24. April 2013

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.

4 Kommentare:

Carsten hat gesagt…

In F# könnte man
42 |> c_vorbereitung |> s |> c_nachbereitung
schreiben, würde man aber nicht.
Viel eher erkennt man, dass es sich hier um eine Komposition handelt und würde entweder eine Funktion definieren:
let F = c_vorbereitung >> s >> c_nachbereitung
und dann F 42 schreiben oder eher sowas:
c_vorbereitung 42
|> s |> c_nachbereitung
die Version mit der Konstanten first sieht zu hässlich aus.

BTW: es wird immer deutlicher, dass hier der Denkprozess in die Richtung geht, die FP schon von Anfang an vertritt (kleine, reine Funktionen und die Komposition derren, statt verwobene, schwerfällige Methoden, die man häufig nicht mal benennen kann)

Ralf Westphal - One Man Think Tank hat gesagt…

@Carsten: Dass du da ne Composition siehst, ist schon klar. Ist ja auch so - nur habe ich sie unsichtbar gelassen.

An der grundsätzlichen Notation von links nach rechts ändert >> vs |> aber nichts. Der Fluss wird halt deutlich.

Dass ich hier in Richtung FP argumentiere, ist klar.

Aber dabei wird es nicht bleiben :-) Denn in FP steckt das nächste Prinzip, das ich näher betrachten werde, nicht drin. Das muss auch ein FPler willentlich anwenden.

Und - wie du weißt - bin ich ganz offen für zustandshaltende Funktionseinheiten. Durch Reifen springen, nur um Zustand, der ganz natürlich irgendwo verortet ist, in Fluss zu bringen, ist nicht mein Ding. Da möchte ich mehr Flexibilität.

Was FP mir da bietet mit immutability und dem Hinweis auf async/parallel, ist mir zuviel Effizienz in eine Richtung, wenn ich sie nicht brauche.

Jürgen hat gesagt…

Ich bin noch nicht überzeugt. Gerade das in [1] vertagte Problem macht mir Sorge.
In deinem Ansatz erkenne ich die Vorgehensweise des TDD - Arrange, Act, Assert - wieder. Aber eine Lösung aller Probleme sehe ich nicht:

#1: Die Funktionen wissen nichts voneinander, dies war bei deinem Beispiel aber auch schon so:
Die Vorbereitung (string report = "..."; ...) wusste nichts von LadeDaten oder von der Nachbereitung (foreach …). Ich sehe hier nur die Verlagerung und nicht die Lösung des Problems.

#2: Wie gesagt Verlagerung, statt c(){ B.s(); } wird es class A { .. c(){ s(); } … s() { B.s(); } …}
c ist nicht mehr abhängig, aber die Klasse A bleibt es, das Problem hat nun die Funktion s.

#3: Klar, es gibt nur noch die 3xA, plus die von dir genannten Transformer.

#4: Ich beziehe mich auf dein Beispiel; würde es nicht mehr Sinn machen den Transformer in der Funktion s() anzuwenden oder besser in s_nachbereitung()? c() wäre eine Änderung von der Logik in s unabhängig und würde keiner Änderung bedürfen.

Ich bin gespannt auf den "späteren Blogartikel".

Ralf Westphal - One Man Think Tank hat gesagt…

@Jürgen: Das Problem aus [1] wird noch behandelt.

Im Beispiel gibt es nur eine abhängige Funktion ReportGenerieren(). Die kennt LadeDaten(). Der Rest ist Teil von ReportGenerieren(). Dass du da von "Nichtwissen" bei "string report = ..." sprichst, macht keinen Sinn. Das ist ja Teil der Problemfunktion.

Solange es Interfaces gibt (also Sammlungen von Funktionen), solange gibt es auch noch die topologische Kopplung. Ob das Interface explizit oder implizit ist, ist egal. Deshalb: auf funktionale Abhängigkeit verzichten.

Die Transformer in s() zu verstecken, ist das übliche Vorgehen. Damit wird dann aber s() verändert. Das willst du nicht. Du willst am liebsten keine Funktionseinheit je verändern :-) Das sagt das OCP ganz klar.

Ohne Veränderung geht es ja aber nicht. Fragt sich also, wo? Zwischen c_vorbereitung() und s() in der gezeigten Weise ist es ungefährlicher. Dazu aber später mehr.