3. Zusammengesetzte Datentypen

3.3 Heterogene Daten mit Custom Types

Zusätzlich zu Records gibt es in Elm eine weitere Methode, neue Datentypen zu definieren, die fast noch wichtiger ist (und zu der es kein offensichtliches Gegenstück in Java gibt). Gehen wir zu unserem Datentyp Resident zurück, den wir am Ende von Kapitel 3.1 definiert haben. In stark vereinfachter Form nun:

type alias Resident =
    { name : String
    , dateOfBirth : Date
    , married : Bool
    }

Das Problem hier? Das Feld married sollte eher maritalStatus heißen, weil der Familienstand ja nicht nur die Werte ledig und verheiratet annehmen kann, sondern auch noch verwitwet und geschieden. Ganz oft sind wir beim Programmieren in der Situation, dass es für eine gewisse Variable nur endlich viele, von vornherein feststehende Werte geben kann. Wochentag kann sieben Werte annehmen. Aggregatszustand nur fest, flüssig und gasförmig (im Alltag; in ungewöhnlicheren physikalischen Situationen kommen noch Plasma, Bose-Einstein-Kondensat etc. hinzu). Wie repräsentieren wir also den Familienstand in Elm? Ein Weg, den man in Sprachen wie C/C++ oft geht, ist, einfach Zahlencodes für die verschiedenen Zustände einzuführen:

module Resident exposing (..)

import Date exposing (..)


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


single : Int
single =
    0


married : Int
married =
    1


divorced : Int
divorced =
    2


widowed : Int
widowed =
    3

Wir verwenden also die Zahlen 0 bis 3, um die vier Zustände zu codieren. Andere Entwickler, die auf unserem Code aufbauen, müssten dann hoch und heilig versprechen, nie ungültige Werte (4 oder -1) zu verwenden, und am Besten auch nie 1 zu schreiben sondern immer divorced. Zum Beispiel:

computeTaxRate : Resident -> BunchOfOtherData -> Float 
computeTaxRate resident otherData = 
    if resident.maritalStatus == married then 
        ... some value here 
    else 
        ... some other value here  

Hier werden also verheiratete Personen auf eine Weise besteuert und alle anderen, egal ob ledig, verwitwet oder geschieden, auf eine zweite Weise. Nun führt das Parlament einen weiteren Familienstand ein: in Lebenspartnerschaft (civil union), und stellt diesen steuerrechtlich verheirateten Leuten gleich. Ihr Team muss nun die Software aktualisieren und fügt dem Modul Resident eine weitere Zeile hinzu:

civilUnion : Int
civilUnion =
    4

Jetzt müssen natürlich auch allen Stellen im Code (nicht nur Ihr Team; alle anderen auch) anpassen, in welchen der Familienstand abgefragt wird. Was passiert, wenn Sie vergessen, computeTaxRate zu aktualisieren? Ihr Code läuft immer noch; es kommen keine Fehlermeldungen, dafür werden Personen in Lebenspartnerschaft wie ledige Personen, und eben nicht wie verheiratete Personen besteuert, da auch sie im else-Teil, also in Zeilen 819/820 landen.

Custom Types für endliche Aufzählungen

Anstatt in Lebenspartnerschaft lebende Personen falsch zu besteuern, wollen wir, dass der Elm-Compiler dies bereits als Fehler erkennt und uns mitteilt: Halt! Sie haben hier eine Möglichkeit unberücksichtigt gelassen! Das geht ganz automatisch mit Custom Types.

module Resident exposing (..)

import Date exposing (..)


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


type MaritalStatus
    = Single
    | Married
    | Divorced
    | Widowed

Ähnlich wie Bool, das nur zwei Werte annehmen kann, nämlich True und False, so ist MaritalStatus ein Typ, der nur vier Werte annehmen kann: Single, Married, Divorced, Widowed. Diese Werte nennt man Konstanten. Wir haben also einen neune Custom-Typ und vier neue Konstanten eingeführt. Konstanten müssen in Elm grundsätzlich mit einem großen Buchstaben beginnen (wie ja auch True und False). Sie verarbeiten Sie ganz normal wie andere Werte auch:

a = Married                        
b = Divorced
a == b
False : Bool
a == Married
True : Bool

