F# - funktionales Pendant zu C#

F# ist im Gegensatz zu C# eine funktionale Sprache. Was zunächst als Spezialsprache für mathematische Algorithmen erscheint, erweist sich schnell als flexible und gut geeignete Sprache für andere Anwendungsgebiete.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 17 Min.
Von
  • Golo Roden
Inhaltsverzeichnis

F# ist im Gegensatz zu C# eine funktionale Sprache. Was zunächst als Spezialsprache für mathematische Algorithmen erscheint, erweist sich schnell als flexible und gut geeignete Sprache für andere Anwendungsgebiete.

Bei Programmiersprachen unterscheidet man prinzipiell zwischen Sprachen für einen speziellen Zweck und Sprachen zur Bewältigung jeglicher Aufgaben. Letztere sind als so genannte "General Purpose Languages" bekannt. Als bekannten Vertreter der ersten Sorte kann man beispielsweise Fortran heranziehen, das auf numerische Berechnungen spezialisiert ist. Das zeigt sich bereits im Namen, denn der Begriff stellt ursprünglich eine Zusammensetzung der Begriffe "Formula" und "Translation" dar.

Ein bekannter Vertreter des zweiten Sprachtyps ist Java, das genau genommen auf kein Anwendungsgebiet besonders spezialisiert ist. Zwar gibt es Aufgaben, die sich mit Java besser oder schlechter lösen lassen, grundsätzlich ist Java aber für jeden beliebigen Zweck einsetzbar. Auch C# zählt zur Gattung der "General Purpose Languages".

Seit Visual Studio 2010 gehört F# zu den in die IDE integrierten Sprachen.

Die Sprache F# gehört seit der Version 2010 zum festen Lieferumfang von Visual Studio. In der deutschen Wikipedia heißt esvlediglich, dass es sich bei F# um eine funktionale Sprache handele, die zusätzlich objektorientierte und imperative Konstrukte enthalte und eine gewisse Verwandtschaft zu den Sprachen OCaml und ML aufweise. Die englische Wikipedia gibt sich zwar auskunftsfreudiger, aber auch sie behandelt das Thema "General Purpose Language" nicht weiter.

Auffällig ist zunächst, dass F# in der Regel als Multiparadigmensprache gilt, die sowohl funktionale als auch objektorientierte und imperative Programmierung ermöglicht. Der Schwerpunkt liegt jedoch stets auf der funktionalen Programmierung. Da es sich bei den meisten objektorientierten Sprachen um "General Purpose Languages" handelt, stellt sich die Frage eher, wie es sich in der Hinsicht mit funktionalen Sprachen verhält. Zur Beantwortung ist es erforderlich, die Konzepte funktionaler Programmierung zu analysieren. Prinzipiell gibt es deren vier – nämlich Unveränderlichkeit, Typsicherheit, deklarative Entwicklung und
Rekursion.

In C# ist ein Typ standardmäßig veränderlich. Unveränderliche, so genannte "Immutable Types", sind eher die Ausnahme als die Regel. Das bekannteste Beispiel hierfür stellt die Klasse System.String dar:

string foo = "Hallo ";
string bar = "Welt!";

string foobar = String.Concat(foo, bar);

Auch Delegates sind unveränderlich, was in der Regel beim Thread-sicheren Auslösen von Ereignissen zum Tragen kommt:

public class Foo
{
public Action EventHandler Bar;

protected virtual void OnBar()
{
Action bar = this.Bar;
if(bar != null)
{
bar();
}
}
}

In der funktionalen Programmierung – und damit auch in F# – ist das genau umgekehrt: Typen sind per se unveränderlich, es sei denn, man markiert sie explizit als veränderlich. Das geschieht bei F# mit dem Schlüsselwort mutable:

let mutable x = 1
x <- x + 1

Das Vorgehen entspricht der Variante, Klassen in C# als reine Read-only-Klassen zu implementieren und das Setzen der Werte nur initial im Konstruktor zu erlauben.

public class Immutable
{
private readonly int _foo;
private readonly int _bar;

public Immutable(int foo, int bar)
{
this._foo = foo;
this._bar = bar;
}

public Immutable Multiply(int factor)
{
return
new Immutable(
this._foo * factor,
this._bar * factor);
}
}

