Follow my new blog

Sonntag, 28. April 2013

Software fraktal - Funktionale Abhängigkeit entschärfen

Wir kommen nicht wirklich raus aus der funktionalen Abhängigkeit. Was aber können wir dann gegen ihre Probleme tun?

Neulich habe ich gezeigt, wie Sie funktionale Abhängigkeiten auflösen. Dabei bleibe ich auch. Funktionseinheiten - Methode oder Klasse - sollten für ihre Spezifikationen selbst verantwortlich sein. Soweit möglich. Doch damit wird das Problem der funktionalen Abhängigkeit nur eine Ebene höher geschoben.

Vorher:
int c() {
  ...  // Vorbereitung
  var y = s(x);
  ... // Nachbereitung
}

Nachher:
var x = c_vorbereitung();
var y = s(x);
return c_nachbereitung(y);

Wo c() vorher funktional von s() abhängig war, da ist das, was c() ausgemacht hat nachher auf c_vorbereitung() und c_nachbereitung() verteilt - und nicht mehr von s() abhängig.

In Bezug c() ist das gut. Aber nun ist der Code, der nachher die drei Methoden aufruft funktional von ihnen abhängig. Denn in Wirklichkeit sieht das ja so aus:

Vollständiges Nachher:
int i() {
  var x = c_vorbereitung();
  var y = s(x);
  return c_nachbereitung(y);
}

Die neue Methode i() hat den schwarzen Peter funktionale Abhängigkeit zugeschoben bekommen.

Oder?

Ich denke, wir sollten genau hinschauen, bevor wir die Situation als unverändert beurteilen.

Operationen

Ich möchte für die weitere Diskussion den Begriff Operation einführen. Eine Operation ist eine Funktion, die Logik enthält. Das bedeutet, sie enthält Kontrollstrukturen (if, for, while usw.) und/oder Ausdrücke (+, *, && usw.).

Die Funktion

int Add(int a, int b) { return a+b; }

ist nach dieser Definition eine Operation. Die obige Funktion i() hingegen nicht. Sie enthält keine Kontrollstrukturen und auch keine Ausdrücke; sie besteht lediglich aus Funktionsaufrufen und Zuweisungen [1].

Operationenhierarchien

Normalerweise findet die Verarbeitung in Operationenhierarchien statt. Es gibt tiefe funktionale Abhängigkeitsbäume und in jedem Knoten (Funktion) befindet sich Logik.

void s(int a) {
  var n = ... a ...;
  var l = t(n);
  foreach(var e in l) { ... }
}

List t(int n) {
  var l = new List();
  for(int i=0; i<n; i++)
    l.Add(u(i));
  return l;
}

int u(int i) {
  var x = ... + i * ...;
  var y = v(x);
  return ... - y / ...;
}

...

