3. Zusammengesetzte Datentypen

3.5 Typenvariable

Im letzten Kapitel haben wir einen Datentyp DateOrWrongFormat definiert:

type DateOrWrongFormat
    = WrongFormat
    | CorrectFormat Date

Sie werden beim Programmieren, besonders in der Praxis, ständig mit Fehlerszenarios konfrontiert sein. Eine Datei kann vielleicht nicht geöffnet werden; ein Http-Request wird womöglich nicht beantwortet; der User gibt negative Zahlen ein, wo keine erlaubt sind, und so weiter. Nehmen wir zum Beispiel einen Datentyp Circle:

type alias Circle =
    { center : Vector, radius : Float }

Vielleicht wollen wir erzwingen, dass Radius immer positiv ist (oder zumindest nicht negativ). Wir können, analog zum Datum, einen opaken Datentyp defineren, einen CirlceOrWrongFormat-Typ und eine Creator-Funktion. Also

type Circle
    = TheCircle Vector Float


type CircleOrWrongFormat
    = Correct Circle
    | WrongFormat


createCircle : Vector -> Float -> CircleOrWrongFormat
createCircle center radius =
    if radius < 0 then
        WrongFormat

    else
        Correct (TheCircle center radius)

Nebenbemerkung: Konstruktoren sind Funktionen

Am Rande: in Zeile 35 haben wir zwei ineinander verschachtelte Konstruktoren: TheCircle nimmt einen Vector und einen Float und macht daraus ein Cirlce-Objekt; Correct nimmt einen Circle und macht daraus einen CircleOrWrongFormat. Damit klar ist, welches Argument wozu gehört, brauchen wir Klammern. Wenn wir die lammern wegließen, also Correct TheCircle center radius dann würde Elm denken, der Konstruktor Correct hat drei Payloads: TheCircle, center und raidius, was natürlich Unsinn ist.

Konstruktor-Aufrufe schauen also syntaktisch genauso aus wie Funktionsaufrufe. Semantisch sind Sie es auch: Betrachten wir beispielsweise den Typ von TheCirlce:

TheCirlce
<function> : Vector -> Float -> Circle

Es ist also tatsächlich in Syntax und Seminatik eine Funktion! Allerdings eine Funktion ohne Körper; wenn Sie sich "echte" Funktionen (wie f x = x^2) als Maschinen vorstellen, wo man oben was reinfüllt, das dann verarbeitet wird und unten wieder rauskommt, dann sind Konstruktoren eher Verpackungsautomaten; TheCircle tut nichts wirklich, es verpackt einfach seine drei Argumente und schreibt TheCircle auf die Verpackung.

Allgemeine CorrectOrWrongFormat-Typen

Gehen wir zurück zu unseren Typen DateOrWrongFormat und CircleOrWrongFormat. Wenn der User nun auch noch Zahlen über die Tastatur eingeben kann, dann müssen Sie die erstmal von String nach Int konvertieren, also "13" : String zu 13 : Int. Was, wenn der User etwas eingibt, das keine Zahl ist? Hierfür bräuchten wir wiederum einen Typ IntOrWrongFormat. Und so weiter, für jeden Datentyp. All diese Datentypen würden fast gleich aussehen. Um das alles mit einem Schlag für alle Typen zu erledigen, gibt es in Elm die Typenvariablen:

module CorrectOrWrongFormat exposing (..)


type CorrectOrWrongFormat a
    = Correct a
    | WrongFormat

Das a in Zeile 4 ist eine Typenvariable, also eine Vorlage, die dann im Anwendungsfall durch einen konkreten Typ ersetzt werden muss. Wir können jetzt DateOpaque umschreiben:

module DateOpaque exposing (Date, createDate)

import CorrectOrWrongFormat exposing (..)


type Date
    = TheDate Int Int Int


createDate : Int -> Int -> Int -> CorrectOrWrongFormat Date
createDate day month year =
    if day < 0 then
        WrongFormat

    else if day > 31 then
        WrongFormat

    else if (month <= 0) || (month > 12) then
        WrongFormat

    else
        Correct (TheDate day month year)

und dann so verwenden:

import DateOpaque exposing (..)                            

createDate 32 10 2002
WrongFormat : CorrectOrWrongFormat Date
createDate 31 10 2002
Correct (TheDate 31 10 2002) : CorrectOrWrongFormat Date

