F# - Funktionales Programmieren unter .NET

Informatik Seminar WS09/10 von Sören Militzer


Seminarthemen WS09/10 :: Einleitung | Grundlagen Funktionaler Programmierung | Datenstrukturen | Imperative Aspekte | Objektorientierte Aspekte


Grundlagen Funktionaler Programmierung

"Hallo Welt!"

Nach dem nun also der aktuellste CTP installiert ist sowie die interaktive Konsole gestartet wurde, wird es Zeit für das erste Programm in F#. Zu Verdeutlichung ist die Ausgabe der FSI in fett und kursiv geschrieben.

let myThing = "World"

val myThing : string = "World"

let sayHelloTo thing =
    printfn "Hello %s!" thing

val sayHelloTo : string -> unit

sayHelloTo myThing

Hello World!
val it : unit = ()

Hier wurde eine Funktion "sayHalloTo" definiert, die einen Parameter "thing" entgegen nimmt und die textuelle Repräsentation dieses Parameters mit einem "Hallo" voran ausgegeben wird. Die Funktion gibt einen Wert vom Typ Unit zurück. Dieser Typ kennt nur einen Wert "()" und ist vergleichbar mit dem Wert "void" aus imperativen Programmiersprachen wie C# oder Java. Danach wird eben jene Funktion mit dem Parameter "World" aufgerufen und es erscheint das allseits bekannt "Hallo World!" in der Konsole. Außerdem vorsorgt uns die Konsole mit allerlei Typinformationen, die im nachfolgend genauer beschrieben wird.


Werte und Symbole

Betrachtet man sich den Quelltext des Programmes, fällt als Erstes in der ersten Zeile das Schlüsselwort "let" auf. "let" bedeutet so viel wie "Binde einen Wert zu einem Symbol" und unterscheidet sich damit stark von den imperativen Sprachen wie C# oder Java, in denen dieser Sachverhalt eher zu übersetzen ist mit "Weise einen Wert einer Variablen zu". So minimal der sprachliche Unterschied ist, so gravierende Auswirkungen hat er. In F# werden keine Variablen deklariert, deren Zustand sich über die Zeit ändert, sondern Symbole definiert die bis zum Verlassen des entsprechenden Gültigkeitsbereich diesen Wert behalten.

Die linke Seite einer Let-Bindung ist häufig ein simpler Bezeichner, kann aber auch ein Muster sein, dazu später mehr. Die rechte Seite ist ein Ausdruck über den Daten, berechnete Werte, Funktionen und Prozeduren angegeben werden können. In diesem Fall ist der Ausdruck auf der rechten Seite eine einfache Date und die FSI quittiert diese Let-Bindung mit der Angabe, dass das Symbol "myThing" an den Wert (val) "World" vom Typ string gebunden wurde. Da als Ausdruck nicht nur einfache Daten sondern auch Funktionen benutzt werden können, werden über das Let-Schlüsselwort auch Funktionen in F# definiert, hier am Beispiel der Funktion "sayHelloTo". Auch hier wird wieder ein Symbol an einen Wert gebunden, allerdings ist die hier verwendete Schreibweise nicht sofort eingängig. Korrekt ausgeschrieben würden diese Codezeilen lauten:

let sayHelloTo = (fun thing ->
    printfn "Hello %s" thing)

In dieser Schreibweise ist das Muster der Let-Bindung eher sichtbar. Auf der linken Seite ist das Symbol "sayHelloTo" welches wir an den Wert binden möchten und auf der rechten Seite wird eine Funktion definiert, die einen Parameter "thing" entgegen nimmt und diesen einfach auf der Konsole ausgibt. Die Typinformation der FSI bestätigt diesen Sachverhalt.

Bevor wir uns nun weiteren Beispielen zuwenden, müssen noch einige Sachverhalte erwähnt werden die so aus dem Quelltext unseres Hello-World-Programms nicht sofort ersichtlich sind, dennoch eine sehr wichtige Rolle in F# spielen.


Typ Inferenz