Die Logik auf jeder Ebene mag derselben Domäne angehören oder unterschiedlichen. Das ist egal. Es ist Logik. Und solche Logik gemischt mit funktionaler Abhängigkeit tendiert dazu, sich horizontal auszudehnen (vgl. Problem #3 in diesem Artikel). Das bedeutet, tendenziell sind die Operationen auf jeder Ebene umfangreich und somit nicht leicht zu verstehen [2].

Operationenhierarchien leiden deshalb unter einigen Problemen:

Problem #A: Grenzenloses Wachstum

Operationenhierarchien kennen keine Grenze beim Wachstum. In Breite und Tiefe können sie beliebig zunehmen. Neue Anforderungen oder auch gut gemeinte Refaktorisierungen führen zur Ausdehnung - und verschmieren damit Logik immer mehr. Logik kann ja auf jeder Ebene der Hierarchie in jedem Operationsknoten vorkommen.

Spüren Sie nicht, wie schwer es dadurch wird, Software überhaupt nur zu verstehen? Um nur irgendwie noch durchzublicken, müssen Sie Tools bemühen, die Ihnen Abhängigkeitsbäume darstellen. Nur so bekommen Sie ungefähr eine Idee davon, wo bestimmte Logik sitzen könnte.

Problem #B: Umfängliche Unit Tests auf jeder Ebene

Operationen sind die Orte, wo die Musik einer Software spielt. Also müssen sie solide getestet werden. Jeder Pfad durch sie sollte mit einem automatisierten Test abgedeckt sein. Deshalb ist ja auch TDD in aller Munde. Deshalb gibt es Tools, die die Code Coverage messen.

Wenn Code eine endlose Operationenhierarchie ist, müssen solch aufwändige Tests für jeden Knoten durchgeführt werden. Puh... Und dann enthalten diese Knoten auch noch funktionale Abhängigkeiten. Es gibt also noch ein weiteres Problem:

Problem #C: Attrappen auf jeder Ebene

Auf jeder Ebene einer Operationenhierarchie müssen funktionale Abhängigkeiten durch Attrappen ersetzt werden. Sonst wird nicht nur eine Unit, d.h. eine Operation getestet. Kein Wunder, dass es soviele Mock Frameworks gibt. Der Bedarf an Attrappen ist durch Operationenhierarchien riesig.

Integration

Operationen machen viel "Dreck". Den muss man mit viel Clean Code Development dann wieder wegräumen. Schauen Sie zum Vergleich nun aber noch einmal die obige Funktion i() an. Finden Sie die "dreckig"?

Ich nicht. Für mich ist i() ein Leuchtturm an Sauberkeit. Was i() wie leistet, ist ganz einfach zu erkennen. Zugegeben, das Beispiel ist abstrakt. Nehmen wir deshalb ein konkreteres:

string Erste_Seite_aufblättern() {
  var dateiname = Dateiname_von_Kommandozeile_holen();
  var alleDatenzeilen = Zeilen_lesen(dateiname);
  var ersteSeite = new Seite {
    Überschrift = Überschrift_extrahieren(alleDatenzeilen),
    Datenzeilen = Zeilen_der_ersten_Seite_selektieren(alleDatenzeilen)
  };
  return Seite_formatieren(ersteSeite);
}

Das ist ebenfalls keine Operation. Es passiert zwar einiges, doch Kontrollstrukturen und Ausdrücke sind abwesend. Alles, was passiert, steckt in Funktionen, die aufgerufen werden.

Die Funktion Erste_Seite_aufblättern() ist also stark funktional abhängig - dennoch ist sie leicht zu verstehen. Woran liegt das?

Hier wurde das SRP sauber beachtet. Diese Funktion hat wirklich nur eine Verantwortlichkeit: sie integriert. Sie tut nichts im Sinne einer Domänenlogik selbst, sondern delegiert ausschließlich. Ihre einzige Aufgabe ist es, Funktionen mit kleinerem Zweck zu einem größeren zusammenzustellen. Sie stellt eine Integration dar.

Operationen hingegen vermischen immer zwei Verantwortlichkeiten, wenn sie funktional abhängig sind. Ihr Hauptverantwortlichkeit ist die Ausführung von Logik. Neben der übernehmen sie jedoch auch noch integrierende Verantwortung, wenn sie zwischendurch andere Funktionen aufrufen. Das macht sie so schwer zu verstehen und zu testen.

Trennung von Integration und Operation

Für mich ergibt sich aus den Problemen der Operationshierarchien und der Probleme der funktionalen Abhängigkeit, dass wir etwas anders machen müssen, als bisher. Ein bisschen SOLID mit TDD reicht nicht. Wir müssen radikaler werden.

Ich glaube, besser wird es nur, wenn wir ganz klar Integrationen von Operationen trennen (Integration Operation Segregation Principle (IOSP)). SPR und das Prinzip Single Level of Abstraction (SLA) sind nicht genug. Sie führen nicht geradlinig genug zu verständlichen Strukturen.

Um Hierarchien von Funktionen kommen wir nicht herum. Ohne sie beherrschen wir nicht-triviale Logik nicht. Wir müssen Logikblöcke "wegklappen" und wiederverwendbar definieren können. Dafür sind Unterprogramme (Prozeduren und Funktionen) nötig.

Aber wir können uns darin beschränken, wie wir diese Hierarchien aufbauen. Wir können uns auferlegen, dass Operationen nur in ihren Blättern stehen dürfen - mit beliebig vielen Ebenen von Integrationen darüber.

Jede der beliebig vielen Integrationsfunktionen oberhalb von Operationen ist dann so gut zu lesen wie Erste_Seite_aufblättern().

Die Probleme funktionaler Abhängigkeit wären in den Integrationen zwar noch relevant; doch sie wären auf ein erträgliches Maß gedämpft:

Bereitstellungsaufwand wäre zu treiben (Problem #1) und es gäbe topologische Kopplung (Problem #2). Aber das grenzenlose Wachstum (Problem #3) sähe anders aus. Integrationsfunktionen mit vielen LOC wären viel, viel besser zu lesen als Operationen von gleicher Länge. Entstünden lange Integrationsfunktionen aber überhaupt? Nein. Die Möglichkeit zum grenzenlosen Wachstum gibt es zwar, doch da Integrationsfunktionen so übersichtlich sind, werden sie viel schneller refaktorisiert.

Wenn Sie in Erste_Seite_aufblättern() zwei Verantwortlichkeiten vermischt sehen (Widerspruch gegen SRP) oder meinen, da sei nicht alles auf dem selben Abstraktionsniveau (Widerspruch gegen SLA), dann klappen Sie einfach einen Teil weg in eine andere Integrationsfunktion:

string Erste_Seite_aufblättern() {
  var ersteSeite = Erste_Seite_laden();
  return Seite_formatieren(ersteSeite);
}

Seite Erste_Seite_laden() {
  var dateiname = Dateiname_von_Kommandozeile_holen(); 
  var alleDatenzeilen = Zeilen_lesen(dateiname);
  return new Seite {
    Überschrift = Überschrift_extrahieren(alleDatenzeilen),
    Datenzeilen = Zeilen_der_ersten_Seite_selektieren(alleDatenzeilen)
  };
}

Aus eins mach zwei. Das kann jedes Refactoring-Tool, das etwas auf sich hält.

Dadurch steigt zwar die Tiefe der Funktionshierarchie, doch das macht nichts. Jede Ebene für sich ist ja leicht verständlich. Sie brauchen nicht mal ein Werkzeug, das Ihnen Abhängigkeitsbäume zeichnet, da die Ihnen ja deutlich vor Augen stehen und nicht in "Logikrauschen" verborgen sind.

Auch die syntaktische (Problem #4) und semantische Kopplung (Problem #5) verlieren an Gewicht. Da Integrationen selbst keine Logik enthalten, haben Änderungen in Syntax oder Semantik keinen Einfluss auf die Integration selbst. Syntaktische Änderungen können durch Typinferenz womöglich verschluckt werden. Und wenn nicht, dann hilft wahrscheinlich ein kleiner Einschub zwischen den aufzurufenden Funktionen wie hier gezeigt.

Integrationen sind zwar grundsätzlich gerade an die Semantik der Funktionen gekoppelt, von denen sie abhängen. Sie sollen ja aus ganz konkreten Teilverantwortlichkeiten eine neue Summenverantwortlichkeit herstellen. Doch diese Kopplung ist loser, weil die nicht an eigener Logik hängt.

Soweit die Milderung der Probleme #1..#5 funktionaler Abhängigkeit durch das IOSP. Aber was ist mit den Problemen #A..#C der Operationenhierarchien?

ad Problem #A: Einzelne Integrationen können zwar wachsen, doch tendenziell tun sie das viel weniger als Operationen (s.o.).

Integrationshierarchien können auch wachsen und tun das durch Refaktorisierungen womöglich sogar mehr als Operationshierarchien. Doch das ist nicht schlimm. Jede Ebene ist einfach zu verstehen. Logik ist nicht vertikal und horizontal verschmiert. Sie steckt allein in den Blättern, den Operationen.

ad Problem #B: Integrationen haben von Hause aus eine sehr niedrige zyklomatische Komplexität. Es gibt nur wenige Pfade durch sie. Die kann man sehr einfach testen. Oft reicht ein einziger Test. Oder Sie lassen eine Integration auch mal ganz ohne Test.

Das meine ich ernst. Da Integrationen so leicht zu verstehen sind, kann ihre Korrektheit auch mal nur durch einen Code Review geprüft werden. Die Korrektheit besteht ja nur darin, dass eine Integration von den richtigen Funktionen abhängt und zweitens diese Funktionen in passender Weise für den Zweck der Integration "verdrahtet" sind. Ob das der Fall ist, ergibt sich oft durch Augenschein.

Allerdings sollten Sie automatisierte Tests der Integrationen auf oberster Ebene haben. Die prüfen den Gesamtzusammenhang. Solange der korrekt ist, sind auch darunter liegende Integrationen korrekt.

Und Sie decken natürlich Operationen mit automatisierten Tests ordentlich ab. Dort spielt die Musik der Logik, das ist nicht einfach zu verstehen, also müssen Tests helfen.

Mein Gefühl ist, dass das Testen von Funktionshierarchien, die dem IOSP folgen, deutlich einfacher ist. Allemal, da die Operationen ja nicht mehr voneinander abhängig sind. Sie sind durch das Principle of Mutual Oblivion (PoMO) entkoppelt.

ad Problem #C: Nicht zuletzt ist das Testen einfacher, weil der Bedarf an Einsätzen eines Mock Frameworks deutlich sinkt. Ich zum Beispiel benutze schon lange gar keinen mehr.

Attrappen sind hier und da noch nötig. Doch dann schnitze ich sie mir kurz für den konkreten Fall selbst. Wissen über einen Mock Framework vorzuhalten oder gar deren Entwicklung zu verfolgen, wäre viel umständlicher.

Warum sind Attrappen viel seltener nötig? Weil die vielen Tests von Operationen keine mehr brauchen. Sie sind ja in Bezug auf die Domäne ohne funktionale Abhängigkeit. Und weil Tests von Integrationen seltener sind.

Skalieren mit dem IOSP

Dass Sie Ihre Software nur mit einer Hierarchie von Integrationen realisieren können, an denen unten wie Weihnachtsbaumkugeln Operationen baumeln, glaube ich auch nicht. Aber ich denke, dies sollte die Grundstruktur sein:


Operationen sind die Black Boxes an der Basis funktionaler Abhängigkeitshierarchie. Nur dort sollte sich Logik befinden. Hier gilt es ausführlich zu testen. Diese "Büchsen" wollen Sie so selten wie möglich öffnen. Besser ist es, auf einer Integrationsebene darüber simple Veränderungen anzubringen und neue "Büchsen" unten hinzuzufügen.

Für die ganze Struktur gilt das IOSP, für jeden Knoten darin das PoMO.

Wenn diese Grundstruktur jedoch wachsen soll, dann reicht es nicht, an der Basis in die Breite zu gehen und bei der Integration in die Höhe. Ich denke, dann muss Schachtelung dazukommen.

Black Boxes sind nur aus einem bestimmten Blickwinkel oder aus einer gewissen Entfernung undurchsichtig und quasi von beliebiger Struktur. Wenn eine Operation wächst, dann sollten Sie das ebenfalls nach dem IOSP tun.


Software ist also von ähnlich fraktaler Struktur wie diese quadratischen Koch-Kurven:



Fazit

IOSP + PoMO sind für mich eine ganz pragmatisches Paar, um Software verständlich, testbar und evolvierbar zu halten.

Sie mögen einwänden, das ließe sich doch auch mir SRP und SLA und OCP (Open-Closed Principle) erreichen - und da haben Sie wohl recht. Nur passiert das nicht. SOLID allein reicht nicht. Deshalb finde ich es sinnvoll, daraus diese zwei Prinzipien zu destillieren. Oder diese simplen Regeln, wenn Sie wollen:
  • Teile Funktionen klar in Operationen und Integrationen:
    • Halte Funktionen, die Logik enthalten, frei von funktionalen Abhängigkeiten (Operationen).
    • Sammle funktionale Abhängigkeiten in Funktionen ohne Logik (Integration).
Wenn Sie Ihren Code so aufbauen, dann fühlt sich das zunächst natürlich ganz anders an als bisher. Sie werden Widerstand spüren. Und schon vorher werden Sie wahrscheinlich daran zweifeln, ob das alles überhaupt nötig sei.

Keine Sorge, das kenne ich alles. Ich bin selbst durch diese Zweifel gegangen. Ich kann Sie davon mit Argumenten auch nicht völlig befreien. Sie müssen das ausprobieren. Nehmen Sie sich eine Aufgabe vor, deren Code Sie einfach mal nach der Regel versuchen zu strukturieren. Eine Application Kata hat da gerade die richtige Größe [3].

Ebenfalls kann ich Ihr Schmerzempfinden nicht sensibilisieren. Wenn Sie heute darunter stöhnen, dass Ihre Codebasis schwer wartbar sei, aber keinen Schmerz empfinden, wenn Sie nur mit SOLID und TDD bewaffnet daran herumwerkeln, dann ist das halt so. Dann werden Sie in IOSP+PoMO als Extremformen von SPR, SLA und OCP keine Perspektive zur Besserung sehen. Aber vielleicht lesen Sie dann mal Watzlawicks "Vom Schlechten des Guten" :-)

Ich jedenfalls arbeite seit einigen Jahren zunehmend und heute nur noch so. Mein Leben ist dadurch glücklicher geworden :-) Ich kann meinen Code auch noch nach Monaten verstehen. Änderungen fallen mir leichter. Mit Mock Frameworks muss ich mich nicht mehr rumschlagen.

Versuchen Sie es doch einfach auch mal. Nur so als Experiment. Und dann sagen Sie mir, wie es Ihnen damit gegangen ist.

Endnoten

[1] Zuweisungen sind keine Ausdrücke, da nichts zu einem neuen Ergebnis kombiniert wird. Auf der linken Seite steht derselbe Wert wir auf der rechten Seite. Ohne weitere Modifikation durch Ausdrücke können Variablennutzungen durch Funktionsaufrufe ersetzt werden. i() könnte auch so aussehen:

int i() {
  return c_nachbereitung(s(c_vorbereitung()));
}

Die Variablen in i() sind nur eine Lesehilfe oder könnten bei mehrfacher Nutzung der Performance dienen.

[2] Ich weiß, das soll nicht sein. Und wenn man das Single Responsibility Principle (SRP) beachtet, dann werden einzelne Operationen auch nicht so groß.

Das stimmt. Aber erstens: Beachten Sie das SRP konsequent? Und zweitens: Sehen Sie, was kleine Operationen für eine Folge haben? Sie lassen die Operationenhierarchie weiter wachsen. Die einzelnen Operationen sind zwar kleiner, doch dafür ist die Hierarchie breiter und tiefer.

[3] Wenn Sie bei solcher Übung auf Schwierigkeiten stoßen, Fallunterscheidungen in Operationen auszulagern, ohne die funktional abhängig zu machen, dann ist das selbstverständlich. Aber vertrauen Sie mir: auch das ist ein lösbares technisches Problem.

An dieser Stelle will ich nicht näher darauf eingehen. Doch als Stichwort sei Continuation Passing Style (CPS) genannt.

25 Kommentare:

Anonym hat gesagt…

Sagen wir mal, ich habe auf der untersten Ebene Operationen, und auf der Ebene darüber integrierende Funktionen wie z.B. "Erste_Seite_aufblättern()". Und was passiert jetzt auf der nächsthöheren Operations-Ebene darüber, die diese Funktion "Erste_Seite_aufblättern()" wieder innerhalb von Kontrollogik verwenden will? Die Funktion, die das aufruft, enthält dann doch Kontrolllogik und ist gleichzeitig funktional abhängig von "Erste_Seite_aufblättern". Oder stellst du dir das so vor, dass eine solche Operation alle anderen Funktionen, die sie aufrufen möchte, als Parameter erhält?

Ralf Westphal - One Man Think Tank hat gesagt…

Du hast eine Schicht Operationen. Fein. Dazu könnte Dateiname_von_Kommandozeile_holen() gehören.

Du hast darüber eine Integration wie Erste_Seite_laden().

Und du hast darüber eine Integration wie Erste_Seite_aufblättern().

Und nun? Du kannst beliebig so nach oben weitermachen, z.B.

void Start() {
var text = Erste_Seite_aufblättern();
Seitentext_anzeigen(text);
}

Es gibt in Integrationen keine Kontrollstrukturen. Das ist ihre Definition. Wenn du irgendwo welche einbaust oberhalb einer Integration, dann schaffst du eine Operation. Damit widersprichst du dem IOSP.

Das hat nichts mit DI oder so zu tun. IoC/DI ist nur ein Mechanismus, um funktionale Abhängigkeiten bereitzustellen.

Integrationen haben funktionale Abhängigkeiten. Das ist ihr Zweck. Da kann man also irgendwie injizieren, wenn man mag.

Aber vielleicht hast du noch ein Missverständnis. Einen Aspekt habe ich nicht so hervorgehoben:

Operationen können von Integrationen auf jeder Ebene benutzt werden. Nur sind sie dann eben in der Hierarchie ein Blatt, eine Black Box:

void I1() {
I11();
O1();
I12();
}

void I11() {
I111();
O2();
I112();
}

void I111() {
I1111();
O3();
I1112();
}

void I1111() {
O4();
O5();
}

...

Du siehst, auf jeder Ebene werden Operation O*() aufgerufen. Das ist völlig ok.

Anonym hat gesagt…

Wie kommunizieren verschiedene Operationen miteinander?

Beispiel:
Dateiname_von_Kommandozeile_holen(), Datei_laden(dateiname) und Datei_lesen(datei) sind Operationen.

Situation: Es existiert keine Datei mit dem angegebenen Namen!

=> Datei_laden(name_einer_fehlenden_datei) gibt null zurück.

Wer fängt diesen "Fehler" auf?
Die darüber liegende Integration darf ja nicht einfach auf null prüfen?!

Erste_Seite_laden() { // Integration
dn = Dateiname_von_Kommandozeile_holen();
datei = Datei_laden(dn);

return lese_Daten(datei);
}

Es ist doch nicht die Aufgabe von Datei_laden bei Erfolglosigkeit eine Fehlermeldung zu generieren.

Ralf Westphal - One Man Think Tank hat gesagt…

Ich sehe mal davon ab, dass eine Methode wie Datei_laden() nie null als Ergebnis zurückliefern sollte.

Entweder, das Fehlen einer Datei ist ausgeschlossen - außer es passiert dann doch mal. Dann kommt es zu eine Exception und die sollte das Programm beenden. (Allerdings aufgefangen durch einen globalen Exception Handler.) Das ist kein Fall, der uns hier interessieren muss.

Oder das Fehlen der Datei ist ein erwartbarer Fall. Dann muss dafür Vorkehrung getroffen werden. Es gibt dann zwei Pfade durch Erste_Seite_laden(). Wie kann man das machen?

Die Lösung liegt in Continuations:

void Zeilen_lesen(string dateiname, Action zeilen, Action dateiNichtGefunden) {...}

Hier der Code von Erste_Seite_laden() dazu: https://gist.github.com/ralfw/5486673#file-gistfile1-cs

So zu denken und zu arbeiten ist ungewöhnlich - aber es lohnt sich. (Leider macht es C# schwieriger als nötig. Mit F# geht das einfacher.)

(Was Datei_nicht_gefunden() tut, sei mal dahingestellt. Da wird in diesem Falle ein Fehler geworfen nach Ausgabe in eine Log-Datei oder so ;-) Aber es hätte auch ein Fehlerbehandler in Erste_Seite_laden() hineingereicht werden können.)

Jürgen hat gesagt…

Deine Einleitung trifft genau das, was ich gemeint hatte. (neue Fkt. i, abhängig von c_vor.., c_nach.., s).

Finde den Ansatz sehr interessant und werde mal einige Dojos ausprobiren, aber:

Seite Erste_Seite_laden() {
var dateiname = Dateiname_von_Kommandozeile_holen();
var alleDatenzeilen = Zeilen_lesen(dateiname);
return new Seite {
Überschrift = Überschrift_extrahieren(alleDatenzeilen),
Datenzeilen = Zeilen_der_ersten_Seite_selektieren(alleDatenzeilen)
};
}

Was ist mit der "new Seite", haben wir hier nicht wieder das Problem #1. "Böse", d.h. wir brauchen hier IoC und DI - genau wie am Anfang.

Anonym hat gesagt…

Ok, meine Eingangsfrage hat sich geklärt. Insofern finde ich, dass dein "Fraktal"-Vergleich aber nicht so ganz stimmt - die Selbstähnlichkeit eines Fraktals suggeriert meiner Meinung nach doch eher, dass "Integrationen" auf einem höheren Abstraktionslevel selber als atomar gelten und dort wieder als Bestandteil von Funktionen Verwendung finden können. Und geht nicht genau dieses wichtige Konstruktionsprinzip hier "über Bord"? Ich meine: was als "atomar" gilt, und was nicht, ist doch ein Stück weit willkürlich, oder sollte doch zumindest von dem Abstraktionslevel abhängen, auf dem man sich gerade befindet.

Anonym hat gesagt…

Hallo Ralf,
Danke für das Code-Beispiel (https://gist.github.com/ralfw/5486673#file-gistfile1-cs
)

Derart (IoC) arbeite ich auch seit einiger Zeit, aber mit JavaScript...wie du schon sagtest, geht das mit C# nur sehr umständlich :-(

Das machst sogar richtig "Spaß", da ich mich für funktionales Programmieren begeistern kann.

Aber innerhalb "richtiger" (imperativer) Programmierung bin ich mir unsicher, ob es wirklich das ist, was ich möchte. Ich mag LINQ, ich mag Delegaten/Funktionsparameter und ich wünsche mir einen Pipelining-Operator in C#, aber das gesamte Programm so zu strukturieren ist ... hm ... ungewohnt?, oder unpraktisch?

Ich führe dieses Prinzip mal zu Ende:

Seite Erste_Seite_laden(action) {
Seite seite;
Dateiname_von_Kommandozeile_holen((dateiname) => {
Zeilen_lesen(dateiname,
zeilen => {
Überschrift = "";
Datenzeilen = "";

Überschrift_extrahieren((ü) => { Überschrift = ü; });
Zeilen_der_ersten_Seite_selektieren((z) => { Datenzeilen = z; });

action(new Seite {
Überschrift = Überschrift;
Datenzeilen = Datenzeilen;
});
},
() => Datei_nicht_gefunden()
);
});
}

Vorteile, die ich sehe:
- Erinnert stark an Async/Await => Parallelität (hier: nur asynchrones Bearbeiten) sollte sich sehr leicht umsetzten lassen (allgemein ein Vorteil von funktionaler Programmierung)
- Sieht funktional aus

Nachteile:
- Quellcode wächst in die Breite (horizontal)
- Sollte das Programm wirklich mit Dateiname_von_Kommandozeile_holen beginnen??? Ist DAS wirklich aussagekräftig? In meinen Augen verkörpert Dateiname_von_Kommandozeile_holen dadurch die oberste Abstraktionsebene. Das kann nicht gewollt sein.

Fragen:
- Ist der Kontrollfluss besser lesbar?

Anonym hat gesagt…

Damit man den Code etwas besser lesen kann:

Seite Erste_Seite_laden(action) {
....Seite seite;
....Dateiname_von_Kommandozeile_holen((dateiname) => {
........Zeilen_lesen(dateiname,
........zeilen => {
............Überschrift = "";
............Datenzeilen = "";

............Überschrift_extrahieren((ü) => { Überschrift = ü; });
............Zeilen_der_ersten_Seite_selektieren((z) => { Datenzeilen = z; });

................action(new Seite {
....................Überschrift = Überschrift;
....................Datenzeilen = Datenzeilen;
....................});
............},
............() => Datei_nicht_gefunden()
............);
........});
....}

Ralf Westphal - One Man Think Tank hat gesagt…

@Jürgen: new Seite ist nicht böse. Das ist eine Abhängigkeit von Daten. Die darf sein. Die muss sein. Irgendwann muss irgendwer mal ein konkretes Wort aussprechen und nicht immer nur Indirektionen bemühen.

Außerdem ist der Gebrauch von Seite hier trivial. Da gibt es nichts zu mocken.

Ralf Westphal - One Man Think Tank hat gesagt…

@Anonym: Breitenwachstum? Das ist eine Frage der Formatierung.

Ich finde Schachtelungen in jedem Fall schlechter zu lesen.

Natürlich muss die Funktion mit Dateiname_von_Kommandozeile_holen() beginnen. Das ist der erste Schritt, der getan werden muss.

Dass die Methode wichtiger erscheint, liegt ja nur daran, dass du da sofort mit Continuation arbeitest. Das ist nicht nötig. Sind ja kein Selbstzweck.

Aber auch wenn du mit Cont. arbeitest, kommt es dann auf die Formatierung an. Hier meine Version deines Codes: https://gist.github.com/ralfw/707512ea1f8f8c5e9ec2#file-gistfile1-cs

Im Detail mögen Continuations den Fluss manchmal etwas vernebeln. Aber mit Übung und Formatierung nimmt man diese Hürde in C#. Der Vorteil der besseren Evolvierbarkeit durch Entkopplung wiegt den kleinen Nachteil auf. (Und kurze Integrationen tun ihr Übriges.)

Anonym hat gesagt…

Etwas, das noch nicht detailliert aufgezeigt wurde, ist die Behandlung des Falles, wenn die Datei nicht existiert.

Zeilen_lesen(string dateiname, Action mach_etwas, Action dateiNichtGefunden) {...}

In den Tiefen von Zeilen_lesen muss es zu einer Fall-Unterscheidung kommen:
if (Datei_existiert(dateiname) mach_etwas(...);
else dateiNichtGefunden(...);

Diesen Code stecken wir in eine Funktion Datei_lesen. Diese Funktion ist aufgrund der Bedingung keine Integration, aber aufgrund der Funktionsaufrufe auch kein Blatt (und damit keine Operation).
Oder zählen die Funktionsaufrufe hier nicht, da sie von "oben kommen"/durchgereicht wurden?

Ralf Peine hat gesagt…

Ich denke das mal weiter:

Eine typische Integrator-Methode sieht ja z.B. dann so aus:

int integrator_methode(int input)
{
...norm_i next_i = to_norm_i(input);
...
...// Codeblock 1
...f1_input input_f1 = convert_to_f1_input(next_i);
...f1_output output_f1 = f1(input_f1);
...next_i = convert_to_norm_i(output_f1);
...
...// Codeblock 2
...f2_input input_f2 = convert_to_f2_input(next_i);
...f2_output output_f2 = f2(input_f2);
...next_i = convert_to_norm_i(output_f2);
...
...// Weitere Codeblöcke
...
...// Codeblock n
...fn_input input_fn = convert_to_fn_input(next_i);
...fn_output output_fn = fn(input_fn);
...next_i = convert_to_norm_i(output_fn);
...
...int result = to_int(next_i);
...return result;
}

Wenn man jetzt die Codeblöcke in eine anonyme Funktion lambda_n umwandelt,
erhält man folgende Struktur:

int integrator_methode(int input)
{
...norm_i next_i = to_norm_i(input);
...
...next_i = lambda_1(next_i);
...next_i = lambda_2(next_i);
...
...
...
...next_i = lambda_n(next_i);
...
...int result = to_int(next_i);
...return result;
}

Schreibt man die lambda_i in ein Array, erhält man folgendes

int integrator_methode(int input)
{
...norm_i next_i = to_norm_i(input);
...
...for (int i=1..n) {
......next_i = array[i](next_i);
...}
...
...int result = to_int(next_i);
...return result;
}

Wenn man das Array jetzt noch von außen übergibt,
dann erhält man generativen Code.

int integrator_methode(int input, Array array)
{
...norm_i next_i = to_norm_i(input);
...
...for (int i=1..n) {
......next_i = array[i](next_i);
...}
...
...int result = to_int(next_i);
...return result;
}

Jetzt können der Entwickler per Code und sogar der Anwender per GUI
bestimmen, welche Aktionen, sprich lambda_i durchgeführt werden
sollen.

Das ist besonders interessant für die Massendatenverarbeitung, falls
z.B. integrator_methode(...) 100.000 mal oder öfter aufgerufen werden
soll.

Beispiel: Ausgabe von Daten in Tabellenform.

Eine ASCII-Text-Tabelle kann nur die Optionen -align und -width für
eine Spalte implementieren, Färbung, Fonts, Fettschrift usw. nicht.
Dann enthält der Array im ASCII-Format nur lamba_align() und
lambda_width(), im HTML-Format alle und im LaTex-Format bei
Schwarz-Weiss-Druck alle bis auf lambda_farbe().

In Klassen gedacht, würde man für ASCII lamda_farbe(), lambda_font()
und lambda_fett() leer implementieren und jeweils 1.000.000 (1 Mio)
mal aufrufen, bei einer Tabelle mit 100.000 Zeilen und 10 Spalten.
Das sind 3 Mio überflüssige lamda_calls inkl. Hin- und
Rückkonvertierung.

Gerade in Perl sind Methoden-Calls sehr teuer, was die Laufzeit
angeht. Daher gehe ich da noch einen Schritt weiter, und erzeuge statt
Lambdas einen Perl-Code-String $code_string, den ich mit eval
($code_string) als Perl-Code implementiere und verwende die daraus
resultierende anonyme Funktion (nicht Methode, daher schneller!) als
integrator_methode(i);

Jetzt hat mein Programm also ein eigenes Code-Stück geschrieben,
compiliert und 100.000 Mal aufgerufen.

Und das gibt es auch schon zum Download unter:
http://www.jupiter-programs.de/prj_public/erg/index.htm

Das ist noch die alte Version, die neuere Variante mit verbesserter
API ist fast fertig. Das Schreiben dieses Blogs hat mich ein wenig
davon abgehalten, weiter daran zu arbeiten.

Ralf Peine

Ralf Westphal - One Man Think Tank hat gesagt…

@Anonym: In einer Operation darf eine Continuation aufgerufen werden. Das ist im Grunde dasselbe, wie ein Funktionsergebnis zurückgeben.

int Berechnen(...) {...}

oder

void Berechnen(..., Action ergebnis) {...}

das ist egal.

Entscheidend ist, dass der Delegat eine Action<...> ist. Dann kann er nämlich kein Dienstleistung für Berechnen() darstellen.

Und der Name des Delegat-Parameters sollte deutlich machen, dass unbekannt ist, wie es weitergeht. Der Delegat steht für einen Event.

Falsch:

void Berechnen(..., Action tueEtwasGanzKonkretes) {...}

Richtig:

void Berechnen(..., Action ergebnis) {...}

Die Benennung soll so sein, dass das, was mit einem Ergebnis passiert, "anonym" ist. Die Methode selbst weiß nicht, was folgt. Sie erwartet auch nichts zurück.

Nachteil von Continuations: Der Aufruf einer solchen Methode wird umständlicher.

Vorteil: Mit einer Cont. kann man mehr als ein Ergebnis zurückliefern; es lassen sich ganz simpel Datenströme herstellen.

Ralf Westphal - One Man Think Tank hat gesagt…

@Ralf: Ein solches Muster, wie du es beschreibst, habe ich noch nicht gesehen. Du hast da irgendwie eine Schleife serialisiert, weil du die Kontrollstruktur vermeiden willst.

Aber so arbeitet man nicht. Jedenfalls habe ich in mehreren Jahren dazu noch keine Not gehabt.

Schleifen müssen gar nicht vermieden werden. In Operationen dürfen sie ja stehen. Man geht nur anders mit ihnen um.

Wenn du ein weniger abstraktes Beispiel nennst, kann ich dir das gern mal in IOSP/PoMO Code umwandeln. Unter dem, was du da geschrieben hast mit den ganzen Konvertierungen, kann ich mir nichts vorstellen. Nimm mal etwas aus deiner Praxis, was für mich auch noch verständlich ist ;-)

Ralf Peine hat gesagt…

Das kommt davon, wenn man in Beispielen zu abstrakt wird. Nehmen wir
einmal an, die Integrative Methode erwartet als Input einen double
Wert und liefert wieder einen double zurück. Und zwischen den
einzelnen Operationen wird der Wert immer als double weitergereicht.

double running_value;

// Block 1
// Block 2
.......
// Block j: Wurzel aus reeller Zahl berechnen
...complex complexe_zahl = convert_to_complex(running_value);
...complex ergebnis = quadrat_wurzel(complexe_zahl);
...running_value = convert_to_double(ergebnis);

// --- Erläuterung ----
// Block j

// Dieser Block berechnet die Wurzel

// Da die Wurzel-Funktion komplexe Zahlen als input erwartet,
// muss also voher die reelle Zahl z in eine komplexe Zahl c + di
// umgewandelt werden.

...complex complexe_zahl = convert_to_complex(running_value);

// Jetzt die Aktion durchführen, also die Quadrat-Wurzel aus der Zahl
// ziehen, das geht für komplexe Zahlen bekanntlich immer

...complex ergebnis = quadrat_wurzel(complexe_zahl);

// Und dann das Ergebnis wieder in eine reelle Zahl umwandeln.
// Hier stellt sich die Frage, was passiert,
// wenn der imaginäre Anteil di != 0 ist?
// - Man kann ihn unter den Tisch fallen lassen,
// - oder den Betrag der Zahl liefern
// - oder eine Exception werfen.
// Was passiert, entscheidet in dieser Implementierung
// allein convert_to_double()

...running_value = convert_to_double(ergebnis);

Am Ende ist der Wert wieder im running_value als double gespeichert,
und der nächste Block kann starten,
der als Input wieder einen double erwartet.

Dadurch kann man die Blöcke in beliebiger Reihenfolge durchführen oder
einzelne weglassen. Und das zum Start oder sogar zur Laufzeit beliebig
konfigurieren.

Das ist nur eine weitere Möglichkeit, mit Operationen umzugehen.
Aber natürlich nicht die einzige.

Benötigt man dieses hohe Maß an Flexibilität nicht, kann man
den Code natürlich einfach wie in den vorigen Beispielen hinschreiben
und eventuell überflüssige Konvertierungen weglassen.

Aber um z.B. Workflows vom Kunden konfigurierbar zu implementieren,
wäre das eine Lösung, weil die Bausteine ja - technisch - beliebig
kombiniert werden könnten.

Auch wenn das nicht automatisch zu sinnvollem Code führt ;-)