Obwohl das Prinzip zunächst ungewohnt erscheint, schließlich ist für jede Veränderung eines Typs stets eine neue Instanz mit den neuen Werten zu erzeugen, bietet es einen gravierenden Vorteil: Da alle Methoden den Zustand des Objekts nach außen sichtbar ändern, können keine unbeabsichtigten Zustandsänderungen auftreten. Das Objekt lässt sich schlichtweg nicht manipulieren.

Der Vorteil zeigt sich beispielsweise in der Parallelprogrammierung, bei der gleichzeitige Zustandsänderungen eines Objekts aus Threads heraus leicht zu Data Races und ähnlichen Problemen führen können. In der funktionalen Programmierung ist das von vornherein ausgeschlossen.

Der Aspekt wirkt zunächst ein wenig verwunderlich – schließlich handelt es sich bei C# ebenfalls um eine typsichere Sprache. Jedoch treibt die funktionale Programmierung das Konzept auf die Spitze, indem bei ihr jeder Ausdruck innerhalb eines Programms einem konkreten Typen entspricht. Die Typsicherheit stellt nicht nur die syntaktische, sondern auch die semantische Korrektheit der Software ansatzweise sicher.

F# setzt die Typisierung ebenfalls wesentlich konsequenter um als C#: Beispielsweise kann man mit der Sprache Zahlen eine Maßeinheit mitgeben: Eine Ganzzahl lässt sich demnach mit der Maßeinheit km versehen, eine andere mit Stunde, sodass sich durch Division der beiden eine Zahl der Maßeinheit km/Stunde ergibt. Erwartet ein anderer Programmteil die Geschwindigkeit nicht als km/Stunde, sondern als Meilen/Stunde, führt das in F# zu einem Typkonflikt:

let maxSpeed = 50.0<km/h>
let actualSpeed = 40.0<mile/h>

if(actualSpeed > maxSpeed) then
printfn "Sie fahren zu schnell!"

ERROR FS0001: Type mismatch.
Expecting a float<mile/h> but given a float<km/h>.
The unit of measure 'mile/h' does not match the unit of measure 'km/h'.

Typen können in der funktionalen Programmierung einem von zwei Konzepten folgen. Entweder handelt es sich um einen einfachen (wie einem der eingebauten Wertetypen) oder um einen komplexen Typ. Letzterer kann wiederum ein zusammengesetzter sein, der mehrere einfache oder komplexe kapselt, oder ein Auswahltyp, der einen der unterschiedlichen vorgegebenen Werte annehmen kann.

Interessant ist daran zweierlei: Einerseits lassen sich zusammengesetzte Typen nicht nur als Klassen implementieren, sondern beispielsweise auch als sogenannte Tupel darstellen, was es ermöglicht, mehr als einen Wert aus einer Funktion zurückzugeben. Andererseits bieten die meisten funktionalen Sprachen – darunter F# – direkte Unterstützung zur Arbeit mit Auswahltypen.

Seit Microsoft mit .NET 3.5 und C# 3.0 die LINQ-Technik eingeführt hat, ist die deklarative Entwicklung in der objektorientierten Microsoft-Welt salonfähig geworden: LINQ war lediglich der erste Ansatz – inzwischen haben mit XAML und den Parallel Extensions für .NET weitere deklarative Techniken das Microsoft-Feld betreten. Ihnen ist gemeinsam, dass sie nicht mehr im Detail beschreiben, wie eine Aufgabe, sondern was überhaupt zu lösen ist. In LINQ wird das besonders deutlich: Statt anzugeben, wie die einzelnen Elemente zu sortieren sind, gibt man lediglich mit der Erweiterungsmethode OrderBy an, dass und nach welchem Kriterium sortiert werden soll.

var processes =
from p in Process.GetProcesses()
orderby p.ProcessName
select p;

Wie OrderBy das intern umsetzt, bleibt als Blackbox verborgen. An der Stelle wird Komfort also definitiv höher bewertet als das letzte Quäntchen Performance zu erzielen – was im Regelfall allerdings eher hilfreich als störend sein dürfte.

