layout | title |
---|---|
post |
Grundlagen |
In diesem Kapitel führen wir die Grundlagen der Programmiersprache Elm ein. Am Ende des Kapitels werden wir in der Lage sein, einfache Funktionen in Elm zu programmieren. Wir schaffen damit die Grundlagen, um anschließend im Abschnitt Eine Erste Anwendung zu lernen, wie wir eine einfache Elm–Frontend-Anwendung programmieren.
Zur Illustration der Beispiele verwenden wir das Kommando elm repl
.
Das Akronym REPL steht für Read Evaluate Print Loop und beschreibt eine textuelle, interaktive Eingabe, in der man einfache Programme eingeben (Read), die Ergebnisse des Programms ausrechnen (Evaluate) und das Ergebnis auf der Konsole ausgeben (Print) kann.
Mit dem Begriff Loop wird dabei ausgedrückt, dass dieser Vorgang wiederholt werden kann.
Wir werden die folgenden Programme immer in eine Datei mit der Endung elm
schreiben.
Um die Datei als Modul in der REPL importieren zu können, müssen wir den folgenden Kopf verwenden.
module Test exposing (..)
Die zwei Punkte in den Klammern beschreiben dabei, dass wir alle Definitionen im Modul Test
zur Verfügung stellen wollen.
Später werden wir in den Klammern explizit die Definitionen auflisten, die unser Modul nach außen zur Verfügung stellen soll.
Um unser Modul in der REPL nutzen zu können, müssen wir zuerst ein Elm-Projekt anlegen.
Zu diesem Zweck muss der Aufruf elm init
ausgeführt werden.
Das Kommando elm init
legt unter anderem eine Datei elm.json
an, die unsere Anwendung beschreibt.
In der elm.json
ist zum Beispiel angegeben, dass es sich um eine Anwendung und keine Bibliothek handelt, dass die Elm-Dateien im Ordner src
liegen und welche Pakete unsere Anwendung als Abhängigkeiten nutzt.
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}
Der Aufruf elm init
installiert Basispakete, die bei der Arbeit mit Elm zur Verfügung stehen.
Das Paket elm/core
stellt zum Beispiel grundlegende Datenstrukturen wie Listen und Funktionen darauf zur Verfügung und elm/html
stellt Kombinatoren zur Verfügung, um HTML-Seiten zu erzeugen.
{% include callout-important.html content="Unter https://package.elm-lang.org kann man die Dokumentationen zu den Elm-Paketen elm/core
, elm/html
und vielen anderen einsehen." %}
Wir legen die Datei mit unserem Modul im src
-Verzeichnis ab, das elm init
erstellt hat.
Wir können dann das Modul laden, indem wir import Test exposing (..)
in der REPL eingeben.
Die Punkte bedeuten dabei, dass wir alle Definitionen, die das Modul Test
zur Verfügung stellt, importieren wollen.
Später werden wir bei einem Import immer genau angeben, welche Definitionen wir importieren wollen.
Der folgende Ausschnitt demonstriert, wie man in Elm Kommentare schreibt.
-- This is a line comment
{-
This is a block comment
-}
Durch die folgende Angabe kann man in Elm eine Variable definieren.
secretNumber : Int
secretNumber =
42
Dabei gibt die erste Zeile den Typ der Variable an, in diesem Fall also ein Integer und die zweite und dritte Zeile ordnen der Variable einen Wert zu.
Wir nutzen hier um im Folgenden immer elm-format
um Elm-Programme zu formatieren, damit unsere Programme immer einheitlich formatiert sind.
Dieser Code Formatter sorgt dafür, dass der Wert der Variable in die nächste Zeile geschrieben wird.
Wir erhalten aber auch ein valides Elm-Programm, wenn wir stattdessen secretNumber = 42
schreiben.
Bei einer Definition wie secretNumber
bezeichnet man den Teil hinter dem =
-Zeichen als rechte Seite der Definition.
{% include callout-info.html content="In Haskell wird statt des einfachen Doppelpunktes :
der doppelte Doppelpunkt ::
verwendet, um den Typ einer Definition anzugeben." %}
In einer rein funktionalen Programmiersprache sind Variablen nicht veränderbar wie in einer imperativen Sprache, sondern sind lediglich Abkürzungen für komplexere Ausdrücke. In diesem Fall wird die Variable sogar nicht als Abkürzung verwendet, sondern nur, um dem Wert einen konkreten Namen zu geben und diesen an verschiedenen Stellen verwenden zu können. Das heißt, wenn wir die Zeile
secretNumber =
43
zu unserem Modul hinzufügen, erhalten wir einen Fehler, da wir die Variable nicht neu setzen können.
Wir haben den Datentyp Int
bereits kennengelernt.
Daneben gibt es noch die folgenden Grunddatentypen.
float : Float
float =
4.567
bool1 : Bool
bool1 =
True
bool2 : Bool
bool2 =
False
char1 : Char
char1 =
'a'
char2 : Char
char2 =
' '
{-| This comment illustrates how to attach a comment to a definition
-}
string : String
string =
"Hello World!"
Das heißt, im Unterschied zu JavaScript, unterscheidet Elm zwischen dem Typ Int
und dem Typ Float
.
Wenn ein Kommentar zu einer Definition geschrieben werden soll, muss ein sogenannter Doc-Kommentar verwendet werden.
Diese Art von Kommentar wird einer Definition zugeordnet.
Wenn wir Elm-Programme schreiben, werden wir das Programm elm-format
verwenden, um den Quellcode zu formatieren.
Auf diese Weise ersparen wir uns das manuelle Formatieren des Quellcodes.
Bei den Kommentaren, die wir bisher kennengelernt haben, wird durch elm-format
eine Leerzeile zwischen Kommentar und Definition hinzugefügt.
Da ein Doc-Kommentar sich auf eine Definition bezieht, fügt elm-format
zwischen den Kommentar This comment illustrates how to attach a comment to a definition
und die Definition von s
keine Leerzeile ein.
Wir haben gesagt, dass in einer funktionalen Sprache und damit auch in Elm ein Programm ausgeführt wird, indem der Wert eines Ausdrucks berechnet wird. Dies lässt sich sehr schön mithilfe von arithmetischen und booleschen Ausdrücken illustrieren. Wir müssen für einen Ausdruck in Elm keinen Typ angeben, da der Compiler in der Lage ist, den Typ selbst zu bestimmen. Man sagt, dass Elm den Typ inferiert und spricht von Typinferenz.
Die folgenden Definitionen zeigen einige Beispiele für arithmetische Ausdrücke.
arith1 =
1 + 2
arith2 =
19 - 25
arith3 =
2.35 * 2.3
arith4 =
2.5 / 23.2
Elm erlaubt es nicht, Zahlen unterschiedlicher Art zu kombinieren. So liefert die folgende Definition zum Beispiel einen Fehler.
typeError = secretNumber + float
Wir können Zahlen nur mit +
addieren, wenn sie den gleichen Typ haben.
Daher müssen wir Zahlen ggf. explizit konvertieren.
Um einmal zu illustrieren, dass Elm sich sehr viel Mühe bei Fehlermeldungen gibt, wollen wir uns den Fehler anschauen, den die REPL liefert, wenn wir versuchen, zwei Zahlen, die unterschiedliche Typen haben, zu addieren.
-- TYPE MISMATCH -------------------------------------------------- src/Test.elm
I need both sides of (+) to be the exact same type.
Both Int or both Float.
15| secretNumber + float
^^^^^^^^^^^^^^^^^^^^
But I see an Int on the left and a Float on the right.
Use toFloat on the left (or round on the right) to make both sides match!
Note: Read <https://elm-lang.org/0.19.1/implicit-casts> to learn why Elm does
not implicitly convert Ints to Floats.
Wir wollen uns also an den Rat halten und die Funktion toFloat
verwenden, um den Wert vom Typ Int
in einen Wert vom Typ Float
umzuwandeln.
Bisher haben wir nur gesehen, wie binäre Infixoperatoren, wie +
und *
verwendet werden.
{% include callout-important.html content="Um eine Funktion, wie toFloat
in Elm anzuwenden, schreiben wir den Namen der Funktion, dann ein Leerzeichen und dann das Argument, auf das wir die Funktion anwenden wollen." %}
Um den Wert der Variable secretNumber
also in einen Float
umzuwandeln, schreiben wir toFloat secretNumber
.
Dieser Ausdruck wendet die Funktion toFloat
auf das Argument secretNumber
an.
Im Unterschied zu vielen anderen Programmiersprachen, wie Java, C# oder JavaScript werden in Elm die Argumente einer Funktion/Methode nicht geklammert.
In JavaScript schreibt man zum Beispiel toFloat(secretNumber)
, um eine Funktion toFloat
auf ein Argument secretNumber
anzuwenden.
Wir werden im Kapitel Funktionen höherer Ordnung genauer lernen, welchen Hintergrund der Unterschied in der Schreibweise von Funktionsanwendungen hat.
Um unser konkretes Problem zu lösen und die Zahlen secretNumber
und float
zu addieren, können wir die folgende Definition nutzen.
Das Ergebnis dieser Addition ist dann wieder vom Typ Float
, das heißt, die Variable convert
hat den Typ Float
.
convert = toFloat secretNumber + float
Im Unterschied zu anderen Sprachen führt der Operator /
nur Divisionen
von Fließkommazahlen durch.
Das heißt, ein Ausdruck der Form
secretNumber / 10
liefert ebenfalls einen Typfehler.
Um zwei ganze
Zahlen zu dividieren, muss der Operator //
verwendet werden, der eine
ganzzahlige Division durchführt.
{% include callout-important.html content="Durch Elms Typinferenz müssen wir die Typen von Definitionen zwar nicht angeben, es ist aber guter Stil, die Typen dennoch explizit anzugeben." %}
Die Typangaben fungieren als eine Art überprüfte Dokumentation und helfen Leser*innen, sich schneller im Code zurechzufinden. Daher werden wir im folgenden bei allen Definitionen immer explizit Typen angeben. Im Vergleich zu Programmiersprachen wie Java müssen wir dennoch wesentlich weniger Stellen mit Typinformationen versehen, da wir uns durch die Typinferenz wiederholende Typangaben sparen können.
Elm stellt die üblichen booleschen Operatoren für Konjunktion und Disjunktion zur Verfügung.
Die Negation eines booleschen Ausdrucks wird in Elm durch eine Funktion not
durchgeführt.
bool3 : Bool
bool3 =
False || True
bool4 : Bool
bool4 =
not (bool1 && True)
Im Beispiel bool4
sehen wir auch gleich eine weitere Besonderheit bei der Funktionsanwendung in Elm.
{% include callout-important.html content="Während das Argument bei der Anwendung einer Funktion auf ein Argument an sich nicht geklammert wird, müssen wir das Argument aber klammern, wenn es sich selbst um das Ergebnis einer Anwendung handelt." %}
In diesem Beispiel wollen wir etwa das Ergebnis der Berechnung bool1 && True
negieren.
Daher klammern wir den Ausdruck bool1 && True
und übergeben so das Ergebnis dieser Berechnung an die Funktion not
.
Wir könnten auch (not bool1) && True
schreiben.
In diesem Fall würden wir aber das Ergebnis der Berechnung not bool1
als erstes Argument an &&
übergeben.
Neben den booleschen Operatoren gibt es die üblichen Vergleichsoperatoren ==
und /=
, so wie <
,
<=
, >
und >=
.
{% include callout-important.html content="
Die Funktion ==
führt immer einen Wert-Vergleich und keinen Referenz-Vergleich durch.
" %}
Das heißt, die Funktion ==
überprüft, ob die beiden Argumente die gleiche Struktur haben.
Das Konzept eines Referenz-Vergleichs existiert in einer rein funktionalen Sprache wie Elm nicht.
bool5 : Bool
bool5 =
'a' == 'a'
bool6 : Bool
bool6 =
16 /= 3
bool7 : Bool
bool7 =
5 > 3 && 'p' <= 'q'
bool8 : Bool
bool8 =
"Elm" > "C++"
{% include callout-important.html content="
Die Funktionen ==
und /=
stehen für jeden Datentyp zur Verfügung.
Die Funktionen <
, <=
, >
und >=
stehen dagegen nur für bestimmte Datentypen zur Verfügung.
" %}
Im Kapitel Spezielle Typvariablen wird dieser Aspekt im Detail diskutiert. Zum Verständnis werden aber Kenntnisse aus den Kapiteln zuvor benötigt.
Um einen Ausdruck der Form 3 + 4 * 8
nicht klammern zu müssen, definiert Elm für Operatoren Präzedenzen (Bindungsstärken).
Die Präzedenz eines Operators liegt zwischen 0 und 9.
Der Operator +
hat zum Beispiel die Präzedenz 6 und *
hat die Präzedenz 7.
Da die Präzedenz von *
also höher ist als die Präzedenz von +
bindet *
stärker als +
und der Ausdruck 3 + 4 * 8
steht für den Ausdruck 3 + (4 * 8)
.
Wie auch in anderen Programmiersprachen üblich binden die relationalen Operatoren, wie <
, <=
, >
, >=
, ==
und /=
stärker als die logischen Operatoren &&
und ||
.
Daher steht der Ausdruck 5 > 3 && 'p' <= 'q'
ohne Klammern für den Ausdruck (5 > 3) && ('p' <= 'q')
.
Wenn Code mit Operatoren mehrzeilig ist, formatiert elm-format
den Code so, dass die Operatoren am Beginn der jeweiligen Zeile stehen.
Das Beispiel bool7
formatiert elm-format
zum Beispiel wie folgt.
bool7 =
5
> 3
&& 'p'
<= 'q'
Wenn ein Ausdruck mit Operatoren so lang ist, dass er in mehrere Zeile geschrieben werden sollte, können wir explizit Klammern setzen, um eine etwas lesbarere Formatierung zu erhalten.
bool7 =
(5 > 3)
&& ('p' <= 'q')
{% include callout-important.html content="Die Präzedenz einer Funktion ist 10, das heißt, eine Funktionsanwendung bindet immer stärker als jeder Infixoperator." %}
Der Ausdruck not True || False
steht daher zum Beispiel für (not True) || False
und nicht etwa für not (True || False)
.
Wir werden später noch weitere Beispiele für diese Regel sehen.
Neben der Bindungsstärke wird bei Operatoren noch definiert, ob diese links- oder rechts-assoziativ sind. In Elm (wie in vielen anderen Sprachen) gibt es links- und rechts-assoziative Operatoren. Dies gibt an, wie ein Ausdruck der Form x ∘ y ∘ z interpretiert wird. Falls der Operator ∘ linksassoziativ ist, gilt x ∘ y ∘ z = (x ∘ y) ∘ z, falls er rechts-assoziativ ist, gilt x ∘ y ∘ z = x ∘ (y ∘ z). Das heißt, im Unterschied zur Bindungsstärke wird die Assoziativität genutzt, um auszudrücken, wie ein Ausdruck geklammert ist, wenn er mehrfach den gleichen Operator enthält. Im Kapitel Funktionen höherer Ordnung werden wir sehen, dass für einige Konzepte der Programmiersprache Elm, die Assoziativität eine entscheidende Rolle spielt.
In diesem Abschnitt wollen wir uns anschauen, wie man in Elm einfache Funktionen definieren kann. Funktionen sind in einer funktionalen Sprache das Gegenstück zu (statischen) Methoden in einer objektorientierten Sprache. Bevor wir uns die Definition von Funktionen anschauen, führen wir erst einmal ein paar Sprachkonstrukte ein, die wir in der Definition einer Funktion nutzen werden.
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.
Da Elm eine statisch getypte Programmiersprache ist -- das heißt, wenn wir das Programm übersetzen, wird eine Typprüfung durchgeführt -- müssen beide Zweige eines if
-Ausdrucks außerdem 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 einmal 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 Definition wie items
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 dieser 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 Regeln, 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 Ausdruck (String.fromInt quantity) ++ " Gegenstände"
.
Das heißt, wir hängen das Ergebnis des Aufrufs String.fromInt quantity
vorne an den String " Gegenstände"
.
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
.
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"
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 die erste Regel und wir erhalten "Kein Gegenstand"
als Ergebnis.
Werten wir dagegen items 3
aus, so passen die ersten beiden Regeln nicht.
Die dritte Regel mit dem Unterstrich ist eine Default-Regel, die immer passt und daher nur als letzte Regel genutzt werden darf.
Das heißt, wenn wir die Anwendung items 3
auswerten, wird anschließend der Ausdruck String.fromInt 3 ++ " Gegenstände"
ausgewertet.
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 einer case
-Regel als Pattern Matching.
Das heißt, wenn wir den Ausdruck items 3
auswerten, führt die Funktion Pattern Matching durch, da überprüft wird, welche der Regeln in der Funktion auf den Wert von quantity
passt.
Die Konstrukte auf der linken Seite der Regel, 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).
{% include callout-important.html content="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 nicht auf negative 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
einen if
-Ausdruck nutzen.
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. Das heißt, wir können auch die folgende nicht sehr sinnvolle Funktion definieren.
items : Int -> String
items quantity =
case quantity + 1 of
1 ->
"Keine Gegenstände"
2 ->
"1 Gegenstand"
_ ->
String.fromInt quantity ++ " Gegenstände"
Diese Funktion verhält sich genau so, wie die zuvor definierte Funktion.
Statt der Addition können wir für den Scrutinee einen beliebigen Ausdruck nutzen.
Zum Beispiel könnten wir auch eine Fallunterscheidung über das Ergebnis eines if
-Ausdrucks durchführen.
Wir werden später Anwendungsfälle kennenlernen, bei denen es sinnvoll ist, eine Fallunterscheidung über einen komplexen Ausdruck durchzuführen.
Wenn man eine Programmiersprache lernt, sieht man häufig nur bestimmte Formen von Beispielen.
Die meisten Beispiele für case
-Ausdrücke in Elm haben etwa eine Variable als Scrutinee.
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 } "}" ;
| "(" expression "," expression { "," expression } ")", ;
| "[" [ expression { "," expression } ] "]" ;
| "{" [ field_expression, { "," field_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.
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
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 einen Pfeil 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 pluralize
anzuwenden, schreiben wir ebenfalls die Argumente durch Leerzeichen getrennt hinter den Namen der Funktion.
Das heißt, der folgende Ausdruck wendet die Funktion pluralize
auf die Argumente "Gegenstand"
und "Gegenstände"
an.
pluralize "Gegenstand" "Gegenstände" 3
Wenn eines der Argumente der Funktion pluralize
das Ergebnis einer anderen Funktion sein soll, so muss diese Funktionsanwendung mit Klammern umschlossen werden.
So wendet der folgende Ausdruck die Funktion pluralize
auf "Gegenstand"
und "Gegenstände"
und die Summe von 1
und 2
an.
pluralize "Gegenstand" "Gegenstände" (1 + 2)
Diese Schreibweise stellt für viele Nutzer*innen, die Programmiersprachen wie Java gewöhnt sind, häufig eine große Hürde dar.
{% include callout-important.html content="Bei der Anwendung einer Funktion kann man sich anhand der Klammern und der Leerzeichen überlegen, wie viele Argumente man bei einer Funktionsanwendung an eine Funktion übergibt. Diese Anzahl kann man dann mit der Anzahl der Parameter der Funktion vergleichen." %}
Wir betrachten zum Beispiel die Anwendung pluralize "Gegenstand" "Gegenstände" 1 + 2
.
Nach der Leerzeichen- und Klammerregel erhält die Funktion pluralize
hier fünf Argumente, nämlich "Gegenstand"
, "Gegenstände"
, 1
, +
und 2
, denn diese Argumente sind alle durch Leerzeichen getrennt und keines der Argumente ist von Klammern umschlossen.
Die Funktion pluralize
soll aber nur drei Argumente erhalten, daher fehlen an dieser Stelle Klammern.
Wenn wir dagegen die Anwendung pluralize "Gegenstand" "Gegenstände" (1 + 2)
betrachten, dann werden drei Argumente an pluralize
übergeben, nämlich "Gegenstand"
, "Gegenstände"
und (1 + 2)
.
In diesem Abschnitt wollen wir die Verwendung einiger einfacher Datentypen vorstellen, die wir zur Implementierung unserer ersten Anwendung benötigen.
In Elm kann ein neuer Typ eingeführt werden, indem ein neuer Name für einen bereits bestehenden Typ definiert wird.
Der folgende Code führt zum Beispiel den Namen Width
als Synonym für den Typ Int
ein.
Das heißt, an allen Stellen, an denen wir den Typ Int
verwenden können, können wir auch den Typ Width
verwenden.
type alias Width =
Int
{% include callout-info.html content="In Haskell wird statt der Schlüsselwörter type alias
nur das Schlüsselwort type
verwendet, um ein Typsynonym zu definieren." %}
Ein Typsynonym wird verwendet, um einem komplexen Typ einen kürzeren Namen zu geben. Wir werden diesen Effekt sehen, wenn wir Recordtypen kennenlernen.
{% include callout-important.html content="Ein Typsynonym wie Width
ist eigentlich schlechter Programmierstil, da wir ein Typsynonym für einen einfachen Typ einführen.
Wir werden zu Anfang aus didaktischen Gründen diese Form eines Typsynonyms nutzen, später dann aber darauf verzichten." %}
Bei dieser Modellierung können wir weiterhin jeden Wert vom Typ Int
als Width
verwenden, auch wenn es sich gar nicht um eine Breite handelt.
Wir werden später sehen, wie wir diese Fehlnutzung besser verhindern können.
Wie andere Programmiersprachen stellt Elm Aufzählungstypen (Enumerations) zur Verfügung. So kann man zum Beispiel wie folgt einen Datentyp definieren, der die Richtungstasten der Tastatur modelliert.
type Key
= Left
| Right
| Up
| Down
{% include callout-info.html content="In Haskell wird statt des Schlüsselwortes type
das Schlüsselwort data
verwendet, um einen Aufzählungstyp zu definieren." %}
Wir können für den Datentyp Key
Funktionen mithilfe von Pattern Matching definieren.
Bei den einzelnen Werten des Typs spricht man auch von Konstruktoren.
Das heißt, Left
und Up
sind zum Beispiel Konstruktoren des Datentyps Key
.
Die folgende Funktion verwendet Pattern Matching um zu testen, ob es sich um eine der horizontalen Richtungstasten handelt.
isHorizontal : Key -> Bool
isHorizontal key =
case key of
Left ->
True
Right ->
True
_ ->
False
Der Unterstrich ist ein Default-Fall, der für alle Konstruktoren von Key
passt.
Das heißt, der Fall mit dem Unterstrich (Underscore Pattern) passt für alle möglichen Fälle, die key
noch annehmen kann.
Im Fall der Funktion isHorizontal
wird der Unterstrichfall zum Beispiel verwendet, wenn key
den Wert Up
oder den Wert Down
hat.
Wir können diese Funktion auch definieren, indem wir im Pattern Matching alle Konstruktoren aufzählen und auf den Unterstrich verzichten.
Das heißt, die folgende Funktion isHorizontalComplete
verhält sich genau so wie die Funktion isHorizontal
.
isHorizontalComplete : Key -> Bool
isHorizontalComplete key =
case key of
Left ->
True
Right ->
True
Up ->
False
Down ->
False
Die Verwendung des Unterstrichs ist zwar praktisch, sollte aber mit Bedacht eingesetzt werden.
Wenn wir einen weiteren Konstruktor zum Datentyp Key
hinzufügen, würde die Funktion isHorizontal
zum Beispiel weiterhin funktionieren.
Bei der Definition isHorizontalComplete
erhalten wir vom Elm-Compiler dagegen in diesem Fall einen Fehler, da einer der Fälle nicht abgedeckt ist.
Das heißt, wir erhalten einen Fehler zur Kompilierzeit, also bevor der Nutzer die Anwendung verwendet.
Es ist besser, wenn der Compiler einen Fehler liefert, da sich sonst, ohne dass wir es bemerken, Fehler in der Anwendung einschleichen können, die schwer zu finden sind.
{% include callout-important.html content="Man sollte ein Unterstrich-Pattern nur verwenden, wenn man damit viele Fälle abdecken kann und somit den Code stark vereinfacht." %}
Ein Beispiel ist etwa die Funktion items
, die wir mithilfe von Pattern Matching definiert haben.
In dieser Funktion müssen wir einen Unterstrich verwenden, da es zu viele mögliche Werte des Typs Int
gibt, um sie alle explizit aufzuzählen.
Im Fall von isHorizontal
sparen wir durch den Unterstrich aber nur eine einzige Regel.
In solchen Fällen sollte man auf den Unterstrich verzichten und lieber alle Fälle explizit auflisten.
Als weiteres Beispiel für Pattern Matching betrachten wir einen Datentyp für Monate, der im Elm-Paket elm-time verwendet wird.
type Month
= Jan
| Feb
| Mar
| Apr
| May
| Jun
| Jul
| Aug
| Sep
| Oct
| Nov
| Dec
Wenn wir mit dem Paket elm-time arbeiten, können wir wie folgt eine Funktion definieren, die für einen Monat einen für deutsche Nutzer*innen lesbaren Namen liefert.
monthToString : Month -> String
monthToString month =
case month of
Jan ->
"Januar"
Feb ->
"Februar"
Mar ->
"März"
Apr ->
"April"
May ->
"Mai"
Jun ->
"Juni"
Jul ->
"Juli"
Aug ->
"August"
Sep ->
"September"
Oct ->
"Oktober"
Nov ->
"November"
Dec ->
"Dezember"
Da Elm als JavaScript-Ersatz gedacht ist, unterstützt es auch Recordtypen. Wir können zum Beispiel eine Funktion, die für einen Nutzer testet, ob er volljährig ist, wie folgt definieren.
hasFullAge : { firstName : String, lastName : String, age : Int } -> Bool
hasFullAge user =
user.age >= 18
Diese Funktion erhält einen Record mit dem Feldern firstName
, lastName
und age
als Argument und liefert einen Wert vom Typ Bool
.
Im Record haben die Felder firstName
und lastName
Einträge vom Typ String
und das Feld age
hat einen Eintrag vom Typ Int
.
Der Ausdruck user.age
ist eine Kurzform für .age user
, das heißt, .age
ist eine Funktion, die einen entsprechenden Record erhält und einen Wert vom Typ Int
, nämlich das Alter zurückliefert.
Man nennt eine Funktion wie .age
einen Record-Selektor, da die Funktion aus einem Record einen Teil selektiert.
Das heißt, hinter dem Ausdruck user.age
steht eigentlich auch nur eine Funktionsanwendung, nur dass es eine etwas vereinfachte Syntax für diesen Aufruf gibt, die näher an der Syntax ist, die wir aus anderen Sprachen gewohnt sind.
Es ist recht umständlich, den Typ des Nutzers in einem Programm bei jeder Funktion explizit anzugeben. Um unser Beispiel leserlicher zu gestalten, können wir das folgende Typsynonym für unseren Recordtyp einführen.
type alias User =
{ firstName : String
, lastName : String
, age : Int
}
hasFullAge : User -> Bool
hasFullAge user =
user.age >= 18
Das heißt, wir führen den Namen User
als Kurzschreibweise für einen Record ein und nutzen diesen Typ dann an allen Stellen, an denen wir zuvor den ausführlichen Recordtyp genutzt hätten.
Es gibt eine spezielle Syntax, um initial einen Record zu erzeugen.
exampleUser : User
exampleUser =
{ firstName = "Max", lastName = "Mustermann", age = 42 }
Wir können einen Record natürlich auch abändern.
Zu diesem Zweck wird die folgende Update-Syntax verwendet.
Die Funktion maturing
erhält einen Record in der Variable user
und liefert einen Record zurück, bei dem die Felder firstName
und lastName
die gleichen Einträge haben wie user
, das Feld age
ist beim Ergebnis-Record aber auf den Wert 18
gesetzt.
maturing : User -> User
maturing user =
{ user | age = 18 }
{% include callout-important.html content="Da Elm eine rein funktionale Programmiersprache ist, wird hier der Record nicht wirklich abgeändert, sondern ein neuer Record mit anderen Werten erstellt." %}
Das heißt, die Funktion maturing
erstellt einen neuen Record, dessen Einträge firstName
und lastName
die gleichen Werte haben wie die entsprechenden Einträge von user
und dessen Eintrag age
auf 18
gesetzt ist.
Dieses Beispiel demonstriert eine sehr einfache Form von deklarativer Programmierung.
In einem sehr imperativen Ansatz, müssten wir den Code, um den neuen Record zu erzeugen und die Felder firstName
und lastName
zu kopieren, explizit schreiben.
In einem deklarativeren Ansatz verwenden wir stattdessen eine spezielle Syntax oder eine vordefinierte Funktion, um das gleiche Ziel zu erreichen.
Wir können das Verändern eines Recordeintrags und das Lesen eines Eintrags natürlich auch kombinieren. Wir können zum Beispiel die folgende Definition verwenden, um einen Benutzer altern zu lassen.
increaseAge : User -> User
increaseAge user =
{ user | age = user.age + 1 }
Es ist auch möglich, mehrere Felder auf einmal abzuändern, wie die folgende Funktion illustriert.
japanese : User -> User
japanese user =
{ user | firstName = user.lastName, lastName = user.firstName }
Zu guter Letzt können wir auch Pattern Matching verwenden, um auf die Felder eines Records zuzugreifen. Zu diesem Zweck müssen wir die Variablen im Pattern nennen wie die Felder des entsprechenden Recordtyps.
fullName : User -> String
fullName { firstName, lastName } =
firstName ++ " " ++ lastName
Wir müssen dabei nicht auf alle Felder des Records Pattern Matching machen, es ist auch möglich, nur einige Felder aufzuführen. Das heißt, auch die folgende Definition ist erlaubt.
firstNames : User -> List String
firstNames { firstName } =
List.words firstName
Pattern Matching auf Records eignet sich sehr gut, wenn wir die Felder des Records nur lesen möchten.
Durch das Pattern Matching können wir den Code kürzen, da die Verwendung der Record-Selektoren länger ist.
Außerdem kann es sehr sinnvoll sein, Pattern Matching auf einem Record zu verwenden, wenn es schwierig ist, für den gesamten Record einen sinnvollen Namen zu vergeben.
Ein solches Beispiel werden wir zum Beispiel weiter unten bei der Funktion rotate
kennenlernen.
Wenn wir für einen Record ein Typsynonym einführen, gibt es eine Kurzschreibweise, um einen Record zu erstellen.
Um einen Wert vom Typ User
zu erstellen, können wir zum Beispiel auch User "John" "Doe" 20
schreiben.
Dabei gibt die Reihenfolge der Felder in der Definition des Records an, in welcher Reihenfolge die Argumente übergeben werden.
Wir werden im Kapitel Funktionen höherer Ordnung sehen, dass diese Art der Konstruktion bei der Verwendung einer partiellen Applikation praktisch ist.
Diese Konstruktion eines Records hat allerdings den Nachteil, dass in der Definition des Records die Reihenfolge der Einträge nicht ohne Weiteres geändert werden kann, da dadurch unser Programm ggf. nicht mehr kompilieren würde.
An dieser Stelle soll noch kurz ein interessanter Anwendungsfall für Records erwähnt werden.
Einige Programmiersprachen bieten benannte Argumente als Sprachfeature.
Das heißt, Argumente einer Funktion bzw. Methode können einen Namen erhalten, um Entwickler*innen beim Aufruf der Methode klarzumachen, welche Semantik die einzelnen Argumente haben.
Wir betrachten als Beispiel die folgende Funktion, die genutzt werden kann, um das transform
-Attribut in einer SVG-Graphik zu setzen.
rotate : String -> String -> String -> String
rotate angle x y =
"rotate(" ++ angle ++ "," ++ x ++ "," ++ y ++ ")"
Wir können diese Funktion nun zum Beispiel mittels rotate "50" "60" "10"
aufrufen.
Um bei diesem Aufruf herauszufinden, welches der Argumente welche Bedeutung hat, müssen wir uns die Funktion rotate
anschauen.
In einer Programmiersprache mit benannten Argumenten, können wir den Argumenten einer Funktion/Methode Namen geben und diese beim Aufruf nutzen.
In einer Programmiersprache mit Records können wir diese Funktionalität mithilfe eines Records nachstellen.
Wir können die Funktion rotate
zum Beispiel wie folgt definieren.
rotate : { angle : String, x : String, y : String } -> String
rotate { angle, x, y } =
"rotate(" ++ angle ++ "," ++ x ++ "," ++ y ++ ")"
Wenn wir die Funktion rotate
nun aufrufen, nutzen wir rotate { angle = "50", x = "60", y = "10" }
und sehen am Argument der Funktion direkt, welche Semantik die verschiedenen Parameter haben.
Wir können die Struktur der Funktion rotate
noch weiter verbessern.
Zuerst können wir observieren, dass die Argumente der Funktion rotate
nicht alle gleichberechtigt sind.
Anders ausdrückt gehören die Argumente x
und y
der Funktion stärker zusammen, da sie gemeinsam einen Punkt bilden.
Diese Eigenschaft können wir in unserem Code wie folgt explizit darstellen.
type alias Point =
{ x : String, y : String }
rotate : { angle : String, origin : Point } -> String
rotate { angle, origin } =
"rotate(" ++ angle ++ "," ++ origin.x ++ "," ++ origin.y ++ ")"
Wir können diese Implementierung aber noch in einem weiteren Aspekt verbessern.
Aktuell arbeitet unsere Anwendung mit Werten vom Typ String
.
Das heißt, wir können auch "a"
als Winkel an die Funktion rotate
übergeben und müssen dann erst observieren, dass die Anwendung nicht das gewünschte Ergebnis anzeigt.
Um eine offensichtlich falsche Verwendung wie diese zu verhindern, können wir statt des Typs String
einen Datentyp mit mehr Struktur nutzen.
type alias Point =
{ x : Float, y : Float }
rotate : { angle : Float, origin : Point } -> String
rotate { angle, origin } =
"rotate("
++ String.fromFloat angle
++ ","
++ String.fromFloat origin.x
++ ","
++ String.fromFloat origin.y
++ ")"
Wenn wir nun versuchen würden, den String
"a"
als Winkel an die Funktion rotate
zu übergeben, würden wir direkt beim Übersetzen des Codes einen Fehler vom Compiler erhalten.
Grundsätzlich sind Fehler zur Kompilierzeit (Compile Time) besser als Fehler zur Laufzeit (Run Time), da Fehler zur Kompilierzeit nicht bei Kund*innen auftreten können.
Elm stellt einen vordefinierten Datentyp für Listen zur Verfügung.
Wir werden hier die Details dieses Datentyps erst einmal ignorieren und uns vor allem damit beschäftigen, wie man eine Liste konstruiert.
Der Datentyp heißt List
und erhält nach einem Leerzeichen den Typ der Elemente in der Liste.
Das heißt, wir nutzen den Typ List Int
für eine Liste von Zahlen.
{% include callout-info.html content="In Haskell nutzt der Listendatentyp eine spezielle Syntax und statt List Int
schreiben wir [Int]
." %}
Listen werden in Elm mit eckigen Klammern konstruiert und die Elemente der Liste werden durch Kommata getrennt. Das heißt, die folgende Definition enthält eine konstante Liste, welche die ersten fünf ganzen Zahlen enthält.
list : List Int
list =
[ 1, 2, 3, 4, 5 ]
Eine leere Liste stellt man einfach durch zwei eckige Klammern dar, also als []
.
Der Infixoperator ::
hängt vorne an eine Liste ein zusätzliches Element an.
Das heißt, der Ausdruck 1 :: [ 2, 3 ]
liefert die Liste [ 1, 2, 3 ]
.
{% include callout-info.html content="In Haskell wird statt des doppelten Doppelpunktes ::
der einfache Doppelpunkt :
für die Konstruktion einer Liste verwendet." %}
Der Infixoperator ++
hängt zwei Listen hintereinander.
Das heißt, der Ausdruck [ 1, 2 ] ++ [ 3, 4 ]
liefert die Liste [ 1, 2, 3, 4 ]
.
Dabei ist immer zu beachten, dass in einer funktionalen Programmiersprache Datenstrukturen nicht verändert werden.
Das heißt, der Operator ::
liefert eine neue Liste und verändert nicht etwa sein Argument.
Listen können häufig genutzt werden, um repetitiven Code besser zu strukturieren.
Als Beispiel betrachten wir die Verwendung der Funktion String.concat : [String] -> String
.
Diese Funktion erhält eine Liste von String
s und hängt diese alle aneinander.
Wir können diese Funktion zum Beispiel wie folgt nutzen, um die Definition von rotate
erweiterbarer zu gestalten.
rotate : { angle : Float, origin : Point } -> String
rotate { angle, origin } =
String.concat
[ "rotate("
, String.fromFloat angle
, ","
, String.fromFloat origin.x
, ","
, String.fromFloat origin.y
, ")"
]
In Elm wird grundsätzlich Caml Case verwendet. In der funktionalen Programmierung ist es nicht unüblich kurze Bezeichner für Variablen zu verwenden. Der folgende Code-Ausschnitt stammt zum Beispiel von der offiziellen Seite zur Programmiersprache Haskell.
primes = filterPrime [2..] where
filterPrime (p:xs) =
p : filterPrime [x | x <- xs, x `mod` p /= 0]
Hier werden die Variablennamen p
, xs
und x
verwendet.
Das s
im Namen xs
ist dabei die Pluralbildung in der englischen Sprache.
Das heißt, eine Variable xs
enthält normalerweise eine Datenstruktur, die mehrere x
enthält.
Im Fall von Haskell wird mit xs
in den meisten Fällen eine Liste bezeichnet.
Im Unterschied zu Haskell, ist im offiziellen Elm Style Guide die folgende Aussage zu finden.
Be Descriptive. One character abbreviations are rarely acceptable, especially not as arguments for top-level function declarations where you have no real context about what they are.
Das heißt, Elm versucht explizit längere Variablennamen zu fördern.
Unabhängig davon sollte man bei der Benennung die Größe des Gültigkeitsbereichs (Scope) einer Variable beachten.
Das heißt, bei einer Variable, die einen sehr kleinen Scope hat, kann ein Name wie x
angemessen sein, während er es bei einer Variable mit größerem Scope auf jeden Fall nicht ist.
Für Hilfsfunktionen nutzt man in Haskell gern den Suffix '
.
Das heißt, wenn der Name primes
schon vergeben ist, nutzt man primes'
.
{% include callout-info.html content="
In Elm ist das Zeichen '
als Bestandteil von Bezeichnern nicht erlaubt.
Stattdessen nutzt man den Unterstrich, das heißt, man nutzt Namen wir primes_
.
" %}
An dieser Stelle soll es noch ein paar grundlegende Hinweise zu guter Benennung geben, die auch in anderen Programmiersprachen gültig sind. Benennungen von Variablen sollten concise und consistent sein1.
Mit konsistent (consistent) ist dabei gemeint, dass identische Konzepte im Programm auch identisch benannt sein sollten.
Das heißt zum Beispiel, wenn in der Funktion rotate
der Winkel als angle
bezeichnet wird, sollte diese Bezeichnung auch an anderen Stellen im Programm verwendet werden.
Es wäre also zum Beispiel keine gute Idee, den Winkel an einer Stelle mit angle
zu bezeichnen und an einer anderen Stelle mit rotationAngle
.
Um das Konzept der conciseness zu beschreiben, fordern wir erst einmal, dass Bezeichnungen korrekt sind.
Damit ist gemeint, dass der Name zumindest ein Oberbegriff des Konzeptes ist, den es beschreibt.
Zum Beispiel wäre der folgende Funktionskopf korrekt, da das Argument der Funktion rotate
den Mittelpunkt des Objektes beschreibt und der Begriff Punkt ein Oberbegriff von Ursprung ist.
Wenn wir dagegen den Bezeichner color
an Stelle von point
wählen würden, wäre dieser nicht korrekt, da Farbe kein Oberbegriff des Konzeptes ist, auf das sich der Bezeichner bezieht.
rotate : { angle : Float, point : Point } -> String
Der Bezeichner point
ist zwar korrekt, aber vermutlich nicht präzise (concise).
Wenn wir in unserer Anwendung neben dem Mittelpunkt noch eine weitere Art Punkt nutzen und beide mit dem Bezeichner point
bezeichnen, so ist der Bezeichner nicht mehr präzise, da wir aus dem Bezeichner nicht ableiten können, welches der beiden Konzepte gemeint ist.
Das heißt, wir versuchen bei der Benennung einen Namen zu wählen, der im Kontext der Anwendung möglichst eindeutig bestimmt, welches Konzept wir meinen.
Footnotes
-
Concise and consistent naming - Software Quality Journal 14 (2006): 261-282. ↩