Ralf Westphal - One Man Think Tank hat gesagt…

@Ralf: Nun haben wir aneinander vorbeigeredet. Ich hatte den Gedanken hinter deinem generischen Code schon verstanden - nur ist das eben kein typisches Muster.

Ich würde z.B. eher nicht die 3 Funktionsaufrufe in Block j so in die integrierende Funktion schreiben. Stattdessen würde ich sie in eine eigene Funktion wegklappen.

Und auch den Punkt mit der dynamischen Konfiguration sehe ich gemischt. Vom technischen Standpunkt aus ne coole Sache. Aber wer daran zuerst denkt, der ist schon beim Framework basteln, wenn das konkrete Problem nicht klar ist. "Könnte man ja brauchen..."

Davon halte ich nicht soviel. Da kommt man schnell auf YAGNI.

Damit will ich nicht sagen, dass eine solche Strukturierung von Software keine Hilfe gebrauchen könnte. Aus dem Grunde habe ich schon vor längerer Zeit die Flow Runtime gebastelt: https://github.com/ralfw/NPantaRhei

Mit der kann man Integrationen mit beliebiger Tiefe in einer DSL formulieren. Die muss man gar nicht mehr in C# oder so codieren. Nur noch Operationen sind zu schreiben.

Das funktioniert nett und bietet hübsche Möglichkeiten. Nur am Ende bin selbst ich davon wieder abgekommen. Braucht man einfach nicht. Oder zu selten. Ein bisschen Disziplin beim Codieren mit der vorgestellten Regel reicht. Damit kommt man weit, ohne noch lange etwas basteln oder lernen zu müssen.

