Flow-Design – Wider den Abhängigkeiten

#JAVAPRO #Architecture #IODA #FlowDesign

Abhängigkeiten sind das Grundübel in Software-Systemen. Sie machen Softwarekomplex und oft unwartbar. Auf unterster Ebene werden Abhängigkeiten durch Methodenaufrufe induziert, die sich dann auf abstrakteren Ebenen zu einem Abhängigkeitsgestrüpp hochschaukeln, das manchmal kaum noch zu entwirren ist. Flow-Design ist angetreten, die Abhängigkeiten einzudämmen und auf ein gesundes Maß zu beschränken.

Ohne Methoden und ohne Methodenaufrufe – das geht doch gar nicht! Wie sonst soll Logik modularisiert und zu größeren Logikkonstrukten zusammengebaut werden als durch Methoden und Methodenaufrufe. Wir sind so in der Denkwelt der Subroutinen, Methoden und Funktionen verhaftet, dass eine andere Lösung undenkbar erscheint. Aber ist dies wirklich die einzige Möglichkeit der Programmierung, alles in aufrufbare Subroutinen zu zerteilen?

Message-Passing

An dieser Stelle soll nicht dagegen argumentiert werden, sondern ein Beispiel gegeben werden, dass es auch anders geht. Die Unix-Shell-Programmierung unter Verwendung des Pipe-Operators ist eines der bekanntesten Programmiermodelle, die in ihrem zentralen Prinzip nicht auf Subroutinen-Aufrufen, sondern auf Message-Passing beruht. Und man kann nicht behaupten, dass es nicht erfolgreich wäre.  Nachfolgend ein Beispiel-Skript aus der RPM-Toolbox.

(Listing 1) – find-requires
#!/bin/sh
. ./determineLibraryDependencies.sh
echo $* | determineLibraryDependencies | sort -u -r | xargs -r -n
1 basename

(Listing 1) zeigt ein leicht angepasstes Shell-Skript des Kommandos find-requires. Das Skript bestimmt für eine Liste von ausführbaren Dateien die Bibliotheksabhängigkeiten (im Linux-System) durch Anwendung des Befehls determineLibraryDependencies auf jede einzelne Datei – lokal definiert im importierten Shell-Skript determineLibraryDependenies.sh.

Die Liste der Bibliotheksabhängigkeiten, alles .so-Dateien, wird anschließend sortiert, Duplikate werden eliminiert, absolute Pfade abgeschnitten.

Jeder der hier beschriebenen Schritte wird im Skript durch eine Funktionalität realisiert, die entweder als Kommando des Betriebssystems bereits existiert oder in einem externen Skript definiert ist. Betrachtet man die letzte Zeile des find-requires-Skripts eingehender, wird der eigentliche Zweck des Skriptes selbst klar: es ist nur dazu da, vorhandene Funktionalität zu integrieren und Funktionseinheiten miteinander zu verbinden, damit sie miteinander kommunizieren können. Die Daten fließen quasi durch die Funktionseinheiten hindurch und werden durch sie verändert. Es gibt im Skript selbst keinerlei logische Ausdrücke oder Kontrollstrukturen, nur integrative Logik – den Pipe-Operator
und eine Skript-Import-Anweisung.

In (Listing 2) wird die einzige gegenüber dem Original neu programmierte Funktionseinheit determineLibraryDependencies näher betrachtet.

(Listing 2) – DetermineLibraryDependencies.sh
determineLibraryDependencies() {
  read input
  for f in $input; do
    ldd $f | awk ‚/=>/ { print $1 }‘
  done
}

Auch wenn es wie eine Methode aussieht, ist es keine im Sinne einer prozeduralen Sprache! Die Eingaben werden vom Standard-Eingabestrom gelesen ohne Kenntnis, welche Funktionseinheit diesen Eingabestrom erzeugt. Aber noch wichtiger ist: Der Funktionseinheit ist auch unbekannt, wer den von ihr selbst erzeugten Ausgabestrom konsumieren wird. Diese Funktionseinheit ist tatsächlich unabhängig von ihrem Kontext.