Als Erstes sei drauf hingewiesen, dass der Parameter "thing" der Funktion "sayHelloTo" keine Typangabe besitzt, dennoch die FSI den Datentyp des Parameters als Zeichenkette erkennt. Dies resultiert aus der Typinferenz in F# oder einfacher ausgedrückt, einer impliziten Typisierung. Der Quelltext wird vom F# Compiler analysiert und die einzelnen Symbole mit Restriktionen belegt, auf welchen Typ sie gebunden sein müssen um den Quelltext kompilieren zu können. Wird ein Symbol an eine Funktion weitergegeben, die eine Zeichenkette erwartet, wird das Symbol mit der Restriktion belegt vom Typ string zu sein. Schließen sich zwei Restriktionen gegenseitig aus, so kann das Programm nicht übersetzt werden, ist allerdings ein Symbol mit keiner Restriktion belegt, ist dessen Typ generisch und kann beliebig verwendet werden. Ein Vorteil der Integration von F# in Visual Studio ist die Anzeige dieser impliziten Typinformationen zur Entwicklungszeit vor der Kompilierung.

Als Beispiel sei hier die folgende Funktion gegeben, die einfach den übergebenen Parameter quadriert:

let sqr x = x * x

val sqr : int -> int

Der Compiler erkennt automatisch anhand der Multiplikation, das der Parameter eine Zahl sein muss. Die Standardrestriktion für Zahlen in F# ist das die Zahl eine Ganzzahl vom Typ int sein muss.

Der Compiler kann aber nicht nur die Funktion selbst und die Schritte innerhalb der Funktion erkennen, welchen Typrestriktionen für Paramter und Rückgabewert gelten müssen, sondern auch anhand der Umgebung in der diese Funktion aufgerufen ist.

let sqr x = x * x
let myX = sqr 2.0

val sqr : float -> float
val myX : float = 4.0

In diesem Beispiel hat sich die Definition der Funktion selbst nicht verändert, direkt nach der Definition wird sie allerdings mit einem Parameter vom Typ float aufgerufen. Der Compiler reagiert auf diesen Aufruf und anstatt das er mit einem Fehler oder einer Warnung antwortet, ändert er automatisch die Typen von Paramter und Rückgabewert der Funktion zu float.


Leichtgewichtige Syntax

Als Zweites möchte ich drauf hinweisen, dass die Einrückung des Programmcodes im Gegensatz zu vielen anderen Programmiersprachen semantisch relevant ist. In Sprachen wie Java oder C# werden Gültigkeitsbereiche im Code mit geschwungenen Klammern gekennzeichnet, in Object Pascal mittels Schlüsselwörter wie "begin" oder "end", in F# werden die Gültigkeitsbereiche von Funktionen oder Typdefinition mittels der Einrückung angegeben. Dieses Verhalten ist Teil der "leichtgewichtigen Syntax" von F#, deren Zweck es ist dem Entwickler zu erlauben Code zu schreiben, der einfacher zu verstehen ist in dem einige immer wiederkehrende F# Schlüsselworte wie "in", "done", ";", ";;", "begin" oder "end" weggelassen werden. Die leichtgewichtige Syntax ist das Standardverhalten der FSI und wird auch standardmäßig von Visual Studio erwartet, in dem Visual Studio automatisch an den Anfang jedes Moduls das Schlüsselwort "#light" setzt, welches dem Compiler anweist, die leichtgewichtige Syntax zu verwenden. Da die leichtgewichtige Syntax überall als Standard angesehen wird, wird innerhalb dieser Ausarbeitung die Benutzung der leichtgewichtigen Syntax innerhalb von Modulen sowie der FSI impliziert.

Die oben bereits definitierte Funktion "sayHelloTo" würde also ohne die Einrückung des Funktionsrumpfes einen Fehler vom F# Compiler verursachen.


Unveränderlichkeit

Wie vorhergehend beschrieben, sind standardmäßig die meisten Symbole in F# fest an ihre Werte gebunden und können bis zum Verlassen des Gültigkeitsbereich nicht mehr geändert werden. Zu diesen Symbolen gehören die aus der .NET Welt kommenden primitiven Datentypen wie int, float, string und DateTime aber auch die F# Bibliothek definiert eine Reihe von unveränderlichen Datentypen wie "Set" und "Map", die auf binären Bäumen basieren.

Unveränderliche Datenstrukturen bringen viele Vorteile, auch wenn diese nicht sofort auf dem ersten Blick ersichtlich sind. So brauch man sich bei einem Wert, von dem man weiß dass er unveränderlich ist, keine Gedanken darüber machen ob der Wert von einer anderen Funktion verändert werden könnte, folglich ist die Funktion frei von Seiteneffekten und referentiell transparent.

Der derzeit größte Vorteil der sich daraus ergibt, ist das unveränderliche Datenstrukturen per se Threadsafe sind und die enthaltenden Daten beliebig zwischen Threads hin und her gegeben werden können ohne das man als Programmierer sich Gedanken über unsichere Zugriffe auf die Daten machen muss.

