3. Zusammengesetzte Datentypen

3.1 Daten bündeln mit Records

Erinnern Sie sich an unseren Versuch aus dem letzten Teilkapitel, Kalenderdaten in als Tripel darzustellen, also beispielsweise (3, 10, 2023). Das Problem ist, dass die einzelnen Positionen (erste, zweite, dritte) nicht benannt sind und andere Entwickler nicht wissen, ob Sie die Daten im Format (day, month, year) oder (month, day, year) oder (year, month, day) repräsentieren. Besser geht es mit Records:

firstDayOfLectures = {day = 4, month = 10, year = 2023}

Jetzt kann es keine Verwechslungen mehr geben. Was wir gerade getan haben, ist, dem Bezeichner firstDayOfLectures einen Wert mit einem Record-Typ zugewiesen zu haben. Records sind im Prinzip nichts wirklich Anderes als Tupel, nur dass wir für die einzelnen Einträge selbst Namen vergeben können, statt sie als erste Koordinate und zweite Koordinate zu bezeichnen. Öffnen Sie mit Ihrem Code-Editor eine neue Datei und speichern Sie sie als PP/elm/src/Date.elm ab. Kopieren Sie folgenden Code hinein:

module Date exposing (..)


type alias Date =
    { day : Int
    , month : Int
    , year : Int
    }


wiedervereinigung : Date
wiedervereinigung =
    { day = 3, month = 10, year = 1990 }

Die Zeilen 4-8 zeigen Ihnen, wie wir einen neuen Record-Datentyp definieren. Der Name für den neuen Typ, hier Code, muss zwingend mit einem Großbuchstaben beginnen. Die Konvention, dass Typen mit Großbuchstaben und Bezeichner mit Kleinbuchstaben beginnen, gilt auch in anderen Sprachen wie Java. In Elm ist es aber Teil der Syntax, d.h. Ihr Programm wird nicht starten, wenn Sie sich nicht an diese Konvention halten. In Zeile 11-13 konstruieren wir einen Ausdruck vom Typ Date, indem wir allen Einträgen Werte zuweisen (hier muss jeder Wert ein Int sein).

Die einzelnen Einträge eines Records, also im Beispiel Date die Einträge day, month und year heißen Felder (fields). Der Type Date hat also drei Felder, die alle den Typ Int haben.

Jetzt wissen Sie, wie man ein Record-Objekt konstruiert (Zeile 11-13). Als nächstes lernen Sie, wie man auf die einzelnen Felder zugreift. Öffnen Sie Elm im Repl-Modus:

H:\PP\elm\> elm repl
import Date exposing (..)
wiedervereinigung.month
10 : Int

Auf die einzelnen Felder greifen Sie also genauso zu wie in Java. Wenn Sie aus einem Record-Objekt ein neues bauen wollen, dass sich in einigen wenigen Feldern unterscheidet, also beispielsweise das Feld year auf 2023 setzen, dann geht das so:

einheitstag2023 = {wiedervereinigung | year = 2023}

Alternativ können Sie natürlich auch

einheitstag2023 = {year = 2023, month = wiedervereinigung.month, day = wiedervereinigung.day}

schreiben, also jedes Feld "per Hand" kopieren.

Übungsaufgabe Was hat die zweite Methode, das "Kopieren von Hand", für Nachteile gegenüber dem ersten mit { recordObject | fieldName = newValue}? Klar, es ist mehr Schreibarbeit. Es gibt aber noch einen gewichtigeren Nachteil.
Übungsaufgabe Schreiben Sie eine Funktion tomorrow : Date -> Date, die das Folgedatum ausrechnet.

Achtung: Schreiben Sie nicht alles in eine Datei, sondern delegieren Sie Unteraufgaben an Helferfunktionen (Beispiel: Sie werden mit Sicherheit etwas wie isLeapYear : Int -> Bool brauchen).

Übungsaufgabe Je nachdem, wie weit Sie kommen, schreiben Sie folgende Funktionen:
  1. compareDate : Date -> Date -> Int, die zwei Daten vergleicht. Der Aufruf compareDate date1 date2 soll -1 zurückgeben, wenn date1 vor date2 kommt, 1 wenn danach, und 0, wenn sie gleich sind.
  2. yesterday : Date -> Date
  3. addDays : Int -> Date -> Date. Der Aufruf addDays n date soll das Datum berechnen, das von date aus n Tage in der Zukunft liegt.

    Tip: Hier brauchen Sie Rekursion. Was machen Sie, wenn \(n\) negativ ist?

  4. dateDifference : Date -> Date -> Int.

