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 mdateMarried { 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 -> FloatcomputeTaxRate resident otherData =case resident.maritalStatus ofSingle ->0.3Married date->if date.year < currentYear then...else...Divorced ->0.3Widowed ->0.3CivilUnion 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 -> DaytimealreadyTooLate deadline currentDate currentTime =case deadline ofNoDeadline ->-- keine Deadline, also nie zu spätFalseUntilDay date ->compareDate currentDate date > 0UntilTimeDateDaytime 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
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.
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) WaterGaseous : StateOfMattergetStateOfMaterial (Kelvin 400) GoldSolid : StateOfMattergetStateOfMaterial (Fahrenheit 110) WaterLiquid : 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.
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 DatecreateDate : Int -> Int -> Int -> DateOrWrongFormatcreateDate year month day =if(month <= 0)|| (month > 12)|| (day <= 0)|| (day > daysInMonth year month)thenWrongFormatelseCorrectFormat { 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.
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 -> DateOrWrongFormatcreateDate day month year =if day < 0 thenWrongFormat "Day is negative"else if day > 31 thenWrongFormat "Day is too large"else if (month <= 0) || (month > 12) thenWrongFormat "Month must be a number in {1, ..., 12}"elseCorrectFormat (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 Inttype DateOrWrongFormat= WrongFormat String| CorrectFormat DatecreateDate : Int -> Int -> Int -> DateOrWrongFormatcreateDate day month year =if day < 0 thenWrongFormat "Day is negative"else if day > 31 thenWrongFormat "Day is too large"else if (month <= 0) || (month > 12) thenWrongFormat "Month must be a number in {1, ..., 12}"elseCorrectFormat (TheDate day month year)
Probieren wir es aus:
H:\PP\elm\> elm replimport DateOpaque exposing (..)createDate 21 9 2023CorrectFormat (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 -> IntgetDay date =case date ofDate 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.
getMonth und getYear.
Schönheitskorrekturen
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 } -> DateOrWrongFormatcreateDate rawDate =if rawDate.day < 0 thenWrongFormat "Day is negative"else if rawDate.day > 31 thenWrongFormat "Day is too large"else if (rawDate.month <= 0) || (rawDate.month > 12) thenWrongFormat "Month must be a number in {1, ..., 12}"elseCorrectFormat (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.
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? DatentypstateOfMatter "water" 97"liquid"stateOfMatter "water" 97 == "Liquid"False
Ausweis mit
den Varianten Passport / Perso / Fuehrerschein?
Funktion whichWeekday
und so weiter.whichWeekday<function> : String -> HopefullyWeekday