Funktionale Programmierung erhebt die deklarative Entwicklung nun zum Standard: Funktionen beschreiben nicht mehr, wie die einzelnen Algorithmen im Detail arbeiten, stattdessen modellieren sie die Algorithmen:

let rec factorial(n) =
if (n <= 1) then
1
else
n * factorial(n - 1)

Die Was-Programmierung wird demnach zum Prinzip erhoben. Das erleichtert die Komponierbarkeit und Analyse von Algorithmen wesentlich, da diese sich bausteinartig zusammenstecken und miteinander kombinieren lassen.

Gerade für spezielle algorithmische Anwendungsgebiete wie Animationen eignet sich die deklarative Programmierung deutlich besser als die klassische, wodurch F# an der Stelle auch eine geeignete Sprache für XAML ist.

Der vierte Aspekt funktionaler Programmierung ist schließlich die Rekursion. Zwar gibt es sie in der nichtfunktionalen Programmierung ebenfalls, jedoch spielt sie in der funktionalen eine zentrale Rolle: Hier dient Rekursion nicht nur als Alternative zum iterativem Vorgehen, sie ist stattdessen die einzige Möglichkeit. Was sich zunächst wie eine Einschränkung anhört, stellt sich als probates Mittel zur effizienten Verarbeitung jeglicher Datenstrukturen dar:

let rec sumList list =
match list with
| [] -> 0
| head::tail -> head + sumList(tail)

Als Einwand gegen Rekursion mag zunächst der erhöhte Bedarf an Laufzeit und Speicher gelten, durch die Struktur der Sprache F# entsprechen viele rekursiv definierte Funktionen jedoch dem Schema der Endrekursion, sodass der Compiler sie in eine iterative Methode umwandelt und dadurch nicht nur beschleunigt, sondern auch speichersparend ausführt.

F# fehlen einige Sprachkonstrukte, die in Sprachen wie C# durchaus verbreitet sind: Am auffälligsten ist das bei Schleifen, die komplett mit der Rekursion nachzubilden sind. Allerdings weist F# Konstrukte auf, die anderen, nichtfunktionalen Sprachen in der Regel fehlen. Allen voran sei das Pattern Matching genannt, das bereits im letzten Code-Schnipsel Anwendung gefunden hat: Damit lassen sich verschiedene Ausdrücke auflisten, die Code zugewiesen bekommen haben. Essenziell ist dabei, dass die Ausdrücke jeweils unterschiedliche Instanzen repräsentieren, im Beispiel oben etwa die leere Liste und eine weitere, die in ein Kopfelement und eine verbleibende Liste (die gegebenenfalls wiederum der leeren Liste entspricht) aufgespalten wird.

Zunächst scheint das Sprachmerkmal nicht mehr als eine aufgebohrte switch-Anweisung zu sein, dem sich Ausdrücke anstelle von Konstanten übergeben lassen. Doch das Besondere am Pattern Matching in F# ist, dass der Compiler überprüft, ob alle Fälle beachtet wurden, und gegebenenfalls warnt, dass einer übersehen wurde. Das gewährleistet, selten benötigte, aber relevante Fälle aus Versehen nicht zu behandeln.

Eine weitere Besonderheit von F# ist, dass die Sprache Funktionen letztlich nicht anders handhabt als andere Typen. Das bedeutet insbesondere, dass sich Funktionen als Parameter und Rückgabewerte von Funktionen verwenden lassen. Die Funktion, die andere entgegen nimmt oder zurückgibt und sie verarbeitet, bezeichnet man in dem Fall als "Higher Order Function":

let twice (input:int) f  = f(f(input))

twice 2 (fun n -> n * n)

Das Konzept gibt es in C# in eingeschränkter Form, indem Delegates über- oder zurückgegeben werden. F# treibt es jedoch weiter. Beispielsweise ist es möglich, Funktionen zu verketten, wobei sich die Anzahl der Parameter verringern lässt, indem man einige Parameter mit Standardwerten vorbelegt. Das Verfahren heißt "Partial Function Application".

let add a b = a + b
let addTen = add 10

List.map addTen [ 1 .. 10 ]

