Einführung in F#
von Dario Stein
Übersicht
Einleitung
In letzter Zeit sind zahlreiche neue .NET-Sprachen entstanden. Waren zu Anfang nur C# und VB vertreten, so gibt es nun fast für jede Sprache schon eine .NET-Implementierung.
Bei den meisten Sprachen läuft es letztendlich immer auf das Selbe hinaus, nur die Syntax unterscheidet sich. In diesem Tutorial möchte ich eine kleine Einführung in eine neue und ganz besondere .NET-Sprache geben, die völlig anders "tickt" als ihre Verwandten: F#
An wen richtet sich dieses Tutorial?
Dieses Tutorial richtet sich an jeden, der sich einen Überblick über F# verschaffen möchte oder gerne einmal in die funktionale Programmierung hineinschnuppert. Damit dieser Artikel nicht jeglichen Rahmen sprengt, kann ich nicht alles ganz von Grund auf erklären; ein paar Programmierkenntnisse in anderen Sprachen, Zeit und Experimentierfreude schaden also nicht - neue Konstruktionen werden aber immer aus sich heraus verständlich sein. Einige Themen möchte ich auch nur anreißen - Für tiefgreifendere Informationen genügt eine Anfrage bei Google oder im exzellenten englischen Wikibook zu F#.
Vor Allem geht es aber darum, sich in F# einzulesen, weshalb ich sehr viel mit kleinen Codeschnipseln und Aufgaben arbeiten werde.
Inhalt
-
Funktionale Programmierung
-
Wozu F# lernen?
-
Erste Schritte
-
Das Typsystem
-
Funktionen
-
Pipelines
-
Verzweigungen
-
Rekursive Funktionen
-
Erste grundlegende Datentypen
-
Listen
-
Arrays
-
Sequenzen
-
Option types
-
Mengen und Wörterbücher
-
Imperative Elemente
-
Discriminated unions
-
Objektorientierung
-
Language-Oriented Programming
-
Computation Expressions
-
Schlusswort
-
Quellen
Funktionale Programmierung
Was macht F# nun so besonders?
Die Antwort lautet: Es ist besonders, weil die "Denkweise", das Prinzip hinter F# eine völlig andere ist, als bei den meisten anderen Sprachen. Visual Basic oder C# zählt man zu den sogenannten imperativen Programmiersprachen. Imperativ heißt: Ich gebe dem Computer Befehle und sage ihm Schritt für Schritt, wie er ein Problem zu lösen hat.
F# ist hier anders - Es ist eine funktionale Programmiersprache.
Historisch gesehen geht die funktionale Programmierung nicht auf Maschinensprachen wie dem heutigen Assembler zurück, sondern basiert auf dem mathematischen Konzept des sog. Lambda-Kalküls, einer formalen Sprache zum Untersuchen und Bearbeiten von Funktionen. Man hatte daraufhin festgestellt, dass dieses System den herkömmlichen Berechnungssystemen (Programmiersprachen) in Mächtigkeit um Nichts nach stand und bald entstand mit LISP die erste funktionale Sprache. Mit ML (Meta-Language) keimte im Weiteren eine ganze Familie von solchen Sprachen auf (Standard ML, OCaml), deren neuster Vertreter nun F# ist. Der Einfluss dieses Programmierparadigmas ist seit dem immer mehr gewachsen und hat bei nahezu allen modernen Sprachen seine Spuren hinterlassen.
Funktionale Programmierung orientiert sich am Begriff der mathematischen Funktion - Ein Programm ist letztendlich eine einzige, komplexe Funktion. Man konzentriert sich hauptsächlich darauf, was ein Programm tun soll und überlässt das Wie dem Compiler. Der Vorteil, der sich hieraus ergibt, ist, dass oft viel knapper und näher an einem Problem formuliert werden kann. Funktionale Programme sind dadurch oft viel kürzer, verständlicher und fehlerunanfälliger als vergleichbare imperative Programme.
Was genau aber funktionale Programmierung bedeutet werden wir im weiteren Verlauf klären.
Wozu F# lernen?
F# hat Zukunft - Als neues Mitglied in der .NET-Familie eignet es sich sehr gut, in bereits vorhandene Projekte integriert zu werden und ist damit, im Gegensatz zu mach anderen funktionalen Sprachen, in Projekten der "wirklichen Welt" tatsächlich einsetzbar.
Als vollwertige .NET-Sprache lässt sich mit F# zwar alles anstellen wie mit VB oder C# auch, allerdings hat es wie alle anderen Sprachen seine Stärken und Schwächen. Die folgende Liste soll einen kleinen Ausblick geben, wo und weshalb F# so eine mächtige Ergänzung in unserer Programmierlandschaft ist:
- F# produziert hochgradig fehlerunanfälligen, präzisen und wartbaren Code.
- F# hat ein außerordentlich weit entwickeltes, striktes Typsystem, das dem Programmierer viel Arbeit abnimmt anstatt abverlangt. Eine große Zahl von Fehlern kann bereits beim Kompilieren abgefangen werden.
- F#-Code ist sehr leicht parallelisierbar - Programme skalierbar und effizient auf Mehrkernprozessoren auszuführen ist kein Problem.
- F# selbst ist syntaktisch sehr anpassungsfähig an spezielle Bedürfnisse eines Problemfeldes.
- F# verändert und bereichert durch seinen unkonventionellen Ansatz die Denkweise, mit der wie an Programmierung herangehen .
- F# kann uneingeschränkt mit allen .NET-Sprachen und Bibliotheken interagieren und nahtlos in deren Programme integriert werden.
Das prädestiniert es für folgende Aufgabenfelder:
- Wissenschaftliches Arbeiten (Mathematik, Physik, Finanzen)
- Problemlösung
- Parallele Programmierung (Mehrkernprozessoren) / Netzwerke
- Sicherheitskritische Aufgaben
- Text/Datenverarbeitung
- Entwicklung eigener Programmiersprachen (Domain-specific languages)
Wer hingegen benutzeroberflächen-intensive Programme zu schreiben gedenkt, wird schnell feststellen, dass dies, obgleich natürlich möglich, einer funktionalen Sprache nicht sehr liegt. Statt dessen kann eine F#-Bibliothek im Hintergrund sinnvoller sein.
Aber letztendlich ist vor Allem auch für "normale Hobbyprogrammierer" F# unbedingt einen Blick wert, denn immerhin ermöglicht es, Aufgaben auf eine neue Art sehr schnell und präzise zu formulieren und zu lösen.
Erste Schritte
F#-Programme sehen auf den ersten Blick zunächst sehr viel anders aus, als man es gewohnt ist. Umso wichtiger ist es, direkt loszulegen und sich gleich mit der funktionalen Denkweise auseinanderzusetzen.
Installation:
Es gibt leider zur Zeit keine F#-ExpressEdition wie für VB. In den Standard-Editionen des Visual Studio ist es integriert und kann wie jede andere Sprache auch verwendet werden (Screenshot aus der kostenlosen Beta des VS2010 Professional):
Abbildung 1: F# in VS2010 Beta
Für alle, die keine der regulären VisualStudio-Editionen haben, müssen wir F# von Hand installieren. Es kann bei Microsoft kostenlos unter diesem Link heruntergeladen werden. Als kostenlose Entwicklungsumgebung mit (leider nicht sehr ausgereifter) F#-Unterstüztung kann #Develop oder die bessere Visual Studio Shell (isolated mode) von Microsoft verwendet werden. Nach der Installation von F# stehen uns zwei Programme zur Verfügung. Ein Kommandozeilen-Compiler, der aus unseren Quellcodes normale .NET-EXE-Dateien macht, und ein Konsolen-Interpreter namens FS harp I nteractive ( FSI ). Im FSI werden wir unsere ersten Schritte mit F# machen. Wir können Anweisungen in das Konsolenfenster tippen und direkt das ausgeführte Ergebnis sehen:
Abbildung 2: F# im FSI
Das Erste, was wir eingeben, ist folgende Zeile:
#light;;
Listing 1: Erste Anweisung
#light ist ein Kommando, das den Compiler veranlasst, bestimmte Vereinfachungen an der Syntax zu aktivieren und das in jedem Programm als erste Zeile stehen sollte. Die ;; terminieren im FSI eine Eingabe und werden in normalem F#-Code weggelassen. Man könnte jetzt theoretisch die Interaktivkonsole als eine Art Taschenrechner missbrauchen, oder aber wir beginnen mit unserem ersten F#-Programm:
let x = "Hallo, Welt"
Listing 2: Erstes F#-Programm
Abbildung 3: Erstes F#-Programm
Wie man sieht, erhalten wir prompt die Ausgabe:
val x : string
Man ahnt, es wurde eine Art Variable angelegt. Wir können x jetzt anzeigen lassen:
> x;; val it : string = "Hallo, Welt"
Listing 3: Ausgabe von x
Das Typsystem
Let, das erste Schlüsselwort, das wir kennengelernt haben, ist eines der wichtigsten in F#. Und wie wir gesehen haben, dient es dazu, einen Wert unter einem Namen zu speichern.
Wieso Wert und nicht Variable? Das x aus unserem Beispiel ist doch eine Variable?
Nein! Das ist es nicht - und zwar, weil es in funktionalen Programmiersprachen keine Variablen gibt. Schockierend? Hier ist es sinnvoll sich vorzustellen, dass bei Berechnungen aus Werten neue Werte entstehen, ohne dass sich der eine dafür ändern muss.
Betrachten wir eine Anweisung, die in fast allen Programmiersprachen permanent auftritt, einmal mathematisch.
X = X + 1
Listing 4: Paradoxon vieler Programmiersprachen
X, z.B. eine Zählvariable, wird hier um eins erhöht. Was logisch aussieht ist mathematisch bedenklich, denn wenn man auf beiden Seiten X abzieht, kommt man zur hochsinnvollen Aussage:
Es gibt kein X, dass gleich seinem Nachfolger ist, auch wenn das von obiger Aussage vermittelt wird. Das ist ein Sachverhalt, an dem sich funktionale Programmierung stört.
In bestimmten "Hardcore"-funktionalen (rein funktionalen) Sprachen wie Haskell ist dieser Gedanke so stark durchgezogen, dass schon für eine einfache Bildschirmausgabe tief in die mathematische Trickkiste gegriffen werden muss.
So extrem stehen die Dinge in F# nicht, immerhin muss die Sprache ja auch noch mit dem nicht funktionalen .NET-Framework interagieren können. Man kann konventionelle Variablen erzeugen (über einen speziellen mutable-Modifizierer), allerdings sind diese sehr selten und nicht unbedingt als schön oder F#-mäßig empfunden, weshalb man sich angewöhnen sollte, wo es geht nur mit Let zu arbeiten und seine Werte nicht zu ändern. Man kann so Fehlern vorbeugen und verschiedene funktionale Features besser nutzen. Die Aufforderung
x = "Foo"
Listing 5: Vergleich, nicht Zuweisung
ist keine Zuweisung, sondern nur ein Vergleich, der false ergibt.
Noch ein zweiter Aspekt ergibt sich aus unserer ersten F#-Zeile:
Die FSI-Ausgabe
val x : string
Listing 6: Explizite Typangabe
bedeutet: x ist ein Wert vom Typ string ( Zeichenkette ).
Wenn wir die Eingabe betrachten steht da aber gar nichts von string, wir haben x doch nur "Hallo, Welt" zugewiesen und doch weiß der Compiler, dass x ein string sein muss.
Diese Fähigkeit, die schon VB und C# an bestimmten Stellen boten, nennt sich Typableitung oder Type inference und wurde von F# perfektioniert. Sie ist sogar derart mächtig, dass nicht selten in kompletten F#-Programmen kein einziger Datentyp angegeben werden muss. Zum Eingewöhnen in die Typschreibweise werde ich hier dennoch immer wieder die Typsignaturen angeben.
let a = 1 let b = 15 let c = a * a + b
Listing 7: Einige Werte
Auch hier ist der Compiler direkt in der Lage, a, b und c als Ganzzahlen (int) zu bestimmen.
Wichtig : Alles in F# hat einen Wert, er ist nur manchmal "Nichts" (unit).
Ein normales Hallo-Welt-Programm lautet in F# unter Verwendung der .NET-Funktionen folgendermaßen:
System.Console.WriteLine("Hallo, Welt")
Listing 8: Hallo, Welt in F# mit .NET
F# hat allerdings die eingebaute Funktion printf(n):
printfn "Hallo, Welt"
Listing 9: Hallo, Welt in F# mit printf(n)
Beide geben "Hallo, Welt" in der Konsole aus und geben sinnigerweise nichts (also ()) zurück.
printfn ist selbst hochflexibel, wie folgendes Beispiel zeigt:
let name = "Max Mustermann" let alter = 42 printfn "%s ist %i Jahre alt" name alter
Listing 10: Spielereien mit printfn
Die %-Werte sind (wie im printf() der Sprache C) Platzhalter für die hinten angeführten Argumente.
Interessant ist es, dass die Typsicherheit der Argumente zur Kompilierzeit geprüft wird:
printfn "%s" 42.23
Listing 11: Typsicherheit bei printfn
ist somit ungültig, da ein string (%s) mit einer Gleitkommazahl (float) belegt werden sollte.
%A passt auf alles.
Ein komplettes, kompilierbares F#-Programm sieht z.B. so aus:
#light // System-Namespace einbinden open System // Ausgabe machen printfn "Hallo, Welt" (* Verhindern, dass die Konsole geschlossen wird *) ignore Console.ReadKey()
Listing 12: Ein erstes Programm mit mehreren Zeilen
Anders als in VB muss hier keine Programm-Klasse angegeben werden.
Hier noch eine Übersicht der gängisten Typen.
Typ | Bedeutung | Beispiel |
---|---|---|
int | Ganzzahl (Integer) | 42 |
float | Gleitkommazahl | -11.3 |
string | Zeichenkette (Wort/Text) | "Hallo, Welt" |
bool | Wahrheitswert | true/false |
unit | Nichts (Kein Wert, vgl. void) | () |
Tabelle 1 : Gängige Datentypen
Für Typumwandlungen existieren gleichnamige Konverterfunktionen:
let x = int 42.3
Listing 13: Typkonvertierung
Funktionen
F# ist eine funktionale Programmiersprache! Wie dieser Name vermuten lässt, sind Funktionen folglich für die Sprache von großer Bedeutung. Die Deklaration von Funktionen ist denkbar einfach: Man verwendet Let.
Let? Wird das nicht für Werte (nicht Variablen!) verwendet? Ja und nein. In einer funktionalen Sprache ist ein Wert selbst prinzipiell nichts anderes als eine Funktion, die keine Werte übergeben bekommt und einen zurückgibt. Weil sich Werte, von denen sie abhängt, ja nicht ändern dürfen und das Ergebnis einer Funktion nur von ihren Eingabewerten abhängen darf, ergibt diese Definition keine Probleme. F# sieht das zwar etwas lockerer (als z.B. Haskell) und Funktionen dürfen sich auch ein wenig unfunktional verhalten (sonst wäre wie gesagt eine Konsolenausgabe oder ein Zufallsgenerator schon etwas schwierig), aber so lässt sich zumindest das Let erklären.
Erste Funktionen
Hier kommt unsere erste Funktion:
let plus1 x = x + 1
Listing 14: Unsere erste Funktion
Und der Aufruf:
let a = 41 let b = plus1 a let c = plus1 22
Listing 15: Aufruf unserer ersten Funktion
Wichtig: Das Gleichheitszeichen ist keine Zuweisung, sondern eine Definition. Mathematisch steht hier:
(Lies: Plus1 von x)
Eine ganze Funktion in nur einer Zeile und wieder ohne irgendeine Typangabe. Man denke an VB:
Function Plus1(ByVal a As Integer) As Integer Return a + 1 End Function
Listing 16: Plus1() in VB
F#: Keine Klammern, keine Kommata, keine Typangaben und kein Return. Die letzte Berechnung einer F#-Funktion ist automatisch ihr Rückgabewert .
val plus1 : int -> int
Listing 17: Typangabe einer Funktion
Der Typ dieser Funktion wird in F#, ähnlich der mathematischen Definition, so notiert, was aber wie gesagt zumeist überflüssig ist, da der Compiler den Typen ermittelt. Mit Typangabe:
let plus1 (x : int) : int = x + 1
Listing 18: Funktionsdeklaration mit Typangabe
So - Jetzt wollen wir uns an eine etwas größere Funktion wagen:
let leseString text = printf "%s: " text System.Console.ReadLine() let name = leseString "Gib deinen Namen ein" printfn "Hallo, %s" name
Listing 19: Eine etwas größere Funktion
leseString (Funktionen sollten üblicherweise mit Kleinbuchstaben beginnen) gibt eine Aufforderung aus und gibt den eingegebenen Text zurück. Der Compiler weiß wiederum, dass es sich bei text um einen String handeln muss, denn er muss im printf auf das Typkennzeichen %s passen.
Mehrzeilige Funktionen, generell alle Blöcke, haben keine schließenden Klammern oder Ends, sondern werden, wie in Python, über ihre Einrücktiefe identifiziert (Tabs sind in F# ungültig, man nehme Leerzeichen). Die letzte Berechnung wird zurückgegeben.
Weiter geht's:
let pythagoras a b = let quadrat a = a ** 2.0 sqrt ((quadrat a) + (quadrat b)) let res = pythagoras 3.0 4.0
Listing 20: Pythagorasfunktion
Diese (gewiss auch sinnvoller implementierbare) Funktion verdeutlicht zwei Dinge:
- Man braucht keine Klammern, um eine Funktion aufzurufen, die Argumente kommen ohne Kommata einfach hintereinander.
- Man kann zwei Funktionen / Let's problemlos verschachteln.
val pythagoras : float -> float -> float
Listing 21: Typsignatur von pythagoras
Die Aussage float entstammt der Verwendung des Hochoperators **, der nur für floats funktioniert - Das eigentlich verwunderliche sind die Pfeile. Wieso jetzt zwei? Müsste es nicht vielmehr irgendwie so lauten?
val pythagoras : (float, float) -> float
Listing 22: Vermeintliche Typsignatur von pythagoras
Die Antwort auf diese syntaktische Besonderheit entstammt einem so genialen wie verwirrenden Konzept namens Currying. Wer nur einen groben Überblick über F# gewinnen will, für den genügt es, diesen Teil zu überfliegen.
Currying (von einem Herrn namens Curry erfunden), bedeutet, dass eine Funktion immer nur einen Parameter zur Zeit aufnimmt und gebenenfalls eine weitere Funktion zurückgibt. Die Definition lautet geklammert so:
val pythagoras : float -> (float -> float)
Listing 23: Typsignatur von pythagoras mit Klammern
Wenn ich
pythagoras 3 4
Listing 24: Aufruf von pythagoras
aufrufe, entspricht das dem Aufruf
(pythagoras 3) 4
Listing 25: Aufruf von pythagoras mit Klammern
pythagoras 3 ist selbst eine Funktion, die noch einen Parameter aufnimmt und dieser ist hier 4.
Per Currying kann man Funktionen teilweise belegen:
let plus a b = a + b let plus1 = plus 1
Listing 26: Currying
Man sieht:
val plus : int -> int -> int val plus1 : int -> int
Listing 27: Typsignaturen von plus und plus1
Generell gilt: Der letzte Typ ist der Rückgabetyp, die ersten sind Parametertypen.
Currying beschränkt sich nicht nur auf Funktionen, Operatoren selbst sind nichts anderes.
let plus = (+) let durch2 = (/) 2
Listing 28: Operatoren als Funktionen
mit
val plus : int -> int -> int val durch2 : int -> int
Listing 29: Typsignaturen von plus und durch2
Wichtig: Nur F# und ähnliche Sprachen unterstützen Currying - Eine solche Funktion ist .NET-weit einzigartig. Eine "normale" .NET-Funktion Pythagoras ließe sich in F# so nachbilden:
let pythagoras (a, b) = ...
Listing 30: .NET-Ähnliche Pythagorasfunktion
Sie hat die Signatur
val pythagoras : (float * float) -> float
Listing 31: Typsignatur der .NET-Funktion Pythagoras
und würde folgendermaßen aufgerufen:
let res = pythagoras (3, 4)
Listing 32: .NET-mässiger Aufruf von pythagoras
Mathematisch gesehen:
Die Funktion bekommt hier ein sogenanntes Tupel aus zwei Floats übergeben.
Die Tupelschreibweise für Parameter ist F#-untypisch und dient vor Allem dazu, die Standard-.NET-Funktionen aufzurufen, während sie an anderer Stelle sehr nützlich ist:
Per Tupel können Funktionen auch "mehrere" Rückgabewerte besitzen:
let divMod a b = (a / b, a % b) let (div, mod) = divMod 10 8
Listing 33: Funktionen mit mehreren Rückgabewerten
mit
val divMod : int -> int -> (int * int)
Listing 34: Typsignatur bei mehreren Rückgabewerten
Hier werden im zweiten let beide Werte gleichzeitig zugewiesen.
Generische Funktionen
Ich komme nun zu einem weiteren Highlight im F#-Typsystem, der ebenfalls in den Standard-.NET-Sprachen schon teilweise vorhanden ist. Dieses ist der Grund, weshalb man in F# so prägnant formulieren kann - Generizität.
Als Einführungsbeispiel betrachten wir folgende Funktion:
let getFive x = 5
Listing 35: getFive
Sie nimmt einen Parameter x auf und gibt einfach immer 5 zurück. Die Frage ist: Welchen Typen hat x?
Die Antwort ist hier: Es ist egal, welchen Typen x hat, die Funktion funktioniert immer, d.h. vom Typen unabhängig, wie folgendes Beispiel beweist:
getFive 1 getFive "Hello, World" getFive (1, "xy", 4.0)
Listing 36: Anwendung von getFive
Wir brauchen nicht zu sagen, x hat den Typen int oder string. Wir sagen lediglich, x kann jeden beliebigen Typen haben. FSI notiert das wie folgt:
val getFive : 'a -> int
Listing 37: Typsignatur von getFive
Der Buchstabe für die sog. Typvariable ist dann einfach alphabetisch fortlaufend. Generische Typen sind extrem wichtig in der funktionalen Programmierung und treten unbewusst fast überall auf, wenngleich man sie fast nie explizit notiert, sondern der Compiler sie bequem ableitet.
Higher-Order functions
Da in funktionalen Sprachen alles als Funktion gehandhabt wird, ist es ohne Weiteres möglich, mit Funktionen genau so zu rechnen, wie mit normalen Werten. Funktionen können andere Funktionen als Parameter haben oder welche zurückgeben - Man spricht von sog. Funktionen höherer Ordnung (Higher-Order functions).
Betrachten wir folgende Definition:
let zahlVerdoppeln x = 2 * x let stringVerdoppeln x = x ^ x // ^-Operator verknüpft Strings let zweimalAnwenden f x = f (f x)
Listing 38: Higher-Order functions
ZweimalAnwenden ist eine Funktion, die eine andere Funktion f auf einen Wert x zwei mal anwendet.
Logischerweise ergibt folgener Ausdruck den Wert 4:
zweimalAnwenden zahlVerdoppeln 1
Listing 39: Funktionen als Parameter
Genauso ergibt Folgendes den Wert "XXXX":
zweimalAnwenden stringVerdoppeln "X"
Listing 40: Funktionen als Parameter mit strings
Die Funktionen zum Verdoppeln sind als ganz normale Parameter übergeben worden.
Interessant sind hier die Typsignaturen:
val zahlVerdoppeln : int -> int val stringVerdoppeln : string -> string val zweimalAnwenden : ('a -> 'a) -> 'a -> 'a
Listing 41: Typsignaturen von Funktionen höherer Ordnung
Hier sieht man, der erste Parameter von zweimalAnwenden hat den Typen ('a -> 'a), d.h. eine Funktion, die einen beliebigen Typen aufnimmt und den selben Typen wieder ausgibt. Deshalb funktioniert auch die Funktion sowohl mit dem Verdoppeln von Zahlen als auch von Strings, sie funktioniert sogar mit jeder beliebigen Funktion, die obiger Typangabe folgt. Beispiel:
let zahlVervierfachen x = zweimalAnwenden zahlVerdoppeln x
Listing 42: Weitere higher-order function
Durch Currying ermöglicht sich sogar folgende Definition:
let zahlVervierfachen = zweimalAnwenden zahlVerdoppeln
Listing 43: Higher-order function mit Currying
Anonyme Funktionen
Bis jetzt hatten unsere Funktionen immer einen Namen. Das muss aber nicht sein, mit dem fun-Schlüsselwort kann man direkt anonyme Funktionen einführen, die komplett gleichwertig mit "richtigen" sind. Diese Funktion (int -> int) verdoppelt wiederum den Wert x:
fun x -> 2 * x
Listing 44: Verdoppeln als anonyme Funktion
Folgende Definition
let verdoppeln x = 2 * x
Listing 45: verdoppeln in erster Variante
ist komplett identisch mit dieser:
let verdoppeln = fun x -> 2 * x
Listing 46: verdoppeln in zweiter Variante
Anonyme Funktionen können problemlos mehrzeilig sein - Einrücktiefe und letzte Anweisung entscheiden wiederum wie bei normalen Funktionen. Betrachten wir nun einmal ein sinnvolles Beispiel: Wir möchten eine Funktion compose schreiben, die zwei Funktionen f und g miteinander kombiniert (verkettet):
let compose f g = fun x -> f (g x) let mal2 x = 2 * x let plus1 x = x + 1 let mal2Plus1 = compose plus1 mal2
Listing 47: compose als verkettete Funktion
compose nimmt zwei Funktionen und gibt eine anonyme zurück. Mal2Plus1 ist eine Funktion, die von einem Wert x den Wert berechnet.
An dieser Stelle möchte ich auf die VB-Version desselben Codes hinweisen:
Sub Main() Dim Mal2Plus1 = Compose(New Func(Of Integer, Integer)(AddressOf Plus1), _ New Func(Of Integer, Integer)(AddressOf Mal2)) End Sub Function Plus1(ByVal x As Integer) As Integer Return x + 1 End Function Function Mal2(ByVal x As Integer) As Integer Return 2 * x End Function Function Compose(Of A, B, C)(ByVal f As Func(Of B, C), _ ByVal g As Func(Of A, B)) As Func(Of A, C) Return Function(x) f(g(x)) End Function
Listing 48: compose in VB.NET
Die Typsignatur von compose ist schon etwas trickreich. Wiederum müssen wir die in F# zum Glück nicht angeben, dass tut ja der Compiler für uns!
val compose : ('b -> 'c) -> ('a -> 'b) -> ('a -> 'c)
Listing 49: Typsignatur von compose
Hier wird eindrucksvoll deutlich, wie nah man in funktionalen Sprachen an Problem / Aufgabenstellung arbeiten kann, ohne sich mit Typisierungen oder Deklarationen aufzuhalten.
Pipelines
F# hat eine syntaktische Besonderheit, den Pipeline-Operator. Von manchen wird er zwar als nicht schön empfunden, aber er hat durchaus seine Vorzüge und wird in F# oft ausgiebig eingesetzt. Wie der Name schon sagt dient er dem Zwecke, eine Reihe von Transformationen hintereinander durchzuführen.
Nehmen wir an, wir wollten eine Kommazahl x quadrieren, zu einer Ganzzahl umwandeln und auf dem Bildschirm ausgeben.
Klar - Das können wir:
printfn "%i" (int (quadriere x))
Listing 50: Quadrieren und in eine ganze Zahl umwandeln
Resultat: Mengen an Klammern (ein Punkt, bei dem immer über LISP gelästert wird) und die Operationen in falscher Reihenfolge. Durch den magischen Pipeline-Operator |> lässt sich das merklich entzerren:
x |> quadriere |> int |> printfn "%i"
Listing 51: Quadrieren und in eine ganze Zahl umwandeln mit dem Pipeline-Operator
Durch die Pipeline wird also immer der zuletzt berechnete Wert als letztes Argument der folgenden Funktion verwendet. Das war's schon zu diesem Thema.
Verzweigungen
If
Die einfachste Programmverzweigung ist das aus VB hinreichend bekannte If. If prüft eine bestimmte Bedingung und führt, je nachdem, ob sie erfüllt ist, eine von zwei Alternativen aus. Ein Beispiel sagt mehr als tausend Worte:
let passwort = System.Console.ReadLine() if passwort = "xyz" then printfn "Juhu, du hast das richtige Passwort eingegeben" else printfn "Nein!"
Listing 52: If in F#
Der Unterschied zu VB ist, dass das If in F# stehts einen Wert zurückgibt, auch wenn er in diesem Beispiel wieder nichts (()) ist. If-Blöcke werden wiederum durch Einrücktiefe bestimmt und die letzte Anweisung pro Zweig bestimmt das Ergebnis.
Deshalb funktioniert auch Folgendes.
let abs x = if x >= 0 then x else -x
Listing 53: abs-Funktion in F#
Es existiert auch eine If-Variante ohne else, diese muss vom Typ unit (()) sein:
if irgendwas then // Tu was
Listing 54: If ohne else
Pattern-Matching
Pattern-Matching, zu Deutsch Mustervergleich, ist eine "funktionalere" und viel mächtigere Alternative zu Ifs. Es kann als eine Weiterentwicklung zu Select...Case gesehen werden und wird benutzt, um Vergleiche zu tätigen und Daten (benutzerdefiniert) auseinanderzunehmen. Pattern-Matchings geben wie Ifs einen Wert zurück.
Die einfachste Variante:
match System.Console.ReadLine() with | "xyz" -> printfn "Juhu, du hast das richtige Passwort eingegeben" | "xYz" -> printfn "Fast" | _ -> printfn "Nein!"
Listing 55: Pattern-Matching im Stil von Select-Case
Der Tiefstrich (_) passt auf jeden Eingabewert und ist quasi das Case...Else.
Der Clou am Pattern-Matching ist allerdings, dass weniger gegen Werte als gegen Datenmuster geprüft wird:
let parse x = match System.Int32.TryParse x with | true, res -> res | false, _ -> 0 parse "42" parse "xyz"
Listing 56: Pattern-Matching für Datenmuster statt Werte
Möchte man z.B. einen Eingabe-String, der eine Zahl enthält, in diese Zahl umwandeln, verwendet man dazu meist die Funktion TryParse. Das Ergebnis von TryParse ist ein Tupel (Typ bool * int) der Form (Umwandlung erfolgreich?, Ergebnis).
Es wird nun geprüft, ob die Rückgabe auf das Muster "true und ein Ergebnis" passt - In diesem Falle war das Umwandeln der Zahl erfolgreich und das Ergebnis steht in res, andernfalls erfüllt es das Muster "false und irgendwas", was bedeutet, dass die Eingabe keine gültige Zahl war und es wird 0 zurückgegeben.
In den Mustern können auch sog. Guards, d.h. kleine Prüfungsbedingungen, verwendet werden:
let sign x = match x with | 0 -> 0 | _ when x > 0 -> 1 | _ -> -1
Listing 57: Pattern-Matching mit Guards
Durch die verkürzte Notation mit function kann dieses Beispiel noch weiter gekürzt werden - ein Parameter kommt hinzu und es wird ein Matching eröffnet.
Merke : function erschafft einen zusätzlichen anonymen Parameter, der nur in den Matching-Alternativen auftritt.
let sign = function | 0 -> 0 | x when x > 0 -> 1 | _ -> -1
Listing 58: sign-Funktion mittels Pattern-Matching
Pattern-Matchings können beliebig viele Bedingungen enthalten, verschachtelt werden und die Ausdrücke können auch mehrzeilig sein.
match ... with | x -> printfn "..." 42 | 0 -> printfn "..." 23
Listing 59: Noch mehr Pattern-Matching
Spätestens hier erkennt man, dass es in F# oft viele Techniken gibt, das Selbe zu erreichen bzw. auszudrücken.
Rekursive Funktionen
Einfache Rekursion
Bis jetzt können wir in F# Werte anlegen, Funktionen definieren und mit ihnen rechnen. Wir konnten alles aber höchstens einmal ausführen lassen, sog. Schleifen und ähnliches fehlt. Das wird sich jetzt mit rekursiven Funktionen ändern.
Eine Funktion heißt rekursiv, wenn sie sich selbst aufruft. Folgendes Beispiel: Wie berechne ich die Summe aller Zahlen von 1 bis 100? Antwort in VB:
Function Summe(ByVal n As Integer) As Integer Dim Sum = 0 For i = 1 To n Sum += i Next Return i End Function Console.WriteLine(Summe(100))
Listing 60: Listensumme in VB
Dieser Code ist denkbar "unfunktional", es werden ständig Lauf- und Summenvariable geändert.
Stellen wir statt dessen folgende Überlegung an: Die Summe von 1 bis 100 ist doch 100 plus die Summe von 1 bis 99. Die Summe von 1 bis 99 ist 99 plus die Summe von 1 bis 98. Und so weiter.
Wir können also sagen:
- Die Summe von 1 bis n ist n plus die Summe von 1 bis n-1.
- Weiterhin ist die Summe von 1 bis 1 gleich 1.
Über Rekursionen können wir das direkt als Programm formulieren:
let rec summe = function | 1 -> 1 | n -> n + (summe (n - 1)) printfn "%i" (summe 100)
Listing 61: Listensumme in F#
Hinzugekommen ist hier lediglich das Schlüsselwort rec, das angibt, dass eine bestimmte Funktion rekursiv ist. Und siehe da: Keine Mehrfachzuweisungen, keine Schleifen, sondern eine perfekt funktionale Summenfunktion. Viele Zusammenhänge, Baumstrukturen, Listen etc. sind rekursiv strukturiert und daher mit diesen funktionalen Mechanismen gut zu beschreiben.
Endrekursion
Ein Problem besteht noch bei obiger Summenfunktion: Für große Werte bekommt man Fehlermeldungen. Das liegt daran, dass sich das Programm bei jedem Funktionsaufruf, auch bei einem rekursiven, merken muss, wo es vor dem Aufruf war, damit es danach dorthin zurückkehren kann. Das geschieht auf dem sog. Stapel (Stack) und dieser läuft bei großen Datenmengen schlichtweg über.
Die Lösung für dieses Problem nennt sich Endrekursion. Eine Funktion ist endrekursiv, wenn ihr Selbstaufruf der letzte ausgeführte Befehl ist, denn dann muss sich keine Addresse gemerkt werden! Um die berechnete Summe zwischenzuspeichern verwendet man einen sog. Akkumulator-Parameter. Das eigentliche Funktionsargument ist hier durch function anonym gehalten und quasi im Pattern-Matching versteckt:
let rec summe acc = function | 1 -> acc | n -> summe (n + acc) (n - 1)
Listing 62: Summenfunktion mit Endrekursion
Der Aufruf müsste nun
summe 1 100
Listing 63: Aufruf der endrekursiven Summe
lauten. Der Compilier kann diese Art der Rekursion komplett wegoptimieren.
Um das noch einmal zu verdeutlichen, zeige ich für die erste, nicht endrekursive Variante der Summenfunktion einen sog. Auswertungsbaum, also die Schritte, die das Programm zur Auswertung der Summenfunktion geht:
summe 6 = 6 + (summe 5) = 6 + (5 + (summe 4)) = 6 + (5 + (4 + (summe 3))) = 6 + (5 + (4 + (3 + (summe 2)))) = 6 + (5 + (4 + (3 + (2 + (summe 1))))) = 6 + (5 + (4 + (3 + (2 + 1)))) = 6 + (5 + (4 + (3 + 3))) = 6 + (5 + (4 + 6)) = 6 + (5 + 10) = 6 + 15 = 21
Listing 64: Auswertungsbaum der einfach rekursiven Summenfunktion
Selbst für diese relativ kleine Eingabe sieht man, dass der Baum zwischenzeitig sehr stark wächst.
Hier nun die Auswertung einer Endrekursion:
summe 1 6 = summe (6 + 1) 5 = summe 7 5 = summe (5 + 7) 4 = summe 12 4 = summe (4 + 12) 3 = summe 16 3 = summe (3 + 16) 2 = summe 19 2 = summe (2 + 19) 1 = summe 21 1 = 21
Listing 65: Auswertungsbaum der endrekursiven Summenfunktion
Die Größe des Baumes ist hier beschränkt, kein Stapel läuft über.
Anschaulich kann man sich den Akkumulator als den Beginn oder Ausgangspunkt einer Berechnung vorstellen. Weil wir von diesem Akkumulatorparameter eigentlich nichts wissen wollen, können wir ihn sogar in einer nichtrekursiven Hauptfunktion verstecken:
let summe n = let rec summeRec acc = function | 1 -> acc | n -> summe (n + acc) (n - 1) summeRec 1 n // Aufruf: printfn "%i" (summe 100)
Listing 66: Akkumulator versteckt
Ein weiteres Beispiel für die Fakultätsfunktion:
let factorial n = let rec factorialRec acc n = if n = 1 then acc else factorialRec (n * acc) (n - 1) factorialRec 1 n // Aufruf: let n = 6 printfn "%i! = %i" n (factorial n)
Listing 67: Fakultätsfunktion
Wechselseitige Rekursion
Hier werde ich kurz auf einen Spezialfall von Rekursionen hinweisen: Wechselseitige Rekursion.
Zwei Funktionen heißen wechselseitig rekursiv, wenn beide die jeweils andere aufrufen. Dies ist auch auch mehrere Funktionen übertragbar. Hier ein Beispiel in Visual Basic, über dessen Sinnhaftigkeit man sich streiten kann ;-)
Sub Main() ZahlenZeigen(1, 10) End Sub Sub ZahlenZeigen(ByVal Zahl As Integer, ByVal Maximum As Integer) If Zahl Mod 2 = 0 Then GeradeZahlZeigen(Zahl, Maximum) Else UngeradeZahlZeigen(Zahl, Maximum) End Sub Sub GeradeZahlZeigen(ByVal Zahl As Integer, ByVal Maximum As Integer) If Zahl > Maximum Then Return Console.WriteLine("Gerade Zahl: {0}", Zahl) UngeradeZahlZeigen(Zahl + 1, Maximum) End Sub Sub UngeradeZahlZeigen(ByVal Zahl As Integer, ByVal Maximum As Integer) If Zahl > Maximum Then Return Console.WriteLine("Ungerade Zahl: {0}", Zahl) GeradeZahlZeigen(Zahl + 1, Maximum) End Sub
Listing 68: Wechselseitige Rekursion in VB
Was nicht weiter spektakulär zu sein scheint stellt uns in F# vor ein Problem - Funktionen werden strikt von oben nach unten deklariert und verarbeitet!
Dieser eigentlich korrekt anmutende Code kann deshalb nicht funktionieren, weil die Funktion ungeradeZahlZeigen zum Zeitpunkt, da geradeZahlZeigen diese aufruft, "noch gar nicht existiert".
let geradeZahlZeigen zahl maximum = if zahl <= maximum then printfn "Gerade Zahl: %i" zahl ungeradeZahlZeigen (zahl + 1) maximum let ungeradeZahlZeigen zahl maximum = if zahl <= maximum then printfn "Ungerade Zahl: %i" zahl geradeZahlZeigen (zahl + 1) maximum
Listing 69: Vermeintlich richtige wechselseitige Rekursion in F#
Streng genommen ist dieses Problem sogar zunächst in keiner Sprache elegant lösbar, da wir hier, anders als im obigen VB-Code nicht nur Funktionen, sondern auch Werte ("Variablen") deklarieren, die sich gegenseitig referenzieren, was auch VB nur durch mehrfache Zuweisung umgehen kann. Da wir in F# aber gerne funktional schreiben und ohne jene auskommen möchten, existiert glücklicherweise ein Konstrukt, um wechselseitige Rekursionen doch auszudrücken:
let rec geradeZahlZeigen zahl maximum = if zahl <= maximum then printfn "Gerade Zahl: %i" zahl ungeradeZahlZeigen (zahl + 1) maximum and ungeradeZahlZeigen zahl maximum = if zahl <= maximum then printfn "Ungerade Zahl: %i" zahl geradeZahlZeigen (zahl + 1) maximum let zahlenZeigen n = if n % 2 = 0 then geradeZahlZeigen n else ungeradeZahlZeigen n zahlenZeigen 2 11
Listing 70: Korrekte wechselseitige Rekursion in F#
Erste grundlegende Datentypen
F# hat, wie jede funktionale Sprache, verschiedene grundlegende Datentypen zusätzlich zu den "normalen" .NET-Typen. Diese eingebauten Typen sind speziell auf die Sprache zugeschnitten und können sehr intuitiv verwendet werden.
Tupel
Den ersten dieser Typen haben wir bereits kennen gelernt: Tupel
Sie können beliebig viele Werte aufnehmen und werden mit * notiert (bspw. int * string * float - Ähnlich der mathematischen Schreibweise des kartesischen Produktes). Durch Zuweisungen können sie wieder in ihre Bestandteile zerlegt werden:
let meinTupel = (1, "Hallo", 3.141) let a, b, c = meinTupel
Listing 71: Verwendung von Tupel
Eine weitere Möglichkeit Tupel zu zerlegen ist das Pattern-Matching.
Typenaliase
Typenaliase sind keine Datentypen, sondern "Ersatznamen" für andere. Man kann so Typangaben einen gesonderten Sinn geben:
type Alter = int let alterAusgeben (jahre : Alter) = printfn "%A" jahre
Listing 72: Verwendung von Typenaliasen
Records
Records sind Tupeln ähnlich, nur dass hier Werte explizit benannt werden können. Sie entsprechen im weitesten Sinne den Structures aus VB.
type Name = { Vorname : string; Nachname : string }
Listing 73: Aufbau von Records
Die einzelnen Felder können durch "normale" Punkt-Schreibweise abgefragt werden. Ein Record ist, genau wie jeder andere Typ, unveränderbar. Eine Instanz wird durch einfache Angabe der Felder oder aus einem Prototypen erstellt. Der Typ wird automatisch abgeleitet.
let max = { Vorname = "Max"; Nachname = "Mustermann" } let erika = { max with Vorname = "Erika" }
Listing 74: Verwendung von Records
In Funktionen kann der Record-Typ manchmal über die Felder identifiziert werden, da es aber auch uneindeutige Fälle gibt, sollte man den Record-Typen explizit angeben.
let grüssen (name : Name) = printfn "Hallo, %s, %s" name.Vorname name.Nachname
Listing 75: Record mit impliziter Typangabe
Records können, wie andere benutzerdefinierte Datenstrukturen auch, mit generischen Typen belegt werden.
type 'a Eintrag = { Position : int; Wert : 'a } let foo = { Position = 42; Wert = "Hallo, Welt" }
Listing 76: Record mit generischen Typen
Die Typschreibweise für foo lautet:
val foo : string Eintrag
Listing 77: Typsignatur von foo
.NET-Typen
F# kann alle .NET-Typen normal instantiieren und verwenden:
let zufallsgenerator = new System.Random() let zufallszahl = zufallsgenerator.Next(1, 10)
Listing 78: .NET-Typen in F#
Für IDisposable-Typen existiert eine Using-Schreibweise, die diese automatisch am Ende des Blocks entsorgt:
use foo = new Objekt() // Objekt verwenden
Listing 79: IDisposable-Typen mit use
Listen
Listen sind die funktionalen Datenstrukturen schlechthin. Eine Liste enthält eine bestimmte Anzahl von Objekten eines bestimmten Typs. Auf Elemente kann nur nacheinander zugegriffen werden, nicht über einen Index. Listen sind unveränderbar, man erstellt aus Listen immer neue Listen, was aber extrem effizient möglich ist. Allem voran sind Listen aber keine Arrays!
Listen erstellen
Ein paar Listen:
let liste1 = [1; 2; 3; 4] let liste2 = [1..4] let liste3 = [1, 2; 3, 4] let liste4 = ["Hallo"; ", "; "Welt"]
Listing 80: Listen in F#
Wie man sieht ist das Erstellen von Listen denkbar einfach: Eckige Klammern und dazwischen mit Semikolon getrennte Einträge. Kommata erstellen Listen von Tupeln! Eine weitere Möglichkeit ist die Bereichsnotation mit zwei Punkten. Die Beispiellisten haben folgendermaßen notierte Typen (Siehe generische Records):
val liste1 : int list val liste2 : int list val liste3 : (int * int) list val liste4 : string list
Listing 81: Typsignaturen der Listen
Der Hintergrund für Listen ist folgender: Eine Liste ist aufgebaut aus Knoten - Jeder Knoten enthält einen Wert und einen nächsten Knoten. Die Liste wird durch einen sog. Nil-Knoten beendet, der keine weitere Liste enthält:
Abbildung 4: Darstellung einer Liste
Eine gleichwertige Listenkonstruktion in der Sprache LISP sieht folgendermaßen aus:
(cons 1 (cons 2 (cons 3 '())))
Listing 82: Listen in LISP
Auch unter F# können wir den Cons-Operator :: verwenden. Die leere Liste ist [].
let liste = 1::2::3::[]
Listing 83: Cons-Operator (::) in F#
Das steckt letztendlich hinter der Schreibweise [1; 2; 3].
Mithilfe des ::-Operators können wir leicht und unglaublich effizient eine Liste nach vorne verlängern:
let liste1 = [1..4] // 1, 2, 3, 4 let liste2 = 0::liste1 // 0, 1, 2, 3, 4
Listing 84: Listenverlängerung mittels ::
Da sich die Listen wie alle Werte nicht ändern können, muss hier nicht einmal Speicher kopiert werden. Es werden einfach Zeiger "umgehängt".
Das Anfügen an das Ende einer Liste ist währenddessen vergleichsweise ineffizent. Die Prozedur muss sich durch alle Knoten "durchhangeln":
let liste1 = [1..4] // 1, 2, 3, 4 let liste2 = [6..8] // 6, 7, 8 let liste3 = liste1 @ [5] @ liste2 // 1, 2, 3, 4, 5, 6, 7, 8
Listing 85: Listenverlängerung mittels @
Wegen dieser Ineffizienz bauen F#-Programme Listen oft falschherum auf und drehen sie am Ende um, was sich wiederum schnell bewerkstelligen lässt.
Manuelle Listenverarbeitung
Listenverarbeitung ist in F# sehr wichtig.
Es gibt hier zahlreiche Techniken, die grundlegende manuelle Listenverarbeitung ist trotzdem oft unumgänglich. Als klassische, rekursive Datenstruktur lässt sich funktional gut mit Listen arbeiten. Exemplarisch suchen wir eine Funktion, die die Summe einer Liste berechnet.
Hier hilft wieder der rekursive Denkansatz von vorher. Wir wissen: Eine Liste ist aus Kopfelement und der Restliste aufgebaut.
- Die Summe einer Liste ist um das Kopfelement größer als die Summe des Rests.
- Die Summe von einer leeren Liste ist 0
Mit ein wenig Pattern-Matching lässt sich das fast 1:1 übernehmen:
let rec summe liste = match liste with | [] -> 0 | x::xs -> x + (summe xs)
Listing 86: Listensumme in F#
Das Matching ist hier folgenermaßen zu sehen: Passt die Liste auf das Muster [], also leere Liste, so gib 0 zurück. Passt die Liste auf das Muster Kopf::Rest, dann addiere Kopf und Summe des Rests. Die Benennung der einzelnen Teile ist beliebig, es haben sich aber verschiedene Konventionen eingebürgert:
- x::xs (das s ist als Plural-s zu verstehen - Beispiel: apple::apples)
- x::x'
- h::t (head/tail)
Unsere Summenfunktion ist leider noch nicht endrekursiv - Wir bemühen also wieder einen Akkumulatorparameter (und schreiben aus optischen Gründen das Match...With durch function um):
let summe liste = let rec summeRec acc = function | [] -> acc | x::xs -> summeRec (x + acc) xs summeRec 0 liste
Listing 87: Bessere Listensummenfunktion mit Endrekursion
Schön an Listen ist auch, dass der Compiler den Umgang mit ihnen so durchschaut, dass er automatisch auf ihre Verwendung schließt und wir (wie im Beispiel) wieder keinen einzigen Typen angeben müssen. Die Pattern-Matchings sind sehr variabel - Man kann sich fast beliebige Kombinationen aus Listenschreibweise ([]), Konstruktorschreibweise (::) und Platzhaltern (_) zusammenbauen.
| [] -> | x::xs -> | x::[] -> | _::xs -> | _::x::_ -> | a::b::rest -> | _::[a, b; c, d] ->
Listing 88: Einige Patterns für Listen
Die Syntax lässt sich sogar in Funktionsdeklarationen übertragen:
let erstesElement (x::_) = x // val erstesElement :: 'a list -> 'a
Listing 89: Listen in Funktionsdeklarationen
Zurück zur Summenfunktion: Es wäre doch sinnvoll, die Funktion so zu verallgemeinern, dass sie nicht nur die Summe, sondern jede beliebige Operation über die Listen akkumulieren kann. Eine Summenbildung wäre dann lediglich ein Spezialfall, bei dem diese Operation Plus lautet. Eine derartige Funktion nennt sich fold. Dank Higher-Order-Functions hat man hier kaum Mehraufwand. Man kann sich sogar sparen, den Akkumulatorparameter zu verstecken.
let rec fold func acc = function | [] -> acc | x::xs -> fold func (func acc x) xs
Listing 90: fold-Funktion
Mit dieser Funktion lassen sich jetzt Summen, Produkte und Vieles mehr auf einen Schlag abdecken.
let sum = fold (+) 0 // Summe: Eine Liste von 0 beginnend mit + zusammenrechnen let prod = fold (*) 1 // Produkt: Eine Liste von 1 beginnend mit * zusammenrechnen let factorial n = prod [1..n]
Listing 91: Anwendung der fold-Funktion
List-Comprehensions
List-Comprehensions sind extrem mächtige Konstruktionen, um Listen in andere zu überführen und ihre Benutzung ist denkbar intuitiv:
let zahlen = [1..10] let quadratZahlen = [ for x in zahlen -> x * x ] let geradeZahlen = [ for x in zahlen do if x % 2 = 0 then yield x ]
Listing 92: List-Comprehensions
Hier sieht man zwei List-Comprehensions in Aktion. Die obere Syntax ist stark vereinfacht, die untere ist die Langform, wobei yield letztendlich einen Wert in die neue Liste schreibt (die Pfeilschreibweise gilt als veraltet und sollte nur in sehr einfachen Fällen wie dem obigen verwendet werden).
List-Comprehensions sind letztendlich nur ein möglicher Fall von Computation Expressions, einer Möglichkeit für F#-Programmierer, den Kontrollfluss von Programmen extrem flexibel verändern zu können, auf die ich später noch einmal zu sprechen kommen werden. Ihre imperativ anmutende Syntax ändert nichts daran, dass ihre Funktionsweise rein funktional ist. Sie können ganz normalen F#-Code enthalten und sich über viele Zeilen erstrecken - wie man es aus normalen Programmen kennt. Auch die for-Syntax ist nichts Spezielles, sie wird auch in normalem F# verwendet. Mögliche Formen sind
- for x in Aufzählung do
- for x in Anfang..Ende do
- for x = Anfang to Ende do
Weiterhin unterstützt F# auch while-Schleifen (Siehe Schleifen).
Komplett-Beispiel:
for (i, x) in [for x in 1..10 -> x, x * x] do printfn "#%i -> %i" i x
Listing 93: Beispiel zur Verwendung einer for-Schleife mit Listen
Mit yield! kann jedes Element einer Liste in eine neue geschrieben werden. Beispiel:
let copy list = [ yield! list ]
Listing 94: Verwendung von yield!
Listenfunktionen
Der Dreh- und Angelpunkt jeder F#-Listenverarbeitung sind und bleiben die integrierten Listenfunktionen. Viele lassen sich durch for, Pattern-Matching oder List-Comprehensions besser ausdrücken, manche hingegen sind wichtig. Für die Verwendung muss jeweils ein List. ergänzt werden.
Funktion | Zweck | Syntax | Beispiel |
---|---|---|---|
hd/tl | Kopf/Rest einer Liste | hd liste | hd [1..10] |
exists | Erfüllt min. ein Element eine Bedingung? | exists bedingung liste | exists even [1..10] |
for_all | Erfüllen alle Elemente eine Bedingung? | for_all bedingung liste | for_all even [1..10] |
length | Länge der Liste | length liste | length [1..10] |
filter | Bestimmte Elemente per Bedingung wählen | filter funktion liste | filter gerade [1..10] |
map | Funktion auf alle Elemente anwenden | map funktion liste | map mal2 [1..10] |
mapi | Funktion mit Index auf alle Elemente anwenden | mapi funktion liste | mapi anfangGross ["a"; "b"] |
rev | Liste umkehren | rev liste | rev [1..10] |
min/max | Minimum/Maximum finden | min liste | max [1..10] |
minBy/maxBy | Minimum/Maximum anhand eigenen Vergleichs finden | minBy funktion liste | maxBy sin [1..10] |
sum | Liste aufsummieren | sum liste | sum [1..10] |
sort | Liste sortieren (Mergesort) | sort liste | sort [1..10] |
sortBy | Liste anhand eigenen Vergleichs sortieren | sort funktion liste | sort sin [1..10] |
fold/fold_left | Liste akkumulieren (siehe oben) | fold funktion startwert liste | fold (+) 0 [1..10] |
zip | Zwei Listen zu einer Liste aus Tupeln verschmelzen | zip liste1 liste2 | zip [1..10] [11..20] |
Tabelle 2 : Listenfunktionen
Ein paar Beispiele
Jetzt, da wir einiges über Listenverarbeitung gelernt haben, ist es Zeit für ein paar komplexere Beispiele.
Mittelwert, Varianz und Standardabweichung
let summeDerQuadrate = [1..10] |> List.fold_left (fun sum i -> sum + i * i) 0
Listing 95: Summe der Quadrate (Statistik)
Quicksort
Quicksort ist der Klassiker unter den Sortieralgorithmen.
Man wählt aus einer Liste ein sog. Pivotelement (Trennelement) und teilt sie dann so auf, dass man zwei Listen erhält: Eine, die alle kleineren Elemente verglichen zum Pivotelement enthält und eine für die Größeren. Dann werden beide Teillisten abermals mit Quicksort weitersortiert und die Ergebnisse in richtiger Reihenfolge zusammengehängt.
Wer möchte, kann sich vor dem Weiterlesen an dieser Stelle zunächst selbst am Algorithmus versuchen ... Ein Tipp: Eine leere Liste ist sortiert immer noch leer!
Geschafft? Hier ist ein fertiges Programm:
#light let rec quickSort = function | [] -> [] | pivot::rest -> let linkeHälfte = [for x in rest do if x < pivot then yield x] let rechteHälfte = [for x in rest do if x >= pivot then yield x] (quickSort linkeHälfte) @ [pivot] @ (quickSort rechteHälfte) let zahlen = [4; 1; 7; 6; 6; 1; 0; 3; 4] let sortiert = quickSort zahlen printfn "Zahlen : %A\nSortiert: %A" zahlen sortiert System.Console.ReadKey() |> ignore
Listing 96: Quicksort in F#
Als weitere Übung könnte man probieren, quickSort so umzuschreiben, dass es nicht < zum Vergleichen verwendet, sondern eine benutzerdefinierte Funktion.
Notiz: Die obige Implementierung ist natürlich nicht optimal, da die Liste zum Aufteilen zwei mal durchlaufen wird, in einer zeitkritischen Version verwendete man List.partition zum Aufteilen (oder ohnehin das eingebaute List.sort!)
Merge
Unser zweites Beispiel soll eine Funktion bieten, die zwei sortierte Listen miteinander zu einer großen, sortierten Liste verschmilzt.
Beispiel: [1, 2, 7, 8] + [0, 3, 3, 4, 9] -> [0, 1, 2, 3, 3, 4, 7, 8, 9]
In imperativen Programmiersprachen ist das relativ umständlich und nur mit temporärem Speicher durchzuführen, während wir das relativ intuitiv formulieren können. Gehen wir schrittweise vor:
- Zwei leere Listen ergeben eine leere Liste.
- Wenn beide Listen mindestens ein Element beinhalten, kommt das Kleinere von beiden in die Zielliste und die Reste werden verschmolzen.
#light let merge list1 list2 = let rec mergeRec acc list1 list2 = match list1, list2 with | [], [] -> acc | x::xs, [] -> mergeRec (x::acc) xs [] | [] , y::ys -> mergeRec (y::acc) [] ys | x::xs, y::ys when x < y -> mergeRec (x::acc) xs (y::ys) | x::xs, y::ys -> mergeRec (y::acc) (x::xs) ys mergeRec [] list1 list2 |> List.rev let list1 = [1; 2; 7; 8] let list2 = [0; 3; 3; 4; 9] printfn "%A + %A -> %A" list1 list2 (merge list1 list2) System.Console.ReadKey() |> ignore
Listing 97: Verschmelzen zweier sortierter Listen (merge)
Arrays
Arrays kennen wir aus VB, sie sind dort quasi die Standard-Listenstruktur.
Ein Array ist eine durchnummerierte Aufzählung fester Größe. Es ist deshalb auch nur in den Fällen sinnvoll, bei denen sich die Aufzählung in ihrer Größe nicht ändert und man zwingend über einen Index auf die Werte zugreifen muss. Sonst sind die flexibleren Lists die bessere Wahl.
Der gesamte Umgang mit Arrays ist dem mit Listen sonst sehr ähnlich.
let meinArray = [| 1; 2; 3 |] let meineWörter = "Hallo Welt".Split [| ' ' |]
Listing 98: Deklaration von Arrays
Arrays können durch for-Schleifen durchlaufen werden, genau so wie durch Zugriff per nullbasiertem Index. Die Elemente in einem Array sind veränderlich , was prinzipiell "unfunktional", aber stellenweise auch sinnvoll ist. Hierfür verwendet man eine Notation mit <- . Für Arrays existiert ein Pendant zu den List-Comprehensions sowie für fast alle List.*-Methoden, die jetzt Array.* lauten. Auf Elemente kann in der Syntax .[] zugegriffen werden. Dieser Indizierungszugriff wird bei vielen .NET-Typen wie den System.Collection.-Listen oder auch Strings verwendet. Auch haben Arrays eine .Length-Eigenschaft, die die Anzahl der enthaltenen Elemente angibt.
Ein paar Beispiele:
let ersteBuchstaben = [| for wort in meineWörter -> wort.[0] |] let meinArray = [1; 2; 3] |> List.to_array let meinArray2 = [| 1..3 |] printfn "Zweiter Eintrag: %i" meinArray.[1] meinArray.[0] <- 42 printfn "Zweiter Eintrag: %i" meinArray.[1]
Listing 99: Beispiele zur Verwendung von Arrays in F#
Ein klassisches Beispiel für einen imperativen Algorithmus mit veränderlichen, indexbasierten Daten ist das Primzahlsieb des Eratosthenes:
let primeEratosthenes n = let sieb = [| for i = 0 to n do yield 1 |] for i = 2 to n do if sieb.[i] >0 then for j in 2*i..i..n do sieb.[j] <- 0 [ for i in 2..n do if sieb.[i] > 0 then yield i ]
Listing 100: Sieb des Eratosthenes
Arraytypen werden folgendermaßen notiert:
val sieb : int array
Listing 101: Typsignatur von einem Array
Sequenzen
Sequenzen sind eine abstraktere Form, eine Aufzählung von Objekten zu repräsentieren. Sowohl Listen als auch Arrays sind Spezialformen der Sequenzen. (Für die eingefleischten .NET-ter: Eine Sequenz ist alles, was IEnumerable<T> implementiert).
Ein paar Stichpunkte zu den Eigenschaften von Sequenzen:
- Typisiert
- Unveränderlich
- Nur schrittweise zugreifbar
- Sequence-Expressions (Spezialfall: List-Comprehensions) als bequeme Syntax
- Iteration erfolgt per for
- Zahlreiche Funktionen in Seq.*
Unterschiede:
- Keine Initialisierungsschreibweise - Nur yield kann Elemente generieren
- Bedarfsauswertung
- Kein Pattern-Matching zur Dekomposition
- Selten manuelle Verarbeitung
let zahlen = seq { 1..100 } let quadratZahlen = seq { for i in zahlen -> i * i } let alleNatürlicheZahlen = Seq.init_infinite (fun i -> i) let alleQuadratzahlen = seq { for i in zahlen -> i * i } printfn "%A" (quadratZahlen |> Seq.take 20) printfn "%A" (alleQuadratzahlen |> Seq.take 20)
Listing 102: Verwendung von Sequenzen
Man sieht: Es besteht kein Handhabungs-Unterschied zwischen endlichen und unendlichen Sequenzen, da beide hier nur für 20 Elemente ausgewertet werden. Hinter der Schreibweise seq {} verbirgt sich eine sehr interessante Konstruktion namens Computation-Expression, die im Grunde gar nicht speziell für Sequenzen geschaffen wurde und uns bei asynchronen Berechnungen nocheinmal begegnet.
Notiert werden Sequenzen so:
val zahlen : seq<int>
Listing 103: Typsignatur einer Sequenz
Der Trick an Sequenzen ist, einfach mit allen Möglichkeiten zu rechnen und es dem Programm zu überlassen, dass es die Möglichkeiten, die nie in Frage kommen, gar nicht berechnet.
let prim = let rec primRec altePrimzahlen n = seq { if altePrimzahlen |> List.forall (fun p -> n % p <> 0) then yield n yield! (primRec ( altePrimzahlen @ [n]) (n + 2)) else yield! (primRec altePrimzahlen (n + 2)) } seq { yield 2; yield! primRec [2] 3 } printfn "Die ersten 20 Primzahlen: " for p in prim |> Seq.take 20 do printfn "%i" p
Listing 104: Unendliche Sequenz von Primzahlen
Berechnen wir einmal als Beispiel die unendliche Sequenz aller Primzahlen. Dabei können die bereits berechneten Primzahlen gespeichert werden, um die Berechnung später zu beschleunigen.
Eine andere Anwendungsform ist z.B. das sehr effiziente Auswerten von Spielbäumen, denn auch hier werden nur die Lösungen generiert, die überhaupt in Betracht kommen.
Hier nocheinmal eine Verdeutlichung des Terms Bedarfsauswertung:
let rec numb3rs() = seq { printf "Gib einen Wert ein: " let num = int(System.Console.ReadLine()) yield num yield! numb3rs() } numb3rs() |> Seq.take 10 |> Seq.iter (printfn "Erhalte Wert: %i\n") System.Console.ReadKey() |> ignore
Listing 105: Sequenz zur Verarbeitung einer Zahlenreihe
Hier wird nicht etwa die Eingabe von 10 Werten gefordert und dann eine Ausgabe gemacht, vielmehr wird nach einem Wert gefragt, dieser eingegeben und direkt danach ausgegeben. Die Sequenzfunktion selbst hat dabei keine Abbruchbedingung, wird aber dennoch nur so weit berechnet, wie benötigt.
Option types
Option-Types werden an vielen Stellen in F# benötigt und zwar überall dort, wo Berechnungen nicht immer ein Ergebnis produzieren. Sie entsprechen den NULL-fähigen Datentypen aus SQL oder VB. Die Signatur
val x : int option
Listing 106: Typsignatur eines Option-Types
kann also verstanden werden als: "x ist das Ergebnis einer Berechnung, die eine Zahl ergeben kann, oder aber fehlschlägt und nichts zurückgibt".
Ein klassisches Beispiel für Nullwerte ist die Division zweier Zahlen - entweder funktioniert die Division oder es wurde durch 0 geteilt, was bekanntlich nicht funktioniert:
let safeDiv a b = if b <> 0 then Some(a / b) else None
Listing 107: Überprüfte Division
Eigentlich recht simpel - Wenn der Divisor ungleich 0 ist, dann geben wir etwas zurück, nämlich a/b, andernfalls nichts. Hierzu verwenden wir die Datenkonstruktoren (siehe Discriminated unions) Some und None. Die zugehörige Signatur:
val safeDiv : int -> int -> int option
Listing 108: Typsignatur des Option-Types für sichere Division
Über Pattern-Matching kann der Options-Type wieder auseinandergebaut werden. An dieser Stelle folgt das längst überfällige und obligatorische Zahlenraten:
let zahlenRaten max zahlVersuche = let ziel = (new Random()).Next(1, max) printfn "Willkommen beim Zahlenraten\nErraten Sie die Zahl zwischen 1 und %i" max printfn "Sie haben %i Versuche\n**********************\n\n" zahlVersuche let rec rateZahl versuch = if versuch > zahlVersuche then None else printf "#%i - Geben sie ihren Tipp ein: " versuch match Int32.TryParse(Console.ReadLine()) with | false, _ -> printfn "Keine Zahl!\n"; rateZahl versuch | true, n when n < ziel -> printfn "Zu klein\n" ; rateZahl (versuch + 1) | true, n when n > ziel -> printfn "Zu groß\n" ; rateZahl (versuch + 1) | true, n -> Some(versuch) match rateZahl 1 with | Some(n) -> printfn "Juhu - Sie haben die Zahl in %i Versuchen erraten" n | None -> printfn "Sie haben verloren - Ergebnis: %i" ziel zahlenRaten 20 5
Listing 109: Zahlenraten
Mengen und Wörterbücher
Oft benötigt man Datenstrukturen für Mengen (Set) und Wörterbücher (Map). Eine Menge ist eine Auflistung von einzigartigen Elementen (ohne doppelte Exemplare), ein Wörterbuch speichert Wert/Schlüssel-Paare. Obschon beides zurecht durch Listen machbar erscheint, bietet uns F# hierfür die Datenstrukturen Set<T> und Map<TKey, TValue> an, die durch ihre interne Repräsentation durch sog. Rot-Schwarz-Bäume immer geordnet vorliegen und gleichzeitig die Effizienz des Zugriffs stark erhöhen. Beide Datenstrukturen sind unveränderlich. Map und Set können hier als Konstruktoren aus Listen fungieren. Ähnlich wie für Listen sind die nötigen Funktionen (union, intersect, size, etc) unter Set.* bzw. Map.* vorhanden. Per Internet oder .NET-Reflector kann man die komplette Liste erhalten. Hier zwei Beispiele:
Sets:
let nums = Set [1..10] let nums2 = nums + Set [5..20] let quadrate = nums2 |> Set.map (fun i -> i * i) let diff = squares - Set [1..5] let schnitt = Set.intersect diff (Set [1..20]) for i in schnitt do printfn "%i" i
Listing 110: Verwendung von Sets
Maps:
let personen = Map ["Max", 42; "Erika", 41] let name = printf "Gib deinen Namen ein: " System.Console.ReadLine() let personen2 = personen.Add("John", 23) match personen2 |> Map.tryfind name with | Some(alter) -> printfn "Du bist %i Jahre alt" alter | None -> printfn "Du bist nicht im System verzeichnet - Glückwunsch" let foo = "Erika" printfn "Hallo %s, du bist %i Jahre alt" foo personen.[foo]
Listing 111: Verwendung von Maps
Imperative Elemente
Auch wenn man in F# nach Möglichkeit funktional programmieren sollte - wenn man das nicht will sollte man es gar nicht erst verwenden, sondern bei VB/C# bleiben - unterstützt F# zwecks Interaktion mit dem .NET-Framework und mancher Anwendungsgebiete wie OCaml selbst auch imperative Elemente.
Variablen
In F# gibt es neben den Werten auch "echte" Variablen, die, ganz im Sinne des Wortes, variabel sind. Sie werden sehr selten verwendet - Ein Beispiel sollte hier genügen:
let mutable foo = 23 foo <- 42 let mutable summe = 0 for x in 1..10 do summe <- summe + x printfn "%i" summe
Listing 112: Verwendung von Variablen in F#
Die Zuweisungssyntax per <- findet auch bei Zuweisungen an Eigenschaften von .NET-Objekten Verwendung.
Verzweigungen
Verzweigungen können natürlich auch intuitiv eingesetzt werden - sowohl mit zwei als auch mit nur einer Alternative.
let passwortAbfragen() = printf "Geben sie ihr Passwort ein: " let eingabe = System.Console.ReadLine() if eingabe = "Foo" then printfn "Gut" passwortAbfragen()
Listing 113: Verwendung von if-Verzweigungen
Der Rückgabetyp ist entsprechend nichts: ().
Schleifen
Der Vollständigkeit wegen hier nocheinmal die verschiedenen (schon in List-Comprehensions angesprochenen) Schleifenkonstrukte auf eine Blick:
printf "Programm fortsetzen? [y/n]" while System.Console.ReadLine() <> "n" do printf "Wirklich? " for i = 1 to 10 do printfn "%i" i for i in 11..20 do printfn "%i" i for i in [21..30] do printfn "%i" i
Listing 114: Verschiedene Schleifentypen in F#
Referenzzellen
Sollten wirklich einmal veränderliche Werte nötig sein - und das kommt wohl oder übel vor - neigt man in F# wegen ihrer geringen Flexibilität dazu, Variablen durch eine Alternative auszudrücken - Referenzzellen. Die Referenzzelle kann selbst nicht verändert werden und speichert einen Wert. Dieser wiederum kann verändert werden. Vorteil der Referenzzellen ist, dass diese normal in Datenstrukturen gespeichert, typabgeleitet und aus Funktionen zurückgegeben werden können - Dinge, die bei mutable-Variablen nicht der Fall sind.
let foo = ref 23 foo := 42 let summe = ref 0 for x in 1..10 do summe := !summe + x printfn "%i" !summe printfn "%A" [summe, foo]
Listing 115: Verwendung von Referenzzellen
Notiert werden Referenzzellen so:
val summe : int ref
Listing 116: Typsignatur einer Referenzzelle
Die Verwendung der Operatoren := und ! sind dabei charakteristisch und können von der Typinferenz als Anhaltspunkt verwendet werden.
Eine interessante Technik ist es, veränderliche Werte in anonymen Funktionen wegzukapseln.
Beispiel 1: Die oberste Funktion selbst hat nichts mit veränderlichen Werten zu tun - Die Funktionalität entsteht erst durch die übergebene anonyme Funktion:
let alleVerarbeiten list op = for x in list do op x let summe = ref 0 alleVerarbeiten [1..10] (fun x -> summe := !summe + x) printfn "Summe: %i" !summe
Listing 117: Kapselung von Referenzzellen in anonymen Funktionen
Beispiel 2: Hier wird die Referenzzelle ganz verborgen:
let counter = fun () -> let i = ref 0 fun () -> i := !i + 1 !i let foo = counter() let bar = counter() printfn "%i" (foo()) printfn "%i" (foo()) printfn "%i" (bar()) printfn "%i" (bar())
Listing 118: Noch mehr Kapselung einer Referenzzelle
Dieses Programm hat erstaunlicherweise die Ausgabe 1 2 1 2 - Der vom Benutzer komplett abgekapselte Zähler i in foo oder bar erhöht sich bei jedem Aufruf um Eins. Counter könnte dabei als eine Art Konstruktor gesehen werden - foo und bar als Instanzen.
val counter : unit -> (unit -> int) val foo : unit -> int val bar : unit -> int
Listing 119: Typsignatur von counter, foo und bar
Veränderliche .NET-Aufzählungsklassen
Die (unfunktionalen) Aufzählungsklassen des Frameworks können in F# natürlich ebenfalls verwendet werden.
open System.Collections.Generic let zufall = new System.Random() let meineListe = new ResizeArray<int>() // Aliasname für List<int> for x in 1..10 do meineListe.Add (zufall.Next(1, 100)) meineListe.[0] <- 0 meineListe.Add (42) meineListe.Sort() for x in meineListe do printfn "%i" x
Listing 120: Verwendung von .NET-Aufzählungsklassen
Die Werte der Klassen können verändert werden und da auch sie auf IEnumerable basieren, lassen sie sich mit den Seq.*-Funktionen sowie F#-Schleifen intuitiv verarbeiten. Wird beim generischen Parameter _ angegeben, versucht der Compiler, den Typen selbst abzuleiten.
Vergleich zu imperativen Programmiersprachen
An dieser Stelle möchte ich noch ein kleines Beispiel geben, wie eine funktionale Herangehensweise bestimmte Fehler vermeiden kann. Exemplarisch behandle ich ein Programm, welches die Anzahl der Nullen in einer Liste ermitteln soll. Das funktional zu formulieren ist kein Problem:
- Eine leere Listen enthält keine Nullen
- Eine Liste, die mit 0 beginnt enthält eine Null mehr als die Restliste
Code:
let rec nullenZählen = function | [] -> 0 | 0::xs -> nullenZählen xs + 1 | _::xs -> nullenZählen xs
Listing 121: Funktion nullenZählen
Hier ist die Korrektheit des Verfahrens unmittelbar erkenntlich. Zum Vergleich muss nun ein imperativer C-Code herhalten: (Der Operator ++ erhöht eine Variable um Eins, laenge ist die Arraylänge).
int nullen; for (int i = 1; i <= laenge; i++) { if (zahlen[i] = 0) { nullen++; } }
Listing 122: Nullen zählen in C
Das sieht augenscheinlich richtig aus: Wir gehen alle Indizes durch, rufen das Arrayelement ab und wenn dieses 0 ist, dann erhöhen wir einen Zähler. Falsch gedacht, dieser winzige Code enhält gleich vier Fehler. Gefunden? Nein? Sie sind sehr versteckt, und auch wenn man hier auf die C-Syntax schimpfen kann, ist doch die eigentliche Ursache, dass man hier dem Programm Schritt für Schritt sagen muss, wie es zu rechnen hat.
- zahlen[i] = 0 ist kein Vergleich, sondern eine Zuweisung an das aktuelle Arrayelement. Diese lässt deshalb in einer If-Anweisung verwenden, weil der gesamte Ausdruck den Wert 0 annimmt, der als false gehandhabt wird.
Richtig: zahlen[i] == 0 - Die Schleife zählt von 1 bis laenge; Arrays werden von 0 indiziert, deshalb lautet das richtige Intervall [0; laenge)
- Nullen, unsere Zählvariable, wurde nicht initialisiert und hat deshalb irgendeinen Wert, aber nicht 0, wie am Anfang gewünscht.
Unser korrigierter Code muss also folgenermaßen lauten:
int nullen = 0; for (int i = 0; i < laenge; i++) { if (zahlen[i] == 0) { nullen++; } }
Listing 123: Korrektes Zählen von Nullen in C
All dies sind Fehler, die in funktionalen Sprachen nicht oder nur selten auftreten können.
In C++ bieten die Standardbibliothek und z.B. BOOST Lösungen für solche Probleme und genau an diesen Stellen wird die Sprache ein wenig mehr funktional.
Im Gegenzug muss man wiederum betrachten, dass unsere Computer imperativ arbeiten. Es gibt einen Arbeitsspeicher, Festplatten, Bildschirme uvm. in denen laufend geschrieben, gelesen und verändert wird. Alles, was irgendwie mit Eingaben und Benutzerinteraktion zu tun hat, ist zwangsläufig nicht mehr rein funktional - Nach diesem Prinzip wäre es ja egal, wie oft und wann ein Benutzer einen Button anklickt.
Discriminated unions
Discriminated unions kann man am einfachsten als eine Menge von alternativen Typen verstehen.
Am besten lässt sich das wie so oft mit einem Beispiel zeigen. Definition: Ein Wahrheitswert (Boolean) ist entweder True (Wahr) oder False (Falsch). Das können wir in F# notieren.
type Boolean = True | False
Listing 124: Eigener Boolean in F#
Es folgt das Anwendungsbeispiel:
let wahr = True let falsch = False let zuBool = function | True -> true | False -> false let ist4 n = if n = 4 then True else False if zuBool (ist4 4) then printfn "Wahr" else printfn "Falsch"
Listing 125: Unser Boolean zum standard-boolean
True und False sind hier sog. Konstruktoren des Typs Boolean (diese werden konventionell groß geschrieben). Wir können sie wie jeden anderen Wert auch zurückgeben oder an Funktionen übergeben. Interessant ist hierbei v.A. die Funktion zuBool, die unseren Boolean-Typen zu einem Standard-F#-Boolean umwandelt. Hier wird Pattern-Matching eingesetzt, um entsprechend der Alternativen zu verzweigen. Folglich können wir schreiben:
let tauschen = function | True -> False | False -> True
Listing 126: Negation mit unserem Boolean
Die Funktionssignaturen lauten:
val wahr : Boolean val falsch : Boolean val zuBool : Boolean -> bool val ist4 : int -> Boolean val tauschen : Boolean -> Boolean
Listing 127: Funktionssignaturen mit eigenem Boolean
Parameterisierte und generische Varianten
Unsere Typen und Konstruktoren können aber nicht nur statische Alternativen ausdrücken, sie können auch Werte aufnehmen. Beispiel wäre ein erweiterter Boolean, der neben Wahr und Falsch auch ein Fehlerergebnis mit Meldung aufnehmen kann.
type BoolComputation = | True | False | ComputationError of string
Listing 128: Erweiterter Boolean
Beispiel:
let kannTeilen a b = if b <> 0 then if a % b = 0 then True else False else ComputationError("Fehler - Teilen durch 0")
Listing 129: Anwendung des erweiterten Booleans
Eine Auswertung erfolgt, wie gewohnt, mit Pattern-Matching:
match kannTeilen 14 0 with | True -> printfn "Teilbar" | False -> printfn "Nicht teilbar" | ComputationError(message) -> printfn "Fehler: %s" message
Listing 130: Auswertung eines erweiterten Booleans
Soll ein Konstruktor mehrere Werte aufnehmen, so werden Tupel-Typen übergeben.
Auch können (wie bei generischen Records) Typargumente übergeben werden. So können wir einen wichtigen F#-Typen selbst nachbauen:
type 'a Option = | None | Some of 'a
Listing 131: Typargumente in discriminated unions
Dieser ist identisch mit den Option-Typen, die wir bereits kennengelernt haben, da sie in eben dieser Weise definiert wurde.
Rekursive Typen
Hier folgt der Hauptgrund, weshalb discriminated unions so wichtig in F# sind: Sie können mit sich selbst (rekursiv) parameterisiert werden. Berechnungen, Bäume und verkettete Listen können alle direkt in ihrer Definition entsprechend definiert werden.
Beispiel 1 - Verkettete Listen:
Eine verkettete Liste ist entweder
- Leer oder
- Ein Kopfelement und eine Restliste
Voilà:
type 'a LinkedList = | Nil | Cons of 'a * 'a LinkedList let meineListe = Cons(1, Cons(2, Cons(3, Nil))) // int LinkedList let rec summe = function | Nil -> 0 | Cons(x, xs) -> x + summe xs
Listing 132: Verkettete Liste
Diese Definition der Liste entspricht wiederum genau den in F# integrierten lists, wobei Nil (not in list) als [] und Cons (construct) als :: notiert wird.
Beispiel 2 - Binäre Bäume:
Ein binärer Baum ist entweder
- Leer oder
- Ein Wurzelelement und ein rechter sowie linker Teilbaum
Auch hier kann die Definition direkt in einen Typen gefasst werden.
type 'a Tree = | Leaf | Node of 'a * 'a Tree * 'a Tree let myTree = Node(5, Node(3, Node(1, Leaf, Leaf), Node(4, Leaf, Leaf)), Node(7, Leaf, Node(10, Leaf, Leaf)))
Listing 133: Binärbaum
Bäume so zu erstellen ist wenig sinnvoll, gemeinhin verwendet man sie eher, um z.B. Werte zu sortieren. Dazu benötigen wir Funktionen zum Einfügen und Durchlaufen des Baumes.
Einfügen eines Elementes x in einem Baum:
- Wenn x <= aktueller Knotenwert: Füge x in den linken Teilbaum ein
- Wenn x > aktueller Knotenwert: Füge x in den rechten Teilbaum ein
- Wenn der Baum leer ist, dann besteht der neue Baum aus x
So sei es:
let rec einfügen element baum = match baum with | Node(wurzel, links, rechts) when element <= wurzel -> Node(wurzel, einfügen element links, rechts) | Node(wurzel, links, rechts) when wurzel <= element -> Node(wurzel, links, einfügen element rechts) | _ -> Node(element, Leaf, Leaf) let berechneBaum liste = List.fold (fun baum element -> einfügen element baum) Leaf liste
Listing 134: Binärbaum zum Sortieren von Werten
Jetzt benötigen wir nur noch eine Funktion zum Durchlaufen eines Baumes und fertig ist das Binärbaum-Sortieren. Hier gilt: Erst den linken Baum durchlaufen, dann das Wurzelelement und dann den rechten.
let rec durchlaufen baum = match baum with | Leaf -> Seq.empty | Node(wurzel, links, rechts) -> seq { yield! durchlaufen links yield wurzel yield! durchlaufen rechts }
Listing 135: Durchlaufen des Binärbaums
Hier nocheinmal die komplette Baum-Bibliothek mit Sortierfunktion:
type 'a Tree = | Leaf | Node of 'a * 'a Tree * 'a Tree let rec einfügen element = function | Node(wurzel, links, rechts) when element <= wurzel -> Node(wurzel, einfügen element links, rechts) | Node(wurzel, links, rechts) when wurzel <= element -> Node(wurzel, links, einfügen element rechts) | _ -> Node(element, Leaf, Leaf) let berechneBaum liste = List.fold (fun baum element -> einfügen element baum) Leaf liste let rec durchlaufen = function | Leaf -> Seq.empty | Node(wurzel, links, rechts) -> seq { yield! durchlaufen links yield wurzel yield! durchlaufen rechts } let sortieren daten = (berechneBaum >> durchlaufen) daten for x in sortieren [1; 4; -1; 0; 3; 1; 2; 1] do printf "%i, " x
Listing 136: Komplette Implementierung des Binärbaums
Berechnungen mit Discriminated unions
Discriminated unions eignen sich sehr gut, um komplexe Daten im Speicher zu repräsentieren. Ein interessantes Beispiel ist hierbei die Darstellung eines Programms, wie in diesem Fall einer arithmetischen Berechnung, im Speicher.
Beispiel: Ein Term ist hiermit definiert als eine Zahl, eine Variable oder eine Operation, die zwei Terme verknüpft.
type Term = | Zahl of int | Var of string | Plus of Term * Term | Minus of Term * Term | Mal of Term * Term | Durch of Term * Term
Listing 137: Muster für mathematische Terme
Ein Ausdruck wie 2x+1 würde entsprechend so notiert:
let beispiel = Plus(Mal(Var("x"), Zahl(2)), Zahl(1))
Listing 138: 2x+1 als Term
Das Ausrechnen gestaltet sich dank Mustervergleich und Rekursion extrem einfach:
let auswerten variablen = let vars = Map.of_list variablen let rec eval = function | Zahl(n) -> n // Eine Zahl wir zu sich selbst ausgewertet | Var(name) -> vars.[name] // Variablen werden durch konkrete Werte ersetzt | Plus(a, b) -> (eval a) + (eval b) | Minus(a, b) -> (eval a) - (eval b) | Mal(a, b) -> (eval a) * (eval b) | Durch(a, b) -> (eval a) / (eval b) eval let ergebnis = auswerten ["x", 10] beispiel printfn "%i" ergebnis
Listing 139: Berechnen eines mathematischen Ausdrucks
Diese Art der Repräsentation von Berechnungen ist extrem hilfreich. Man kann in F# beispielsweise relativ leicht eigene Programmiersprachen schreiben, deren Quelltext in solche Strukturen überführt und dann ausgewertet wird. Später werden wir sehen, dass es sehr praktische Wege gibt, Text-Eingaben zu parsen, d.h. sie vom Programm in eine solche, verwertbare Form umzuwandeln.
Als Minimal-Beispiel werde ich trotzdem einmal zeigen, wie man einen Term in sog. UPN-Notation (umgekehrte polnische Notation) parst.
Bei einem UPN-Term stehen die Operatoren (Rechenzeichen), also z.B. + oder -, nicht zwischen ihren Operanden, sondern hinter ihnen - Man spricht auch von Postfix-Notation, während ein "normaler" Term in Infix-Notation steht.
Beispiel: Der Term
(1 + 2) * 3
entspricht folgendem in UPN.
1 2 + 3 *
Der Vorteil dieser Notation im Bezug auf Computer ist vor Allem, dass sie sich viel leichter auswerten lässt und man keine Operatorprioritäten berücksichtigen muss (Punkt-Vor-Strich).
Tatsächlich ist das Ausrechnen oder auch Umwandeln eines solchen Terms in konventionelle Notation mithilfe eines sog. Stapels nahezu trivial.
- Eine Zahl wird oben auf den Stapel gelegt.
- Ein Operator nimmt die beiden obersten Werte vom Stapel, rechnet mit ihnen und schreibt sie wieder oben hinauf
Der folgende Ablauf zeigt, wie man obigen Term schrittweise ausrechnet:
Beginn: Leerer Stapel => [] 1 : 1 auf Stapel legen => [1] 2 : 3 auf Stapel legen => [2, 1] + : Werte addieren => [1 + 2] 3 : 3 auf Stapel legen => [3, 1 + 2] * : Werte multiplizieren => [(1 + 2) * 3] Ende : Oberstes Element enthält das Ergebnis => (1 + 2) * 3
Das können wir nun problemlos in funktionalen F#-Code schreiben. Wir betrachten eine rekursive Funktion parse, die eine Liste von Symbolen (Zahlen, Variablen oder Operatoren) sowie einen Stapel von Termen als Argument enthält. Ein Stapel ist dabei nichts als eine konventionelle Liste, an die, wie üblich, von vorne angehängt wird.
let rec parse stapel symbole =
Listing 140: Kopf der parse-Funktion
Jetzt gilt es, die einzelnen Vorschriften umzusetzen. Mit Pattern-Matching müssen wir nichteinmal groß umformulieren.
match stapel, symbole with | y::x::stapel', "+"::symbole' -> parse ((Plus(x, y))::stapel') symbole' | y::x::stapel', "-"::symbole' -> parse ((Minus(x, y))::stapel') symbole' | y::x::stapel', "*"::symbole' -> parse ((Mal(x, y))::stapel') symbole' | y::x::stapel', "/"::symbole' -> parse ((Durch(x, y))::stapel') symbole'
Listing 141: Körper der parse-Funktion
- Ist das aktuelle Symbol ein Operator und enthält der Stapel mind. zwei Elemente, dann verrechne beide und setze die Berechnung mit den restlichen Symbolen und dem aktuellen Ergebnis oben auf dem Stapel fort:
| _, sym::symbole' -> match Int32.TryParse(sym) with | (true, i) -> parse ((Zahl(i)::stapel)) symbole' | _ -> parse ((Var(sym))::stapel) symbole'
Listing 142: Elemente verrechnen
- Wenn wir keinen Operator vorliegen haben, prüfe, ob es sich um eine Zahl oder eine Variable handelt und schreibe sie entsprechend auf den Stapel.
- Sind alle Symbole durch, sind wir fertig, geben also das Oberste (und hoffentlich Einzige) Element des Stapels zurück.
| _, [] -> List.hd stapel
Listing 143: Element auf den Stapel legen
Die ganze Funktion sieht dann so aus:
let rec parse stapel symbole = match stapel, symbole with | y::x::stapel', "+"::symbole' -> parse ((Plus(x, y))::stapel') symbole' | y::x::stapel', "-"::symbole' -> parse ((Minus(x, y))::stapel') symbole' | y::x::stapel', "*"::symbole' -> parse ((Mal(x, y))::stapel') symbole' | y::x::stapel', "/"::symbole' -> parse ((Durch(x, y))::stapel') symbole' | _, sym::symbole' -> match Int32.TryParse(sym) with | (true, i) -> parse ((Zahl(i)::stapel)) symbole' | _ -> parse ((Var(sym))::stapel) symbole' | _, [] -> List.hd stapel
Listing 144: Komplette parse-Funktion
Dieser Code ist tatsächlich völlig ausreichend, um einen mathematischen UPN-Ausdruck in eine F#-Struktur zu überführen. Was allerdings auffällt ist noch eine gewisse Redundanz in den einzelnen Matching-Fällen. Es tritt immer dasselbe Muster auf: parse (...::stapel') symbole'
Ein solches Muster haben wir bereits kennen gelernt - Es entspricht einer Fold-Funktion aus dem Kapitel über Listenverarbeitung. Dabei summieren wir quasi die Eingabesymbole zu einem Stapel auf.
Wir können also tatsächlich die gesamte Funktion als einen Aufruf von Fold vereinfachen - Die Vorschrift, die wir dabei geben, heißt lediglich: "Wenn wir einen Stapel und ein Eingabesymbol haben, was kommt raus?" Und zwar das:
let stapelKombinieren stapel symbol = match symbol, stapel with | "+", y::x::stapel' -> (Plus(x, y))::stapel' | "-", y::x::stapel' -> (Minus(x, y))::stapel' | "*", y::x::stapel' -> (Mal(x, y))::stapel' | "/", y::x::stapel' -> (Durch(x, y))::stapel' | sym, _ -> match Int32.TryParse(sym) with | (true, i) -> (Zahl(i))::stapel | _ -> (Var(sym))::stapel
Listing 145: Vereinfachung mit Fold
Als arbeitsfähiges Gesamtpaket mit Verarbeitung der Eingabe sieht unsere Parser-Funktion also so aus:
let parse2 (input : string) = let stapelKombinieren stapel symbol = match symbol, stapel with | "+", y::x::stapel' -> (Plus(x, y))::stapel' | "-", y::x::stapel' -> (Minus(x, y))::stapel' | "*", y::x::stapel' -> (Mal(x, y))::stapel' | "/", y::x::stapel' -> (Durch(x, y))::stapel' | sym, _ -> match Int32.TryParse(sym) with | (true, i) -> (Zahl(i))::stapel | _ -> (Var(sym))::stapel input.Split [| ' ' |] |> Array.fold stapelKombinieren [] |> List.hd
Listing 146: Vereinfachte parse-Funktion
Mit den generierten Term-Objekten kann man jetzt vielerlei Berechnungen durchführen, sie z.B. optimieren, vereinfachen, ableiten oder einfach nur schreiben.
Als Beispiel zeige ich hier ein kleines Programm, das einen solchen Ausdruck wieder als Infix-Term schreibt:
let rec alsInfix = function | Zahl(n) -> sprintf "%i" n | Var(sym) -> sprintf "%s" sym | Plus(a, b) -> sprintf "%s + %s" (alsInfix a) (alsInfix b) | Minus(a, b) -> sprintf "%s - %s" (alsInfix a) (alsInfix b) | Mal(a, b) -> sprintf "%s * %s" (klammern a) (klammern b) | Durch(a, b) -> sprintf "%s / %s" (klammern a) (klammern b) and klammern expr = match expr with | Plus(_) | Minus(_) -> sprintf "(%s)" (alsInfix expr) | _ -> alsInfix expr printf "Geben sie einen UPN-Ausdruck an: " let upn = Console.ReadLine() let term = parse2 upn printfn "Term in Infix-Notation: %s" (alsInfix term)
Listing 147: Rückweg von Postfix- zu Infix-Notation
Objektorientierung
Bis hierher haben wir viele interessante Konzepte kennengelernt, doch eines, das für einen Großteil der heute populären Programmiersprachen zentral ist, fehlt noch: Die Objektorientierung .
Objektorientiertes Programmieren ist, wie funktionales auch, ein Ansatz, um Programme sinnvoll zu gestalten und dem Programmierer so das Leben leichter zu machen. Die Idee dahinter ist, Klassen von Objekten zu erstellen, die über bestimmte Eigenschaften und Fähigkeiten verfügen und aus denen dann konkrete Objekte (Instanzen) erstellt werden können, die wiederum miteinander wechselwirken. So werden Daten und Quellcode in sinnvolle, wiederverwendbare Einheiten gruppiert.
Beispiel: Ein Auto. Ein Auto hat verschiedene Eigenschaften, wie eine Farbe, einen Hersteller, ein Kennzeichen, seine Fahrtgeschwindigkeit und verschiedene Fähigkeiten, z.B. Anfahren oder Bremsen. Aus dieser Vorschrift heraus kann man nun "konkrete" Autos erstellen und im Programm fahren lassen.
Die Vorteile der Objektorientierung bestehen in der anschaulichen, oft wirklichkeitsnahen Darstellung von Zusammenhängen. Zudem beugt es Fehlern vor, da in einer Klasse Programmcode gekapselt, also vor dem Programmierer verborgen wird. Bildlich gesprochen: Ich kann ein Auto fahren, ohne wissen zu müssen, wie ein Motor exakt funktioniert und ohne dort Schaden anrichten zu können - der Motor ist vor dem Fahrer verborgen.
Die .NET-Umgebung, auf der F# aufbaut, ist stark objektorientiert - letztendlich ist sogar alles, womit gearbeitet wird, Listen, Zahlen oder Funktionen ein Objekt. Dem kann sich auch F# nicht entziehen und stellt zusätzlich zu seinen funktionalen Fähigkeiten auch objektorientierte bereit. Beide Paradigmen haben Vorteile und Nachteile, Gemeinsamkeiten und Differenzen. Allgemein lässt sich sagen, dass es sehr problemabhängig ist, ob nun funktionale oder OO-Ansätze besser geeignet sind.
Bis jetzt und ein bisschen weiter
Daten in sinnvolle Einheiten gruppieren...
Das können wir doch schon:
- Records
- Tupel
- Discriminated unions
Tatsächlich handelt es sich hier um sehr einfache Klassen, gemacht um Daten zu speichern.
Ein Beispiel zur Erinnerung:
type Punkt = { x : float; y : float } type Linie = { von : Punkt; bis : Punkt } let linie = { von = { x = 1.0; y = 2.5 }; bis = { x = 4.0; y = 5.5 } }
Listing 148: Typdeklarationen als eine Art von Klassen
Und wenn wir nun die Länge einer Linie bestimmen wollen? Kein Problem - Eine Funktion muss her.
let länge li = ((li.von.x - li.bis.x)**2.0 + (li.von.y - li.bis.y)**2.0)**0.5 printfn "Länge: %f" (länge linie)
Listing 149: Funktion zur Bearbeitung der Daten einer Klasse
Der objektorientierte Ansatz wäre hier allerdings ein anderer. Man würde sagen, die Länge ist eine Eigenschaft des Objekts Linie, ein Element (member) dieses Typs. Das lässt sich in F# ebenfalls notieren.
type Linie = { von : Punkt; bis : Punkt } with member this.Länge = ((this.von.x - this.bis.x)**2.0 + (this.von.y - this.bis.y)**2.0)**0.5
Listing 150: member in F#
member fügt dem Typ ein neues Element hinzu - In diesem Fall die Eigenschaft Länge. Man kann sich member als das Pendant zu let auf Klassenebene vorstellen, Funktionen und Werte funktionieren wiederum auf gleiche Art. Einziger Unterschied ist das this, man kann es sich als den Stellvertreter der Instanz vorstellen, für die die Funktion aufgerufen wird. Man kann statt dessen auch andere Namen verwenden.
Anwendung:
printfn "Länge: %f" Linie.Länge
Listing 151: Datenzugriff im Objekt mittels member-Wert
Voilà - Die neue Eigenschaft/Funktion kann einfach per Punkt angesprochen werden.
Das ganze Verfahren ist bei Discriminated Unions nicht verschieden.
type 'a Berechnung = | Ergebnis of 'a | Fehler with member private this.Fehlgeschlagen = match this with | Fehler -> true | _ -> false member this.Ausruf fehlermeldung = if this.Fehlgeschlagen then printfn "%s!" fehlermeldung else printfn "Juhu" let geradeZahl n = if n % 2 = 0 then Ergebnis n else Fehler for zahl in [13; 42] do (geradeZahl zahl).Ausruf "Nein"
Listing 152: Objektorientierung bei discriminated unions
In diesem Beispiel wurden dem Typen Berechnung gleich zwei Member hinzugefügt - Der eine ist nun eine Funktion vom Typ string -> unit und Fehlgeschlagen eine Eigenschaft vom Typ bool, die bestimmt, ob die Berechnung fehlgeschlagen ist. Bemerkenswert ist, dass durch member private letztere als privat gekennzeichnet wurde. Das bedeutet, die Eigenschaft ist nur innerhalb des Objekts sichtbar, also in diesem Fall nur für Ausruf. Von außen kann die Eigenschaft nicht abgefragt werden.
Erste Klasse
Bis jetzt haben wir die F#-Strukturen wie Records oder Discriminated Unions um weitere objektorientierte Merkmale bereichert. Was jetzt folgt, setzt noch einen Schritt tiefer an: Wir schreiben unsere erste eigenständige F#-Klasse.
Als Beispiel verwenden wir hier ein Auto. Als Eigenschaften bekommt es eine Maximalgeschwindigkeit und eine Modellbezeichnung.
type Auto(bezeichnung : string, vmax : int) = member this.VMax = vmax member this.Bezeichnung = bezeichnung member this.InfosAnzeigen() = printfn "Ich bin ein %s und kann %i km/h schnell fahren" bezeichnung vmax
Listing 153: Auto-Klasse in F#
So, nehmen wir das Beispiel schrittweise auseinander:
Es wird hier offensichtlich ein neuer Typ (eine Klasse) namens Auto beschrieben. Dieser verfügt über zwei öffentliche Eigenschaften - VMax und Modell - sowie eine Methode (Funktion) namens InfosAnzeigen, die die entsprechenden Daten des Autos auf der Konsole ausgibt.
Aber was bedeutet die Zeile type Auto(bezeichnung : string, vmax : int) nun genau? Modell und vmax sind hier sog. Konstruktorargumente. Wenn später eine Auto-Instanz erzeugt wird, übergeben wir dieser direkt jene beiden Werte. Sie sind nur innerhalb der Klasse sichtbar.
Das geht so:
let porsche = new Auto("Porsche", 330) let truck = new Auto("Vierzigtonner", 80)
Listing 154: Auto-Instanz erzeugen
Diese beiden Objekte können wir nun verwenden.
for auto in [ porsche; truck ] do auto.InfosAnzeigen()
Listing 155: Methodenaufruf der Auto-Objekte
Große Klasse
Die F#-Klassensyntax erlaubt alles, was mit Klassen in VB oder C# auch machbar ist. Von lokalen Variablen über Eigenschaften bis hin zu Methoden und gekapselten Werten.
Language-Oriented Programming
Herzlichen Glückwunsch - Mit den grundlegenden Kapiteln sind sie an dieser Stelle durch. Womit es im Folgenden weiter geht, sind Konzepte des sog. Language-Oriented Programming (LOP).
Damit bezeichnet man zusammenfassend die Möglichkeiten, in einer Sprache "an dieser Sprache" zu programmieren. Und gerade hier ist man mit F# wegen seiner hohen Flexibilität sehr effizient in der Lage, diese Sprache seinen aktuellen Bedürfnissen anzupassen, um direkter und meist einfacher an einem Problem formulieren zu können. (Anmerkung: Bei den folgenden Kapiteln werde ich mich oft am englischsprachigen F#-Wikibook orientieren)
Operatordefinition
Schon in VB ist es möglich, bestimmte Operatoren wie +, -, * oder / für eigene Klassen zu überladen.
Dim x = -SomeVector * New Vector(-1, 3) * New Vector(0, 2) + Vector2
Listing 156: Operatorüberladung in VB.NET
F# geht hier noch einen Schritt weiter: Es ist möglich, völlig neue, eigene Operatoren aus Sonderzeichen zu erschaffen. Ein Operator unterscheidet sich sonst kaum von einer herkömmlichen Funktion. Betrachten wir die Fakultätsfunktion:
let (!) n = seq {1..n} |> Seq.fold ( * ) 1 printfn "6! = %i" !6
Listing 157: Fakultätsfunktion in F#
Für Infixoperatoren (Operatoren zwischen 2 Operanden - a + b) ist das Konzept gleich:
let (@@) x y = 10 * x + y let res = 2 @@ 3 printfn "%i" res
Listing 158: Eigene Infixoperatoren
Wenngleich sich bei diesem Beispiel über die Sinnhaftigkeit gestritten werden kann, sind solche Operatoren z.T. Doch sehr nützlich! Im Folgenden wird eine neue Syntax für optisch ansprechende Abfragen mit Regulären Ausdrucken eingeführt:
open System open System.Text.RegularExpressions let (=~) text pattern = (new Regex(pattern)).IsMatch(text) printf "Geben sie eine dreistellige Zahl ein: " if Console.ReadLine() =~ "\d{3}" then printfn "Danke" else printfn "Falsche Eingabe"
Listing 159: Perl-mässige Verarbeitung von Regex
Weiterhin sehen wir hier etwas, was auf viele Bereiche von F# zutrifft: F# besteht aus F#! Viele "eingebaute" Sprachelemente sind nichts weiter als normale Funktionen und Typen, wie man sie selbst formulieren kann:
let (|>) x f = f x 1 |> printf "i: %i"
Listing 160: Pipe-Operator selbstgemacht
Sogar das ganze System der Referenzzellen ist rein durch Benutzertypen- und Operatoren definiert:
type 'a ref = { mutable contents : 'a } let ref x = { contents = x } let (:=) p v = p.contents <- v let (!) p = p.contents
Listing 161: Eigene Referenzzellen
Maßeinheiten
In F# kann man, Wissenschaftler wird's freuen, endlich Werten eine Maßeinheit mitgeben. Unmittelbarer Nutzen hiervon ist, dass immer klar ist, was für Werte man vorliegen hat und dass v.A. Fehler (Äpfel mit Birnen vergleichen) vom Compiler als solche erkannt werden.
Wir können Einheiten als leere Typen definieren, die mit dem Measure-Attribut versehen sind:
[<Measure>] type m [<Measure>] type s [<Measure>] type kg
Listing 162: [<Measure>] für Masseinheiten
Auch können wir Typsynonyme einrichten:
[<Measure>] type N = kg * m / s ^ 2
Listing 163: Typsynonyme für zusammengesetzte Masseinheiten
Wenn wir nun einheitenbehaftete Werte notieren, hängen wir die gewünschte Einheit in spitzen Klammern an - Alle Operationen verhalten sich wie intuitiv zu erwarten.
let g = 9.81<m/s^2> let masse = 1.0<kg> let kraft = masse * g let kraft2 = 10.0<N> let deltaF = kraft2 - kraft
Listing 164: Rechnen mit Masseinheiten
Der Compiler kann dabei die verwendeten Typen automatisch prüfen und ggf. ableiten, so dass für kraft und deltaF korrektermaßen die Einheit Newton angenommen wird.
Der Code
let delta = kraft2 - masse
Listing 165: Ungültige Berechnung mit Masseinheiten
schlägt bereits beim Kompilieren fehl - Einheiten inkompatibel!
Bei Funktionen verhält sich das Ganze nicht anders:
let fallzeit höhe : float<s> = sqrt (höhe / (0.5 * g))
Listing 166: Typinferenz auch mit Einheiten
Mit der Angabe, die Fallzeit solle in Sekunden angegeben sein, kann F# den Parameter höhe automatisch auf Meter inferieren.
printfn "%A" (fallzeit 10.0<m>) printfn "%A" (fallzeit 20.3<kg>) // Fehler! Inkompatible Einheiten
Listing 167: Korrekte und inkorrekte Berechnung einer Fallzeit
Um zwischen Einheiten umzurechnen, muss lediglich mit den Umrechnungsfaktoren multipliziert werden.
In der Fsharp.Powerpack.dll werden die gängigen SI-Einheiten und physikalischen Konstanten bereits definiert!
Aktive Muster
Wie der Name schon vermuten lässt, sind Aktive Muster (Active Patterns) eine Möglichkeit, mit der wir benutzerdefiniertes, aktives Verhalten in Mustervergleichen definieren können. Sie sind nichts Fundamentales und doch oft eine große Hilfe, wenn es darum geht, Strukturen von Daten sehr kompakt zu verarbeiten oder zu vergleichen.
Einfache Muster
Zu erkennen definieren sind sie leicht - Aber ein Beispiel sagt mehr als tausend Worte:
let (|Gerade|Ungerade|) x = if x % 2 = 0 then Gerade else Ungerade printf "Geben sie eine Zahl ein: " match int (Console.ReadLine()) with | Gerade -> printfn "Die Zahl ist gerade" | Ungerade -> printfn "Die Zahl ist ungerade"
Listing 168: Active patterns
Das oben stehende dünkt einen nicht sonderlich kompliziert, sein Effekt ist unmittelbar ersichtlich. Das aktive Muster wird durch eine Funktion in den Banana-Brackets (||) repräsentiert. Dabei werden hier sozusagen zwei anonyme Fälle einer Discriminated Union generiert - Gerade und Ungerade. Beim Auswerten des Pattern-Matchings wird die Funktion ausgewertet und zu dem Fall verzweigt, den diese zurückgibt - Fertig.
Eine weitere gern genommene Verwendungsmöglichkeit aktiver Muster ist das elegante Zerlegen von Datenstrukturen, die normales Pattern-Matching nicht erlauben. Typisch hierfür sind z.B. Sequenzen. Zwar existieren hier viele integrierte Hilfsfunktionen und die sehr hilfreiche Notation der Sequence-comprehensions, aber trotzdem ist, anders als bei Listen, die manuelle Verarbeitung nur mit einiger Kenntnis des .NET-Frameworks und relativ unschönen, nicht-funktionalen Schleifenkonstrukten möglich. Abhilfe schaffen aktive Muster:
let (|Leer|Knoten|) sequenz = if Seq.isEmpty sequenz then Leer else Knoten (Seq.hd sequenz, Seq.skip 1 sequenz) let rec seqSumme = function | Leer -> 0 | Knoten(x, xs) -> x + seqSumme xs
Listing 169: Sequenzzerlegung mit aktiven Mustern
Einziger Unterschied zum vorherigen Muster ist, dass der Fall Knoten einen Wert in Form eines 2-Tupels zurückgibt.
Partielle Muster
Die nächste Variante ist schon zu mehr fähig. Es handelt sich um partielle Muster, also solche, die fehlschlagen können und dabei Werte transportieren. Beim Mustervergleich werden die angegebenen Fälle so lange ausgewertet, bis einer Some(wert) zurückgibt.
Folgendes Beispiel zeigt einen eleganten Weg, Daten zu interpretieren und zu konvertieren.
let (|Zahl|_|) eingabe = match Int32.TryParse eingabe with | true, res -> Some(res) | _ -> None let (|Kommazahl|_|) eingabe = match Double.TryParse eingabe with | true, res -> Some(res) | _ -> None printf "Geben sie etwas ein: " match Console.ReadLine() with | Zahl n -> printfn "Zahl: %i" n | Kommazahl x -> printfn "Kommazahl: %f" x | str -> printfn "<unbekannt>: \"%s\"" str
Listing 170: Dateninterpretation mittels aktiven Mustern
Viel Neues ist auch hier nicht dazugekommen - Der Tiefstrich in der Funktionsdefinition symbolisiert die Partialität des Vergleichs, der Typ der Funktion ist immer ein Option-Value.
Interessant ist es dabei auch, die Muster als Spezifikationen für Funktionsargumente zu betrachten.
let (|NichtNull|_|) x = if x <> 0 then Some(x) else None let teilen a (NichtNull b) = a / b printfn "%A" (teilen 1 2) printfn "%A" (teilen 2 0)
Listing 171: Aktive Muster als Spezifikation für Funktionsargumente
Der zweite Fall wird mit einer MatchFailureException abbrechen, da der 2. Funktionsparameter die Bedingung, er solle nicht 0 sein, nicht erfüllt.
Die Möglichkeit, Muster ineinander zu kombinieren, lässt extrem mächtige Ausdrücke zu.
Folgender Ausdruck kann einen Eingabestring gegen einen regulären Ausdruck prüfen und gibt im Erfolgsfall die angegebenen Gruppen als Liste zurück.
let (|RegMatch|_|) (grps : int list) regex eingabe = let treffer = Regex.Match(eingabe, regex) if not treffer.Success then None else let res = [| for g in treffer.Groups -> g.Value |] Some([ for i in grps -> res.[i] ])
Listing 172: Regex mit aktivem Muster anwenden
Mit den bereits vorhandenen Funktionen kombinieren wir dies zu einer Funktion, die einen String als Angabe von 2D-Koordinaten interpretieren soll, wobei beide Koordinaten ganzzahlig und ungleich 0 sein sollen!
let punkt = function | RegMatch [1;2] "\((.+?), (.+?)\)" [Zahl(NichtNull x); Zahl(NichtNull y)] -> (x, y) | _ -> failwith "Falsches Format"
Listing 173: 2D-Koordinaten parsen
Hier wird die vom ersten Muster ausgegebene Ergebnissequenz von weiteren immer mehr zerlegt.
printfn "%A" (punkt "(1, 2)") printfn "%A" (punkt "(1, 0)") printfn "%A" (punkt "foo")
Listing 174: Aufruf des Koordinatenparsers
Logischerweise wird also der erste Aufruf korrekt funktionieren, die beiden anderen aber mit Fehler abbrechen, da eben das spezifizierte Muster nicht erfüllt ist.
Computation Expressions
Willkommen zu den sog. Computation Expressions. Diese sind zweifellos ein Highlight von F# und machen es unter den Programmiersprachen vielleicht nicht einzigartig, aber auf jeden Fall besonders.
Eine Computation Expression ist eine Verallgemeinerung eines Programmablaufs, den der Programmierer selbst definieren kann. Dabei können in normaler F#-Syntax komplizierte Abläufe automatisch generiert werden.
Einleitendes Beispiel
Ich beginne dieses Kapitel mit einer einfachen Aufgabe: Es gilt eine Funktion zu entwickeln, die den Benutzer auffordert, drei Zahlen a, b und c einzugeben, und deren Summe zurückgibt. Da der Benutzer Fehler machen kann, verwenden wir Option-Typen.
Geschafft?
Das Ergebnis wird vielleicht so aussehen:
let zahlEingeben id = printf "%s: " id match Int32.TryParse (Console.ReadLine()) with | true, res -> Some(res) | _ -> None let summe() = let a = zahlEingeben "a" let b = zahlEingeben "b" let c = zahlEingeben "c" match a, b, c with | Some(x), Some(y), Some(z) -> Some(x + y + z) | _ -> None
Listing 175: Zahleneingabe und Auswertung wie gehabt
Sieht richtig aus, funktioniert auch. Es gibt nur ein Problem: Man betrachte folgende Eingabe:
A: xyz B: 10 C: 12
Hmm, da hat doch der Benutzer schon beim ersten Wert etwas Ungültiges eingegeben, und trotzdem geht die Abfrage weiter. Eigentlich ist doch klar: Das kann gar nichts mehr werden, an dieser Stelle müsste eigentlich Schluss sein! Aber gut, ich habe Sie vermutlich unterschätzt ;) Also ab zur richtigen Funktion:
let summe() = match zahlEingeben "a" with | None -> None | Some(a) -> match zahlEingeben "b" with | None -> None | Some(b) -> match zahlEingeben "c" with | None -> None | Some(c) -> Some(a + b + c)
Listing 176: Zahleingabe mit schrittweiser Ergebnisprüfung
Bitte was? ZEHN Zeilen für eine so triviale Aufgabe? Die Frage ist berechtigt und im Weiteren geht es darum, wie man diesen Code vereinfachen könnte.
Der obige Code enthält zahlreiche Redundanzen und wie gewöhnlich könnten wir versuchen, den oft verwendeten Code als Funktion auszulagern.
Und hier kommt eine schlagende Idee ins Spiel: Wir definieren eine Funktion namens bind und teilen die Berechnung in einen aktuellen Stand und den Rest der Berechnung auf:
- Wenn der Benutzer einen gültigen Wert eingibt, führen wir mit diesem Wert die Berechnung fort
- Wenn der Benutzer einen Fehler macht, ist Schluss; der Rest der Berechnung wird einfach gar nicht fortgesetzt.
Aber wie geben wir "den Rest einer Berechnung" an? Klar: Als Funktion. Et voilà:
let bind eingabe rest = match eingabe with | Some(ergebnis) -> rest(ergebnis) // Gültig - Rest berechnen | None -> None // Ungültig - Tschüss
Listing 177: 'Rest der Berechnung' als Funktion
Der Typ ist hierbei
val bind : 'a option -> ('a -> 'b option) -> 'b option
Listing 178: Typsignatur von bind
Mithilfe dieser unscheinbaren Funktion können wir unsere Summenfunktion signifikant vereinfachen:
let summe() = bind (zahlEingeben "a") (fun a -> bind (zahlEingeben "b") (fun b -> bind (zahlEingeben "c") (fun c -> Some(a + b + c))))
Listing 179: Einfache Zahleneingabe
Diese Art von Funktionsauswertung, bei der der Rest einer Berechnung als Funktion mitübergeben wird, nennt sich Continuation-passing style (CPS).
Computation Expressions definieren
Verallgemeinert man das Konzept, das wir gerade angewendet haben, so spricht man von sog. Monaden. Vor Monaden haben viele Programmier Angst, denn sie werden oftmals als sehr kompliziert und theoretisch empfunden, aber letzendlich sind sie nur eines: Extrem nützlich.
Dabei ist bei Allem nämlich nichts anderes gemeint, als dass wir für einen Typen zwei Funktionen definiert haben: Die eine ist Bind (oder auch >>=) und tut genau das wie im obigen Beispiel - Einen Wert auf bestimmte Weise mit dem Rest einer Berechnung verknüpfen. Die andere ist Return und dient lediglich dazu, ein Ergebnis zurückzugeben. Für die Option-Typen ist auch Return offensichtlich - Es handelt sich um den Konstruktor Some, denn dieser gibt ja einen (Erfolgs-)Wert zurück.
Hiermit ist auch geklärt, was Computation Expressions eigentlich sind: Monadische Berechnungen
Jetzt geht es nur noch darum, so eine auch zu definieren. Um anzugeben, woher die entsprechenden Funktion (Bind etc.) genommen werden sollen, sieht F# ein sog. Builder-Objekt vor, für das wir eine Klasse schreiben:
type MaybeBuilder() = member this.Delay(f) = f() member this.Bind(x, f) = bind x f member this.Return(x) = Some(x)
Listing 180: Builder-Klasse
Wie wir sehen, definiert das genau die obigen Funktionen - Bind und Return (und Delay, das werden wir aber vernachlässigen).
Von diesem können wir jetzt eine Instanz erstellen
let maybe = new MaybeBuilder()
Listing 181: Builder-Objekt
und schon sehen die Dinge so aus:
let summe() = maybe { let! a = zahlEingeben "a" let! b = zahlEingeben "b" let! c = zahlEingeben "c" return a + b + c }
Listing 182: Zahleneingabe und Summenbildung
Perfekt - Einfacher geht's nicht!
Und so einen Ausdruck nennt man jetzt eben Computation Expression. Die Aufrufe von Bind werden nun vom Compiler automatisch eingesetzt, und zwar immer an den Zuweisungen mit let!.
Weitere Computation Expressions
Aufmerksamen Lesern wird die Funktion, die wir oben geschrieben haben, merkwürdig bekannt vorkommen. Richtig... es existiert doch so eine Syntax mit seq { ... }.
Seq ist tatsächlich der wohl berühmteste Vertreter von Computation Expressions - Er arbeitet nicht mit Option-Werten, sondern mit Listen. Aber nichtsdestotrotz handelt es sich um nichts Anderes als oben, der Zweck ist nur ein anderer. Hier sieht man die außerordentliche Vielfalt von monadischen Berechnungen. F# erlaubt uns nämlich neben der Definition von Bind und Return auch noch, Implementierungen für Kontrollstrukturen zu schreiben - Das bedeutet, innerhalb dieser Blöcke kann man den gesamten F#-Code schrittweise auseinandernehmen und mit "Spezialeffekten aufgerüstet" wieder ins Rennen schicken.
Beweis: Für List-Comprehensions kann ebenso simpel eine Computation Expression definiert werden:
type ListComprehension() = member this.Delay(f) = f() member this.Return(x) = [x] member this.Bind(liste, f) = List.concat [ for x in liste -> f x ] let list = new ListComprehension() let punkte = list { let! x = [1..3] let! y = [1..3] return (x, y) } printfn "%A" punkte // [(1, 1); (1, 2); (1, 3); (2, 1); (2, 2); (2, 3); (3, 1); (3, 2); (3, 3)]
Listing 183: Statt List-Comprehensions eine Computation Expression
Aber genug theoretisch geredet - Folgende Liste gibt eine Reihe von Operationen an, die monadischen Gesetzen folgen und daher durch Computation Expressions beschreibbar sind:
- Option-Werte
- List-Comprehensions
- Sichere Ein- und Ausgaben
- Asynchrone Programmabläufe (Multithreading)
- Transaktionen
- Continuations (Einfrieren und Speichern von Zeitpunkten in Programmen)
- Backtracking (Fehlerrückverfolgung)
- Pseudozufallsgeneratoren
- Zustandsänderungen / State machines
- Stochastische Prozesse (Zufallsmodelle)
- Parser (Texte von Programmen verarbeiten lassen)
- Interpreter für Programmiersprachen
Mit Computation Expressions lässt sich also auf einer Vielzahl von Objekten operieren. Dabei handelt es sich immer um generische Typen ('a option, 'a list, 'a async, ...). Verallgemeinert nennen wir diesen Typen 'a M und erhalten folgende Signaturen für ein Builder-Objekt.
val Bind : 'a M * ('a -> 'b M) -> 'b M val Return : 'a -> 'a M
Listing 184: Typsignaturen von Bind und Return
Pseudozufallsgeneratoren
Zufallsgeneratoren und funktionale Programmierung hatten schon immer eine schwierige Beziehung. Zumeist werden sie, zusammen mit Ein/Ausgaben, von Anfängern oder Gegnern als das Argument gezückt, aufgrund dessen funktionale Programmierung nicht funktionieren könne. Um das spektakuläre Gegenteil zu beweisen, v.A. aber, um einmal die Entwicklung einer Computation Expression vollständig durchzuexerzieren, werden wir nun schrittweise eine vollständig funktionale Zufalls-Monade entwickeln.
Dazu ist ersteinmal wichtig, die Funktionsweise handelsüblicher Zufallsgeneratoren zu verstehen. Normalerweise hält ein jeder Zufallsgenerator einen bestimmten gespeicherten Wert und eine Rechenvorschrift. Ganz am Anfang seiner Existenz wird er mit einem sog. Seed, einem Anfangswert, initialisiert. Bei jeder Anfrage für eine Zufallszahl berechnet er nun aus diesem Seed mithilfe einer hochkomplexen Rechenvorschrift eine Zufallszahl aus. Gleichzeitig kommt er damit auf einen neuen Seed-Wert, der ihn bei der nächsten Berechnung als Ausgangswert dient. (Übrigens: Bei gleichen Startwerten produziert jeder Zufallsgenerator auch die gleiche Zufallsfolge!)
In Ordnung - Das klingt jetzt, von der komplizierten Formel abgesehen, nach nichts, was nicht in ein paar Zeilen machbar ist:
type Random(seed : int) = let mutable value = seed member this.Next(min, max) = value <- komplizierteFormel value min + value % (max - min)
Listing 185: Einfacher Zufallsgenerator in F#
Tjaaaha, freut sich unser Widersacher und das schlechte Gewissen sieht, was er meint (hier zur unmissverständlichen Kenntnis rot hervorgehomben).
type Random(seed : int) = let class="redfont">mutable value = seed member this.Next(min, max) = class="redfont">value <- komplizierteFormel value min + value % (max - min)
Listing 186: mutable und Variablenzuweisung
Veränderliche Werte - Schlimmer: Ein globaler Zustand, der sich permantent ändert. Nein, noch schlimmer: Jede Funktion, die den Zufallsgenerator verwendet, ändert einen globalen Zustand. Wenn jetzt noch Multithreading mit ins Spiel käme, hätten wir ein ernstes Problem. In anderen Sprachen ist sowas zwar Gang und Gäbe (ja, auch der System.Random arbeitet so), aber da wir in F# funktional programmieren und genau soetwas vermeiden wollen, muss einfach eine andere Lösung her.
Und die gibt es - Völlig frei von Veränderlichem und mit einer perfekten, durchs Typsystem getragenen Unterscheidung von zufälligem und "normalem" Code. Aber wo kommt diese her?
Gewieftermaßen reicht hier die Kapitelüberschrift zur Antwort: Computation Expressions
Betrachten wir mal den Zufallsgenerator von allen Zustanden gelöst - Was wollen wir haben? Ein Objekt, das, wenn es einen Anfangswert bekommt, einen Zufallswert und einen neuen Anfangswert produziert!
Das zumindest ist problemlos funktional:
type 'a Random = Random of (int -> 'a * int)
Listing 187: Hülle des Zufallsgenerators
Logisch - Ein Objekt, das einen Startwert (eine Zahl) bekommt und ein zufälliges Ergebnis plus den nächsten Startwert produziert... So weit waren wir.
Ein Zufallsobjekt, das aus einem angegebenen Intervall einen Zufallswert erstellt, können wir auch angeben:
let choose (min, max) = Random (fun seed -> (min + seed % (max - min) (* Zufallswert *), abs(327 * seed - 403471) % 37231) (* Neuer Startwert *) )
Listing 188: Berechnung der Zufallszahlen an sich
(Die Formeln sind geraten und natürlich nicht auf dem neusten Stand der Dinge - Es geht mehr ums Prinzip).
Um das Schreiben noch etwas einfacher zu gestalten, erhält unser Generatorobjekt noch eine Memberfunktion:
type 'a Random = Random of (int -> 'a * int) with member this.Gen seed = let (Random f) = this in fst (f seed)
Listing 189: Memberfunktion für Random
Mittels der Gen-Funktion können wir bei gegebenem Anfangswert nun einfach das Ergebnis ausgeben lassen.
Jetzt können wir beginnen, Zufallsfolgen zu berechnen.
let generator = choose (1, 10) printfn "%i" (generator.Gen 42) printfn "%i" (generator.Gen 42) printfn "%i" (generator.Gen 42)
Listing 190: Verwendung von choose
Wundervoll - Wir erhalten drei mal die Ausgabe 7. Sehr zufällig ist das aber nicht - aber bekanntlich ändern sich ja auch keine Werte. Aber halt, wir wissen ja, dass der Generator noch einen neuen Startwert ausspuckt. Den können wir ja einfach weitergeben.
let (Random generator) = choose (1, 10) let startwert1 = 42 let (ergebnis1, startwert2) = generator startwert1 printfn "%i" ergebnis1 let (ergebnis2, startwert3) = generator startwert2 printfn "%i" ergebnis2 let (ergebnis3, startwert4) = generator startwert3 printfn "%i" ergebnis3
Listing 191: Manuelle Weitergabe des neuen Startwerts
7, 4, 5 - Das lass ich glatt als Zufallsfolge gelten!
Jetzt geht es einzig darum, das Weitergeben der Startwerte irgendwie vor dem Programmierer zu verbergen. Und genau hier kommt wieder die Bind-Funktion ins Spiel.
let bind (Random gen) (next : 'a -> 'b Random) = Random (fun seed -> let (res, seed2) = gen seed let (Random gen2) = next res gen2 seed2)
Listing 192: Automatisierung der Startwert-Weitergabe mit bind
Der neue Zufallsgenerator setzt einen gegebenen Startwert in den ersten ein, verarbeitet dessen Ergebnis und macht mit dem neu gelieferten Wert weiter. Damit wird aus unserem Beispiel Folgendes:
let generator = choose (1, 10) let startwert1 = 42 (bind generator (fun ergebnis1 -> printfn "%i" ergebnis1 bind generator (fun ergebnis2 -> printfn "%i" ergebnis2 bind generator (fun ergebnis3 -> printfn "%i" ergebnis3 Random (fun x -> ((), x)))))).Gen startwert1
Listing 193: Neues Beispiel mit automatisierter Weitergabe
Super - Zumindest müssen wir keine Werte mehr per Hand herumtragen, bind hält sie schön von uns fern. Damit wir die längst überfällige Computation Expression endlich definieren können, fehlt nur noch eine sinnvolle Implementierung von Return.
Sprich: Ein Generator, der einen Wert x zurückgeben soll, tut dies für jede Eingabe!
Damit lautet das Builder-Objekt so:
type RandomBuilder() = member this.Delay(f) = f() member this.Return(x) = Random (fun seed -> (x, seed)) member this.Bind(Random gen, next) = Random (fun seed -> let (res, seed2) = gen seed let (Random gen2) = next res gen2 seed2)
Listing 194: RandomBuilder-Klasse
Hiermit ist es uns also möglich, zufallsabhängigen Code völlig imperativ aussehend zu schreiben. Dennoch kann dieser außerhalb eines Random-Objektes nicht existieren und ist daher perfekt von jeglichem nicht-zufälligen Code getrennt - Seiteneffekte und Wertänderungen haben wir an keiner Stelle! Das war's.
let random = new RandomBuilder() let demo = random { let! a = choose (1, 10) printfn "%i" a let! b = choose (1, 10) printfn "%i" b let! c = choose (1, 10) printfn "%i" c return () } demo.Gen 42
Listing 195: Anwendung des neuen Zufallsgenerators
Dabei gilt:
val demo : unit Random
Listing 196: Typsignatur des Random-Demos
Hier noch ein Beispiel, das verschiedenste Generatoren kombiniert:
let zufallsPunkt = random { let! x = choose (-5, 5) let! y = choose (-10, 10) return (x, y) } let rec zufallsSequenz bereich = function | 0 -> random { return [] } | k -> random { let! wert = choose bereich let! rest = zufallsSequenz bereich (k - 1) return wert::rest } let zufallsWort = let buchstaben = [|'A' .. 'z'|] fun länge -> random { let! ascii = zufallsSequenz (0, buchstaben.Length) länge return new String(ascii |> List.map (fun i -> buchstaben.[i]) |> List.to_array) } let zufallsDemo = random { let! länge = choose (5, 10) let! liste = zufallsSequenz (1, 100) länge printfn "%A" liste let! punkt = zufallsPunkt printfn "%A" punkt printf "Länge des Namens? " let len = int (Console.ReadLine()) let! name = zufallsWort len printfn "Hallo, %s" name return () } zufallsDemo.Gen 42 printfn " ************************* " zufallsDemo.Gen 1
Listing 197: Verschiedenste Zufallsgeneratoren
Asynchrones Programmieren
Aber genug über die Interna von Computation Expressions.
Dieser Abschnitt dreht sich um ein kleines Objekt, das F# von Haus aus definiert und mit wir in der absolut einzigartigen Lage sind, ein sehr kompliziertes Thema über eine Computation Expression abhandeln zu können, ohne auch nur darüber nachdenken zu müssen: Async
Betrachten wir folgende Beispiel: Wir haben eine Berechnung, die potenziell seeehr lange dauert:
// val langsameBerechnung : 'a -> 'a let langsameBerechnung wert = for x in 1..10 do printfn "#%A: %i" wert x Threading.Thread.Sleep 200 // 200 ms schlafen wert
Listing 198: Berechnung mit grosser Laufzeit
Wenn wir hiervon ein paar Werte brauchen, ist das kein Problem:
let ergebnisse = [ for x in 1..5 -> langsameBerechnung x ] let summe = List.sum ergebnisse
Listing 199: Anwendung der Berechnung
Als Ausgabe bekommen wir fein säuberlich hintereinander:
#1: 1 #1: 2 . . #1: 10 #2: 1 #2: 2 . . #2: 10 #3: 1 . . #5: 10
Insgesamt müssen wir auf dieses Ergebnis 200ms*10*5=10 Sekunden warten.
Die berechtigte Überlegung ist nun: Wieso berechnen wir die Werte nicht parallel? Keiner von ihnen ändert einen anderen, es sind unabhängige Berechnungen.
Unsere Funktion parallelisierbar zu machen, ist dank Computation Expressions im Schlaf möglich:
let langsameBerechnung wert = async { for x in 1..10 do printfn "#%A: %i" wert x Threading.Thread.Sleep 200 // 200 ms schlafen return wert }
Listing 200: Langsame Berechnung parallelisiert
Ihre Signatur ändert sich in:
val langsameBerechnung : 'a -> Async<'a>
Listing 201: Typsignatur der parallelisierten Berechnungsfunktion
Das bedeutet, sie gibt ein Async-Objekt zurück, das, wenn es ausgewertet wird, einen Wert vom Typen 'a produziert. Der Trick ist nun, dass wir mit einer weiteren Computation Expression diese Berechnungen bequem kombinieren können.
let summe = async { let! ergebnisse = [ for x in 1..5 -> langsameBerechnung x ] |> Async.Parallel return Array.sum ergebnisse }
Listing 202: Asynchrone Berechnung
Mit Async.Parallel setzen wir eine Liste von asynchronen Berechnungen zu einer neuen zusammen, die die Ergebnisse als Array zusammenfasst.
val Async.Parallel : Async<'a> list -> Async<'a array>
Listing 203: Typsignatur der asynchronen Funktion
Dieses Ergebnis können wir mit der let!-Syntax extrahieren, summieren und zurückgeben. Dabei hat Summe den Typen Async<int>, gibt also eine Berechnung an, die, wenn sie ausgewertet wird, eine Zahl zurückgibt.
Und das tun wir jetzt:
printfn "%i" (Async.RunSynchronously summe)
Listing 204: Aufruf der asynchronen Funktion
Was wir erhalten, sieht nun in etwa so aus:
Abbildung 5: Ausgabe der asynchronen Berechnung
Dieses Ergebnis zeigt: Die Funktionen werden tatsächlich parallel, d.h. von mehreren Threads, ausgewertet. Und viel wichtiger: Die Rechenzeit ist weit unter 10 Sekunden, da die Funktionen nicht mehr aufeinander warten.
Die Möglichkeit zur parallelen Auswertung gibt es bereits in vielen Programmiersprachen - Der entscheidende Vorteil unserer Async-Ausdrücke ist es allerdings, dass wir jede Art von potenziell asynchroner Berechnung in fast normaler Syntax formulieren können - Seien es asynchrone Internet-, Datei- oder Datenbankzugriffe, Message Passing oder nur eine parallele Auswertung. Mit Thread-Handles oder Callback-Funktionen wie unter VB oder C# müssen wir uns dabei an keiner Stelle herumschlagen - Im let!-Aufruf werden diese vom Compiler automatisch hinzugefügt.
In Kombination mit seiner funktionalen Natur macht dies F# zu einer idealen Multithreading-Sprache. Codes, die in anderen Sprachen Hunderte von Zeilen benötigen und dabei oft fehleranfällig sind, lassen sich mit async {}-Blöcken in wenigen Zeilen, einer völlig offensichtlichen Syntax und mit weitaus geringerem Gefahrenpotenzial notieren. Denn ein weiterer Vorteil ist, dass F# (unbeabsichtigte) Veränderung von Werten unterbindet - An dieser Stelle nämlich krankt viel sonstiger Multithreading-Code, da unkontrolliert auf gemeinsame Werte zugegriffen wird und deren Zustand dadurch Schäden erleidet.
Schlusswort
So, das war's. Ich hoffe, ich konnte Ihnen einen kleinen Einblick in die doch etwas "andere" Sprache F# geben. Sollten Fragen auftauchen, können Sie diese selbstverständlich in einem unserer Foren stellen.
Quellen
Ihre Meinung
Falls Sie Fragen zu diesem Tutorial haben oder Ihre Erfahrung mit anderen Nutzern austauschen möchten, dann teilen Sie uns diese bitte in einem der unten vorhandenen Themen oder über einen neuen Beitrag mit. Hierzu können sie einfach einen Beitrag in einem zum Thema passenden Forum anlegen, welcher automatisch mit dieser Seite verknüpft wird.