Es geht um direkte Lesbarkeit und Evolvierbarkeit. Da stören zusätzliche Frameworks und Indirektionen manchmal.

pvblivs hat gesagt…

Empfinde ich als gutes Paradigma, Integration und Operation zu trennen.

Danke, werde es ausprobieren! :-)

Ralf Peine hat gesagt…

Ja klar, normalerweise ist das oversized. Aber für mein Report-Framework genau das richtige. Und wenn die Performance das Generieren von Perl-Code nicht notwendig machen würde, dann würde ich das auch immer direkt auskodieren, genau wegen der Lesbarkeit. Und Compile-Fehler sind im generierten Code schwer auszumachen.

Und die Hin- und Zurückkonvertierung hatte ich ja im ersten Beispiel in die Lambdas "verpackt".

Mir ging es hier mehr darum die Möglichkeit aufzuzeigen, alle statischen Entscheidungen (die nicht vom verarbeiteten Wert oder Zwischenergebnissen abhängen) vorher einmal durchzuführen und nicht im Ablauf über 100.000 Mal.

Mirko Wagner hat gesagt…

Hallo zusammen,

auch mir gefällt vor allem die Regel, Operationen von Integrationen zu trennen. Die zugrundeliegende Idee ist sicherlich schon früher in anderen Prinzipien (zumindest ansatzweise) formuliert worden. Z.B. wurde ja schon immer gefordert, innerhalb einer Methode nur Tätigkeiten "der gleichen technischen Ebene" durchzuführen.

