Funktionen
In diesem Abschnitt wollen wir uns anschauen, wie man in Elm einfache Funktionen definiert. Funktionen sind in einer funktionalen Sprache das Gegenstück zu (statischen) Methoden in einer objektorientierten Programmiersprache. Bevor wir uns die Definition von Funktionen anschauen, wiederholen und vertiefen wir, wie Funktionen angewendet werden.
Anwendung
Wir haben bei den booleschen Ausdrücken die Funktion not kennengelernt und bereits gesehen, wie die Funktion auf ein Argument angewendet wird.
Ein Elm-Projekt hat standardmäßig eine Reihe von Paketen als Abhängigkeiten.
Das Paket elm/core stellt zum Beispiel grundlegende Datenstrukturen und Funktionen darauf zur Verfügung.
Im Paket elm/core sind zum Beispiel der Datentyp Bool und Funktionen wie not und || definiert.
In der Dokumentation der Funktion not steht neben der Beschreibung der Funktion die folgende Zeile.
not : Bool -> Bool
Diese Zeile besagt, dass not den Typ Bool -> Bool besitzt.
Dies wiederum besagt, dass die Funktion not einen Bool als Argument erhält und einen Bool als Ergebnis liefert.
Daher können wir zum Beispiel not (True && False) schreiben.
Die Auswertung des Ausdrucks True && False liefert einen Wert vom Typ Bool und dieser Wert wird an die Funktion not übergeben.
In der Dokumentation der Funktion xor steht neben der Beschreibung der Funktion die folgende Zeile.
xor : Bool -> Bool -> Bool
Dabei sieht der Typ der Funktion auf den ersten Blick etwas ungewöhnlich aus.
Wir werden später sehen, was es mit diesem Typ auf sich hat.
An dieser Stelle wollen wir nur festhalten, dass die Typen der Parameter bei mehrstelligen Funktionen durch Pfeile getrennt werden.
Das heißt, wenn wir den Typ einer Funktion angeben, listen wir die Typen der Argumente und den Ergebnistyp auf und schreiben jeweils -> dazwischen.
Um die Funktion xor anzuwenden, schreiben wir die Argumente durch Leerzeichen getrennt hinter den Namen der Funktion.
Das heißt, der folgende Ausdruck wendet die Funktion xor auf die Argumente False und True an.
xor False True
Wenn eines der Argumente der Funktion xor das Ergebnis einer anderen Funktion sein soll, so muss diese Funktionsanwendung mit Klammern umschlossen werden.
So wendet der folgende Ausdruck die Funktion xor auf die Argumente False und das Ergebnis der Auswertung des Ausdrucks not True an.
xor False (not True)
Diese Schreibweise stellt für Nutzer*innen, die Programmiersprachen wie Java gewöhnt sind, häufig eine Hürde dar.
Anhand der Klammern und der Leerzeichen kann man zählen, wie viele Argumente einer Funktion übergeben werden.
Diese Anzahl kann man dann mit der Anzahl der Parameter der Funktion vergleichen.
Wir betrachten zum Beispiel die Anwendung xor False not True.
Nach der Leerzeichen- und Klammerregel erhält die Funktion xor hier vier Argumente, nämlich False, not und True, denn diese Argumente sind alle durch Leerzeichen getrennt und keines der Argumente ist von Klammern umschlossen.
Die Funktion xor soll aber nur zwei Argumente erhalten, daher fehlen an dieser Stelle Klammern.
Wenn wir dagegen die Anwendung xor False (not True) betrachten, dann werden zwei Argumente an xor übergeben, denn hinter False folgt zwar ein Leerzeichen, das zweite Leerzeichen ist aber von Klammern umschlossen.
Konditional
Elm stellt einen if-Ausdruck der Form if b then e1 else e2 zur Verfügung.
Im Unterschied zu einer if-Anweisung, wie sie in objektorientierten Programmiersprachen zum Einsatz kommt, kann man bei einem if-Ausdruck den else-Zweig nicht weglassen.
Beide Zweige des if-Ausdrucks müssen einen Wert liefern.
Elm ist eine statisch getypte Programmiersprache.
Das heißt, die Typprüfung wird zur Kompilierzeit durchgeführt.
In statisch getypten Sprachen müssen beide Zweige eines if-Ausdrucks Werte liefern, die den gleichen Typ besitzen.
Das heißt, die Ausdrücke e1 und e2 müssen nach der Auswertung Werte vom gleichen Typ liefern.
Um den if-Ausdruck zu illustrieren, wollen wir eine Funktion items definieren.
Die Funktion items könnte zum Beispiel für den Warenkorb eines Online-Shops genutzt werden.
Die Funktion erhält eine Zahl und liefert eine Pluralisierung des Wortes Gegenstand.
Die Zahl gibt dabei an, um wie viele Gegenstände es sich handelt.
Bei einer Funktion bezeichnet man den Teil hinter dem =-Zeichen als rechte Seite der Definition.
items : Int -> String
items quantity =
if quantity == 1 then
"1 Gegenstand"
else
String.fromInt quantity ++ " Gegenstände"
Die erste Zeile gibt den Typ der Funktion items an.
Der Typ sagt aus,
dass die Funktion items einen Wert vom Typ Int nimmt und einen Wert
vom Typ String liefert.
Zwischen den Typ des Arguments und den Typ des Ergebnisses schreiben wir in Elm einen Pfeil.
Der Parameter der Funktion items heißt
quantity und die Funktion prüft, ob der Wert in diesem Parameter gleich 1 ist oder einen sonstigen Wert hat.
Mit dem Operator ++ hängt man zwei Zeichenketten hintereinander.
Die Funktion String.fromInt wandelt einen Wert vom Typ Int in den entsprechenden String um.
Die Funktion fromInt ist im Modul String definiert.
Ein Modul ist vergleichbar mit einer Klasse mit statischen Methoden in einer objektorientierten Programmiersprache.
Wenn wir beim Import nur import String schreiben, ohne ein exposing (..) anzugeben, dann können wir Definitionen aus dem Modul String nur qualifiziert verwenden.
Das heißt, wir müssen vor die Definition, die wir verwenden wollen, noch den Namen des Moduls und einen Punkt schreiben.
Wenn wir statt String.fromInt bei der Anwendung nur fromInt schreiben, nennt man den Namen der Funktion unqualifiziert.
Durch einen qualifizierten Namen können wir direkt am Namen sehen, in welchem Modul die Funktion definiert ist.
Außerdem nutzen wir auf diese Weise den Namen des Moduls als Bestandteil
des Funktionsnamens und können den Namen der Funktion so kürzer fassen.
So kann es zum Beispiel mehrere Funktionen geben, die fromInt heißen
und in verschiedenen Modulen definiert sind.
Durch den qualifizierten Namen ist dann uns (und dem Compiler) klar, welche Funktion gemeint ist.
In diesem Beispiel greift wieder die Regel, dass Funktionsanwendungen – auch Funktionsapplikationen oder nur Applikationen genannt – stärker binden als Infixoperatoren. Daher steht der Ausdruck
String.fromInt quantity ++ " Gegenstände"
für den folgenden Ausdruck.
(String.fromInt quantity) ++ " Gegenstände"
Das heißt, wir hängen den String " Gegenstände" hinter das Ergebnis der Applikation String.fromInt quantity.
Um komplexere Programme zu konstruieren, folgt man in Elm — wie in allen Programmiersprachen — Bauprinzipien.
Zum Beispiel können im then- und im else-Zweig eines if-Ausdrucks wieder Ausdrücke stehen.
Da ein if-Ausdruck selbst ein Ausdruck ist, können wir auf diese Weise Mehrfachfallunterscheidungen umsetzen.
Wir betrachten zum Beispiel die folgende Variante der Funktion items.
Hier ist der Ausdruck, der hinter dem Schlüsselwort else steht wieder ein if-Ausdruck.
items : Int -> String
items quantity =
if quantity == 0 then
"Keine Gegenstände"
else if quantity == 1 then
"1 Gegenstand"
else
String.fromInt quantity ++ " Gegenstände"
Bisher haben wir nur Funktionen kennengelernt, die ein einzelnes Argument erhalten.
Um eine mehrstellige Funktion zu definieren, werden die Parameter der Funktion einfach durch Leerzeichen getrennt aufgelistet.
Wir können zum Beispiel wie folgt eine Verallgemeinerung der Funktion items definieren.
Die Funktion pluralize nimmt die Singular- und die Pluralform eines Wortes und eine Anzahl und verwendet je nach Anzahl die Singular- oder Pluralform.
pluralize : String -> String -> Int -> String
pluralize singular plural quantity =
if quantity == 1 then
"1 " ++ singular
else
String.fromInt quantity ++ " " ++ plural
Die drei Parameter der Funktion pluralize heißen singular, plural und quantity.
Fallunterscheidung
In Elm können Funktionen mittels case-Ausdruck (Fallunterscheidung) definiert werden.
Ein case-Ausdruck ist ähnlich zu einem switch case in imperativen Sprachen.
Wir können in einem case-Ausdruck zum Beispiel prüfen, ob ein Ausdruck eine konkrete Zahl als Wert hat.
Als Beispiel definieren wir die Funktion items mittels case-Ausdruck.
items : Int -> String
items quantity =
case quantity of
0 ->
"Keine Gegenstände"
1 ->
"1 Gegenstand"
_ ->
String.fromInt quantity ++ " Gegenstände"
Die Fälle in einem case-Ausdruck werden von oben nach unten geprüft.
Wenn wir zum Beispiel die Anwendung items 0 auswerten, so passt der erste Fall und wir erhalten "Keine Gegenstände" als Ergebnis.
Werten wir dagegen items 3 aus, so passen die ersten beiden Fälle nicht.
Der dritte Fall mit dem Unterstrich ist ein Default-Fall, die immer passt und daher nur als letzter Fall genutzt werden darf.
Das heißt, wenn wir die Anwendung items 3 auswerten, wird anschließend der folgende Ausdruck ausgewertet.
String.fromInt 3 ++ " Gegenstände"
Die Auswertung dieses Ausdrucks liefert schließlich "3 Gegenstände" als Ergebnis.
Man bezeichnet das Prüfen eines konkreten Wertes gegen die Angabe auf der linken Seite eines case-Falles als Pattern Matching.
Das heißt, wenn wir den Ausdruck items 3 auswerten, führt die Funktion Pattern Matching durch, da überprüft wird, welcher der Fälle auf den Wert von quantity passt.
Die Konstrukte auf der linken Seite des Falles, also in diesem Fall 0, 1 und _ bezeichnet man als Pattern, also als Muster.
Der Ausdruck, über den wir eine Fallunterscheidung durchführen – in diesem Fall also die Variable quantity – wird als Scrutinee bezeichnet.
Dieses Wort bedeutet so viel wie “Der Geprüfte” und stammt vom Verb scrutinize (genau untersuchen, genau prüfen).
Wir nutzen Pattern Matching auf Zahlen hier als einfaches und intuitives Beispiel.
In vielen Fällen ist Pattern Matching für eine Funktion, die einen Int verarbeitet, keine gute Lösung, da nur auf einzelne konkrete Zahlen geprüft werden kann.
In der Funktion items landen negative Argumente zum Beispiel im dritten Fall, was nicht unbedingt gewünscht ist.
Daher sollte man zur Prüfung eines Wertes vom Typ Int in vielen Fällen einen if-Ausdruck nutzen.
Fallunterscheidungen können nicht nur für den Datentyp Int genutzt werden, sondern zum Beispiel auch für den Datentyp Bool.
Wir haben im Abschnitt Grunddatentypen gesehen, dass die booleschen Konstanten in Elm False und True heißen.
Wir können daher die boolesche Negation wie folgt definieren.
not : Bool -> Bool
not bool =
case bool of
False ->
True
True ->
False
Die Funktion not, die von Elm zur Verfügung gestellt wird, ist genau auf diese Weise definiert.
Als weiteres Beispiel wollen wir eine Fallunterscheidung über dem Typ String betrachten.
Die folgende Funktion erhält einen String, der eine Warenkategorie darstellt.
Die Funktion liefert einen lesbaren Namen für die Kategorie.
displayName : String -> String
displayName category =
case category of
"fruits" ->
"Früchte"
"vegetables" ->
"Gemüse"
_ ->
"Unbekannte Kategorie"
An dieser Stelle soll noch erwähnt werden, dass wir eine Fallunterscheidung nicht nur über den Wert einer Variable durchführen können, sondern über den Wert eines beliebigen Ausdrucks.
Als Beispiel nehmen wir an, dass wir unsere Daten aus einer Quelle mit niedriger Datenqualität beziehen.
Es kommt zum Beispiel vor, dass die Namen der Kategorien nicht immer klein geschrieben werden.
Wir nutzen daher die Funktion String.toLower : String -> String, welche alle Buchstaben in einem String in kleine Buchstaben umwandelt.
Mithilfe der Funktion String.toLower können wir die folgende robustere Variante der Funktion implementieren.
displayName : String -> String
displayName category =
case String.toLower category of
"fruits" ->
"Früchte"
"vegetables" ->
"Gemüse"
_ ->
"Unbekannte Kategorie"
Diese Variante der Funktion führt die Fallunterscheidung nicht über dem Wert der Variable category durch, sondern über das Ergebnis des Ausdrucks String.toLower category.
Wenn man eine Programmiersprache lernt, sieht man häufig nur bestimmte Formen von Beispielen.
Die meisten Beispiele für case-Ausdrücke in funktionalen Sprachen haben etwa eine Variable als Scrutinee.
Daher denken viele Studierende, dass der Scrutinee immer eine Variable sein muss.
Dieses Beispiel illustriert, dass man anhand von einzelnen Beispielen eine Programmiersprache nicht vollständig beherrschen kann.
Um wirklich zu verstehen, welche Formen von Programmen erlaubt sind, reichen daher einzelne Beispielprogramme nicht aus.
Um ein tieferes Verständnis für den Aufbau von Programmen zu erhalten, kann es daher hilfreich sein, sich eine Grammatik für die Sprache anzuschauen.
Im Folgenden ist ein Auszug aus einer Grammatik für Elm in Extended Backus-Naur Form angegeben.
expression = literal ;
| identifier ;
| expression expression ;
| "(" expression ")" ;
| expression operator expression ;
| "if" expression "then" expression "else" expression ;
| "case" expression "of" pattern "->" expression { pattern "->" expression } ;
| ...
Man kann an dieser Grammatik erkennen, dass die Scrutinee des case-Ausdrucks eine expression ist.
Außerdem kann man andeutungsweise erkennen, was in Elm ein Ausdruck ist, nämlich ein Literal, ein Bezeichner, eine Funktionsanwendung, ein geklammerter Ausdruck, die Anwendung eines Operators, ein if-Ausdruck, ein case-Ausdruck etc.
Das heißt, all diese Konstrukte können als Scrutinee verwendet werden.
Mit dem Begriff Literal bezeichnet man im Kontext der Syntax einer Programmiersprache einen festen Wert, der in einem Programm steht.
Das heißt, Ausdrücke wie 1, 3.14, 'a', "Hallo" oder False und True sind Literale.