3. Zusammengesetzte Datentypen

3.4 Custom Types mit Ladegut

Gehen wir wieder zurück zu unsem Modul Resident.elm. Unser Auftraggeber legt fest, dass grundsätzlich bei Personen, die verheiratet oder in einer Lebenspartnerschaft sind, dass Datum der Eheschließung mit erfasst werden soll. Wie gehen wir das an? Hier erst einmal der schlechte Weg:

type alias Resident =
    { name : String
    , dateOfBirth : Date
    , maritalStatus : MaritalStatus
    , dateOfMarriage : Date
    }


type MaritalStatus
    = Single
    | Married
    | Divorced
    | Widowed
    | CivilUnion                        
                    

Das ist schon einmal unschön, weil jetzt für jeden Resident ein Feld dateOfMarriage eingetragen werden muss, und sei es der 1. Januar. 0000. Hier kommt das Konzept der Payload (Ladegut) zur Hilfe. Wir können den einzelnen Alternativen eines Custom-Typs noch weitere Werte beiordnen, also zum Beispiel festlegen, dass der Wert Married einen Wert vom Typ Date "tragen" muss. Hierfür ändern wir die Definition von MaritalStatus:

type MaritalStatus
    = Single
    | Married Date
    | Divorced
    | Widowed
    | CivilUnion Date

Der Elm-Compiler weiß jetzt: die Konstante Single ist ein vollwertiges Objekt vom Typ MaritalStatus, aber Married ist es nicht. Es braucht erst ein Datum, um zu einem zu werden:

import Resident exposing (..)
mdate = {year = 2011, month = 11, day = 11}                        
Married mdate
Married { day = 11, month = 11, year = 2011 } : MaritalStatus

Sie sehen: Married { day = 11, month = 11, year = 2011 } ist ein Objekt vom Typ MaritalStatus. Das Datum { day = 11, month = 11, year = 2011 } ist die Payload.

Die Payload auffangen und verarbeiten: Dekonstruktion

Wenn wir jetzt Code schreiben, der einen solchen Custom-Typ verarbeitet, dann müssen wir eventuell an die Payload rankommen. Dafür gibt es in Elm den Pattern-Matching-Mechanismus, insbesondere in einem case-Ausdruck. Tun wir so, als müsste unsere Funktion computeTaxRate auf das Hochzeitsdatum achten und schauen, ob das in diesem Jahr war oder weiter zurückliegt. Dann sähe das so aus:

computeTaxRate : Resident -> BunchOfOtherData -> Float
computeTaxRate resident otherData =
    case resident.maritalStatus of
        Single ->
            0.3

        Married date->
            if date.year < currentYear then 
                ... 
            else 
                ... 

        Divorced ->
            0.3

        Widowed ->
            0.3

        CivilUnion date ->
            if date.year < currentYear then 
                ... 
            else 
                ...                                     
                    

Die Zeile 821 wird sie vielleicht verwirren. Hier findet Pattern-Matching statt. Die Bedeutung ist: wenn das Objekt date von der Form Married ... ist, dann wissen wir ja, dass es eine Payload trägt, und zwar vom Typ Date. Weise diese Payload dem Bezeichner date zu. In Zeile 8212 wird also nebenbei ein neuer Bezeichner eingeführt und mit einem Wert belegt. Ich sage hier, die Payload der Married-Alternative wird hier "aufgefangen". Der technische Ausdruck ist Dekonstruktion. Das MaritalStatus-Objekt wird hier dekonstruiert, also auseinandergenommen.

Ich gebe Ihnen noch ein weiteres Beispiel, das etwas weniger natürlich, dafür aber einfacher und umfassend ist. Sie wollen eine App für Todo-Listen schreiben. Jeder Todo-Task hat neben Inhaltsbeschreibung etc. eine Deadline, bis wann er erledigt sein muss. Halt: nicht jeder! Und bei manchen steht nur ein Tag, bei anderen aber auch eine Uhrzeit. Der Datentyp könnte dann so aussehen:

type Deadline
    = NoDeadline
    | UntilDay Date
    | UntilTime Date Daytime

Jede Alternative hat unterschiedliche Payloads. Dies ist sehr geeignet, um beispielsweise verschiedene Phasen in einer App zu repräsentieren. Zum Beispiel "User hat sich eingeloggt" / "User ist dem Spiel X beigetreten" / "das Spiel X läuft jetzt" und so weiter. Gewisse Felder, wie zum Beispiel das Spiel X, existieren für einen User, der sich gerade eingeloggt hat, noch gar nicht.

