In diesem Kapitel wollen wir die Grundlagen für die Definition von Datentypen in Elm einführen. Datentypen entsprechen Objekten in der objektorientierten Programmierung. In einer funktionalen Sprache sind Funktionen aber nicht an einen Datentyp gebunden, wie es bei Methoden der Fall ist.

Typsynonyme

In Elm kann ein neuer Typ eingeführt werden, indem ein neuer Name für einen bereits bestehenden Typ eingeführt 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
Info

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.

Wichtig

Ein Typsynonym wie Width ist eigentlich schlechter Programmierstil, da wir ein Typsynonym für einen einfachen Typ einführen.

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 zu Anfang aus didaktischen Gründen diese Form eines Typsynonyms nutzen, später dann aber darauf verzichten. Wir werden später sehen, wie wir diese Fehlnutzung besser verhindern können.

Aufzählungstypen

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
Info

In Haskell wird statt des Schlüsselwortes type das Schlüsselwort data verwendet, um einen Aufzählungstyp zu definieren.

Wichtig

Die Werte eines Aufzählungstyps nennt man Konstruktoren.

Das heißt, Left, Right, Up und Down sind die Konstruktoren des Datentyps Key.

Als weiteres Beispiel für einen Aufzählungstyp 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

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 zum Beispiel wie folgt eine Funktion für den Datentyp Key definieren, die testet, ob es sich um eine der horizontalen Richtungstasten handelt.

isHorizontal : Key -> Bool
isHorizontal key =
    case key of
        Left ->
            True

        Right ->
            True

        _ ->
            False

Die Fälle in einem case-Ausdruck werden von oben nach unten geprüft. Wenn wir zum Beispiel die Anwendung isHorizontal Left auswerten, so passt der erste Fall und wir erhalten True als Ergebnis. Werten wir dagegen isHorizontal Up aus, so passen die ersten beiden Fälle nicht. Der dritte Fall mit dem Unterstrich ist ein Default-Fall, die immer passt.

Wichtig

Der Unterstrich-Fall (Wildcard Case oder Underscore Case) darf nur als letzter Fall genutzt werden.

Da der Unterstrich-Fall für alle möglichen Werte passt, werden alle Fälle, die nach dem Unterstrich-Fall kommen, ignoriert.

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 isHorizontal Up auswerten, führt die Funktion Pattern Matching durch, da überprüft wird, welcher der Fälle auf den Wert von key passt.

Wichtig

Die Konstrukte auf der linken Seite des Falles, also in diesem Fall Left, Right und _ bezeichnet man als Pattern, also als Muster.

Der Ausdruck, über den wir eine Fallunterscheidung durchführen – in diesem Fall also die Variable key – wird als Scrutinee bezeichnet. Dieses Wort bedeutet so viel wie “Der Geprüfte” und stammt vom Verb scrutinize (genau untersuchen, genau prüfen).

Wir können die Funktion isHorizontal auch definieren, indem wir im Pattern Matching alle Konstruktoren aufzählen und auf den Unterstrich verzichten. Das heißt, die folgende Funktion verhält sich genau so wie die zuvor definierte Variante.

isHorizontal : Key -> Bool
isHorizontal 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 Variante mit dem Unterstrich zum Beispiel weiterhin funktionieren. Es könnte aber sein, dass das Default-Verhalten für den neu hinzugefügten Konstruktor gar nicht korrekt ist. Bei der Definition, bei der auf das Unterstrich-Pattern verzichtet wird, erhalten wir vom Elm-Compiler dagegen in diesem Fall einen Fehler, da einer der Fälle nicht abgedeckt ist. Wenn wir auf das Unterstrich-Pattern verzichten, können wir daher nach dem Hinzufügen eines Konstruktors zu einem Datentyp alle Fehlermeldungen des Compilers durchgehen, um das Verhalten für den neu definierten Konstruktor in allen Funktionen zu definieren. Diese Strategie, sich vom Compiler leiten zu lassen, ist in der funktionalen Programmierung verbreitet und wird in der wissenschaftlichen Publikation How Statically-Typed Functional Programmers Write Code als Compilers as Directive Tools bezeichnet.

Wichtig

Man sollte ein Unterstrich-Pattern nur verwenden, wenn man damit viele Fälle abdecken kann und somit den Code stark vereinfacht.

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.

Wir können Pattern Matching auch für den Datentyp String verwenden. 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 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"

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 erlernen 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.