Syntactic Sugar … und gut umrühren

Programs must be written for people to read, and only incidentally for machines to execute. – Abelson / Sussman

Im letzten Artikel hatte ich gezeigt, wie das Scala-Sprachkonzept der Traits zu einer kompakten Implementierung von Flow-Design-Funktionseinheiten führt. Es lässt sich jedoch noch mehr an der Lesbarkeit verbessern.
Beim Verbinden der Funktionseinheiten musste auf der rechten Seite der Operation bindOutputTo immer neben der Funktionseinheit auch der Eingangsport angegeben werden, auch wenn die Funktionseinheit nur einen Eingang hatte.

object RunFlow {
    def main(args: Array[String]) {
        ...
        println("bind them...")
        reverse bindOutputTo toLower.input
        reverse bindOutputTo toUpper.input
        toLower bindOutputTo collector.input1
        toUpper bindOutputTo collector.input2
        collector bindOutputTo(msg => {
            println("received '" + msg + "' from " + collector)
        })
        ...
    }
}

Dies ist eigentlich redundant und Ralf Westpfahl und Stefan Lieser verwenden in ihrer DSL auch eine abkürzende Schreibweise: Sie lassen bei Funktionseinheiten, die nur einen Eingang bzw. nur einen Ausgang haben, den Namen des jeweiligen Ports bei der Notation der Verbindung einfach weg:

	Reverse, ToLower

Warum diese Implikation nicht auch in der Flow-Design-Notation für Scala verwenden?

Partiell angewandte Funktionen

Mit Scalas reicher Werkzeugkiste für funktionale Programmierung lässt sich auch das recht einfach bewerkstelligen. Dazu muss zur schon implementierten Methode bindOutputTo eine weitere gleichen Namens implementiert werden, die anstelle eines Continuation-Funktionsobjektes eine Funktionseinheit übergeben bekommt.

trait OutputPort[T] { 
    ...
    private[this] var outputOperations: List[T => Unit] = List() 
    def bindOutputTo(operation: T => Unit) { 
        outputOperations = operation :: outputOperations 
    } 
    def bindOutputTo(functionUnit: InputPort[T]) { 
        outputOperations = functionUnit.input _ :: outputOperations  
        // partially applied function         ^ 
    }
}

Zur Anwendung kommt hier das Sprachkonzept einer partiell angewendeten Funktion. Dieses erlaubt es Funktionsobjekte zu definieren, deren Argumente teilweise bereits gebunden sind, wenn das Funktionsobjekt konstruiert wird. Man kann jedoch auch, so wie es hier verwendet wird, gar kein Argument binden. Dadurch erhält man ein Funktionsobjekt aus einer Klassen-Methode oder-Funktion, das man später als Referenz derselben Funktion anwenden kann. Dieser Vorgang findet bei der ursprünglichen bindOutputTo-Methode automatisch durch Typinferenz im Compiler statt.
Diese Lösung hat jedoch eine Voraussetzung: Sie basiert auf der Konvention, dass Funktionseinheiten mit nur einem Eingang über den Trait InputPort und keinen der anderen indizierten Traits implementiert werden, was prinzipiell möglich wäre. Denn nur dieser Trait definiert die Methode input, für die hier ein Funktionsobjekt registriert wird.
Der angepasste Verbindungscode vereinfacht sich damit wie folgt:

object RunFlow {
    def main(args: Array[String]) {
        ...
        println("bind them...")
        reverse bindOutputTo toLower
        reverse bindOutputTo toUpper
        toLower bindOutputTo collector.input1
        toUpper bindOutputTo collector.input2
        ...
        })
        ...
    }
}

Man beachte, dass bei der Verbindung der Funktionseinheit Collector die Eingangsports erhalten bleiben, denn es sind zwei, die unterschieden werden müssen. In Endeffekt kommt für diese beiden Eingangsports die alte Methode bindOutputTo zur Anwendung.

Operatoren zum Verbinden

Aber man kann noch einen Schritt weitergehen und statt bindOutputTo Operatoren implementieren. Die Frage ist immer, inwieweit sie die Lesbarkeit verbessern. Perl ist hier ein leuchtendes negatives Beispiel, wie Lesbarkeit durch übermäßigen Operator-Gebrauch in einer Sprache reduziert werden kann. Aber trotzdem möchte ich es hier probeweise mal umsetzen. Ich habe mich beim Verbinden für dem Operator „->“ entschieden. Dadurch lassen sich in RunFlow die Funktionseinheiten wie folgt verknüpfen:

object RunFlow {
    def main(args: Array[String]) {
        ...
        println("bind them...")
        reverse -> toLower
        reverse -> toUpper
        toLower -> collector.input1
        toUpper -> collector.input2
        ...
        })
        ...
    }
}

Das scheint mir besser lesbar als vorher. Aber das ist manchmal eine Geschmacksfrage.
Die Realisierung ist recht einfach als Weiterleitung auf die ursprüngliche bindOutputTo Methode implementiert.

trait OutputPort[T] { 
    ... 
    def -> (operation: T => Unit) = bindOutputTo(operation) 
    def -> (functionUnit: InputPort[T]) = bindOutputTo(functionUnit) 
    ...   
} 

Benannte Ein- und Ausgänge

Die Namen für die zwei Eingänge der Funktionseinheit Collector sind nicht sehr aussagekräftig. Angenommen, es wäre von Bedeutung, in welche der beiden Eingänge Nachrichten nur aus Großbuchstaben oder nur aus Kleinbuchstaben einfließen (was in diesem Beispiel hier nicht der Fall ist). Dann hätte das Modell sicher andere Namen für die Eingänge (rot hervorgehoben):

Nach diesem Flow-Modell würde man im Verbindungscode die selben Namen verwenden wollen:

object RunFlow {
    def main(args: Array[String]) {
        ...
        toLower -> collector.lower
        toUpper -> collector.upper
        ...
        })
        ...
    }
}

Auch das ist in Scala durch die Verwendung des Konzeptes der partiell angewandten Funktionen möglich. Dazu müssen einfach nur Referenzen auf die ursprünglichen Methoden input1 und input2 definiert werden.

class Collector(val separator: String) 
    extends FunctionUnit("Collector") 
    with InputPort1[String] 
    with InputPort2[String] 
    with OutputPort[String] 
{ 
    ... 
    // give ports meaningful names
    val lower = input1 _
    val upper = input2 _
    ... 
}

Zusammenfassung

Die durch die Traits eingeführten Namensschemata sind sehr allgemein gehalten. Kein Wunder, sie sind ja Teil einer Infrastrukturkomponente. Scala bietet jedoch über entsprechende Sprachmittel die Möglichkeit, diese kontextspezifisch umzubenennen, um so den Ein- und Ausgängen sinnvolle Namen zu geben. Auch andere sprachtechnische Tricks zur Verbesserung der Lesbarkeit von Flow-Design-Modellen im Code sind in Scala möglich, wie die Definition von speziellen Operatoren für die Verbindung von Funktionseinheiten.
Im nächsten Artikel wird es darum gehen, wie man Funktionseinheiten zu neuen abstrakteren Funktionseinheiten zusammenfassen kann, was echtes Navigieren im Modell auf unterschiedlichen Abstraktionsebenen ermöglicht.

Randnotiz

Der hier in Auszügen dargestellte Code kann auf GitHub nachgelesen werden.

Leave a Reply

Your email address will not be published.