Ein weiterer Vorteil davon ist, dass bereits das Typensystem es unmöglich macht, logisch unsinnige Zustände zu erreichen. So kann es gar keine Objekte vom Typ Deadline geben, die eine Daytime spezifizieren aber kein Date. Schreiben wir jetzt eine Funktion alreadyTooLate, die nachsieht, ob die Deadline einer Aufgabe bereits vorbei ist:

alreadyTooLate : Deadline -> Date -> Daytime
alreadyTooLate deadline currentDate currentTime =
    case deadline of
        NoDeadline ->
            -- keine Deadline, also nie zu spät
            False

        UntilDay date ->
            compareDate currentDate date > 0

        UntilTimeDateDaytime date time ->
            (compareDate currentDate date > 0)
                || ((currentDate == date)
                        && (compareTime currentTime time > 0)
                   )                        
                    

Beachten Sie die drei Fallunterscheidungen in den Zeilen 15, 19 und 22. In Zeile 19 fängt der Bezeichner date die Payload von UntilDay auf; in Zeile 22 gibt es zwei Payloads aufzufangen (Datum und Uhrzeit), daher auch zwei neue Bezeichner: date und time. Die in Zeile 22 aufgefangen Bezeichner date und time sind dann wie ganz normale Variable verwendbar in dem Block, der unterhalb dieser Fallunterscheidung kommt, also in den Zeilen 23-26.

Übung: Temperatur, Material, Aggregatszustand

Übungsaufgabe

Legen Sie in H:\PP\elm\src\ eine Datei Temperature.elm an. Definieren Sie darin einen Typ Temperature mit den Alternativen Celsius, Kelvin und Fahrenheit. Jede Alternative hat eine Payload: die Temperatur als Float.

Schreiben Sie eine Funktionen compareTemperature : Temperature -> Temperature -> Float. Die soll negativ sein, wenn der erste Parameter kälter ist als der zweite; positive wenn wärmer; 0 wenn gleich warm.

Übungsaufgabe Legen Sie eine Datei Material.elm an. Definieren Sie dort einen CustomTyp Material mit den Alternativen Water, Iron und Gold (Sie dürfen auch noch mehr Materialien verwenden). Einen weiteren Typ StateOfMatter mit den Alternativen Solid, Liquid, Gaseous.

Schreiben Sie nun eine Funktion getStateOfMaterial : Temperature -> Material -> StateOfMatter, die den passenden Aggregatszustand berechnet (bei normalem Luftdruck):

getStateOfMaterial (Celsius 200) Water                            
Gaseous : StateOfMatter
getStateOfMaterial (Kelvin 400) Gold
Solid : StateOfMatter
getStateOfMaterial (Fahrenheit 110) Water
Liquid : StateOfMatter

Datenkonsistenz, Creator-Funktionen und Getter-Funktionen

Wenn Sie eine Web-App schreiben, wo Kunden Kalenderdaten eingeben können, dann müssen Sie damit rechnen, dass fehlerhafte eingaben vorliegen (beispielsweise rief Die Partei zum Beispiel rief im März 2020 unter dem Motto Hand in Hand gegen das Virus zu einer Menschenkette gegen Corona am 30. Februar 2020 auf). Wie würden Sie damit umgehen? Die erste Möglichkeit ist, eine Funktion makeDate zu schreiben, die Tag, Monat und Jahr als Eingabe liest und dann ein Tupel (Date, Bool) zurückgibt, wobei der zweite Eintrag uns mitteilt, ob das Format korrekt war. Also

createDate 2023 9 21                        
({day = 21, month = 9, year = 2023}, True) : 
    ({day : Int, month : Int, year : Int}, Bool)
createDate 2023 2 29
({day = 29, month = 2, year = 2023}, False)

Das wäre zum Beispiel eine typische Lösung in C. Ein Problem ist, dass nachlässige Mitglieder Ihres Teams Ihren Code immer noch falsch verwenden könnten und zum Beispiel ungeachtet des Wertes False mit dem 29. Februar 2023 weiterrechnen. Oder dass auf Anderem Wege das unsinnige Objekt ({day = -1, month = 2000, year = -5}, True) erstellt wird.

Übungsaufgabe Implementieren Sie die Funktion createDate mit der oben skizzierten Funktonalität.

Unser Ziel für diesen Abschnitt: garantieren, dass jedes Objekt vom Typ Date konsistent ist, also ein legitimes Kalenderdatum repräsentiert.

Fehlerbehandlung mit Custom-Typen

Fehler wie oben mit einem Zusatzparameter darzustellen, ist natürlich möglich, aber nicht zu empfehlen. Die bessere Lösung ist, einen Custom-Typ zu definieren, der Fehler und Erfolg umfasst:

module DateVerifyFormat exposing (..)

import Date exposing (..)


type DateOrWrongFormat
    = WrongFormat
    | CorrectFormat Date


createDate : Int -> Int -> Int -> DateOrWrongFormat
createDate year month day =
    if
        (month <= 0)
            || (month > 12)
            || (day <= 0)
            || (day > daysInMonth year month)
    then
        WrongFormat

    else
        CorrectFormat { year = year, month = month, day = day }

Aussagekräftige Fehlermeldungen: Nichts ist grauenhafter, als wenn ein Stück Software eine Fehlermeldung der Form Error 918: something went wrong ausgibt, wie hier zum Beispiel die Steuer-Webapp Elster:

Mein Fehler hier: meine Version von OSX und Safari war für Elster zu alt. Ich musste erst ein Update durchführen. Die Fehlermeldung von Elster ist aber ein grober Fehler der Software-Entwickler von Elster: die Seite hätte eine Meldung wiwe Nur kompatibel mit Safari Version 16 oder höher. Klicken Sie hier, um alle Systemvoraussetzung zu sehen. Klären Sie im Fehlerfall also Ihre Nutzer (und damit schließe ich auch Entwickler mit ein, die Ihren Code weiterverwenden) auf, was schiefgelaufen ist, und begnügen Sie sich nicht mit Clientfehler.

Übungsaufgabe Ändern Sie die Typendefinition von DateOrWrongFormat und die Implementierung von createDate, so dass im Fehlerfall das Ergebnis Aufschluss darüber gibt, was falsch war, zum Beispiel "day 31 is too large for month 6".

Wenn jetzt alle Entwickler, die auf Ihrem Code aufbauen, hoch und heilig schwören, aus User-Eingaben ausschließlich mit Hilfe von createDate ein Date zu erschaffen, dann ist alles gut. Aber wenn jemand doch etwas schreibt wie date = {year = userInputYear, month = userInputMonth, day = userInputDay}, dann können nach wie vor inkonsistente Daten entstehen.

Opake Datentypen

Der erste Schritt ist, die Definition von Date selbst zu ändern und in einen Union-Typ abzuändern:

type Date
    = TheDate Int Int Int 

Hier ist Date also ein Custom-Typ, der nur eine Alternative hat, nämlich TheDate, die wiederum drei Variable als Payload trägt: Tag, Jahr und Monat. Seltsam, einen Custom-Typ mit nur einer Alternative zu definieren, aber möglich und nützlich. Die Alternative TheDate, die ja keine Konstante ist, sondern erst durch Zugabe der richtigen Payload zu einem Date-Objekt wird, nennt man Konstruktor oder Datenkonstruktor. Weil er eben Daten konstruiert.

Als nächtes bauen wir unsere Funktion createDate. Ich implementiere die jetzt mit fehlerhafter Logik, weil ich zu faul bin, aber im Moment geht es um das Typensystem von Elm und nicht die Logik des gregorianischen Kalenders:

createDate : Int -> Int -> Int -> DateOrWrongFormat
createDate day month year =
    if day < 0 then
        WrongFormat "Day is negative"

    else if day > 31 then
        WrongFormat "Day is too large"

    else if (month <= 0) || (month > 12) then
        WrongFormat "Month must be a number in {1, ..., 12}"

    else
        CorrectFormat (TheDate day month year)

Wie verhindern wir aber nun, dass ein anderer Entwickler die Funktion createDay umgeht und direkt ein Datum mit dem Konstruktor erstellt, also date = Date -1 13 2002? Indem wir den Zugriff auf den Konstruktor verweigern! Betrachten wir die erste Zeile des Moduls:

module DateOpaque exposing (..)

Jetzt lernen Sie, was es mit exposing (..) auf sich hat: wir erlauben von außen Zugriff auf alles, was wir in diesem Modul definieren. Nun wollen wir aber nur Zugriff auf den Datentyp Date und die Funktion createDate erlauben, nicht aber auf den Konstruktor TheDate. Das geht so:

module DateOpaque exposing (Date, createDate)
Den ganzen Code des Moduls DateOpaque anzeigen:
module DateOpaque exposing (Date, createDate)


type Date
    = TheDate Int Int Int


type DateOrWrongFormat
    = WrongFormat String
    | CorrectFormat Date


createDate : Int -> Int -> Int -> DateOrWrongFormat
createDate day month year =
    if day < 0 then
        WrongFormat "Day is negative"

    else if day > 31 then
        WrongFormat "Day is too large"

    else if (month <= 0) || (month > 12) then
        WrongFormat "Month must be a number in {1, ..., 12}"

    else
        CorrectFormat (TheDate day month year)