Innerhalb von Funktionsdefinitionen können Symbole überschrieben werden, indem man ein anderes Symbol definiert welches den gleichen Namen hat. So ist die folgende Definition

let AddAndSub x =
    let x = x + 2
    let x = x - 2
    x

genau äquivalent zu folgender Definition:

let AddAndSub x =
    let x1 = x + 2
    let x2 = x1 - 2
    x2

Der anfängliche Wert wird nicht überschrieben, stattdessen wird ein neues Symbol erzeugt dessen Namen den des ursprünglichen Symbols im aktuellen Gültigkeitsbereich überlagert. Dieses Verhalten wird auch "outscoping" genannt.


Arbeiten mit den wichtigsten F# Typen

Typenfamilie Typbeispiele Beschreibung
intintEin 32-Bit vorzeichenbehafteter Integer. Z.B. 12, -1.
floatfloat64-Bit IEEE Fließkommazahl. Z.B. 2., 0.3, 4.5
boolboolWahrheitswert. Z.B. true/false
stringstringUnveränderlicher Unicode-String. Z.B. "Hallo"
type optionint option, option<int>Ein Wert des angegeben Types oder None. Z.B. Some 3, Some "Hallo", None.
type listint list, list<int>Eine unveränderliche Liste von Elementen des angegeben, implementiert über eine einfach verlinkte Liste. Z.B. [], [1;2;3].
type1 -> type2int -> stringEin Funktionstyp, der einen Wert repräsentiert, der Werte vom Typ type1 akzeptiert und einen Wert vom Typ type2 zurückgibt. Z.B. (fun x = x*x)
type1 * ... * typeNint * stringEin Tupel-Typ der die Kombination von Paaren, Tripeln oder größerer Kombinationen von Typen ermöglicht. Z.B. (1,"Hallo"), ("Welt", 4, true)
type[]int[]Ein Arraytyp, der eine flache, in der Größe festgelegte Sammlung von veränderlichen Werten enthält.
unitunitEin Typ der den einzelnen Wert () enthält, vergleichbar mit void in einigen imperativen Programmiersprachen.
'a, 'b'Key, 'ValueEin variabler Datentyp, wie er in generischem Code verwendet werden kann.

Funktionstypen

In dem vorhergehenden Beispiel wurde eine Funktion definiert, die nur einen Parameter annimmt, dadurch wurde in dem Beispiel eine Eigenart von F# nicht ersichtlich, die ansonsten relativ großen Einfluss auf die Programmierung von F# Modulen hat. Wenn das Beispiel aus dem Hello-World-Programm um einen Parameter erweitert wird, erhalten wir von der FSI folgende Typinformation:

let sayHelloTo greet thing =
    printfn "%s %s!" greet thing

val sayHelloTo = string -> string -> unit

sayHelloTo "Moin" "Welt"

Moin Welt!
val it : unit = ()

In der Schreibweise ist die Typinformation der Funktion ein wenig missverständlich, wenn zwei Klammern gesetzt werden, wird der Sachverhalt weitaus besser dargestellt.

val sayHelloTo = string -> (string -> unit)

Und somit zeigt sich das wahre Gesicht der Funktionen in F#, sie akzeptieren standardmäßig nur einen Parameter und nicht mehrere. Anstatt das die obere Funktion zwei Parameter akzeptiert, akzeptiert sie nur Einen und gibt eine Funktion zurück, die einen weiteren Parameter akzeptiert und dann die gewünschte Funktionalität ausführt. Noch genauer ist dies zu sehen, wenn die Funktion mit Klammerungen aufgerufen wird.

(sayHelloTo("Hallo"))("Welt")
Hallo Welt!
val it : unit = ()

Die Schreibweise ohne Klammern ist also ein Komfort des F# Compilers und täuscht geschickt darüber hinweg, das hier mehr geschieht als das bloße Aufrufen einer Funktion, sondern das eine Funktion erstellt wird, die mit dem Grußwort Hallo arbeitet und diese Funktion dann aufgerufen wird, um das Objekt Welt zu grüßen.

Doch wofür ist das nützlich? Zum Beispiel könnte die erstellte Funktion an ein Symbol gebunden werden, welches fortan dafür zuständig ist alle möglichen Objekte in einer Sprache oder mit einem Grußwort zu grüßen.

let KuddelsGreeting = sayHelloTo "Moin moin"
val KuddelsGreeting : (string -> unit)

