Follow my new blog

Sonntag, 19. Juli 2009

Verständnisvorteil für Flows – Funktionale Programmierung lässt grüßen

Flows sind verständlicher als Schachtelungen, glaube ich inzwischen. Und zwar nicht nur asynchrone Flows, sondern auch synchrone.

Hier hatte ich ja schonmal über Flows sinniert. Inzwischen habe ich dann auch eine kleine Bibliothek für asynchrone Flows gebaut, die CCR Flows (http://ccrflows.codeplex.com). Doch neulich hat ein Engagierter Entwickler mit darauf hingewiesen, dass solche asynchronen Flows womöglich bei kleineren Aufgaben einen Performanceoverhead durch die Asynchronizität erzeugen, der gegen sie spricht. Da war ich erstmal ein wenig geknickt. Ja, das stimmt wohl. Es ist wie bei der Verteilung von Code. Verteilung erzeugt auch einen Overhead bei der Kommunikation gegenüber dem lokalen Stack. Doch ab einer gewissen Aufgabengröße überwiegen die Vorteile von Verteilung und Asynchronizität natürlich auch. Bis dahin ist synchrone lokale Programmierung vorzuziehen. Klar.

Machen deshalb aber Flows ebenfalls bis dahin keinen Sinn? Dazu habe ich ein wenig experimentiert. Hier mein Szenario:

Eine Textdatei mit Zeilen bestehend aus Worten ist umzuformatieren. Es soll eine neue Textdatei mit anderer Zeichenzahl pro Zeile erzeugt werden. Die Worte müssen also neu umgebrochen werden.

Übliche synchrone Lösung

Wenn ich für dieses Szenario mal eine Lösung einfach so hinschreibe, dann sieht sie z.B. so aus:

using(var sr = new StreamReader("quelldatei.txt", Encoding.Default))

using(var sw = new StreamWriter("zieldatei.txt", false, Encoding.Default))

{

    LineBuilder lb = new LineBuilder(sw, 40);

 

    while(!sr.EndOfStream)

    {

        string line = sr.ReadLine();

 

        foreach (var word in line.Split(' '))

            lb.Add(word);

    }

    lb.Emit();

}

Das ist nicht super clean, aber ja auch nicht so umfangreich. Quelldatei zeilenweise lesen, Zeilen in Worte splitten, neue Zeilen aus den Worten zusammebauen und in Zieldatei schreiben.

Für den Zusammenbau und das Wegschreiben lohnt eine Hilfsklasse, finde ich. Über die Worte hinweg muss etwas Zustand gehalten werden (die neue Zeile). Das würde mir den obigen Code zu sehr aufblähen. Die Aufgabe scheint mit eine genügend große Verantwortlichkeit, um eine eigene Klasse dafür zu rechtfertigen:

class LineBuilder

{

    private StreamWriter sw;

    private int max_line_length;

 

    private StringBuilder line = new StringBuilder();

 

 

    public LineBuilder(StreamWriter sw, int max_line_length)

    {

        this.sw = sw;

        this.max_line_length = max_line_length;

    }

 

 

    public void Add(string word)

    {

        if (line.Length + word.Length + 1 > max_line_length)

            Emit();

 

        if (line.Length > 0) line.Append(" ");

        line.Append(word);

    }

 

 

    public void Emit()

    {

        this.sw.WriteLine(this.line);

        this.line = new StringBuilder();

    }

}

Mit 60-70 Zeilen habe ich also eine Lösung für das Problem. Die sieht “normal” aus, finde ich. Ist sie aber deshalb auch verständlich? Hm… joa, so “normal verständlich”, oder?

Flow-basierte synchrone Lösung

Jetzt dagegen eine Lösung auf der Basis von synchronen Flows:

var flow = new SyncFlow<string, string>(SplitFileIntoLines)

    .Do<string>(SplitLineIntoWords)

    .Do<string>(new LineBuilder(40).AddWord)

    .Do(new FileAssembler("zieldatei.txt").WriteLine);

 

flow.Execute("quelldatei.txt");

Wie ist das? Ist finde es viel besser verständlich. Die Verantwortlichkeiten sind deutlicher getrennt. Das Abstraktionsniveau ist einheitlicher. Klar, das wäre irgendwie auch “normal” gegangen, aber auf dem “normalen” Weg muss ich dafür mehr Selbstdisziplin aufbringen, finde ich.

Die Flow-basierte Lösung hingegen zwingt mich dazu, die einzelnen Schritte zu verpacken und damit zu “entwirren”. In der synchronen Lösung sind Lesen und Splitting und Zeilenerzeugung miteinander stark verwoben. Hier hingegen stehen sie sauber nacheinander gelistet als “Stages” in einem Flow. Mit einer anderen Sprache hätte ich vielleicht auch so schreiben können:

“quelldatei.txt” | SplitFileIntoLines | SplitLineIntoWords
      | new LineBuilder(40).AddWord | new FileAssembler("zieldatei.txt").WriteLine

Aber mit C# geht das halt nicht. Die obige Formulierung finde ich allerdings auch nicht so schlimm. Die Do<>()-Aufrufe verbergen den generellen Fluss des Prozesses nicht.

Insgesamt brauche ich für diese Flow-Lösung zwar ein paar mehr Zeilen Code. Aber das finde ich vernachlässigbar. Hier der Rest:

IEnumerable<string> SplitFileIntoLines(string filename)

{

    using (var sr = new StreamReader(filename, Encoding.Default))

    {

        while (!sr.EndOfStream)

            yield return sr.ReadLine();

        yield return null;

    }

}

 

 

IEnumerable<string> SplitLineIntoWords(string line)

{

    if (line == null)

        yield return null;

    else

        foreach (var word in line.Split(' '))

            yield return word;

}

 

 

class LineBuilder

{

    private int max_line_length;

 

    private StringBuilder line = new StringBuilder();

 

 

    public LineBuilder(int max_line_length)

    {

        this.max_line_length = max_line_length;

    }

 

 

    public IEnumerable<string> AddWord(string word)

    {

        if (word == null)

        {

            yield return this.line.ToString();

            yield return null;

        }

        else

        {

            if (line.Length + word.Length + 1 > max_line_length)

            {

                yield return this.line.ToString();

                this.line = new StringBuilder();

            }

 

            if (line.Length > 0) line.Append(" ");

            line.Append(word);

        }

    }

}

 

 

class FileAssembler

{

    private StreamWriter sw;

 

    public FileAssembler(string filename)

    {

        this.sw = new StreamWriter(filename, false, Encoding.Default);

    }

 

    public void WriteLine(string line)

    {

        if (line == null)

        {

            this.sw.Close();

        }

        else

            this.sw.WriteLine(line);

    }

}

Der entscheidende Vorteil liegt für mich in dem Zwang zur Strukturierung der Lösung. Flows geben mir ein Denkmodell: Formuliere jeden Arbeitsschritt so, dass er keine Abhängigkeiten hat. Verlasse dich in einem Arbeitsschritt nur auf den Input und den eigenen Zustand.

Ich denke, das ist Funktionale Programmierung. Und damit habe ich für mich nun verstanden, glaube ich, wo deren Vorteil liegt. Die Zustandslosigkeit finde ich da gar nicht so wichtig. Die ist nett insbesondere für eine Parallelisierung. Unmittelbar relevanter und hilfreicher finde ich jedoch den Flow-Gedanken, den Funktionale Programmiersprachen nahelegen. F# enthält nicht umsonst den |> Operator.

Doch wer will schon auf F# umsteigen müssen, um Verarbeitung mit Flows leichter verständlich zu strukturieren? Wie der Code oben zeigt, geht es auch mit C#. Dafür ist etwas Umdenken nötig – aber es winken höhere Verständlichkeit und auch bessere Evolvierbarkeit als Gewinn auch schon für synchrone Programme.

Kommentare:

Schimpanski hat gesagt…

Das ist tatsächlich einer der Vorteile funktionaler Programmierung.

Ein anderer Vorteil funktionaler Programmiersprachen, der hier nicht vorhanden sein dürfte, ist Performancesteigerung ohne explizit beschriebene Optimierungen: Dies geschieht über die sogenannte Lazy Evaluation funktionaler Sprachen. Und die wiederum setzt Zustandslosigkeit voraus.

Ralf Westphal - One Man Think Tank hat gesagt…

@Schimpanski: Lazy Eval... hm... ja, das mag die Performance verbessern. Aber insofern halte ich es für eine Optimierungstaktik, die ich erstmal nicht aus der Kiste holen würde.

Lazy Eval macht Programme schwerer zu verstehen. Effekte die ich an einem Ort erwarte (auch wenn sie negativ sein mögen), treten dann an einem anderen Ort auf. Steht für mich insofern durchaus gegen das Prinzip vom Least Astonishment.

-Ralf