Geometrie

Für Punkte im zweidimensionalen Raum, gerichtete Geraden und Dreiecke legen wir nun Record-Typen an. Legen Sie mit Ihrem Code-Editor eine neue Datei an und speichern Sie sie unter PP/elm/src/Geometry.elm. Kopieren Sie folgenden Code hinein:

module Geometry exposing (..)


type alias Vector =
    { x : Float
    , y : Float
    }


type alias DirectedLine =
    { from : Vector
    , to : Vector
    }


type alias Triangle =
    { a : Vector, b : Vector, c : Vector }

All dies hätten wir auch mit Tupeln geschafft. Allerdings ist es nun klarer, was womit gemeint ist (und später wird der Unterschied noch klarer). Ich zeige Ihnen nun, wie Sie Records anlegen und auf die einzelnen Einträge zugreifen:

v : Vector                        
v = {x = 0.4, y = 0.8} -- so initialisieren Sie einen Record
v.x -- so greifen Sie auf die Einträge zu; wie in Java
0.4 : Float

Als nächstes implementieren wir die Funktion rotateVector. Dies haben Sie ja hoffentlich in Kapitel 2.7 bereits getan, allerdings mit Tupeln und nicht mit Records.

rotateVector : Float -> Vector -> Vector
rotateVector angle v =
    let
        sinAngle : Float
        sinAngle =
            sin angle

        cosAngle : Float
        cosAngle =
            cos angle
    in
    { x = v.x * cosAngle - v.y * sinAngle, y = v.x * sinAngle + v.y * cosAngle }                        
                    
Übungsaufgabe Implementieren Sie nun eine Funktion
leftRightOrOnLine : DirectedLine -> Vector -> Int
wobei leftRightOrOnLine line v Ihnen 1, -1 oder 0 zurückgibt, je nachdem, ob der Punkt v links von, rechts von oder auf der gerichteten Geraden line liegt.
Übungsaufgabe Implementieren Sie eine Funktion
insideTriangle : Triangle -> Vector -> Bool 
so dass insideTriangle triangle v zurückgibt, ob der Punkt v im Inneren des Dreiecks triangle liegt.
Übungsaufgabe Definieren Sie einen Datentyp Circle bestehend aus einem Mittelpunkt und einem Radius. Schreiben Sie eine Funktion insideCircle, die überprüft, ob ein gegebener Punkt im inneren eines gegebenen Kreises liegt.
  1. Definieren Sie den Datentyp analog zu den obigen Beispielen mit type alias Circle = ...
  2. Legen Sie eine Signatur für insideCircle fest. Was sollte der erste Parameter sein? Der Kreis oder der Punkt? Ihre Entscheidung, überlegen Sie sich aber, was besser wäre.

Heterogene Daten

Nur damit die bisherigen Beispiele Sie nicht glauben machen, alle Felder eines Record-Typs müssten die gleichen Typen haben (Bei Kalenderdaten waren alle Felder Int; bei unseren Vektoren waren sie alle Float): dies ist sicherlich nicht der Fall. Sie dürfen gerne heterogene Records erstellen (das ist sogar der Regelfall), wie beispielsweise

type alias Resident =
    { firstname : String
    , lastname : String
    , dateOfBirth : Date
    , street : String
    , houseNumber : Int
    , city : String
    , zipCode : Int
    , taxNumber : Int
    , married : Bool
    }
Übungsaufgabe Die Typendefinition von Resident weist mehrere Eigenschaften auf, die in der Praxis zu Problemen führen können. Welche?

Das offensichtlichste Problem ist, dass houseNumber ein Int ist. Denn wie repräsentieren Sie dann Adressen wie Bahnhofstraße 2a? Was tun Sie mit Ihrer Software, wenn die deutsche Post plötzlich beginnt, Buchstaben in die Postleitzahlen (zipCode) zu integrieren?

Auf die Frage, ob man Daten als String oder Int darstellen sollte, hat Phil Scovis auf Quora eine wunderbare Antwort gegeben:

So, here's a quick and easy test for deciding between ints or strings.

Will you be adding them?

There it is. Try it with phone numbers, zip codes, or ABA routing numbers. Try it with inventory counts, pixel positions, time ticks. See the difference?

As you say in your question, room numbers "are not used mathematically". That's like 99% of the "int" interface, right there. On the other hand, you will be comparing them, and possibly extracting digits (like for a floor number). Those are common string operations.

We think of the data type as "what something is"; but it is just as important to ask "what will it need to do". The second question can give valuable insight on the first.