Ein Begriff, der in dem Zusammenhang ebenfalls häufig anzutreffen ist, ist das "Currying". Damit bezeichnet man ein Verfahren, das eine Funktion mit n Parametern abbildet, die lediglich den ersten Parameter entgegen nimmt und eine weitere Funktion zurückliefert, die n - 1 Parameter erwartet.

Die größte Stärke von F# ist zugleich die größte Schwäche: die Flexibilität. Auf der einen Seite ist es ausgesprochen begrüßenswert, dass eine Sprache mehrere Paradigmen unterstützt und diese
nahtlos kombinieren kann, auf der anderen Seite geht dadurch der Fokus verloren.

Die größten Stärken zeigt F# in den anfangs genannten Bereichen aus: Parallelisierung, korrekterer Code und deklarative Entwicklung. Außerdem beweist F# Stärken im mathematisch-algorithmischen Bereich, da eine kompaktere, übersichtlichere und komfortablere Entwicklung realisierbar ist. Die native Unterstützung der Sprache für Konzepte wie Rekursion oder Listen erweist sich hierbei als positiv.

Letztlich folgt F# damit dem von Perl bekannten TIMTOWTDI-Konzept ("There is more than one way to do it"): Als Entwickler hat man die Wahl zwischen unterschiedlichen Wegen, vom funktionalen über den imperativen bis hin zum objektorientierten, und kann beziehungsweise muss stets individuell entscheiden, welcher Weg sich am besten zur Lösung eines gegebenen Problems eignet. Angenehm ist vor allem, dass man mit der Wahl eines dieser Wege nicht auf ebendiesen festlegt ist, sondern dass man für jede einzelne Zeile Code erneut die Wahl hat und dadurch viel Flexibilität gewinnt. Als Entwickler steht man demnach nicht nur vor einer Vielzahl von sprachlichen Mitteln, sondern muss sich zudem noch mit der Frage beschäftigen, welche von mehreren – scheinbar gleichartigen – Vorgehensweisen am ehesten das gewünschte Ergebnis erbringt.

Stan Lees Diktum "With great power comes great responsibility" trifft auch auf F# zu. Während C# den Entwickler weitgehend an die Hand nimmt, um typische Fallstricke zu vermeiden, und ihm Mittel gibt, sich auf die eigentliche Aufgabe statt auf die Sprache zu konzentrieren, geht F# anders an. Allein die Frage, ob Typen in F# als Records oder als Klassen zu implementieren sind – wobei übrige Arten von Typen wie Enumerationen oder Strukturen noch gar nicht berücksichtigt sind –, kann weitreichende Auswirkungen auf die Architektur und das Design der Anwendung haben.

Es besteht durchaus Bedarf an Best Practices oder zumindest Hinweisen, wie man sich als Entwickler in gewissen Situationen verhalten und worauf man achten sollte. Bei C# findet sich das viel einfacher, sodass man sich eher auf die eigentliche Aufgabe konzentrieren kann. Das Argument, dass F# noch eine vergleichsweise junge Sprache ist, überzeugt an der Stelle nicht – zu groß ist die Verwandtschaft zu anderen funktionalen Sprachen wie OCaml oder ML, weshalb deren Best Practices auch für F# gelten. Das Problem ist nur: Die sind in der .NET-Welt eher unbekannt.

Funktionale Programmierung kann die Entwicklung durchaus vereinfachen und erleichtern. F# erfordert als Sprache deutlich mehr Nachdenken und bewusste Entscheidungen für den einen oder anderen Programmierstil seitens des Entwicklers, als das etwa bei C# der Fall wäre.