Dies ist ein signifikanter Unterschied zu Subroutinen, die andere Subroutinen, welche im logischen Datenfluss später folgen, direkt aufrufen. Dadurch wird eine direkte Abhängigkeit zwischen der aufrufenden und der aufgerufenen Subroutine induziert, die bei späteren Anpassungen des Codes nicht unbeachtet bleiben darf. In Message-Passing basierten Design-Ansätzen wie der Unix-Kommando-Shell-Programmierung mit dem Pipe-Operator ist die Abhängigkeit von der aufgerufenen Subroutine in die integrierende Funktionseinheit, hier z.B. find-requires, verschoben. Aber dort gehört sie auch hin, denn integrierende Funktionseinheiten haben nach der IODA-Architektur nur den einen Zweck zu integrieren. Sie sollen keine Logik direkt implementieren.

Prinzip der gegenseitigen Nichtbeachtung

In der Unix-Shell-Programmierung hat sich dieses Programmierprinzip fast automatisch ergeben. Es resultiert aus dem Umstand, dass alle vom Betriebssystem bereitgestellten Kommandozeilenprogramme eine eindeutige Schnittstelle haben – die zeilenbasierte Verarbeitung auf stdin und stdout. Die daraus folgende Methode des Komponierens vorhandener Funktionseinheiten zu neuen, abstrakteren Funktionseinheiten ist in Shell-basierten Programmsystemen weit verbreitet. Außerdem ist es auch für Externe einfach zu verstehen und zu erweitern. Die beteiligten Kommandos können sich gegenseitig nicht kennen, denn sie laufen in vom Betriebssystem voneinander abgeschotteten, separaten Prozessen. Bei der Implementierung der Kommandos konnte nicht auf die Funktionalität der anderen Kommandos zugegriffen werden, so wie wir es von der objektorientierten Programmierung her für Klassen und Methoden kennen.

Für prozedurale Programmiersprachen (dazu werden im weitesten Sinne alle, die auf Subroutinen basierenden, also auch objektorientierte und funktionale Programmiersprachen, gezählt) ist es möglich, nach diesem Prinzip Implementierungen vorzunehmen. Aber die ergeben sich nicht automatisch, sondern erfordern Disziplin, weshalb es Sinn macht, diesem Prinzip einen Namen zu geben. Ralf Westphal und Stefan Lieser, die den Begriff des Flow-Designs geprägt haben, nennen es das „Prinzip der gegenseitigen Nichtbeachtung“ (Principle of Mutual Oblivion – PoMO).

Das Prinzip drückt sich zuallererst darin aus, mit welcher Notation Flow-Design-basierte Systeme modelliert werden. Es ist eine sehr einfache Notation, kein Vergleich zur UML, die für viele überspezifiziert und zu detailliert ist. Im ersten Teil dieser Serie „Bessere Abstraktion mit IODA“ konnte man von der Notation schon einen ersten Eindruck bekommen. Im Flow-Design setzen sich Systeme aus Funktionseinheiten zusammen. Solch eine Funktionseinheit, wie als Beispiel in (Abb. 1) zu sehen, wird als Ellipse oder Rechteck dargestellt und mit der Funktionalität beschriftet, die es realisiert. Die Funktionseinheit hat eine oder mehrere Ein- und Ausgänge, welche die in die Funktionseinheit ein- und ausfließenden Daten repräsentieren sollen und Ports genannt werden. Die Ein- und Ausgabe-Ports sind bei Bedarf mit in Klammern gesetzten Typen der Daten, die darüber fließen, annotiert. Dies kann auch eine Aufzählung oder Liste (mit Stern markiert)
sein. Eine leere Klammer markiert ein Signal ohne spezifisches Datum und symbolisiert zum Beispiel den parameterlosen Aufruf eines Skripts oder Programms.