Was ich aber irgendwie nicht wirklich verstehe: Wie addressiert denn diese strikte Trennung vor allem die Probleme der topologischen, syntaktischen und semantischen Kopplung?

Gerade die semantische Kopplung bereitet mir Kopfzerbrechen. Wenn sich an der Semantik von s() etwas ändert, so muss sich doch zwangsläufig auch die Vorbereitung - also c_vorbereitung - ändern?! Und das obwohl diese Methode nicht einmal etwas von s() weiß. Ich habe den Eindruck, als ob sich wieder mal nur etwas verschoben hat.

Hmmm ...

Vielen Dank und viele Grüße
Mirko

Anonym hat gesagt…

Was mir daran bislang nicht gefällt ist, dass Funktionen, die als Integrationen entstanden sind, und Operationen nicht austauschen lassen. Eine "Integration" könnte doch beispielsweise auf einem höheren Abstraktionsniveau wieder eine Operation sein. Oder eine Operation , die z.B. in Form einer Framework-Funktion daher kommt, von mir durch eine äquivalente Funktion ausgetauscht werden, die ich selber aber als Integration mehrerer anderer Funktionen gebaut habe. Diese Austauschbarkeit geht mir hier verloren.

Ralf Westphal - One Man Think Tank hat gesagt…

@Mirko: Das Prinzip Single Level of Abstraction (SLA) hat schon ansatzweise gewollt, was IOSP will. Aber letztlich hat es keine so große Aufmerksamkeit gefunden, wie es verdient hätte. Denn wirklich konsequent verstanden, ist es eigentlich das IOSP :-) Denn solange noch irgendwo