Die beidenn Objekte, die wir im Repl-Fenster erzeugt haben, sind beide vom Typ CorrectOrWrongFormat Date; der Type CorrectOrWrongFormat ist erstmal ein parametrisierter Typ mit Typenvariable a; erst in DateOpaque wird a durch Date ersetzt und somit ein vollwertiger Typ CorrectOrWrongFormat Date erschaffen.

Typenvariable gibt es auch in Funktionsdefinitionen. In unserer Anwendung wollen wir jetzt aus dem CorrectOrWrongFormat Date ein Date-Objket rauskriegen; zum Beispiel, wenn in Ihrer App bereits ein Daten gespeichert ist und der User es durch ein neues ersetzen kann; ist die Eingabe fehlerhaft, dann soll das alte Datum unverändert bleiben. Der Code in Ihrer Anwendung könnte so aussehen:

            let 

                dateSelectedByUser : CorrectOrWrongFormat Date 
                dateSelectedByUser = createDate userDay userMonth userYear

                newSelectedDate : Date 
                newSelectedDate = 
                    case dateSelectedByUser of 
                        Correct date -> 
                            date 
                        WrongFormat -> 
                            oldSelectedDate 
            in 
            ... 

In Zeile 915 bauen wir aus den User-Eingaben ein neues Datum, oder eher ein CorrectOrWrongFormat-Objekt. Unsere App will nun eine interne Variable für das Datum aktualisierne. Dafür deklarieren wir newSelectedDate. Wenn die Usereingaben das richtige Format haben, dann wählen wir dieses Datum (Zeile 919); falls das Format falsch war, belassen wir das newSelectedDate beim alten oldSelectedDate (Zeile 922). Das stellt sicher, dass oldSelectedDate immer ein korrektes Format hat. In der Praxis müsste die App in Zeile 922 irgendwie sicherstellen, dass der User sinnvolles Feedback bekommt. Da dieser Fall recht oft vorkommt, schreiben wir uns eine Funktion

getValueIfCorrectAndAlternativeOfWrongFormat : CorrectOrWrongFormat -> a -> a 
getValueIfCorrectAndAlternativeOfWrongFormat correctOrWrongData alternative = 
    case CorrectOrWrongFormat of 
        Correct something ->
            something 
        WrongFormat ->
            alternative                             
                        

So wandeln wir also ein Vielleicht-a in ein a um, müssen aber eine Alternative mit angeben für den Fall, dass es sich doch nicht um ein a handelt.

Das Modul Maybe

Sie müssen das alles aber gar nicht selbst implementieren. Alles schon geschehen, in dem Modul Maybe. Das sieht ungefähr so aus:

module Maybe exposing (..)


type Maybe a
    = Just a
    | Nothing


withDefault : a -> Maybe a -> a
withDefault alternative maybeA =
    case maybeA of
        Just something ->
            something

        Nothing ->
            alternative

Das Modul Maybe definiert noch weitere Funktionen, aber im Moment geht es uns um die obigen beiden.

oldValue : Int                            
oldValue = 4
userInput : String
userInput = "13"
String.toInt userInput
Just 13 : Maybe Int
newValue : Int
newValue = Maybe.withDefault oldValue (String.toInt userInput)
13 : Int
newValue = Maybe.withDefault oldValue (String.toInt " 104")
4 : Int

Typenvariable erlauben uns also, mit einer Typendefinition im Prinzip unendlich viele Typen zu erschaffen. Allerdings: ganz so neu ist das uns nicht. Wir haben ja bei Tupeln bereits gesehen, dass sich beliebig viele Datentypen damit realisieren lassen, also (Int, Int) und (Float, Float) und ((Float, Foat), String) und beliebigt verschachtelt. In der Tat ist die (x,y)-Schreibweise für Tupel in Elm nur Syntax-Zucker. Das heißt, es bietet uns beim Programmieren eine vereinfachte Syntax an, ohne wirklich etwas Neues einzuführen. Denn in der Tat: gäbe es Tupel in Elm nicht von Haus aus, so könnten wir uns die ja definieren:

module MyTuple exposing (..)


type MyTuple a b
    = MyTuple a b


first : MyTuple a b -> a
first tuple =
    case tuple of
        MyTuple x y ->
            x


second : MyTuple a b -> b
second tuple =
    case tuple of
        MyTuple x y ->
            y    

und das dann so verwenden:

import MyTuple exposing (..)                            
point2 = MyTuple 0.2 0.5
MyTuple.first point1
0.2 : Float

und natürlich beliebig verschachtelt:

circle = MyTuple point1 4.1                            
MyTuple (MyTuple 0.2 0.5) 4.1
    : MyTuple (MyTuple Float Float) Float