Probieren wir es aus:

H:\PP\elm\> elm repl                            
import DateOpaque exposing (..)                            
createDate 21 9 2023
CorrectFormat (TheDate 21 9 2023) : DateOrWrongFormat

funktioniert. Wenn wir allerdings uns an createDate vorbeimogeln wollen und den Konstruktor direkt aufrufen wollen:

TheDate 21 9 2023                            
-- NAMING ERROR ---- REPL
    
I cannot find a `TheDate` variant:

4|   TheDate 21 9 2023

Das funktioniert nicht, weil der Konstruktor TheDate nicht exponiert (exposed) worden ist und daher der äußere Code ihn nicht kennt und nicht verwenden kann.

Getter-Funktionen

Nun haben wir Date als opaken Datentyp definiert. Ein Nachteil ist nun, dass wir nicht mehr über date.month auf die einzelnen Felder zugreifen können. Wir benötigen nun sogenannte Getter-Funktionen.

getDay : Date -> Int
getDay date =
    case date of
        Date day month year ->
            day

Beachten Sie Zeile 35. Hier pattern-matchen wir die (einzige) Alternative TheDate und fangen die Payload in den drei Bezeichnern day, month und year auf. Pattern-Matchen und auffangen ist in Elm die einzige Möglichkeit, an die Payload ranzukommen.

Übungsaufgabe Schreibene Sie weitere Funktionen getMonth und getYear.

Schönheitskorrekturen

Date statt TheDate. Record als Input für createDate

In im obigen code ist Date ein Typ (und muss mit einem Großbuchtsaben beginnen); TheDate ist ein Konstruktor (also Typ-Alternative mit Payload) und muss auch mit einem Großbuchstaben beginnen. In Elm ist es aber immer aus dem Kontext klar ist, ob ein Wort einen Typ bezeichnet oder eine Alternative/Konstruktor. Da somit keine Verwechslungsgefahr besteht, kann auch das Wort Date doppelt verwendet werden:

    type Date
        = Date Int Int Int 

Da aber der Konstruktor nicht exponiert wird (in Java würden wir sagen: er ist private), ist es letztendlich relativ egal, wie man ihn nennt.

Ich erstelle ein Daten (oder ein DateOrWrongFormat-Objekt) mit der Creator-Funktion createDate 3 10 2023. Da haben wir wieder das Problem, dass die Reihenfolge der Argumente nicht klar ist (ob also Tag, Monat oder Jahr zuerst kommt), was im weiteren Verlauf zu fachlichen Fehlern führen könnte. Wenn wir ganz auf Nummer Sicher gehen wollen, können wir createDate ändern:

createDate : { day : Int, month : Int, year : Int } -> DateOrWrongFormat
createDate rawDate =
    if rawDate.day < 0 then
        WrongFormat "Day is negative"

    else if rawDate.day > 31 then
        WrongFormat "Day is too large"

    else if (rawDate.month <= 0) || (rawDate.month > 12) then
        WrongFormat "Month must be a number in {1, ..., 12}"

    else
        CorrectFormat (TheDate rawDate.day rawDate.month rawDate.year)

Statt einfach als Eingabeparameter drei Int zu nehmen, deren Bedeutung sich nur durch ihre Reihenfolge erschließt, haben wir hier einen Parameter, der selbst wiederum ein Record-Typ ist mit drei klar benannten Feldern day, month und year. Die Verwechslungsgefahr ist also deutlich niedriger.

createDate {month = 10, day = 3, year=2023}                            
CorrectFormat (TheDate 3 10 2023) : DateOrWrongFormat

Allerdings ist das vielleicht übertriebene Vorsicht; ich sehe zumindest recht selten Fälle, in denen in Elm dieser Weg (mit einem Record als Eingabeparameter) gewählt worden ist.

Todo: Beispiele für Aggregatzustand / Material / Temperatur. Funktionsaufrufe wie
stateOfMatter "water" 97                            
"liquid"
stateOfMatter "water" 97 == "Liquid"
False
Verarbeitung von Temperaturen: Payload rausfischen mit Pattern Matching, wo man gleichzeitig einen neuen Identifier einführen kann. Die Temperaturskalen haben ja alle die gleiche Payload. Beispiele für Datentypen mit unterschiedlicher Payload? Datentyp Ausweis mit den Varianten Passport / Perso / Fuehrerschein? Funktion whichWeekday
whichWeekday                            
<function> : String -> HopefullyWeekday
                        
und so weiter.