find-requires als Funktionseinheit. (Abb. 1)

find-requires als Funktionseinheit. (Abb. 1)

 

Über die Ein- und Ausgabe-Ports werden die Funktionseinheiten im Kontext einer integrierenden Funktionseinheit verbunden. Sie ergeben damit eine Datenverarbeitung, die die Funktionalität der integrierenden Funktionseinheit repräsentiert, wie im Beispiel in (Abb. 2) zu sehen. Die Grundidee der Flow-Design-Notation folgt dem Box-Bullet-Line-Prinzip und versucht, die Notation nicht zu überfrachten, um die Einstiegshürden für das Verstehen einer Design-Zeichnung möglichst gering zu halten. Im Idealfall sind die obigen Ausführungen ausreichend. Weitere Details zur Flow-Design-Notation sind in Stefan Liesers Cheatsheet zu finden. Generell sollte man die Notation nicht zu formal begreifen. Hauptziel ist ein gemeinsames Verständnis des Designs, damit man darüber diskutieren kann. Wir werden noch sehen, dass das Flow-Design eines Systems sehr einfach aus dem Code ableitbar ist, sofern dessen Umsetzung vom PoMO-Prinzip geleitet wurde.

Verbundene Funktionseinheiten in find-requires. (Abb. 2)

Verbundene Funktionseinheiten in find-requires. (Abb. 2)

 

Flow-Design implementieren

Zurück zum Beispiel aus Teil 1 unserer Serie. Dort wurde die Umwandlung von römischen Zahlen in arabische als Flow-Design entworfen, mit dem Ziel, eine IODA-Architektur zu erhalten. Dieses Ziel wird mit Flow-Design immer erreicht, denn jedes Flow-Design ergibt automatisch eine IODA-Architektur, wenn man den Code nach dem Prinzip der gegenseitigen Nichtbeachtung (PoMO) umsetzt.

Aber wie soll eine Implementierung aussehen, die keine Methoden-Aufrufe verwendet? Nun, wir werden die Methodenaufrufe nicht ganz los, dafür müssten wir eine andere (wahrscheinlich nicht-existente) Programmiersprache als Java verwenden, die nicht auf der von-Neuman-Architektur heutiger Rechner basiert. Aber es gibt Konzepte wie das Observer-Pattern, die Actor-Programmierung oder die Verwendung von Continuation-Parametern, über die sich die Bestimmung welcher Methodenaufruf erfolgen soll, auf die Laufzeit verschieben lässt. Somit kann das Prinzip der gegenseitigen Nichtbeachtung zur Programmierzeit eingehalten werden.

Oberste Funktionseinheit convert-roman. (Abb. 3)

Oberste Funktionseinheit convert-roman. (Abb. 3)

 

Als erstes Beispiel zur Realisierung einer Java-Implementierung wird die oberste Funktionseinheit convert roman aus Teil 1 der Serie verwendet, wie in (Abb. 3) zu sehen ist. Es ist der Einstiegspunkt für das Programm.

Der resultierende Code ist in (Listing 3) zu sehen. Man liest den Code einfach von oben nach unten und von links nach rechts. Einrückungen repräsentieren verschiedene Pfade im Datenfluss. Die beiden hinteren Argumente sind Lambda-Ausdrücke, wie sie seit Java 8 möglich sind. Sie repräsentieren die beiden Fortsetzungsalternativen im Kontrollfluss, nachdem die Logik in convert abgearbeitet ist. Zudem entsprechen sie den beiden Ausgängen aus dieser Funktionseinheit, wie im Diagramm zu sehen ist. Die erste Lambda-Funktion entspricht der Funktionseinheit display result aus (Abb. 3), die zweite der Funktionseinheit display error.

(Listing 3) – convert roman
public void run() {
  String number = this.input.read_number_to_convert();
  this.body.convert (number,
    result -> this.output.display_result(result),
    errorMessage -> this.output.display_error(errorMessage));
}

Funktionseinheit convert. (Abb. 4)

