“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” – Martin Fowler
Bis jetzt hatten alle in meinem Beispiel verwendeten Funktionseinheiten nur einen Ausgang und das Modell bewegte sich auf nur einer Abstraktionsebene. Jetzt möchte ich eine Funktionseinheit mit zwei Ausgängen und dem Modell eine zweite Abstraktionsebene hinzufügen, um zu demonstrieren, wie elegant man in Flow-Design mit verschiedenen Abstraktionsebenen umgehen kann, indem mehrere Funktionseinheiten zu einer neuen, höher abstrahierten Funktionseinheit zusammengefasst werden.
Für mich ist dies, neben der prinzipiellen Unabhängigkeit der Funktionseinheiten voneinander, die zweite revolutionäre Neuerung, die Flow-Design kennzeichnet. Dadurch können beim Verstehen des Modells Funktionseinheiten niedrigerer Abstraktionsebenen weggelassen werden. Sind diese jedoch zum Verständnis der Funktion einer Einheit notwendig, so kann in diese Einheit so zu sagen hinein vergrößert werden, und die dort enthaltenen Funktionseinheiten werden sichtbar. Dabei sind Abstraktionsebenen des Modells im Code eins zu eins wiederzufinden. Man navigiert also im Modell ganz auf die gleiche Weise wie im Code. Ein großer Vorteil, um das Auseinanderlaufen von Modell und Code zu verhindern. Im Flow-Design ist das Modell immer direkt aus dem Code ableitbar, das Modell als grundlegende strukturelle Dokumentation des Codes veraltet nie.
Das Open-Source-Dilemma
Habt ihr euch schon mal versucht, Euch in Open-Source-Code einzuarbeiten, um eine Kleinigkeit zu ändern, die einem möglicherweise nicht gefällt? Selbst wenn der Code gut geschrieben ist, eins fehlt ihm meistens – Struktur-Dokumentation, wenn überhaupt Dokumentation da ist. Man weiß nicht, an welcher Stelle man in den Code einsteigen soll. Ja klar, man fängt in der Main-Methode an. Aber bis zu der Funktionalität, die man ändern oder anpassen will, ist es von da ein weiter Weg. Man kann sich nur an den Methoden-Aufrufen entlang hangeln. Das Problem hierbei ist, daß man oft das Ziel nicht kennt. In jeder Methode steht man bei jedem weitergehendem Aufruf erneut an einen Scheideweg: Soll man da reingehen oder kann man davon abstrahieren auf der Suche der eigentlichen zu ändernden Funktionalität. Dies kann man normalerweise nur entscheiden, wenn man die Funktion der absteigenden Methode verstanden hat. Diese Entscheidung steht aber immer wieder an, in jeder Methode mehrfach. In einer großen Applikation ergibt sich dadurch eine Komplexität, schier durch die Menge der möglichen Aufrufe, durch die Größe des Methoden-Aufrufgraphen, die man nur schwer im Kopf handhaben kann. Das ganze stellt sich als ein riesiges Netz von Abhängigkeiten dar. Wie ein Fischernetz: egal an welchem Knoten man es hoch hebt, man zieht immer das ganze Netz mit hoch. Es fehlt einfach die Möglichkeit, das Gesamtsystem zu dekomponieren, in einzelne Teile zu abstrahieren. Das ist der Grund, behaupte ich mal, warum so wenige sich an der Weiterentwicklung von Open-Source-Applikationen beteiligen, warum es so schwer ist, rein zukommen, warum der Kreis der Entwickler einer Applikation immer so klein ist.
In kommerziellem Code ist das Problem gleich gelagert, nur ist der Effekt ein anderer: Ist einem ein Coding-Auftrag zugeteilt, kommt man nicht umhin, sich einzuarbeiten. Die Alternative, es sein zu lassen, gibt es nicht. Dadurch ergeben sich lange Einarbeitungszeiten, die im Management keiner versteht und die auch von vornherein nicht transparent und nicht abschätzbar sind. Auch hier wäre Dekomposition und die Möglichkeit, die Implementierung auf verschiedenen Abstraktionsebenen zu erfahren, sehr hilfreich.
Dekomposition
Auf Flow-Design basierende Applikationen bieten genau diese Möglichkeit der Dekomposition. Es gibt keine Aufrufe mehr, nur noch Datenflüsse und strukturelle Komposition von Funktionseinheiten. Das ist der Vorgehensweise im Design von Mikroelektronik-Komponenten sehr ähnlich. Auch dort werden Basis-Komponenten zu Boards zusammengesetzt, für die neue Ein- und Ausgänge spezifiziert werden. Die Basis-Komponenten selbst sind für den Designer Black-Boxes. Es gibt ein Datenblatt über die Funktion der Ein- und Ausgänge. Mehr muss er nicht wissen, er abstrahiert von der internen Realisierung der Basis-Komponenten. Das konstruierte Board hat wiederum spezifizierte Ein- und Ausgänge und kann mit anderen Komponenten derselben Abstraktionsebene zu umfassenderen Boards zusammengebaut werden, mit neuen Ein- und Ausgängen. Dies ließe sich weiter fortsetzen. Und im Prinzip ist jedes elektronische Gerät so aufgebaut. Abstraktion und Dekomposition sind zwei wesentliche Wesenszüge des Designs dieser Geräte. Warum dieses Prinzip nicht auf Software übertragen? Das war Ralf Westphals grundlegende Idee in seinem ursprünglichen Artikel.
Abstrakte Funktionseinheit
In meinem gewählten Beispiel möchte ich zur Verdeutlichung des oben erläuterten Prinzips die beiden Funktionseinheiten ToUpper und ToLower zu einer neuen Funktionseinheit Normalize zusammenfassen. In diese Funktionseinheit fließt ein Text ein. Dieser Text fließt modifiziert über zwei Ausgänge wieder aus der Funktionseinheit aus. Auf dem einen Ausgang sind alle Kleinbuchstaben in Großbuchstaben umgewandelt, auf dem anderen umgekehrt.
Die bisherigen Modell-Zeichnung ließe sich wie folgt abwandeln, um dies darzustellen.
Sieht erst mal nicht viel anders aus, nur dass ToLower und ToUpper von einem weiteren Kasten umgeben sind. Wenn man das System aber mal größer denkt, wenn also z.B. ToLower eine weitere Strukturierung hätte, wäre diese Darstellung nicht mehr passend. Es würden sich bei großen Systemen ähnliche Schachtelmonster ergeben, wie man sie bei UML manchmal sieht.
Nein, von ToUpper und ToLower soll ja abstrahiert werden. Also sollte man sie weglassen auf der obersten Abstraktionsebene.
Will man in das Design der Funktionseinheit Normalize hineinsehen, so zoomt man praktisch hinein. Ich finde die Darstellungsart, die Ralf Westphal in seinen Artikeln für den Vergrößerungsvorgang verwendet, intuitiv zu verstehen:
Aufräumarbeiten
Vor der Implementierung der abstrakten Funktionseinheit Normalize gilt es jedoch noch ein paar Aufräumarbeiten und syntaktische Optimierungen vorzunehmen, um die Verbindung von Ports lesbarer zu gestalten.
Als Aufräummaßnahme habe ich zur Vorbereitung einer Flow-Design-Bibliothek alle Port-Traits und die Basis-Klasse FunctionUnit in ein separates Paket de.grammarcraft.scala.flow verschoben.
Des Weiteren empfinde ich die Spezifikation der Verarbeitung des Collector-Ergebnisses als nicht gut lesbar:
collector letOutputBeProcessedBy(msg => { println("received '" + msg + "' from " + collector) })
Intuitiver besser zu verstehen, und damit besser lesbar, wären folgende Zeilen:
collector.output isProcessedBy(msg => { println("received '" + msg + "' from " + collector) })
Diese Schreibweise ist nicht ganz einfach zu realisieren, da output eine Methode ist, ebenso wie isProcessedBy. Das eine kann nicht auf das andere angewendet werden.
Um dieses Problem zu lösen, muss zunächst erst einmal der Name output frei gemacht werden. Deshalb habe ich die Methode output im Trait OutputPort umbenannt in forwardOutput.
trait OutputPort[T] { … protected def forwardOutput(msg: T) { if (!outputOperations.isEmpty) { outputOperations.foreach(operation => operation(msg)) } … } }
Anstelle der ursprünglichen output-Methode ist ein Value-Objekt output mit Hilfe einer anonymen Klasse definiert.
trait OutputPort[T] { port => … val output = new Object { def -> (operation: T => Unit) = port.outputIsProcessedBy(operation) def isProcessedBy(operation: T => Unit) = port.outputIsProcessedBy(operation) } … }
Die anonyme Klasse nimmt die beiden Methoden „->“ und isProcessedBy auf, damit diese im Kontext der Verknüpfung der Funktionseinheit mit anderen Funktionseinheiten auf das Value-Objekt output angewendet werden können. Beide Methoden sind gleich implementiert, sollen jedoch in verschiedenen Kontexten verwendet werden: die Operation „->“ eher zum Verbinden von Funktionseinheiten, die Methode isPorcessedBy eher, um Seiteneffekte mit Closures zu realisieren.
Das lokale Value-Objekt port wird am Anfang des Traits über die Notation
port =>
definiert und referenziert this – eine Scala-spezifische Möglichkeit für this ein Alias zu definieren.
Der Aufruf der beiden in der anonymen Klasse definierten Methoden wird an die private Trait-Methode outputIsProcessedBy weitergeleitet. Zusätzlich wurde hier noch die alte Methode letOutputBeProcessedBy umbenannt in outputIsProcessedBy.
trait OutputPort[T] { … private def outputIsProcessedBy(operation: T => Unit) { outputOperations = operation :: outputOperations } … }
Mit der obigen Definition von output lässt sich dann die gewünschte Verknüpfungssyntax realisieren:
collector.output isProcessedBy(msg => { println("received '" + msg + "' from " + collector) })
Basierend auf der Definition des Traits OutputPort wurden in gleicherweise die index-behafteten Traits OutputPort1 und OutputPort2 definiert, um Funktionseinheiten mit multiplen Ausgängen spezifizieren zu können. Hier als Beispiel der Trait OutputPort1:
trait OutputPort1[T] { port => private[this] var outputOperations: List[T => Unit] = List() private def output1IsProcessedBy(operation: T => Unit) { outputOperations = operation :: outputOperations } val output1 = new Object { def -> (operation: T => Unit) = port.output1IsProcessedBy(operation) def isProcessedBy(operation: T => Unit) = port.output1IsProcessedBy(operation) } protected def forwardOutput1(msg: T) { if (!outputOperations.isEmpty) { outputOperations.foreach(operation => operation(msg)) } else println("no output port defined for " + this + ": '" + msg + "' could not be delivered") } }
Mit diesen Vorbereitungen können wir daran gehen, die Funktionseinheit Normalize zu implementieren.
Normalisieren
Die Realisierung der Funktionseinheit Normalize folgt einem striktem Schema. Die Funktionseinheit spezifiziert wie jede andere Funktionseinheit die entsprechenden Ein- und Ausgänge.
final class Normalize extends FunctionUnit("Normalize") with InputPort[String] with OutputPort1[String] with OutputPort2[String]
Dann werden zuerst die beteiligten integrierten Funktionseinheiten instanziiert.
val toLower = new ToLower val toUpper = new ToUpper
Es folgt die Verknüpfung der beteiligten Funktionseinheiten untereinander und die Weiterleitung der Ergebnisse an die Ausgabeports der umgebenden integrierenden Funktionseinheit (in unseren Fall ist aufgrund der Beschränktheit des Beispiels nur letzteres notwendig).
// bind toLower.output isProcessedBy(forwardOutput1) toUpper.output isProcessedBy(forwardOutput2)
Wäre die Logik komplexer, ließen sich auch hier die „->“-Operation anwenden. Lediglich die internen Weiterleitungsmethoden erfordern eine gesonderte Behandlung, wie oben zu sehen.
Am Schluss fehlen noch sinnvolle Namen für die beiden Ausgänge und die Verarbeitung der Daten, die über den einzigen Eingang der Funktionseinheit Normalizer einfließen und auf die Eingänge der integrierten Funktionseinheiten verteilt werden müssen.
// for meaningful names on binding to context val lower = output1 val upper = output2 protected def processInput(msg: String) { toLower.input(msg) toUpper.input(msg) }
Die Funktionseinheit Normalize ist im Ganzen dann wie folgt implementiert.
final class Normalize extends FunctionUnit("Normalize") with InputPort[String] with OutputPort1[String] with OutputPort2[String] { val toLower = new ToLower val toUpper = new ToUpper // bind toLower.output isProcessedBy(forwardOutput1) toUpper.output isProcessedBy(forwardOutput2) // for meaningful names on binding to context val lower = output1 val upper = output2 protected def processInput(msg: String) { toLower.input(msg) toUpper.input(msg) } }
Zusammenfassung
Der Wechsel der Abstraktionsebenen ist im Flow-Design kein Problem und intuitiv nachverfolgbar. Modell und Code sind immer synchron. Bei Bedarf lässt sich das Struktur-Modell direkt aus dem Code ableiten. Damit ist das Verstehen des Codes nur noch abhängig von gut gewählten Namen für die Funktionseinheiten oder der ausreichenden Dokumentation ihrer Transformationssemantik.
Was jetzt noch fehlt ist eine Flow-Design-konforme Fehlerbehandlung. Manch einer wird schon die ErrorPort-Komponente im oben genannten Paket entdeckt haben. Im nächsten Artikel kommt sie überarbeitet zu Ehren…
Randnotiz
Der hier in Auszügen dargestellte Code kann auf GitHub nachgelesen werden. Die Flow-Design-Bibliothek ist im Paket de.grammarcraft.scala.flow zu finden. Die altbekannte Beispielimplementierung basierend auf dieser Bibliothek ist in das Paket de.grammarcraft.scala.flowtraits2 gewandert.