Copy and paste is a design error. – David Parnas
Im letzten Artikel haben wir die Funktionseinheiten in Scala zusammengesteckt. Aber bei der Implementierung der Funktionseinheiten wiederholen sich viele Methoden immer wieder, ohne dass sie sich von einer Funktionseinheit zur anderen unterscheiden. Zum Beispiel ist die Methode input gleichlautend sowohl in der Funktionseinheit ToUpper
class ToUpper { def input(msg: String) { process(msg) } … }
als auch in der Funktionseinheit ToLower zu finden.
class ToLower { def input(msg: String) { process(msg) } … }
Traits
Dies widerspricht ganz dem DRY Prinzip. Schön wäre es doch, wenn man die Ein- und Ausgänge deklarativ angeben und sich dann ganz auf die Implementierung der Funktionalität der Einheit konzentrieren könnte. So zum Beispiel:
class ToLower extends FunctionUnit("ToLower") with InputPort[String] with OutputPort[String] { protected def process(msg: String) { output(msg.toLowerCase()) } }
Die Funktionseinheit ToLower hat einen Eingang und einen Ausgang (im Event-Based-Components ursprünglich „Pin“ genannt, später eher „Port“), hier als InputPort und OutputPort angegeben, zusammen mit dem Datentyp, den sie erwarten bzw. ausgeben. Wenn das nicht deklarativ ist…
Die deklarative Spezifikation der Ein- und Ausgänge entspricht dabei dem obigen Bild einer Funktionseinheit, wobei die Namensgebung einem Schema folgt: Die Deklaration InputPort erweitert die Funktionseinheit um einen Eingang mit dem Namen input. OutputPort erweitert die Funktionseinheit um einen Ausgang mit dem Namen output. Die Namen der beiden Ein- und Ausgänge werden dann später beim Zusammenstecken der Funktionseinheiten verwendet. Beide Deklarationen der Ein- und Ausgänge sind mit dem Nachrichtentyp, den sie verarbeiten, parametrisiert.
Aber wie lässt sich die deklarative Spezifikation der Ports in Scala realisieren?
Nun, hier ist Scala den .NET-Sprachen voraus. In Scala kann Obiges über das Sprachkonzept Traits realisiert werden. Traits sind ähnlich wie Mixins (in der dotnetpro gab es eine Artikelserie zu Mixins in C#) und ermöglichen, Klassen um partielle Implementierungen anzureichern. Man kann sie sich als Java- oder C#-Interfaces, die neben abstrakten Methoden auch implementierte Methoden enthalten, vorstellen. Sie ermöglichen eine Art Mehrfachvererbung, wobei die Mehrdeutigkeitsprobleme sauber und eindeutig aufgelöst sind.
Eingangsport
Der Trait InputPort ist recht schnell implementiert. Er definiert eine Methode input, die bei Anwendung des Traits auf die Klassendefinition einer Funktionseinheit diese um einen Eingang diesen Namens erweitert.
trait InputPort[T] { def input(msg: T) { processInput(msg) } protected def processInput(msg: T): Unit }
Des Weiteren wird eine abstrakte Methode processInput definiert, die im Kontext der Anwendung des Traits auf eine Klassendefinition die Verarbeitung der über input eingehenden Nachricht implementiert. Die Methode processInput implementiert also die Verarbeitungslogik der zu definierenden Funktionseinheit bzgl. der über den Eingang input einfließenden Nachrichten.
Typparameter werden in Scala in Anlehnung an UML in eckigen Klammer angegeben. Hier wird der Typparameter T benutzt, um einen Platzhalter für den Nachrichtentyp zu definieren, den die Funktionseinheit unter diesem Eingang erwartet.
Ausgangsport
Der Ausgangsport ist etwas schwieriger als Trait zu realisieren, da hier unter Umständen beliebig viele Ausgänge zu verwalten sind, ohne dass zur Implementierungszeit der Funktionseinheit bekannt ist, wie viele. Im Endeffekt folgt die Implementierung in abgewandelter Form dem Observer-Entwurfsmuster.
Der Trait definiert eine Methode output, die bei Anwendung des Traits auf die Klassendefinition einer Funktionseinheit diese um einen Ausgang diesen Namens erweitert.
trait OutputPort[T] { protected def output(msg: T) { if (!outputOperations.isEmpty) { outputOperations.foreach(operation => operation(msg)) } } private[this] var outputOperations: List[T => Unit] = List() def bindOutputTo(operation: T => Unit) { outputOperations = operation :: outputOperations } }
Der Trait definiert outputOperations als eine Liste von Funktionsobjekten, die man als Continuations verstehen kann. Alle in der Liste enthaltenen Continuations werden in der Methode output auf die aus der Funktionseinheit ausfließenden Nachrichten angewandt. Dadurch wird die ausfließende Nachricht im Kontext der nächsten Funktionseinheit verarbeitet.
Die Methode bindOutputTo ermöglicht es später, Ein- und Ausgänge der Funktionseinheiten zu verbinden. Sie akzeptiert als Argument ein Funktionsobjekt. Der Typ
T => Unit
spezifiziert hier ein Funktionsobjekt mit einem Parameter vom Typ T. Der Typ Unit entspricht in Scala dem, was in Java und C# void ist. Dem Aufruf der Methode bindOutputTo kann also eine void-Methode übergeben werden, die genau einen Parameter vom Typ T hat. Insbesondere könnte das also ein Methode input sein, die durch einen Trait InputPort[T] definiert wird. Genau dies wird beim Verbinden der Funktionseinheiten benötigt.
Multiple Ports
Die Funktionseinheit Collector hat nun mehr als einen Eingang. Hier kann der Trait InputPort nicht verwendet werden. Deswegen habe ich zwei weitere Traits InputPort1 und InputPort2 definiert, die für Collector und alle Funktionseinheiten mit mehr als einem Eingang verwendet werden können.
trait InputPort1[T] { def input1(msg: T) { processInput1(msg) } protected def processInput1(msg: T): Unit }
Das Implementierungsschema ist das selbe wie für den Trait InputPort, nur dass an den notwendigen Stellen eine „1“ hinzugekommen ist, um die Namen eindeutig zu unterscheiden. InputPort2 ist analog implementiert.
Damit ändert sich die Implementierung der Funktionseinheit Collector wie folgt:
class Collector(val separator: String) extends FunctionUnit("Collector") with InputPort1[String] with InputPort2[String] with OutputPort[String] { protected def processInput1(msg: String) { accumulateInput(msg) } protected def processInput2(msg: String) { accumulateInput(msg) } ... }
Für das aktuelle Beispiel reichen die zwei Traits InputPort1 und InputPort2, um die zwei Eingänge der Funktionseinheit Collector eindeutig zu unterscheiden. Allgemein sind natürlich Funktionseinheiten mit mehr als zwei Eingängen denkbar. Anderseits ist normalerweise die Anzahl der Eingänge einer Funktionseinheit zum Implementierungszeitpunkt endlich, sie ist nicht dynamisch zur Laufzeit. (Wie man mit einer dynamischen Anzahl von Eingängen umgeht hat Ralf Westphal in einem Artikel in der dotnetpro für einen Multiplexer beschrieben.) Für ein Scala-Flow-Design-Framework müsste damit nur eine endliche Anzahl von indizierten Traits InputPortN definiert werden. Ich denke, 5 solcher Traits dürften reichen. Mehr als 5 Eingänge pro Funktionseinheit zeugen von einer Überfrachtung und von unpassendem Design.
Analog könnten auch aus dem Trait OutputPort indizierte Traits für multiple Ausgänge abgeleitet werden.
Zusammenfassung
Das in Scala verfügbare Sprachkonstrukt der Traits bringt der Implementierung von Flow-Design viel. Die Funktionseinheiten werden befreit von infrastrukturellem Beiwerk, beschränken sich auf die Implementierung der eigentlichen Funktion. Die Spezifikation der Ein- und Ausgänge erfolgt nahezu deklarativ. Am Zusammenstecken der Funktionseinheiten hat sich mit den Traits nichts geändert, der Code in RunFlow ist derselbe geblieben. Das ist naheliegend, hat sich doch an der Schnittstelle, der Methode bindOutputTo, nichts geändert, die ist nur in einen Trait gewandert. Aber man lebt nicht nur von der Funktionalität allein, es muss auch gut lesbar sein… Davon mehr im nächsten Artikel.
Randnotiz
Der hier in Auszügen dargestellte Code kann auf GitHub nachgelesen werden.
Vielen Dank an Ronny Hapke für das Gegenlesen des Artikels.
Pingback: Syntactic Sugar … und gut umrühren | Beyond Coding