if(prüfe_bedingung()) ...

steht, ist da nicht nur ein level of abstraction im Spiel.

Semantische Kopplung: Wenn da steht...

var x = c_vorbereitung();
var y = s(x);
var z = c_nachbereitung(y);

dann ist der Kontrakt zwischen s() und den anderen beiden Funktionen klar. Die Signaturen bringen ihn auf den Punkt.

Wenn da aber steht...

...
var x = ...
...
x = ...
...
var y = s(x);
...
var z = ...
...
z = ... y ...
...

dann ist nicht so klar, wie der Kontrakt aussieht. Ja, irgendwie sieht man, dass es um x geht, das an s() übergeben wird. Aber wie kommt x zustande? Was passiert mit y? Was ist das Ergebnis der Nachbereitung?

Wenn sich s() ändert, dann muss man grübeln, ob und wo in Vor- und Nachbereitung eingreifen.

Das ist alles viel, viel klarer in einer reinen IOSP Methode. Da kann man im Zweifelsfall gleich sehen, dass man in keiner anderen Methode eingreifen muss, sondern stattdessen etwas zwischen die Aufrufe des Existierenden schiebt:

var x = c_vorbereitung();
var x = c_nachbessern(x);
var y = s(x);

Dito zwischen s() und Nachbereitung.