Funktionseinheit convert. (Abb. 4)

 

Im zweiten Beispiel zeigt sich, wie die Funktionseinheit convert implementiert wird, die ja in convert roman integriert ist. Jedes Rechteck im Diagramm aus (Abb. 4) wird in einen Methodenaufruf übersetzt, mit den Eingängen als Parametern. Schwieriger wird es bei den Ausgängen: Wenn das Rechteck nur einen Ausgang hat, wird eine Funktion verwendet, die das Ergebnis der Berechnung oder Transformation als Return-Wert zurückliefert. Hat ein Rechteck jedoch mehr als einen Ausgang, dann erfolgt die Umsetzung der Ausgänge als Methodenparameter des Java-Typs Consumer mit dem Datentyp als generischem Parameter. Auf diese Weise wird die Implementierung auch typsicher. (Listing 4) zeigt diese Implementierung der Methode convert mit zwei Consumer-Parametern. Diese Art von Parametern werden englisch auch Continuations genannt, weil der Kontrollfluss des Programms über sie weiterläuft.

(Listing 4) – Funktionseinheit convert in Java
public void convert(String number, Consumer<String> onSuccess, Consumer<String> onError){
   RomanConversions.determineNumberType (number,
   romanNumber -> RomanConversions.validateRomanNumber
  (romanNumber,
  () -> {
         int result = FromRomanConversion.convert
         (romanNumber);
         onSuccess.accept(Integer.toString(result));
        },
   onError),
   arabicNumber -> RomanConversions.validateArabicNumber
  (arabicNumber,
  () -> {
         String result = ToRomanConversion.convert(arabicNumber);
         onSuccess.accept(result);
},
onError)
);
}

Anfänglich sind Continuations ungewohnt zu lesen. Als Entwickler ist man darauf trainiert, geschachtelte Aufrufe von rechts nach links zu lesen, obwohl es der natürlichen Leserichtung in unserem Kulturkreis widerspricht. Vielleicht ein zusätzlicher Grund, warum Quellcode oft schwer zu verstehen ist. Aber mit ein wenig Übung ist es erstaunlich, wie gut Quellcode dadurch lesbar wird.

Dies ist so ähnlich wie mit den ersten objektorientierten Implementierungen, die noch auf C basierten. Nachdem man sein Denken darauf umgestellt hatte, dass das erste Argument immer den Empfänger des Aufrufs darstellt, erschloss sich einem der Zweck des Codes viel einfacher.

Fazit:

Wie der objektorientierte Ansatz ist auch der Flow-Design-Ansatz so fundamental, dass sich die Umsetzung als eigenständiges Sprachkonstrukt geradezu aufdrängt. Wie wäre es, wenn man das Flow-Design aus (Abb. 3) auf folgende Weise in einer Sprache ausdrücken könnte:

(Listing 5)
read_number_to_convert → convert
convert.result → display_result
convert.error → display_error

Textuell kommt man wohl kaum näher an die ursprüngliche Intention der Zeichnung heran. Aber man muss nicht gleich eine neue Sprache entwerfen. Heutige moderne Multi-Paradigmen-Sprachen erlauben den Entwurf interner DSLs. In Java sind solche Notationen nicht realisierbar, da die Implementierung interner DSLs leider nicht unterstützt wird.

 


Denis Kuniß arbeitet bei Diebold Nixdorf in der Plattform-Software. Er hat an der TU Berlin studiert und sich dort mit Compiler-Generierung beschäftigt. Seine Interessen gelten den formalen und domänenspezifischen Sprachen und dem Architekturentwurf. Er entwickelt in Java, Xtend, Scala und mit Xtext und Gradle.

blog.grammarcraft.de
Twitter: @DenisKuniss
https://www.xing.com/profile/Denis_Kuniss
https://www.linkedin.com/in/denis-kuniss-3037929/
https://github.com/kuniss
kuniss@grammarcraft.de

Carolyn Molski


Leave a Reply