Elm logo

Logo der Sprache Elm

In dieser Vorlesung wollen wir uns die Grundlagen der funktionalen Programmierung anschauen. Wir werden diese Grundlagen anhand der Programmiersprache Elm lernen. Bei der funktionalen Programmierung handelt es sich um ein Programmierparadigma. Ein Programmierparadigma ist die Grundidee, wie Programme beschrieben werden. In Lehrveranstaltungen, die in die Grundlagen der Programmierung einführen, wird heutzutage häufig eine objekt-orientierte Programmiersprache wie Java oder C# genutzt. Die objekt-orientierten Sprachen gehören zu der größeren Klasse der imperativen Programmiersprachen. Neben den objekt-orientierten Sprachen gehören zu den imperativen Sprachen noch die klassischen Programmiersprachen wie C, die zwar strukturiert sind, das heißt, keine GOTO-Anweisungen mehr verwenden, aber kein Objektkonzept unterstützen.

Die funktionalen Sprachen gehören zur größeren Klasse der deklarativen Programmiersprachen. Deklarativ bedeutet dabei, dass man die Lösung für ein Problem beschreibt. In den deklarativen Sprachen wird eher beschrieben, wie die Lösung eines Problems aussieht. In den imperativen Sprachen wird dagegen eher beschrieben, wie die Lösung Schritt für Schritt berechnet wird. Dadurch liegt bei den imperativen Sprachen mehr Verantwortung bei den Entwickler*innen. Gleichzeitig haben die Entwickler*innen bei einer imperativen Sprachen aber auch mehr Freiheiten, da weniger Aufgaben vom Compiler übernommen werden. Im Gegensatz dazu liegt die Verantwortung bei den deklarativen Sprachen stärker beim Compiler und die Entwickler*innen haben weniger Freiheiten. Der Hauptvertreter der deklarativen Sprachen sind funktionale Programmiersprachen, die wir uns in dieser Vorlesung anschauen wollen. Neben den funktionalen Sprachen gehören zu den deklarativen Sprachen noch die logischen Programmiersprachen, die aber inzwischen vergleichsweise wenig Relevanz in der Praxis haben.

Der Hauptunterschied zwischen funktionalen und imperativen Programmiersprachen besteht darin, dass Variablen in funktionalen Programmiersprachen unveränderbar (immutable) sind. Das heißt, Variablen sind in funktionalen Sprachen nur Namen für einen komplexeren Ausdruck, sie können aber nicht Schritt für Schritt verschiedene Werte annehmen. In einer imperativen Programmiersprache kann man dagegen einer Variable durch die Verwendung von Anweisungen nacheinander verschiedene Werte zuweisen. Die meisten imperativen Programmiersprachen unterstützen zwar unveränderbare Variablen (final in Java, const in JavaScript, let in Swift, val in Kotlin), diese Annotation ist aber optional, während es in den funktionalen Sprachen nur unveränderbare Variablen gibt.

Neben der Eigenschaft, dass Variablen unveränderbar sind, haben funktionale Sprachen gemeinsam, dass sie Funktionen höherer Ordnung unterstützen. Man spricht in diesem Zusammenhang auch davon, dass Funktionen First-Class Citizens sind. Es gibt aber viele Sprach-Features und Ideen, die zwar nicht von allen funktionalen Sprachen unterstützt werden, aber aus diesem Bereich der Forschung stammen und zunehmend Einzug in andere Programmiersprachen nehmen. So gibt es heutzutage zum einen Programmiersprachen, die man als Hybridsprachen bezeichnet, da sie Konzepte aus den objekt-orientierten und den funktionalen Sprachen vereinen. Zu diesen Sprachen gehören zum Beispiel Scala, Swift und Kotlin. Außerdem stehen Programmiersprachen-Features, die früher nur in funktionalen Sprachen zur Verfügung standen, inzwischen auch in imperativen Sprachen zur Verfügung. Zu diesen Features gehören Konzepte wie Polymorphismus (Generics), Funktionen höherer Ordnung und Pattern Matching. Diese Features werden wir unter anderem in dieser Vorlesung kennenlernen. Generics wurden zum Beispiel 2004 in Java 5 und damit neun Jahre nach der Veröffentlichung der Sprache zu Java hinzugefügt. Funktionen höherer Ordnung wurden 2014 in Java 8 zu Java hinzugefügt und Version 14 von Java aus dem Jahr 2020 unterstützt erste Formen von Pattern Matching.