Wir können nun also computeTaxRate wieder mit einer if/else-Kette implementieren. Tun Sie das aber nicht! Wenn Sie es mit einem Custom-Typ zu tun haben, dann verarbeiten Sie die bitte wenn möglich immer mit case ... of:

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

        Married ->
            0.2

        Divorced ->
            0.3

        Widowed ->
            0.3

Die 0.2 und 0.3 sind natürlich Fantasiezahlen. Was ist nun der Vorteil gegenüber den Zahlencodes 0, 1, 2, 3?

Übungsaufgabe Speichern Sie die Dateien Resident.elm und Date.elm in Ihrem Ordner H:\PP\elm\src\. Starten Sie ein Repl-Fenster, legen einen Resident an und testen die Funktion computeTaxRate.

Editieren Sie nun die Datei Resident.elm indem Sie der Typendefinition von MaritalStatus die weitere Alternative CivilUnion hinzufügen. Rufen Sie computeTaxRate auf und schauen, was geschieht!

Fehlermeldungen

Sie bekommen wahrscheinlich eine Fehlermeldung, in etwa

This `case` does not have branches for all possibilities:

23|>    case resident.maritalStatus of
...
Missing possibilities include:

    CivilUnion

Ist das jetzt gut oder schlecht? Ich würde sagen, es ist gut! Der Elm-Compiler hat den Fehler entdeckt und unser Entwicklungsteam kann es korrigieren. Die Alternative mit der if/else-Kette, dass nämlich Personen falsch besteuert werden, wäre ungleich schlimmer. Wir haben es hier mit Compilerfehler versus Laufzeitfehler versus Logikfehlerzu tun:

  1. Compilerfehler. Der Compiler für die Programmiersprache erkennt einen Fehler. Vielleicht haben Sie irgendwo ein Komma vergessen (oder in Java ein Semikolon); vielleicht stimmen Typen nicht überein.

    Compilerfehler in Elm:

    import Resident exposing (..)                                            
    -- MISSING PATTERNS ---- temp/Resident.elm
    
    This `case` does not have branches for all possibilities:
    
    23|>    case resident.maritalStatus of

    Compilerfehler in Java:

    javac Recursion.java                                            
    Recursion.java:6: error: incompatible types: int cannot be converted to String
                       return 0;
    ...
  2. Laufzeitfehler. Der Code läuft und rechnet, kommt aber irgendwann an eine Stelle, an der offensichtlich ein Fehler aufgetreten ist. In Java zum Beispiel können ArrayIndexOutOfBoundException auftreten, wenn Sie ein Array der Länge 8 haben, aber auf Position 12 zugreifen wollen. Oder eine NumberFormatException, wenn Sie beispielsweise einen String in eine Zahl umwandeln wollen, obwohl der gar keine Zahl darstellt:

    Laufzeitfehler in Elm:

    Elm brüstet sich mit:

    No Runtime Exceptions

    Elm uses type inference to detect corner cases and give friendly hints. NoRedInk switched to Elm about four years ago, and 300k+ lines later, they still have not had to scramble to fix a confusing runtime exception in production. Learn more.
    https://elm-lang.org

    Das stimmt fast, aber nicht ganz: seit ich im Sommer 2022 mit Elm begonnen habe, ist mir ein "richtiger" Laufzeitfehler begegnet. Aber immerhin: ein einziger, während man in Java gefühlt alle fünf Minuten einen kriegt.

    Laufzeitfehler in Java:

    java Recursion
    Exception in thread "main" java.lang.NumberFormatException: For input string: "193O"
    ...

    Übrigens: erkennen Sie, was an "193O" falsch ist?

    Ein Laufzeitfehler liegt also immer vor, wenn Ihr Programm "abstürzt".

  3. Logikfehler. Ein Logikfehler liegt vor, wenn Ihr Programm compiliert und nicht abstürzt, aber dennoch ein falsches Ergebnis liefert. Logikfehler können Ihnen in jeder Programmiersprache unterlaufen (Sie können immer \(\cos\) mit \(\sin\) verwechseln, daran hindert Sie kein Compiler und keine Laufzeitumgebung), auch weil ja nicht immer klar ist, was Sie unter einem richtigen Ergebnis verstehen.

    Wie wir allerdings in unserem MaritalStatus-Beispiel gesehen haben, trägt das strenge Typensystem von Elm durchaus dazu bei, Logikfehlern vorzubeugen (hier: Leute nicht inkorrekt zu besteuern).