Um die eingangs gestellte Frage, ob F# eine "General Purpose Language" ist, noch einmal aufzugreifen: Sie lässt sich pauschal zwar mit einem "Ja" beantworten, denn theoretisch ist mit F# all das möglich, was C# kann. Schließlich is t man als Entwickler nicht auf das funktionale Vorgehen beschränkt. Die Antwort lässt jedoch einen wichtigen Aspekt außer Acht: Sie impliziert nämlich, dass F# ebenso leicht wie C# als "General Purpose Language" anzuwenden ist. Genau das ist eben nicht der Fall. Daher sollte man F# nicht leichtfertig einsetzen, sondern sich zuvor einige Fragen stellen, um den Einsatz zu rechtfertigen oder zu verwerfen:

  • Wie hoch ist der zu erwartende Anteil der funktionalen Programmierung an der Gesamtanwendung? Rechtfertigt dieser – gegebenenfalls kleine – Anteil den Einsatz einer weiteren Sprache? Würde man für den Anteil in einem ähnlichen Szenario auch eine andere "exotische" Sprache wie Erlang oder Lua einsetzen?
  • Wie erfahren sind die Entwickler mit funktionalen Konzepten? Sind sie in der Lage, die angebotenen Möglichkeiten verantwortungsvoll, gezielt, effektiv und auch effizient einzusetzen?
  • Wie aufwendig wäre es, die funktionalen Bereiche der Anwendung in einer klassischen Sprache wie C# zu implementieren? Anders formuliert: Wie hoch sind die Gewinne, die daraus entstehen, auf F# und die damit verbundenen funktionalen Konzepte zu setzen?

Golo Roden
ist freiberuflicher Wissensvermittler und Berater für .NET, Codequalität und agile Methoden. Er betreibt die Website guidetocsharp.de und ist in der myCSharp.de-Community aktiv.

  • Michael Stal; F wie funktional; Visual Studio 2010 mit der Programmiersprache F#; iX 8/2010, S. 115
  • Oliver Müller; Ein neuer Halbton; F#: Funktional programmieren in .NET; iX 4/2005, S. 113
  • Tomas Petricek; Real World Functional Programming Examples; Manning Publications, 2009

Die Endrekursion, auch als "Tail Recursion" bezeichnet, ist ein Verfahren zur Optimierung von rekursiven Funktionsaufrufen. Es lässt sich anwenden, wenn der letzte Funktionsaufruf innerhalb der rekursiven Funktion sie selbst ist.

Die Idee dahinter ist, in einem solchen Fall den zusätzlichen Funktionsaufruf zu streichen und die aktuelle Instanz der Funktion samt dazugehörigem Stack wiederzuverwenden. Auf die Art erspart die Endrekursion den aufwendigen Aufbau eines neuen Stacks, den Aufruf einer neuen Funktion samt abschließendem Abbau des erzeugten Stacks.

Bei rekursiven Funktionen, die mit Endrekursion optimiert werden, kann zudem kein Stack Overflow auftreten. Das gehört bei rekursiven Funktionen zu einem der Standardfehler, nämlich wenn die Tiefe der Rekursion zu groß wird und die Verwaltungsinformationen überhand nehmen.

Ein Nachteil von Endrekursion sei jedoch nicht verschwiegen: Sie erschwert das Debuggen einer durch sie optimierten Funktion bedeutend, da die Aufrufreihenfolge und der dazugehörige Call Stack nicht mehr erkennbar sind. Schließlich wird letztlich nur durch die immer gleiche Instanz der Funktion iteriert.

Als Beispiel soll an der Stelle die Methode Sum (in Pseudocode) dienen, die die ersten x natürlichen Zahlen summiert:

function int Sum(int x)
{
if(x == 0)
{
return 0;
}
else
{
return x + Sum(x ? 1);
}
}

Die Funktion ist zwar rekursiv, nicht jedoch endrekursiv: Als letzte Anweisung innerhalb der Funktion wird nämlich nicht, wie man auf den ersten flüchtigen Blick erwarten könnte, die Funktion rekursiv aufgerufen, sondern x zum Ergebnis des rekursiven Funktionsaufrufs addiert. Sum lässt sich jedoch in eine endrekursive Variante umwandeln, die das Assoziativgesetz ausnutzt:

function int Sum(int x)
{
return SumInternal(0, x);
}

function int SumInternal(int x, int y)
{
if(y == 0)
{
return x;
}
else
{
return SumInternal(x + y, y - 1);
}
}

Daraus ergibt sich folgender Ablauf für beispielsweise den Aufruf von Sum(5):

Sum(5) -> SumInternal(0, 5) -> SumInternal(5, 4) -> SumInternal(9, 3) 
-> SumInternal(12, 2) -> SumInternal(14, 1) -> SumInternal(15, 0)
-> 15 (ane)