Während immer mehr Programmiersprachen Features unterstützen, die ursprünglich aus dem Bereich der funktionalen Programmiersprachen stammen, halten auch zunehmend Best Practices aus der funktionalen Programmierung Einzug in Mainstream-Sprachen. So wird in anderen Sprachen auch zunehmend mit unveränderbaren Variablen gearbeitet. Außerdem werden auch zunehmend unveränderbare Datenstrukturen in nicht-funktionalen Programmiersprachen verwendet. In Java 14 wurden zum Beispiel Records, leichtgewichtige, unveränderbare Datenstrukturen, eingeführt, die an algebraischen Datenstrukturen aus der funktionalen Programmierung angelehnt sind. Ein anderes Beispiel ist die Object Spreading-Syntax in JavaScript, die in ES9 im Jahr 2018 eingeführt wurde. Diese Syntax erstellt eine flache Kopie eines Objektes, ohne das ursprüngliche Objekt zu verändern.

Zu den objekt-orientierten Programmiersprachen gehören sehr unterschiedliche Sprachen wie Python und Java. Ebenso unterscheiden sich die funktionalen Programmiersprachen Clojure und Haskell sehr stark. Die starken Unterschiede zwischen Python und Java und zwischen Clojure und Haskell entspringen vor allem den unterschiedlichen Ansätzen im Bereich des Typsystems. Während Python und Clojure dynamisch getypte Sprachen sind, sind Java und Haskell statisch getypte Sprachen. Statisch getypt bedeutet dabei, dass die Typprüfung eines Programms bereits zur Kompilierzeit stattfindet. Im Gegensatz dazu wird die Typkorrektheit bei einem dynamischen Typsystem erst zur Laufzeit des Programms überprüft.

Haskell logo

Logo der Sprache Haskell

In den meisten Vorlesungen, in denen eine statisch getypte funktionale Sprache vermittelt wird, wird die Programmiersprache Haskell eingesetzt, da sie am weitesten verbreitet ist. Wir werden in dieser Vorlesung die Programmiersprache Elm nutzen. Elm ist eine Sprache zur Entwicklung von Web-Frontend-Anwendungen. Die Syntax von Elm ist sehr ähnlich zur Syntax von Haskell. Elm hat aber zwei wesentliche Vorteile im Vergleich zu Haskell.

  • Elm gilt als einsteigerfreundlicher als Haskell.

Haskell stellt sehr viele Sprach-Features zur Verfügung und jedes Jahr kommen neue Features, die aus der Programmiersprachenforschung stammen, hinzu. Elm hat dagegen einen vergleichsweise geringen Umfang an Sprach-Features. Bei den grundlegenden Sprach-Konstrukten stellt Haskell häufig mehrere Wege zur Verfügung, um das gleiche Ziel zu erreichen. Bei der Entwicklung von Elm wurde dagegen Wert darauf gelegt, möglichst wenige Konstrukte zur Verfügung zu stellen. Die fortgeschrittenen Sprach-Features von Haskell sorgen manchmal für sehr unverständliche Fehlermeldungen, da der Compiler nicht unterscheiden kann, ob man ein fortgeschrittenes Sprach-Feature nutzen wollte oder einen grundlegenden Fehler in der Syntax gemacht hat. Außerdem haben die Entwickler von Elm sich sehr viel Mühe bei der Gestaltung der Fehlermeldungen gegeben, um die Sprache einsteigerfreundlich zu gestalten.

Die Ergebnisse der wissenschaftlichen Publikation Understanding beginners’ mistakes with Haskell legen nahe, dass Elm besser für die Einführung in die funktionale Programmierung geeignet ist als Haskell. So wird in der Publikation zum Beispiel empfohlen, dass der Haskell-Compiler bei unvollständigen Pattern eine Warnung ausgeben sollte. Der Elm-Compiler liefert sogar einen Fehler, wenn das Pattern Matching unvollständig ist und nicht nur eine Warnung. In der Publikation wird auch empfohlen, dass partielle Funktionen wie head und tail in Haskell vermieden werden sollten. In Elm sind die Funktionen head und tail nicht partiell und vermeiden damit das Problem mit partiellen Funktionen. In der Publikation wird außerdem darauf verwiesen, dass die Syntax, die Haskell für die Anwendung von Funktionen auf Argumente nutzt, ein großes Problem für Anfänger darstellt. Dieses Problem wird durch den Einsatz von Elm nicht behoben, da Elm die gleiche Syntax für Funktionsanwendungen nutzt wie Haskell.

  • In Elm lassen sich einfach praxisnahe Anwendungen entwickeln.

