3.2.4 Computation Expressions

Technik: Computation Expressions
Zweck: Alternative Auswertung von F#-Konstrukten
Problem: Grundlegende Konstrukte wie Variablenbindungen und Schleifen werden standardmäßig sequenziell ausgeführt, ohne dass darauf Einfluss genommen werden kann. Eine parallele Ausführung, Logging, schrittweise-unterbrechbare Ausführung oder verzögerte Ausführung müssen explizit behandelt werden.
Lösung: Computation expressions bieten die Möglichkeit, über Continuations die Ausführung zu beeinflussen. Das primäre Konstrukt dafür ist der Computation Builder.
Performanz: Hängt von den Methoden des Builders ab; führt zusätzlich Funktionsobjekte für die Continuations ein.Continuations können in Einzelfällen zu Performanz-Problemen führen (z.B. http://www.quanttec.com/fparsec/users-guide/where-is-the-monad.html#why-the-monadic-syntax-is-slow).
Alternative: Alternative Ausführung muss explizit formuliert werden, was aufwendig oder lästig sein kann.

Um Computation Expressions zu verstehen, muss man die Funktionsweise des Computation Builders verstehen. Ein Computation Builder ist ein Objekt einer Klasse, die mindestens diese beiden Methoden besitzt:

Bind(value, continuation) Für das Konstrukt let! identifier = expression in body. Dies ermöglicht Kontrolle über Wertbindungen und die weitere Ausführung nach dieser Bindung.
Return(value) Für das Konstrukt return expression. Damit wird die Ausführung der Computation Expression beendet und liefert einen Wert des gesamten Ausdrucks der Computation Expression.

Ein häufiges Idiom in der imperativen Programmierung sind null-Prüfungen. Da in der funktionalen Programmierung null kein gültiger Wert eines Typs ist, wird der Option-Typ (siehe Option-Typ im Abschnitt Typen) verwendet. Die Intention des fehlenden Wertes wird dadurch deutlicher, anstatt für alle Referenz-Typ null als Wert zu erlauben.

Der Option-Typ enthält zwei Varianten: je eine Variante für einen undefinierten oder definierten Wert.

type Option<'a> = None | Some of 'a

Um mit diesem Typ zu arbeiten, muss mithilfe von Pattern matching unterschieden werden, ob Zwischenergebnisse definiert sind und ggf. die Berechnung mit dem undefinierten Wert zu beenden. Als Beispiel dient hier eine Funktion, die zwei Zahlen aus der Konsole liest und die Summe zurückgibt. Nur für den Fall, dass beide Eingaben Zahlen darstellen, ist das Ergebnis definiert.

open System
/// readIntegerOptionFromConsole: unit -> int option
let readIntegerOptionFromConsole() =
  match Int32.TryParse(Console.ReadLine()) with
  | true, value -> Some(value)
  | _ ->None

match readIntegerOptionFromConsole() with
| None -> None
| Some(firstValue) ->
  match readIntegerOptionFromConsole() with
  | None -> None
  | Some(secondValue) -> Some(firstValue + secondValue)

Da bei der Programmierung mit externen Datenquellen (wie etwa der Konsole, Dateien oder Webinhalten) diese Daten nicht immer ein gültiges Format haben, ist der Code, der mit diesen Daten arbeitet, mit Gültigkeits-Prüfungen versehen. Im Idealfall wird in objektorientierten Programmiersprachen mit Exceptions gearbeitet, sodass der korrekte Arbeitsablauf innerhalb eines Try-Konstrukts geschrieben werden kann und im Falle eines Fehlers die Fehlerbehandlung durchgeführt wird. In der Tat ließe sich diese Methode auch für das angeführte Beispiel verwenden:

open System
/// readIntegerFromConsole: unit -> int (throws FormatException)
let readIntegerFromConsole() = Int32.Parse(Console.ReadLine())

try
  let first = readIntegerFromConsole()
  let second = readIntegerFromConsole()
  Some(first + second)
with :? FormatException as ex -> None

In der funktionalen Programmierung ist jedoch die Verwendung des Option-Typs verbreiteter, weil schon über den Typ wie etwa int option ersichtlich ist, dass dieser Vorgang fehlschlagen kann. Bei Exceptions ist dies nur durch Dokumentation ersichtlich oder im Fall von Java mit checked exceptions, die zur Methodensignatur zählen und im verwendenden Code abgefangen werden müssen.

In beiden Fällen ist die Fehlerbehandlungsstrategie ersichtlich. Mithilfe von Computation Expressions ist eine alternative Auswertung möglich, sodass das Abfangen von Exceptions oder Prüfen auf definierte Werte ein Belang ist, der nicht explizit im Code wie oben formuliert wird, sondern Aufgabe des Computation Builders ist.

Im Fall des Option-Typs ließe sich der Option-Workflow definieren:

type OptionBuilder() =
  member this.Bind(value, continuation) =
    match value with
    | None -> None
    | Some(definedValue) -> continuation(definedValue)
  member this.Return(value) = Some(value)
let optional = OptionBuilder()

Dieser lässt sich dann wie folgt verwenden:

optional {
  let! first = readIntegerOptionFromConsole()
  let! second = readIntegerOptionFromConsole()
  return first + second
}

Der obenstehende Code benutzt die Methoden des OptionBuilder-Typs. Jedes Vorkommen von let! Pattern = Expression in Body (in kann auch durch einen Zeilenumbruch ersetzt werden) wird durch optional.Bind(Expression, fun Pattern -> Body) ersetzt. Ebenso wird return Value in optional.Return(Value) übersetzt.

Hier wird lediglich syntaktischer Zucker verwendet, der unter anderem Ähnlichkeit mit let-Bindungen hat und beim Kompilieren durch Methodenaufrufe des Computation Builders ersetzt wird.
Wichtige Beispiele dieser Technik sind seq für Sequenz-Literale, async für nebenläufige Programmausführung und query für Datenbank-Abfragen. Wie eingangs erwähnt, ist vereinfachte nebenläufige Programmausführung ein Vorteil von deklarativen Programmiersprachen. Dies wird insbesondere deutlich, wenn die Integration von nebenläufigen Methodenaufrufen durch einen Computation Builder komfortabler gemacht wird. Andere Sprachen greifen für solche Zwecke auf neue Schlüsselwörter, Syntax-Erweiterungen und Makros zurück. Auch in der Hinsicht ist eine selbstprogrammierbare alternative Ausführungsumgebung praktisch.

Insbesondere bei Datenbankabfragen ist es vorteilhaft, wenn der Programmierer keine SQL-Strings manipuliert, um eine Anfrage zu erstellen, sondern stattdessen in einer dafür erstellten Auswertungsumgebung Queries schreibt, die entsprechende Datenbank-Typen verwenden.

    let articles = query {
        for article in db.Articles do
            sortBy article.Price
            select (article.Name, article. Price)
    }
    for (name, price) in articles do printfn "%s costs %f €" name price

sortBy und select sind sogenannte Custom operations, die wie kontextabhängige Schlüsselwörter einer Programmiersprache wirken, jedoch nur spezielle Methoden des Computation Builders sind.

Im Fall des Query-Builders wird auch auf Quotations zurückgegriffen, die im nächsten Kapitel behandelt werden und den Einstieg in Metaprogrammierung darstellen.
Dazu wird im Computation Builder die Methode Quote eingeführt [FSharpSpec, S. 62].