Wenn ich das hier so sage, ist das natürlich recht theoretisch. Man muss das einfach mal ausprobieren. Das ist übrigens gelebtes OCP. Die Operationen werden damit recht unempfindlich (closed for modification) für Veränderungen. Aber die Integrationen sind offen (open for extension). Da einen Einschub zu machen, ist trivial.

Das sieht anders aus als das tolle Strategy Pattern zu benutzen. Aber wir wollen ja nicht in pattern mania sterben, sondern was auf die Straße kriegen.

Für eine strategy muss man eine Codeeinheit auch speziell öffnen. Die muss man vorhersehen. Das ist schnell mal premature optimization. Aber eine Integration ist vom Zweck her offen für extensions.

pvblivs hat gesagt…

Ich habe noch eine Frage, Ralf. Betrachte mal bitte:

Betrag berechneSteuer(Betrag b) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = steuer(betragInEuro)
return steuerInEuro
}

und:

Betrag berechneSteuer(Betrag b) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = steuer(betragInEuro, STEUERSATZ)
return steuerInEuro
}

und:

Betrag berechneSteuer(Betrag b) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = betragInEuro.anteil( STEUERSATZ_FAKTOR)
return steuerInEuro
}

und:

Betrag berechneSteuer(Betrag b, Faktor steuersatz) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = betragInEuro.anteil( steuersatz)
return steuerInEuro
}