KuddelsGreeting "mien deern"
Moin moin mien deern!
val it : unit = ()

Durch diese Technik können Funktionen in einem engeren Kontext wiederverwendet werden und somit auch die Komplexität des Aufrufs reduziert werden. Das Reduzieren bzw. Umwandeln von Funktionen mit mehreren Parametern in Funktionen mit je einem Parameter wird Currying genannt.


Rekursive Funktionen

Rekursive Funktionen müssen in F# explizit als rekursiv markiert werden, damit das Symbol der Funktion mit in den Gültigkeitsbereich der Funktion aufgenommen wird. Beispiel:

let rec fac x =
    if x > 0 then
        x * fac (x-1)
    else
        1


Funktionskomposition und der Pipeline-Operator

In F# können alle Funktionen Funktionen höherer Ordnung sein, das heißt das Funktionen als Parameter von Funktionen, aber auch als deren Rückgabewert über- bzw. zurückgegeben werden können. Durch dieses Verhalten können Funktionen beliebig miteinander kombiniert und zu neuen Funktionen "zusammengesteckt" werden.

Um die Kombination von Funktionen zu vereinfachen, beinhaltet F# zwei Operatoren die bewirken das diese Kombination lesbarer und verständlicher wird. Der erste Operator ist der sogenannte Kompositionsoperator (>>), mit dem mehrere Funktionen zu einer zusammengefasst werden können. Der zweite Operator ist der Pipelineoperator (|>) mit dem man die übergebenen Parameter von einer Funktion zur Nächsten "durchreichen" kann.

Um diese Operatoren besser zu verstehen, ist es hilfreich deren Typangabe aus der FSI zu studieren.

let (>>) f g x = g(f(x))

val ( >> ) : ('a -> 'b) -> ('b -> 'c) -> ('a -> 'c)

Der Kompositions-Operator ist also eine Funktion, die drei Parameter entgegennimmt: Zwei Funktionen sowie einen generischen Typen. Die erste Funktion nimmt einen Typ a an und gibt einen Typ b zurück, die Zweite nimmt einen Typ b an und gibt einen Typ c zurück. Eh voila, beide Funktionen kombiniert ergeben eine Funktion die einen Typ a annimmt und einen Typ c zurückgibt.

Der Pipeline-Operator wird ähnlich angewandt wie der Kompositions-Operator, mit jedoch einem kleinen Unterschied. Ist der Kompositions-Operator eher abstrakt und definiert eine Funktion die aus Vielen kombiniert wird, so ist der Pipeline-Operator eher konkret und führt diese aus.

let (|>) x f = f x

val (|>) : 'a -> ('a -> 'b) -> 'b

Die Typangabe der FSI bestätigt genau die obere Information, der Pipeline-Operator nimmt einen Wert a entgegen sowie eine Funktion die a akzeptiert und b zurückgibt, die Funktion wird auf a angewendet und der Pipeline-Operator gibt b zurück.

Zu guter Letzt ein Beispiel für die Verwendung dieser beiden Operatoren. Es seien vier Funktionen gegeben, A bis D. Diese sollen in dieser Reihenfolge auf einen Wert angewendet werden und die Rückgabe an die folgende Funktion weitergeben.

let A x = x * x
let B x = 2 * x
let C x = x + 5
let D x = printfn "Berechnete Zahl: %i" x

Möchten wir diese Funktionen nun miteinander kombinieren oder auf einen Wert anwenden, kann dies folgendermaßen mittels der Operatoren geschehen.

let combined = A >> B >> C >> D

val combined : (int -> unit)

combined 4

Berechnete Zahl: 37
val it : unit = ()

5 |> A |> B |> C |> D

Berechnete Zahl: 55
val it : unit = ()

Dieser Ansatz erinnert stark an Shell-Programmierung, wo auch mittels des Pipe-Operators die Ergebnisse von einer Funktion zur Nächsten weitergegeben werden. Ohne Pipe-Operator sieht das vorherige Beispiel folgendermaßen aus:

D(C(B(A(5))))

Berechnete Zahl: 55
val it : unit = ()

Das Beispiel ist simpel, aber dennoch ist es nicht so simpel sofort zu erkennen wie und in welcher Reihenfolge die Funktionen aufgerufen werden. Würden noch weitere Parameter an die Funktion übergeben werden, wirds noch unübersichtlicher. Mittels Pipe-Operator bleibt der Code übersichtlich, die Funktionen werden wie wir es gewohnt sind zu lesen von links nach rechts aufgerufen.