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
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) 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.
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.
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.
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 } -> 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.
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