Dadurch, dass Elm für die Entwicklung von Web-Frontend-Anwendungen konzipiert wurde, kann man vergleichsweise einfach eine interaktive Anwendung entwickeln. Man könnte ähnliche Beispiele auch in Haskell umsetzen, indem man eine Bibliothek wie Miso verwendet. Die Konzepte sind in Haskell aber nicht so einfach zugänglich wie in Elm. Außerdem kann man am Beispiel einer Web-Frontend-Anwendung in Elm gut illustrieren, wie Konzepte wie Polymorphismus genutzt werden, um praktische Probleme zu lösen.

Die Programmiersprache Elm ist wie Haskell eine rein funktionale Programmiersprache. Das heißt, die Ausführung eines Programms ist immer nur die Auswertung eines Ausdrucks, also die Berechnung eines Ergebnisses. In Programmiersprachen, die nicht rein funktional sind, kann neben der Berechnung noch ein Seiteneffekt (Side Effect) ausgeführt werden. Der Begriff Seiteneffekt ist ein falscher Freund1, da die korrekte Übersetzung des englischen Wortes Side Effect eigentlich Nebenwirkung ist. Der Begriff Seiteneffekt wird trotzdem als Fachbegriff verwendet. Ein Seiteneffekt ist in der Programmierung eine Nebenwirkung, die eine Funktion oder Methode neben der Berechnung des Ergebnisses hat. In einer imperativen Programmiersprache ist das Setzen einer (globalen) Variable zum Beispiel ein Seiteneffekt. Aber auch das Schreiben oder Lesen von Dateien oder das Werfen von Exceptions sind Seiteneffekte, da diese Effekte über das Verarbeiten der Argumente und das Produzieren des Ergebnisses der Funktion hinausgehen. Man bezeichnet eine Funktion, die keinen Seiteneffekt hat, als pure Funktion. Man spricht im Kontext von puren Funktionen auch von referenzieller Transparenz. Ein Ausdruck ist referenziell transparent, wenn der Wert des Ausdrucks nur von den Werten seiner Teilausdrücke abhängt. Damit darf der Wert eines Ausdrucks zum Beispiel nicht vom Zeitpunkt abhängen, zu dem der Ausdruck ausgewertet wird. Ein Beispiel für einen Ausdruck dessen Wert vom Zeitpunkt seiner Auswertung abhängt, ist der aktuelle Zeitstempel. Wenn wir in Java eine Methode schreiben, welche die Methode currentTimeMillis() aufruft, ist die Methode zum Beispiel mit hoher Wahrscheinlichkeit nicht referentiell transparent. Wenn wir zweimal das Ergebnis der Methode berechnen, werden mit ggf. unterschiedliche Ergebnisse herauskommen, obwohl wir die Argumente der Methode nicht ändern.

In Elm werden wir gezwungen, referentiell transparente Programme zu schreiben. In Programmiersprachen, die uns nicht dazu zwingen, solche Programme zu schreiben, ist es aber auch guter Stil, diese Eigenschaft an möglichst vielen Stellen zu gewährleisten. Man kann sich leicht vorstellen, dass es recht schwierig ist, Fehler zu finden, wenn wiederholte Aufrufe der gleichen Methode mit identischen Argumenten immer wieder andere Ergebnisse liefern. Daher versucht man auch in anderen Programmiersprachen den Teil der Anwendung, der nicht referentiell transparent ist, möglichst von dem Teil zu trennen, der referentiell transparent ist.

Neben Elm gibt es nur wenige rein funktionale Programmiersprachen, die eine gewisse Verbreitung aufweisen, wie Haskell oder PureScript. Die anderen rein funktionalen Programmiersprachen, die es gibt, wie Rocq oder Agda, sind zwar schon vergleichsweise alt, ihre noch fortgeschritteneren Programmierkonzepte haben aber bisher noch keinen Einzug in Mainstream-Sprachen gefunden.

Aus den rein funktionalen Sprachen stammen viele Konzepte, um statische Garantien für Eigenschaften von Programmen zu erhalten. Wir werden uns in dieser Vorlesung daher auch immer wieder damit beschäftigen, wie wir statische Garantien über unsere Programme erhalten können. Statische Garantien bedeuten dabei, dass ein erfolgreich kompilierendes Programm unter Garantie bestimmte Eigenschaften aufweist. Zum Beispiel erhalten wir in einer statisch getypten Programmiersprache nie einen Typfehler zur Laufzeit, wenn das Programm erfolgreich kompiliert werden kann. Diese Eigenschaft wird häufig durch das Zitat

Well-typed programs cannot ‘go wrong’.

von Robin Milner zusammengefasst. Das Zitat soll ausdrücken, dass eine Sprache mit einem sinnvollen statischen Typsystem dafür sorgt, dass ein Programm zur Laufzeit keinen Fehler erzeugt.