und:

Betrag berechneSteuer(Betrag b, Faktor steuersatz) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = b.value() * steuersatz.value()
return steuerInEuro
}

und:

Betrag berechneSteuer(Betrag b, Faktor steuersatz) {
def betragInEuro = betragInEuro(b)
def steuerInEuro = b * steuersatz // überladen
return steuerInEuro
}


Konkret interessiert mich: Wo würdest Du die Grenze ziehen? Was ist noch integrativ und was nicht.

Hintergrund ist, dass ich heute eine philosophische Diskussion mit einem Kollegen darüber hatte, dass ja Betrag.anteil auch nur eine funktionale Abhängigkeit ist. Jetzt könnte man argumentieren, dass auch * nur eine funktionale Abhängigkeit ist.

Meine Frage zielt darauf ab, dass auch bei solchen Prinzipien wie dem IOSP natürlich die Grenzen fließend sind. Was ist für Dich Kriterium: Gute, sprechende Namen?

Ralf Westphal - One Man Think Tank hat gesagt…

In einer Integration ist eine funktionale Abhängigkeit erlaubt.

Nun kannst du sagen, Kontrollstrukturen und Operatoren sind auch nur Funktionen.

Haha. Natürlich. Aber es geht nicht um den Mechanismus, sondern den Inhalt. "Funktionale Abhängigkeit" ist die Kombination aus Mechanismus (Funktion) und Semantik. Und damit ist selbstentwickelte Semantik gemeint.

Operationen kapseln die Nutzung von nicht selbst entwickelter Semantik. Das sind Kontrollstrukturen und Operatoren und API-Aufrufe aller Art.

Damit haben sie soviel zu tun, dass sie sich darauf konzentrieren sollen. Sie fassen solche domänenneutralen Funktionen zusammen im Sinne einer konkreten Domäne. Sie geben ihnen eine Bedeutung in Bezug auf die Anforderungen an eine konkrete Software. Das ist der Job von Operationen. Manchmal steht in denen deshalb nur 1 Zeile, z.B.

string[] Alle_Datensätzen_laden(string dateiname) {
..return File.ReadAllLines(dateiname);
}

Das ist eine Operation, die einen API-Aufruf durchschleift, um ihn in die Anwendungsdomäne zu heben. Sie kapselt die Details des Ladens von Datensätzen. Heute ist das eine Zeile, morgen könnten das aber auch viele Zeilen sein.

Integrationen verbinden nun Operationen und andere Integration zu größeren "Domänen. Bei Integrationen bewegt man sich also schon in der Domäne. Die Operation stellt erst Domänensemantik aus neutralen "Materialien" her.

Ich freu mich, dass du gefragt hast. So konnte ich diesen Aspekt hier nochmal herausarbeiten. Ich denke, der ist wichtig.

Operationen "schöpfen" Domänenvokabular. Integrationen "veredeln" Domänenvokabular :-)

pvblivs hat gesagt…

Operationen schöpfen / bilden Domänenvokabular, Integrationen veredeln. Finde ich ein gutes, klares Bild. Eine bessere und konkretere Abgrenzung als 'funktionale Abhängigkeit'.

Anonym hat gesagt…

Hallo,

ich habe mir die Artikel durchgelesen und versucht das ganze umzusetzen. Im Beispiel unten ein Ausschnitt.

Wäre das so korrekt?

Vorher:
public void Enque(T t)
{
var node = new Node(t);
if (firstNode == null)
firstNode = lastNode = node;
else
lastNode = lastNode.next = node;
count += 1;
}

Nachher:
public void Enque(T t)
{
CheckFirstNodeExists(t, x => InsertLastNode(x), x => InsertFirstNode(x));
RaiseElementCounter(1);
}

private void CheckFirstNodeExists(T value, Action OnExist, Action OnNotExist)
{
if (firstNode != null)
OnExist(value);
else
OnNotExist(value);
}

private void InsertLastNode(T t)
{
lastNode = lastNode.next = new Node(t);
}

private void InsertFirstNode(T t)
{
firstNode = lastNode = new Node(t);
}

private void RaiseElementCounter(int value)
{
count += 1;
}