ADTs, GADTS, Typklassen

2023-07-07

ADTs, GADTs, Typklassen #

Ich habe vor Kurzem funktionale Abhängigkeiten kennengelernt und hatte gleich den Impuls, darüber zu schreiben. Das ist eine gute Gelegenheit, um etwas ausführlicher auf Konzepte der Typebene in Sprachen wie Haskell, Purescript, Elm und Ocaml einzugehen.

Einfache Datentypen #

Die meisten Sprachen bringen einen Vorrat an einfachen Datentypen mit: numerische Datentypen für ganze Zahlen und Gleitkommazahlen, Datentypen für einzelne Buchstaben und andere Schriftzeichen, einen Datentyp mit den zwei Wahrheitswerten “falsch” und “wahr”.

In den Details gibt es große Unterschiede zwischen den Sprachen, je nachdem, wo sie ihre Schwerpunkte setzen und welche Form der Programmierung sie unterstützen. Beispielsweise hat C keinen speziellen Datentyp für Wahrheitswerte, Python und Javascript unterscheiden nicht streng zwischen einzelnen Schriftzeichen und Zeichenketten, Javascript unterscheidet nicht zwischen ganzen Zahlen und Gleitkommazahlen, TCL kennt nur Zeichenketten, …

Ein paar einfache Datentypen in Haskell:

  • Int: ganze Zahlen wie -3, 0, 123, 999999999, …
  • Float: Gleitkommazahlen wie -123.45, 1.0, 3.141592653, …
  • Char: Zeichen wie 'a', 'β', '7', '+', '?', '🙂', …
  • Bool: die Wahrheitswerte True und False.

Die Größe einfacher Datentypen #

Man kann einen Datentyp auffassen als die Menge seiner Werte. Hier als Beispiel der Datentyp Bool in Mengenschreibweise:

Bool = { False , True }

Die Größe eines Datentyps ist die Anzahl seiner Werte. Der Datentyp Bool enthält genau zwei Werte. Damit ist seine Größe 2.

Das gleiche Spiel kann man mit allen einfachen Datentypen spielen. Der kleinste Int Wert auf meinem Rechner ist die ganze Zahl -9223372036854775808 und der größte Int Wert die ganze Zahl 9223372036854775807. Daraus ergibt sich für den Datentyp Int die Größe 2^64.

Die Größe eines einfachen Datentyps hängt letztlich auch damit zusammen, wie viele Bits mindestens benötigt werden um einen seiner Werte zu codieren. Ein Int Wert ist auf meinem Rechner in 64 Bits codiert. Daraus ergibt sich, dass 2^64 verschiedene Int Werte codiert werden können. In Sprachen wie C, die dafür gemacht sind, möglichst effiziente Programme zu schreiben und verhältnismäßig nah an der Hardware zu programmieren, bedeutet ein Datentyp im Grunde nicht mehr als die Anzahl von Bits, die einer seiner Werte im Speicher mindestens benötigt.

Bei Char und Float ist die Berechnung der Größe etwas komplizierter. vom Datentyp Char gibt es insgesamt 1114112 Werte. Für Float weiß ich es nicht, aber es lässt sich grundsätzlich ausrechnen.

Zusammengesetzte Datentypen #

Zusammengesetzte Datentypen sind bspw. Arrays, Listen, Strings, Tupel, Records und Structs. Das sind allesamt Datentypen, die sich – wie der Name sagt – auf irgendeine Art aus anderen Datentypen zusammensetzen.

Ein Record-Datentyp für ganzzahlige Punkte in der Ebene könnte so aussehen:

-- Purescript
type Point = { x :: Int , y :: Int }

Ein Tupel-Datentyp für den selben Zweck:

-- Haskell
(Int,Int)

Der Record-Datentyp hat den Vorteil, dass die Felder mit x und y bezeichnet sind. Dadurch ist es vermutlich etwas schwerer, sie zu verwechseln. Außerdem können wir direkt über die Feldnamen auf ihre Werte zugreifen. Beim Tupel-Datentyp verlassen wir uns auf die Position der Felder für ihre Unterscheidung. Der Zugriff auf die Felder erfolgt über Pattern-Matching in Case Expressions oder über spezielle Zugriffsfunktionen wie fst und snd. Dafür deckt sich die Tupel-Schreibweise mit der Schreibweise für Punkte in der Ebene, die auch sonst üblich ist. Das sind aber rein ergonomische Unterschiede. Strukturell unterscheiden sich diese beiden Datentypen nicht voneinander: in beiden Fällen besteht ein Punkt aus zwei Int Werten.

Die Größe zusammengesetzter Datentypen #

Es gibt auf meinem Rechner 2^64 Werte vom Datentyp Int. Für den Datentyp Point ergeben sich damit 2^64 * 2^64 also 2^128 Werte. Das selbe gilt für den Datentyp (Int,Int). Solche Datentypen, die sich aus anderen Datentypen zusammensetzen, so dass ihre Größe das Produkt der Größen der beteiligten Datentypen ist, heißen Produkttypen. Array-Datentypen und C-Structs sind auch Produkttypen. Ein bisschen komplexer ist es bei Listen und Strings. Mehr dazu weiter unten.

In allen herkömmlichen Sprachen kann man Produkttypen definieren, aber oft fehlt die Möglichkeit, Summentypen zu definieren. Das sind Datentypen, die sich aus anderen Datentypen zusammensetzen, so dass ihre Größe die Summe der Größen der beteiligten Datentypen ist.

Algebraische Datentypen #

Algebraische Datentypen (ADTs) vereinen in sich Produkttypen und Summentypen: jeder ADT ist eine Summe von Produkten.

Summen

Einer der einfachsten ADTs ist der Datentyp Bool in Haskell:

-- Haskell
data Bool = False | True

False und True sind sogenannte Wertekonstruktoren (oder einfach nur Werte). Den Balken müssen wir lesen wie ein exklusives Oder: jeder Wert vom Datentyp Bool ist entweder False oder True. Andere als diese zwei Bool Werte gibt es nicht.

ADTs können beliebig viele Wertekonstruktoren haben:

-- Haskell

data Ampel = Grün | Gelb | Rot

data Käse = Gouda | Cheddar | Brie | Camembert | Gruyère

data T = A | B | C | D | E | F | G | H | I | J | K

Wir können auch einen ADT mit genau einem Wertekonstruktor definieren:

-- Haskell
data Eindeutig = Einheit

Hier hat nur der Wert Einheit den Datentyp Eindeutig. Damit besteht zwischen dem Datentyp und dem Wert eine Eins-zu-eins-Beziehung. Sogar der Spezialfall eines ADT mit keinen Wertekonstruktoren ist möglich. Wir geben einfach keine Wertekonstruktoren an:

-- Haskell
data Nichts

Damit hat der Datentyp Nichts keine Werte. Wir haben daher keine Möglichkeit, einen Wert vom Datentyp Nichts zu erzeugen oder eine Funktion anzuwenden, die auf Nichts operiert. Leere Datentypen sind nicht nutzlos, aber darauf werde ich hier nicht weiter eingehen. Stattdessen möchte ich auf eine Sache hinweisen, die Verwirrung stiften kann. Betrachte dafür die folgende Typdefinition:

-- Haskell

data Tasche
    = Tüte
    | Beutel
    | Rucksack
    | Gürteltasche
    | Tasche

Hier definieren wir den Datentyp Tasche mit den Werten Tüte, Beutel, Rucksack, Gürteltasche und Tasche. Es sticht ins Auge, dass der Name Tasche zweimal auftaucht: einmal als der Datentyp Tasche, einmal als der Wertekonstruktor Tasche. Für den Compiler ist das kein Problem. Er unterscheidet streng zwischen Datentypen und Wertekonstruktoren und kann sie nicht verwechseln, auch wenn sie den selben Namen haben. Das Beispiel ist zwar an den Haaren herbeigezogen, aber es ist tatsächlich manchmal sinnvoll, einem Datentyp und einem seiner Wertekonstruktoren den selben Namen zu geben. Weiter unten wird uns so etwas begegnen.

Wertekonstruktoren mit Feldern

Bisher haben wir einfache Wertekonstruktoren wie False, Gouda, Rucksack, A usw. gesehen. Wertekonstruktoren können aber typisierte Felder haben. Hier ein Beispiel:

-- Haskell
data Lampe = Leuchtet Bool

Der Datentyp Lampe hat den Wertekonstruktor Leuchtet mit einem Feld vom Datentyp Bool. Bisher haben wir die Begriffe Wert und Wertekonstruktor als Synonyme behandelt. Das ändert sich sobald Felder ins Spiel kommen. Der Wertekonstruktor Leuchtet ist allein noch kein Wert vom Datentyp Lampe. Wir müssen ihn um einen Bool Wert ergänzen, um einen Wert vom Datentyp Lampe zu erzeugen.

-- Haskell Repl

> data Lampe = Leuchtet Bool

> :type Leuchtet
Leuchtet :: Bool -> Lampe

> lampeAn = Leuchtet True

> :type lampeAn
lampeAn :: Lampe

Produkte

Wertekonstruktoren können mehr als ein Feld haben. Hier ein Beispiel:

-- Haskell
data Datum = Datum Int Int Int  -- Tag Monat Jahr

Der Datentyp Datum hat einen Wertekonstruktor Datum mit drei Feldern vom Datentyp Int. So ein Datentyp entspricht den normalen Produkttypen, die wir oben schon kennengelernt haben.

Summen von Produkten

In einem ADT lassen sich Produkte und Summen kombinieren. Hier ein Datentyp mit drei Wertekonstruktoren, die jeweils ein Feld vom Datentyp Bool haben:

-- Haskell
data MyType = X Bool | Y Bool | Z Bool

Daraus ergeben sich die folgenden konkreten Werte:

  • X False
  • X True
  • Y False
  • Y True
  • Z False
  • Z True

Hier ein Datentyp mit fünf Wertekonstruktoren und unterschiedlichen Anzahlen von Feldern:

-- Haskell

data Zubrot
    = Käse Bool         -- vegan ja/nein
    | Wurst Bool        -- vegan ja/nein
    | Marmelade Int Int -- Frucht- und Zuckergehalt in %
    | Margarine
    | Butter

Die Größe algebraischer Datentypen #

Die Größe eines ADT ist die Summe der Größen seiner Wertekonstruktoren. Die Größe eines Wertekonstruktors ist das Produkt der Größen seiner Feldtypen. Ein Wertekonstruktor ohne Felder hat die Größe 1. Ein Datentyp ohne Wertekonstruktoren hat die Größe 0.

Wir berechnen die Größe für ein paar ADTs:

|Nichts| == 0

|Eindeutig| == |Einheit| == 1

|Bool| == |False| + |True| == 1 + 1 == 2

|Ampel| == |Grün| + |Gelb| + |Rot| == 1 + 1 + 1 == 3

|Lampe| == |Leuchtet Bool| == |Bool| == 2

|Datum| == |Datum Int Int Int|
        == |Int| * |Int| * |Int|
        == 3^64 * 3^64 * 3^64
        == 3^192

|MyType| == |X Bool| + |Y Bool| + |Z Bool|
         == |Bool| + |Bool| + |Bool|
         == 2 + 2 + 2
         == 6

|Zubrot| == |Käse Bool| + |Wurst Bool| + |Marmelade Int Int|
          + |Margarine| + |Butter|
         == |Bool| + |Bool| + |Int| * |Int| + 1 + 1
         == 2 + 2 + 2^64 * 2^64 + 1 + 1
         == 2^128 + 6

Weiter unten werden wir auch unendlich große ADTs sehen.

Der Mehrwert algebraischer Datentypen #

Mit ADTs können wir neue Datentypen jeder beliebigen Größe erzeugen und die Datentypen, die wir schon vorliegen haben, sowohl additiv als auch multiplikativ zu neuen Datentypen verknüpfen. Für jede Datenstruktur, die sich so beschreiben lässt, können wir einen ADT definieren. Ich möchte an zwei Beispielen zeigen, welchen Mehrwert das hat.

Beispiel 1: Verkehrsampel

Ein Datentyp für die drei Zustände einer aktiven Ampel könnte so definiert sein:

--Haskell
data Ampel = Grün | Gelb | Rot

Viele herkömmliche Sprachen scheitern schon daran, einen genau dreiwertigen Datentyp zu erzeugen.

Wenn wir entscheiden müssen, ob jemand für das Überfahren einer Ampel im Straßenverkehr einen Bußgeldbescheid erhalten soll oder nicht, können wir das wie folgt modellieren:

-- Haskell

bußgeldbescheid :: Ampel -> Bool
bußgeldbescheid ampel = case ampel of
    Grün -> False
    Gelb -> False
    Rot -> True

Hier ist bußgeldbescheid eine Funktion von Ampel nach Bool. Die drei Werte des Datentyps Ampel entsprechen den drei Zuständen einer aktiven Ampel. Mit anderen als diesen drei Werten müssen wir uns beim Implementieren nicht auseinandersetzen. Stattdessen können wir uns voll und ganz auf das Wesentliche konzentrieren: die Zuordnung der tatsächlichen Ampelzustände nach Bool. Auch beim Verwenden der Funktion ohne Kenntnis ihrer Implementierung müssen wir uns keine Gedanken über andere als diese drei Werte machen, denn die Funktion akzeptiert nur diese drei und der Compiler sichert das für uns ab.

Ganz anders sieht das aus, wenn wir bei der Modellierung des selben Sachverhaltes ohne ADTs bzw. ohne Summentypen auskommen müssen. Wir sind dann gezwungen, auf bestehende Datentypen zurückzugreifen. Zum Beispiel so:

-- Haskell

grün = 0
gelb = 1
rot = 2

bußgeldbescheid :: Int -> Bool
bußgeldbescheid ampel =
    ampel /= grün && ampel /= gelb

Man kann so programmieren, aber die Funktion hat jetzt für den Compiler einen Definitionsbereich aus 2^64 Werten. Drei davon kümmern uns: nämlich 0, 1 und 2 bzw. grün, gelb und rot. Die übrigen 18446744073709551613 Werte sind für den modellierten Sachverhalt überflüssig. Aber können wir uns darauf verlassen, dass kein anderer Wert an die Funktion übergeben wird? So wie sie jetzt implementiert ist, werden die 18446744073709551613 überflüssigen Werte auf True abgebildet. Das könnte zur Folge haben, dass Bußgeldbescheide an Personen versendet werden, die nicht bei Rot gefahren sind, weil jemand den Aufruf der Funktion vermasselt hat, oder weil jemand die Dokumentation nicht richtig gelesen hat, oder weil die Implementierung sich zwischenzeitlich geändert hat: bei einem Refactoring wurde der Rückgabewert für die 18446744073709551613 überflüssigen Werte verändert und schon tritt ein Fehler auf, den es vorher nicht gab.

Hier liegt ein Fehlerpotenzial vor, das es nicht gibt wenn man ADTs verwendet um die Definitions- und Wertebereiche der Funktionen, Prozeduren und Methoden so festzulegen, dass sie genau zum modellierten Sachverhalt passen.

Beispiel 2: unbekanntes Alter

Stellen wir uns vor, dass wir einen Datentyp für das Alter von Personen als ganze Zahl in Jahren benötigen. Es liegt auf der Hand, dafür den Datentyp Int zu verwenden. Noch besser wäre ein Datentyp für natürliche Zahlen, aber darum kümmern wir uns jetzt nicht.

Komplizierter ist es, wenn wir berücksichtigen müssen, dass das Alter einer Person unbekannt sein kann. Wie bilden wir das ab? Ich zeige erst, wie hier ein ADT helfen kann. Dann kritisiere ich, wie das in Sprachen ohne ADTs gelöst wird. Also, hier mein Vorschlag für einen passenden Datentyp:

-- Haskell
data Alter = AlterUnbekannt | Alter Int

Ein Wert vom Datentyp Alter ist entweder AlterUnbekannt oder Alter mit einem Int Feld. Stellen wir uns die drei Personen Marit, Marta und Max vor. Marit ist 30, Marta ist 11 und von Max kennen wir das Alter nicht.

-- Haskell

maritAlter :: Alter
maritAlter = Alter 30

martaAlter :: Alter
martaAlter = Alter 11

maxAlter :: Alter
maxAlter = AlterUnbekannt

Auf diese Weise unterscheiden wir sauber zwischen einem bekannten und einem unbekannten Alter. Eine Funktion, die Altersklassen bestimmt, könnte so aussehen:

-- Haskell

data Altersklasse
    = Minderjährig
    | Volljährig
    | AltersklasseUnbekannt

altersklasse :: Alter -> Altersklasse
altersklasse Alter = case alter of
    Alter n -> if n < 18 then Minderjährig else Volljährig
    AlterUnbekannt -> AltersklasseUnbekannt

Schauen wir uns die Altersklassen von Marit, Marta und Max an:

-- Haskell Repl

> altersklasse maritAlter
Volljährig

> altersklasse martaAlter
Minderjährig

> altersklasse maxAlter
AltersklasseUnbekannt

Dadurch, dass unser Datentyp Alter ganz ausdrücklich auch den Wert AlterUnbekannt hat, müssten wir uns schon besondere Mühe geben, um diesen Fall bei der Berechnung der Altersklasse unter den Tisch fallen zu lassen.

Aber wie würden wir das ohne ADTs modellieren? Hier noch mal der zu modellierende Sachverhalt: wir müssen das Alter von Personen codieren und dabei berücksichtigen, dass ein Alter unbekannt sein kann. In Sprachen ohne ADTs gibt es für die Codierung solcher Sonderfälle zwei verschiedene Lösungsansätze.

Ansatz 1 ohne ADTs: Numerische Fehlercodes

Bilde Sonderfälle wie Fehlercodes auf Werte ab, die zum selben Datentyp gehören wie die modellierte Größe, aber offensichtlich kein Wert der modellierten Größe sind. Das setzt voraus, dass der Datentyp zusätzlichen Platz dafür bietet. Ein gutes Beispiel dafür ist die Java String-Methode indexOf, die man verwendet um nach Substrings zu suchen. Wenn der Substring gefunden wurde, wertet indexOf zu dem Index aus, bei dem der Substring beginnt. Wenn der Substring nicht gefunden wurde, wertet indexOf zu -1 aus:

// Java
"abc".indexof("c")  // Wertet aus zu: 2
"abc".indexOf("d")  // Wertet aus zu: -1 

Die Idee dahinter ist, dass Indizes natürliche Zahlen sind und dass daher mit dem Rückgabewert -1 offensichtlich kein Index gemeint sein kann. In unserem Fall könnte der Ansatz so aussehen:

-- Haskell

maritAlter :: Int
maritAlter = 30

martaAlter :: Int
martaAlter = 11

maxAlter :: Int
maxAlter = -1

Wenn wir jetzt die Altersklassen berechnen und dabei nicht genau aufpassen, könnte die folgende Funktion zustande kommen:

-- Haskell

data Altersklasse = Minderjährig | Volljährig

altersklasse :: Int ->  Altersklasse
altersklasse alter =
    if alter < 18 then Minderjährig else Volljährig

Die Altersklassen für Marit, Marta und Max sehen dann wie folgt aus:

-- Haskell Repl

> altersklasse maritAlter
Volljährig

> altersklasse martaAlter
Minderjährig

> altersklasse maxAlter
Minderjährig

Das ist natürlich Murks. Wir wissen gar nicht, ob Max minderjährig ist, weil wir sein Alter nicht kennen. Der Fehler ist aber nachvollziehbar: bei der Implementierung von altersklasse legen wir fest, dass Personen minderjährig sind wenn ihr Alter in Jahren kleiner als 18 ist, denn genau so ist Minderjährigkeit definiert. Um den Fehler zu vermeiden, müssen wir wissen, dass hier auch negative Zahlen zu berücksichtigen sind und dass -1 das Fehlen der Altersinformation repräsentiert:

-- Haskell

data Altersklasse
    = Minderjährig
    | Volljährig
    | AltersklasseUnbekannt

altersklasse :: Int ->  Altersklasse
altersklasse alter =
    if alter < 0 then AltersklasseUnbekannt
    else if alter < 18 then Minderjährig
    else Volljährig

Wir gehen hier noch einen Schritt weiter und bilden alle negativen Zahlen auf AltersklasseUnbekannt ab. Dadurch sind wir schon auf der sicheren Seite wenn weitere Fehlercodes dazukommen. Die Altersklassen für Marit, Marta und Max stimmen jetzt wieder:

-- Haskell Repl

> altersklasse maritAlter
Volljährig

> altersklasse martaAlter
Minderjährig

> altersklasse maxAlter
AltersklasseUnbekannt

Javas indexOf Methode hat die gleiche Schwäche. Wenn wir bspw. in vielen Strings den gleichen Substring suchen, um seinen durchschnittlichen Index zu berechnen, dürfen wir nicht vergessen, den Wert -1 gesondert zu verarbeiten. Sonst berechnen wir ein falsches Ergebnis. Besser ist es, wenn wir zwischen der erfolgreichen und der gescheiterten Suche so unterscheiden, dass auch der Compiler diese Unterscheidung absichert. Andernfalls liegt die volle Verantwortung für diese Unterscheidung bei der Person, die programmiert.

Ansatz 2 ohne ADTs: Nullreferenzen

Verwende Referenzdatentypen und stelle fehlende Werte dar als Nullreferenzen. Ein gutes Beispiel dafür ist die Prozedur strstr aus der C-Standard-Bibliothek:

// C
char *result = strstr(myString, mySubstr);

Wenn mySubstr in myString enthalten ist, dann enthält result jetzt eine Referenz auf die Speicheradresse der Anfangsposition von mySubstr innerhalb von myString. Andernfalls enthält result jetzt die Nullreferenz NULL.

In Sprachen, die es erlauben, Nullreferenzen so zu verwenden, muss man höllisch aufpassen: es kann passieren, dass man glaubt, einen Wert zu verarbeiten obwohl man eine Nullreferenz verarbeitet. Das führt leicht zu Laufzeitfehlern. In Haskell, Purescript, etc. kann ich das nicht demonstrieren, weil es in diesen Sprachen keine Referenzdatentypen und keine Nullreferenzen gibt. Deswegen hier ein bisschen Java Code:

// Main.java

class Alter {
    int wert;
    public Alter(int wert) {
        this.wert = wert;
    }
    public int get() {
        return this.wert;
    }
}
class Person {
    public Alter alter;
    enum Altersklasse { minderjährig, volljährig }
    public Altersklasse altersklasse() {
        if (this.alter.get() < 18) {
            return Altersklasse.minderjährig;
        } else {
            return Altersklasse.volljährig;
        }
    }
}
class Main {
    public static void main(String[] args) {
        Person marit = new Person();
        Person marta = new Person();
        Person max = new Person();

        marit.alter = new Alter(30);
        marta.alter = new Alter(11);

        System.out.println(marit.altersklasse());
        System.out.println(marta.altersklasse());
        System.out.println(max.altersklasse());
    }
}

Ich wickle das Alter einer Person hier in einen Datentyp Alter ein, weil Basisdatentypen wie int in Java keine Referenzdatentypen sind. Das passiert wenn wir das Programm compilieren und ausführen:

$ javac Main.java
$ java Main
volljährig
minderjährig
Exception in thread "main" java.lang.NullPointerException
        at Person.altersklasse(Main.java:21)
        at Main.main(Main.java:14)

Wir haben für Max kein Alter festgelegt. Sobald wir seine Altersklasse abrufen, terminiert das Programm mit einer NullPointerException. Selbstverständlich lässt sich das beheben, indem wir die Aufzählung Altersklasse und die Methode altersklasse anpassen, so wie wir es auch weiter oben gemacht haben. Die volle Verantwortung dafür, solche Laufzeitfehler zu vermeiden, liegt hier wieder bei der Person, die programmiert.

Welcher dieser beiden Ansätze ist besser? Das kommt darauf an, ob uns die Vermeidung von Laufzeitfehlern oder die Vermeidung falscher Ergebnisse wichtiger ist. Bei der Haskell-Funktion altersklasse :: Alter -> Altersklasse, mit der wir begonnen haben, erübrigt sich diese Abwägung: Nullreferenzen gibt es nicht und um das numerische Alter mit einem Fehlercode zu ergänzen, führen wir einen neuen Datentyp ein, der durch zwei Wertekonstruktoren sauber unterscheidet zwischen einem bekannten und einem unbekannten Alter.

Parametrische algebraische Datentypen #

Parametriche ADTs sind ADTs, die in ihrer Definition einen sogenannten Typparameter enthalten. Das ist ein Platzhalter, der durch einen konkreten Datentyp ersetzt werden muss, damit aus dem parametrischen ADT ein konkreter Datentyp wird. Solche Datentypen, die erst noch konkretisiert werden müssen, nennt man auch generische Datentypen. Um das Konzept zu demonstrieren, schauen wir uns noch mal den oben definierten Datentyp Alter an. Wir steigen an der Stelle von Haskell auf Purescript um, damit wir weiter unten Record-Literale verwenden können:

-- Purescript
data Alter = AlterUnbekannt | Alter Int

Wir haben diesen Datentyp eingeführt, um zu modellieren, dass das Alter einer Person manchmal bekannt und manchmal unbekannt ist. Aber dieses Problem kann auch bei anderen Daten auftreten. Wir könnten dafür jedes mal einen spezifischen Datentyp einführen:

-- Purescript
data Name = NameUnbekannt | Name String
data Alter = AlterUnbekannt | Alter Int
data Wohnort = WohnortUnbekannt | Wohnort String
data Beruf = BerufUnbekannt | Beruf String
...

Ein beispielhafter Record für Marta mit diesen Datentypen könnte so aussehen:

-- Purescript
marta =
    { name : Name "Marta"
    , alter : Alter 11
    , wohnort : WohnortUnbekannt
    , beruf : BerufUnbekannt
    }

Wir müssen aber nicht jeden Datentyp separat auf diese Weise erweitern. Stattdessen können wir einen generischen Datentyp festlegen, der jeden existierenden Datentyp um die Möglichkeit eines fehlenden Wertes erweitert:

-- Purescript
data Vielleicht a = Unbekannt | Wert a

Damit können wir jeden Datentyp so erweitern, dass ein Wert dieses Typs entweder vorhanden oder abhanden ist. Wenn wir das Alter einer Person kennen, sieht das so aus:

-- Purescript
alter :: Vielleicht Int
alter = Wert 23

Wenn wir das Alter nicht kennen, sieht das so aus:

-- Purescript
alter :: Vielleicht Int
alter = Unbekannt

Das gleiche Spiel mit dem Wohnort:

-- Purescript
wohnort :: Vielleicht String
wohnort = Wert "Leipzig"

Oder wenn der Wohnort unbekannt ist:

-- Purescript
wohnort :: Vielleicht String
wohnort = Unbekannt

Beachte, dass das Alter den Datentyp Int hat aber der Wohnort den Datentyp String. Trotzdem konnten wir in beiden Fällen den generischen Datentyp Vielleicht a einsetzen. Wir haben damit den Datentyp Int auf Vielleicht Int und den Datentyp String auf Vielleicht String erweitert. Ein ganzer Record für Marta könnte so aussehen:

-- Purescript

marta =
    { name : Wert "Marta"
    , alter : Wert 11
    , wohnort : Wert "Leipzig"
    , beruf : Wert "Schülerin"
    }

Damit sind die vier spezifischen Datentypen, die wir oben definiert haben, überflüssig, weil wir sie ersetzen konnten durch einen einzigen generischen Datentyp, der die selbe Aufgabe erfüllt.

In der Praxis würde man einen Datentyp wie Vielleicht a nicht definieren, sondern den Datentyp Maybe a verwenden, der genau für diesen Zweck schon vordefiniert ist und die selbe Struktur hat. Zum Vergleich hier die beiden Datentypen:

-- Purescript
data Vielleicht a = Unbekannt | Wert a
data Maybe a = Nothing | Just a

Es gehört zum guten Programmierstil, die vordefinierten Datentypen zu verwenden wenn die Struktur passt. Das erhöht die Lesbarkeit. Es kann zwar manchmal sinnvoll sein, trotzdem eigene Datentypen zu verwenden, um zu verdeutlichen, worum es geht, aber je mehr man beim Lesen von Code schon kennt, desto verständlicher ist der Code.

Unendlich große Datentypen #

Bisher haben wir nur ADTs mit endlicher Größe betrachtet. Es gibt aber auch ADTs, die unendlich groß sind. Das ist zum Beispiel der Fall bei ADTs, die rekursiv über sich selbst definiert sind. Ein gutes Beispiel dafür sind verkettete Listen:

-- Purescript
List a = Nil | Cons a ( List a )

Nil ist der gebräuchliche Name für die leere Liste. Das hat historische Gründe. Cons nimmt einen Wert und eine Liste und konstruiert daraus wieder eine Liste. Die Idee dabei ist, dass man mit der leeren Liste beginnt und von ihr ausgehend Cons verwendet und durch das sukzessive Hinzufügen weiterer Werte immer größere Listen konstruiert. Hier demonstrieren wir schrittweise den Aufbau einer Liste mit den ersten Ziffern der Kreiszahl Pi 3,1415…

-- Purescript

list0 = Nil
list1 = Cons 5 Nil
list2 = Cons 1 ( Cons 5 Nil )
list3 = Cons 4 ( Cons 1 ( Cons 5 Nil ) )
list4 = Cons 1 ( Cons 4 ( Cons 1 ( Cons 5 Nil ) ) )
list5 = Cons 3 ( Cons 1 ( Cons 4 ( Cons 1 ( Cons 5 Nil ) ) ) )

Haskell unterstützt für verkettete Listen eine kompaktere und freundlichere Syntax, mit der man z.B. list5 schreiben kann als [3,1,4,1,5]. Aber letztlich erfolgt die Konstruktion von verketteten Listen auch in Haskell genau so wie oben beschrieben.

List a ist generisch (bzw. parametrisch). Diese Beispiele haben allesamt den Datentyp List Int. Natürlich können wir auch Listen von Bools oder Floats oder Strings etc. konstruieren. Die Werte einer Liste müssen aber allesamt den selben Datentyp haben.

Aus der Definition des generischen Listendatentyps folgt, dass man aus einer Liste der Größe n immer eine Liste der Größe n+1 konstruieren kann indem man noch ein Element hinzufügt. Daraus ergibt sich, dass Listendatentypen unendlich groß sind. Der Datentyp String ist in Standard-Haskell ein Typalias für List Char und damit auch unendlich groß. In Purescript werden Strings wahrscheinlich irgendwie auf Javascript-Strings abgebildet. Auch davon gibt es unendlich viele, da man sie durch Verkettung beliebig verlängern kann.

Einfache Datentypen sind ADTs #

Ich habe es hier noch nicht erwähnt, aber bestimmt ist es schon aufgefallen: die Namen von Datentypen und von Wertekonstruktoren in einer Typdefinition mit dem data Schlüsselwort beginnen in Haskell und in Purescript immer mit einem Großbuchstaben. In Elm ist es auch so, mit dem Unterschied, dass das Schlüsselwort in Elm nicht data sondern type ist.

Während für den Datentyp Bool mit den Werten False und True in Haskell eine normale Typdefinition vordefiniert ist, ist der Datentyp Boolean mit den Werten false und true in Purescript in den Compiler integriert. Purescript ist dafür gemacht, nach Javascript zu compilieren. Um die zu überbrückende Kluft zwischen den Purescript-Datentypen und den Javascript-Datentypen, die nach der Compilation übrigbleiben, möglichst klein zu halten, orientieren sich die vordefinierten Datentypen in Purescript an den Datentypen, die auch in Javascript verfügbar sind. Deswegen gibt es in Purescript statt Bool den Datentyp Boolean mit den Werten false und true, die vom Compiler in die gleichnamigen Javascript-Werte false und true übersetzt werden. Das gleiche macht der Purescript-Compiler mit Zahlenliteralen, Arrays und Records.

Trotz der Sonderbehandlung durch den Compiler, hätte man die Wahrheitswerte auch in Purescript False und True nennen können. Ich vermute, dass man sich bewusst für die Kleinschreibung entschieden hat, um kenntlich zu machen, dass es sich um keine gewöhnlichen Wertekonstruktoren aus einer data Typdefinition handelt.

Es gibt noch mehr vordefinierte Datentypen – auch in Haskell – deren Werte nicht über eine herkömmliche Typdefinition mit Wertekonstruktoren erzeugt worden sind, sondern auf besondere Weise vom Compiler unterstützt werden. Die numerischen Datentypen sind das beste Beispiel dafür. Eine Sonderbehandlung numerischer Datentypen und Werte durch den Compiler ist schon für die syntaktische Unterstützung der gewöhnlichen Zahlenliterale wie -123.45, 0.0 und 99999 nötig, aber auch für die effiziente Arithmetik auf diesen Datentypen.

In Haskell kommt noch dazu, dass Zahlenliterale wie 1 und 1.0 überladen sind. 1 kann in Haskell sowohl für den Int Wert 1 als auch für den Float wert 1.0 stehen. Es kommen sogar noch weitere Datentypen in Frage:

-- Haskell Repl

> 1 :: Int
1

> 1 :: Integer
1

> 1 :: Float
1.0

> 1 :: Double
1.0

> import Numeric.Natural

> 1 :: Natural
1

Integer ist ein Datentyp für ganze Zahlen, der beliebig große Zahlen aufnehmen kann, solange der Speicherplatz es zulässt. Natural ist ein Datentyp für natürliche Zahlen. Zahlenliterale in Gleitkommaschreibweise sind in Haskell ebenso überladen:

-- Haskell Repl

> 1.0 :: Int
1

> 1.0 :: Integer
1

> 1.0 :: Float
1.0

> 1.0 :: Double
1.0

> import Numeric.Natural

> 1.0 :: Natural
1

Trotz ihrer Besonderheiten, können wir solche Datentypen auch als ADTs auffassen. Die Wahrheitswerte false und true werden in Purescript zwar klein geschrieben, aber das ist nur Syntax. Davon abgesehen können wir komplett ignorieren, dass der Datentyp Boolean nicht auf einer herkömmlichen data Typdefinition basiert. Es folgt daraus nichts, worauf wir beim Programmieren achtgeben müssten. So ist es auch mit den numerischen Datentypen: Zahlenliterale wie -3.1415 und 42 sind zwar keine gewöhnlichen Wertekonstruktoren, aber wir können jeden numerischen Datentyp auffassen als einen Summentyp mit all seinen Werten als Wertekonstruktoren ohne Typfelder:

-- Pseudo-Haskell
data Natural = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ...

Prinzipiell könnten wir die ganzen numerischen Datentypen und die gesamte Zahlenarithmetik auch aus gewöhnlichen data Typdefinitionen nachbauen. Wir müssen uns dafür nur streng an die Konstruktionsregeln halten. Für natürliche Zahlen sind das die Peano-Axiome. Ein selbstgebauter Datentyp für natürliche Zahlen könnte so aussehen:

-- Haskell

data Nat = Null | Nachfolger Nat

plus :: Nat -> Nat -> Nat
plus a b = case a of
    Nachfolger n -> plus n ( Nachfolger b ) 
    Null -> b

mal :: Nat -> Nat -> Nat
mal a b = case a of
    Null -> Null
    Nachfolger Null -> b
    Nachfolger n -> plus b ( mal n b )

Für fast alle praktischen Anwendungen wäre das völlig unbrauchbar. Ohne die spezielle Syntax für Zahlenliterale wäre es sehr sehr umständlich und mühsam, Zahlen aufzuschreiben und zu lesen. Die Zahlenarithmetik wäre damit unendlich langsam. Worum es mir hier geht, ist, dass sich numerische Datentypen, Arrays, Records, Strings usw. zumindest rein formal auch als algebraische Datentypen auffassen lassen. Das verdeutlicht, wie flexibel ADTs sind: jeder Datentyp, der sich als ein Summentyp aus Produkttypen darstellen lässt, kann als ADT beschrieben werden.

Isomorphismen und Aliase #

Bisher haben wir neue Datentypen mit dem data Schlüsselwort definiert. Wir könnten es dabei belassen. Es gibt aber noch zwei weitere Schlüsselwörter, mit denen sich Datentypen definieren lassen: newtype und type.

Isomorphismen

Mit dem newtype Schlüsselwort lassen sich neue Datentypen definieren, die genau einen Wertekonstruktor mit genau einem Feld haben. Damit ist newtype auf den ersten Blick überflüssig, denn dasselbe können wir auch mit dem data Schlüsselwort erledigen:

-- Haskell
data T1 = K1 Bool
newtype T2 = K2 Bool

Wenn man dasselbe auch mit dem data Schlüsselwort machen kann, warum gibt es dann für diesen Spezialfall ein zusätzliches Schlüsselwort? Um das beanworten zu können, müssen wir wissen, dass die Verwendung von Datentypen mit gewissen Laufzeitkosten verbunden ist. Beispielsweise einen Int Wert in einen Maybe Kontext zu stecken und an anderer Stelle wieder aus diesem Maybe Kontext zu befreien: das sind Rechenoperationen, die zur Laufzeit des Programms stattfinden und ein kleines bisschen Speicher und Rechenzeit kosten. Wir nehmen das normalerweise gern in Kauf, weil uns ein korrektes Programm ohne Laufzeitfehler in den meisten Fällen viel wichtiger ist als ein Programm, das möglichst schnell und speichereffizient arbeitet, aber der Sonderfall eines Datentyps T aus genau einem Wertekonstruktor mit genau einem Feld ist hier deswegen interessant, weil bei so einem Datentyp eine Eins-zu-eins-Beziehung zwischen den Werten von T und den Werten des Feldtyps besteht. Hier ein Beispiel:

-- Haskell
data WrapInt = Wrap Int

Dann gilt die Eins-zu-eins-Beziehung:

...
Wrap -3 <--> -3 
Wrap -2 <--> -2 
Wrap -1 <--> -1 
Wrap 0  <--> 0 
Wrap 1  <--> 1 
Wrap 2  <--> 2 
Wrap 3  <--> 3 
Wrap 4  <--> 4 
Wrap 5  <--> 5 
Wrap 6  <--> 6 
Wrap 7  <--> 7 
...

In der Mathematik nennt man so eine Eins-zu-eins-Beziehung einen Isomorphismus. Wenn ein Isomorphismus zwischen Datentypen besteht, ist es schade um den Speicher und die Rechenzeit, die es zur Laufzeit des Programms kostet, Werte zwischen diesen Datentypen zu konvertieren. Hier kommt newtype ins Spiel. Ein Beispiel:

-- Haskell
newtype Password = Password String

Es kann sinnvoll sein, für Passwörter einen eigenen Datentyp einzuführen. Am Ende ist natürlich jedes Passwort einfach nur ein String, aber wenn wir auf der Typebene zwischen String und Password streng unterscheiden, können wir z.B. die Erzeugung von schlechen Passwörtern verhindern. Dafür legen wir ein eigenes Modul an, das den Datentyp Password und bspw. eine sorgfältig geschriebene Funktion makePassword : IO Password enthält. Der entscheidende Trick ist, dass wir den Wertekonstruktor Password nicht exportieren. Dadurch ist Code außerhalb dieses Moduls gezwungen, die Funktion makePassword zu verwenden, um an einen Password Wert zu kommen.

Wir können semantisch (fast) exakt dasselbe auch mit dem data Schlüsselwort erreichen, aber wenn wir das newtype Schlüsselwort verwenden, entfernt der Compiler alle Password Wertekonstruktoren, so dass zur Laufzeit des Programms nur noch gewöhnliche String Werte übrigbleiben. Dadurch entfallen der Speicher und die Rechenzeit, die es sonst zur Laufzeit des Programms gekostet hätte, Werte zwischen diesen Datentypen zu konvertieren.

Übrigens gibt es doch einen semantischen Unterschied zwischen data und newtype, der die Auswertungsstrategie betrifft: Wertekonstruktoren werden bei data verzögert ausgewertet und bei newtype strikt ausgewertet. Was das bedeutet, möchte ich hier nicht erläutern. Der Text ist schon lang genug und der Unterschied ist ohnehin marginal: beide Auswertungsstrategien führen auf den gleichen Wert, wenn sie erfolgreich auswerten. Es gibt aber Fälle in denen die verzögerte Auswertung Erfolg hat aber die strikte Auswertung scheitert. Daher der semantische Unterschied.

Aliase

Das type Schlüsselwort führt einen Typalias bzw. ein Typsynonym ein. Das ist einfach nur ein weiterer Name für einen existierenden Datentyp. Beispielsweise sind herkömmliche Strings in Haskell nichts weiter als verlinkte Listen von Char Werten. Entsprechend ist der Datentyp String wie folgt definiert:

-- Haskell
type String = List Char

Das Beispiel stimmt nicht ganz: der Datentyp List Char wird in idiomatischem Haskell als [Char] geschrieben. Darauf möchte ich hier aber nicht näher eingehen. Leider gibt es in Haskell ein paar Schrulligkeiten wie diese, die die Sprache unnötig und ohne substaniellen Mehrwert verkomplizieren. Deswegen bin ich wirklich froh, dass es mittlerweile Alternativen wie Purescript gibt.

Typklassen #

Typklassen werden unterstützt in Haskell und in Purescript. Sie haben nichts zu tun mit Klassen in der OOP. Ein Konzept aus anderen Sprachen, das mit Typklassen vergleichbar ist, sind Interfaces. Jede Typklasse benennt eine Reihe von Funktionen. Ein Datentyp kann zu einer Typklasse hinzugefügt werden, wenn er alle Funktionen der Typklasse implementiert. Hier ein Beispiel für eine Typklasse mit einer zu implementierenden Funktion:

-- Purscript
class Eq a where
    eq :: a -> a -> Boolean

Ein Datentyp a der Klasse Eq muss also die Funktion eq implementieren, die zwei Werte vom Datentyp a aufnimmt und einen Boolean Wert berechnet. Eq (für equality) ist sowohl in Purescript als auch in Haskell eine vordefinierte Typklasse für die Datentypen, auf denen die Gleichheitsfunktion eq bzw. der Gleichheitsoperator == gegeben sein soll.

Eine Funktion, die wie eq auf mehreren Datentypen operieren kann, nennt man eine polymorphe Funktion. Wenn der Polymorphismus über Typklassen hergestellt wird, spricht man von Typklassenpolymorphismus. Generisch, parametrisch und polymorph sind hier eng verwandte Begriffe. Wenn es um Datentypen geht, spricht man von generischen oder parametrischen Datentypen. Bei Funktionen spricht man von generischen oder polymorphen Funktionen.

Den Code für die Eq Typklasse im Purescript Prelude können wir hier nachlesen:

https://github.com/purescript/purescript-prelude/blob/v6.0.1/src/Data/Eq.purs#L35-L36

Er stimmt mit der oben angegebenen Definition überein. Direkt darunter befindet sich die Definition für den Gleichheitsoperator:

infix 4 eq as ==

Die Zeile legt fest, dass der Infix-Operator == ein Alias für die Funktion eq ist und in der Operatorrangfolge an vierter Stelle steht. Wenn == ein Alias für eq ist und eq eine Funktion der Typklasse Eq ist, dann muss ein Datentyp die Klasse Eq implementieren, damit der Operator == für seine Werte verfügbar ist. Probieren wir das aus:

-- Purescript Repl

> 123 == 123
true

> 123 == 124
false

> "abc" == "abc"
true

> "abc" == "bcd"
false

> false == false
true

> false == true
false

Demnach implementieren die Datentypen Int, String und Boolean die Typklasse Eq. Unser selbst definierter Datentyp Bool implementiert Eq noch nicht:

-- Purescript Repl

> data Bool = False | True

> False == False
...
No type class instance was found for

  Data.Eq.Eq Bool
...

Das können wir aber mit einer Instanzdeklaration nachholen. Mit einer Instanzdeklaration fügt man einen Datentyp zu einer Typklasse hinzu. Anschließend kann man die Funktionen und Operatoren der Typklasse auf Werte von diesem Datentyp anwenden:

-- Purescript

instance Eq Bool where
    eq a b = case [a,b] of
        [False,False] -> true
        [True,True] -> true
        _ ->false

-- Purescript Repl

> False == False
true

Damit ist geklärt, was Typklassen sind: ein Formalismus um polymorphe Funktionen zu beschreiben. Oft hängt an den Funktionen einer Typklasse eine bestimmte Erwartungshaltung hinsichtlich ihrer Semantik, die vom Typsystem nicht erfasst wird. Beispielsweise hätten wir eq auch so angeben können:

-- Purescript

eq a b = case [a,b] of
    [False,False] -> False
    [True,True] -> False
    _ ->True

Damit würde eq nicht mehr auf Gleichheit prüfen sondern auf Ungleichheit. Der Compiler würde die Definition trotzdem akzeptieren, weil der Datentyp passt. Aber der Gleichheitsoperator == würde sich für diesen Datentyp anders verhalten als erwartet und damit für reichlich Verwirrung sorgen.

Man kann zwischen Typklassen auch Abhängigkeiten definieren:

-- Purescript
class (A T, B T, C T) <= D T ...

Das bedeutet, dass ein Datentyp T nur dann zur Typklasse D gehören kann, wenn er auch zu den Typklassen A, B und C gehört. In Haskell sind solche Abhängigkeiten auch möglich. Die Syntax ist fast identisch. Nur der Doppelpfeil wird in Haskell umgekehrt notiert: =>. Wenn links vom Pfeil nur eine Typklasse steht, können die Klammern entfallen.

Elm unterstützt keine Typklassen. In Haskell und Purescript sind zum Beispiel die arithmetischen Operatoren auf den verschiedenen numerischen Datentypen über Typklassen implementiert:

-- Purescript Repl

> :type (+)
forall (a :: Type). Semiring a => a -> a -> a

> :type (-)
forall (a :: Type). Ring a => a -> a -> a

> :type (*)
forall (a :: Type). Semiring a => a -> a -> a

> :type (/)
forall (a :: Type). EuclideanRing a => a -> a -> a

Demnach ist der Operator + in Purescript auf den Datentypen gegeben, die zur Typklasse Semiring gehören, der Operator - auf den Datentypen, die zur Typklasse Ring gehören, usw. Sowohl Haskell als auch Purescript haben eine mathematisch interessante Typklassenhierarchie, aber wenn man sich für Begriffe wie Halbring, Ring und euklidischer Ring nicht interessiert, kann man sie ignorieren: sich mit diesen Begriffen herumzuschlagen, ist keine Voraussetzung dafür, in diesen Sprachen zu programmieren!!!

Typische Idiome der funktionalen Programmierung, wie map, foldr, foldl, traverse, oder der monadische bind Operator >>= sind ebenfalls über entsprechende Typklassen als polymorphe Funktionen definiert. Hier operiert map auf zwei verschiedenen Datentypen:

-- Purescript Repl

> map (_*2) [1,2,3]
[2,4,6]

> import Data.Maybe

> map (_*2) (Just 3)
(Just 6)

Es kommt selten vor, dass ich eigene Typklassen schreibe, weil es mir produktiver erscheint, konkreten Code zu schreiben und dabei nicht darüber nachzudenken, wo sich strukturelle Übereinstimmungen verbergen könnten, aus denen sich vielleicht polymorphe Funktionen ableiten lassen. Probleme, bei denen ich Typklassen vielleicht einsetzen würde, sind Serialisierung und Persistenz. Beispielsweise könnte die Serialisierung und Deserialisierung zwischen einem Datentyp a und Json über zwei polymorphe Funktionen ablaufen:

-- Purescript
class JsonSerializable a where
    toJson :: a -> String
    fromJson :: String -> Maybe a

Dazu würde ich noch fordern, dass für alle Instanzdeklarationen gelten sollte:

fromJson ( toJson value ) == Just value
map toJson ( fromJson string ) == Just string

Diese Regeln kann man zwar nicht vom Compiler absichern lassen, aber eine JsonSerializable Instanz, die sie nicht einhält, ist offensichtlich unbrauchbar.

Eine Typklasse für die Persistenz in einer Datenbank könnte so aussehen:

-- Haskell
class Db a where
    insert :: a -> IO String
    update :: String -> a -> IO ()
    read :: String -> IO ( Maybe a )

Ich muss unbedingt dazusagen, dass ich mir darüber maximal drei Minuten Gedanken gemacht habe. Es ist sehr wahrscheinlich, dass ich hier irgendetwas entscheidendes vergessen oder übersehen habe. Als Beispiel sollte uns das an dieser Stelle trotzdem genügen. Ich beschreibe kurz die Funktionen:

  • insert persistiert einen Wert vom Datentyp a als neuen Record in der Datenbank und liefert einen String, den wir später als Schlüssel verwenden können, um diesen Wert zu aktualisieren oder wieder auszulesen.
  • update nimmt einen String als Schlüssel entgegen und aktualisiert den entsprechenden Record in der Datenbank mit einem Wert vom Datentyp a.
  • read nimmt einen Schlüssel entgegen und liefert den entsprechenden Wert vom Datentyp a aus der Datenbank. Weil wir nicht garantieren können, dass jeder Schlüssel auf einen gültigen Record in der Datenbank verweist, ist dieser Wert in einen Maybe Kontext eingeschlossen.

Ich hätte wirklich Lust, herauszufinden, ob das ein gutes Design oder zu naiv ist, aber erst muss ich diesen Text fertig schreiben.

Im nächsten Abschnitt gehe ich auf Multiparameter-Typklassen ein. Vorher möchte ich noch eine Bemerkung zu Typklassen im Allgemeinen loswerden: Typklassenpolymorphie ist ein produktives Konzept, aber es lohnt sich nicht immer, auf Typklassen und polymorphe Funktionen zu setzen. Wenn die Polymorphie einem formalen Kalkül folgt, so wie es bei der Typklasse Eq und bei den arithmetischen Operationen der Fall ist, oder wenn es darum geht, Datentypen um Funktionalität zu erweitern wie bei der Typklasse JsonSerialize, dann sind Typklassen sinnvoll. Aber man sollte nicht versuchen, Typklassenpolymorphie zu erzwingen: manchmal ist der Code besser ohne Typklassen. Elm kommt ganz ohne Typklassen aus.

Multiparameter-Typklassen #

Eine Vorbemerkung: Man muss bei den Programmierkonzepten der Typebene, die ich hier vorstelle, ein bisschen darauf achten, dass man den Ball flach hält. Wenn man mit einem vordefinierten Datentyp gut auskommt, muss man dafür keinen ADT definieren. Wenn es keinen Grund dafür gibt, einen parametrischen ADT zu definieren, sollte man es bei einem parameterlosen ADT belassen. Wenn man sich nicht ganz sicher ist, ob man eine polymorphe oder mehrere monomorphe Funktionen definieren sollte, ist es besser, auf Polymorphie zu verzichten. Dieser Appell gilt umso mehr für die Konzepte, die jetzt folgen. Mit einem guten Verständnis für ADTs, parametrische ADTs und einfache Typklassen ist man schon sehr gut aufgestellt. Solange man diese Konzepte nicht richtig verinnerlicht hat, muss man sich mit den Konzepten, die jetzt noch folgen, nicht befassen.

Typklassen können mehr als einen Typparameter haben. Auf die Weise können die polymorphen Funktionen einer Typklasse Beziehungen zwischen Datentypen beschreiben. Die Typklasse Db von gerade eben bietet sich an, um zu zeigen, wofür das nützlich sein kann. Mich stört an dieser Typklasse ein bisschen, dass der Datentyp für die Schlüssel auf String festgelegt ist. Warum nicht Int? Oder ein spezieller Datentyp Uuid für sogenannte Universally Unique Identifiers? Also erweitern wir diese einfache Typklasse zu einer Typklasse mit zwei Typparametern:

-- Haskell
class Db key value where
    insert :: value -> IO key
    update :: key -> value -> IO ()
    read :: key -> IO ( Maybe value )

Auf diese Weise sind wir für die Schlüssel nicht auf den Datentyp String festgelegt, sondern können dafür einen Datentyp frei wählen. Hier noch ein Beispiel für eine Multiparameter-Typklasse aus dem Purescript-Buch:

-- Purescript
class Stream stream element where
    uncons :: stream -> Maybe { head :: element , tail :: stream }

Die Idee dabei ist, dass ein Wert vom Datentyp stream ein Datenstrom aus Werten vom Datentyp element ist. Die Funktion uncons trennt diesen Datenstrom auf in den ersten Wert (head) und den Rest des Datenstroms (tail). Auf die Weise lassen sich die Werte in einem Datenstrom durch wiederholtes Aufrufen von uncons der Reihe nach verarbeiten. Sobald der Datenstrom leer ist, liefert uncons den Wert Nothing. Wir fügen die Datentypen String und Char zur Typklasse Stream hinzu, indem wir eine passende uncons Funktion angeben:

-- Purescript
instance Stream String Char where
    uncons = String.uncons

Dass die Funktion String.uncons hier genau passt, ist günstig für uns. Als nächstes fügen wir den generischen Datentyp Array a zusammen mit dem entsprechenden Datentyp a zur Typklasse Stream hinzu. Auch hier gibt es wieder eine passende uncons Funktion für Arrays:

-- Purescript
instance Stream ( Array a ) a where
    uncons = Array.uncons

Jetzt können wir mit der polymorphen uncons Funktion der Stream Typklasse sowohl Char Werte aus String Datenströmen als auch a Werte aus Array a Datenströmen entnehmen.

Funktionale Abhängigkeiten #

Es wäre jetzt naheliegend, aus uncons eine tail Funktion abzuleiten:

-- Purescript
tail :: String -> Maybe String
tail stream = map _.tail ( uncons stream )

Wir wenden erst uncons auf das Argument an. Dann wenden wir die Feldfunktion _.tail auf das Zwischenergebnis an. Das Zwischenergebnis ist in einen Maybe Kontext eingebettet. Deswegen müssen wir map einsetzen, um die Feldfunktion auf den Wert im Kontext anzuwenden. Das ist eine sinnvolle Definition. Trotzdem ist der Compiler nicht mit ihr zufrieden:

No type class instance was found for Main.Stream String t

Was funktioniert hier nicht? Dafür müssen wir (ganz grob) verstehen, wie der Compiler mit polymorphen Funktionen umgeht. Die beiden Instanzdeklarationen haben wir nicht ohne Grund hinzugefügt. Wenn wir die polymorphe Funktion uncons aufrufen, versucht der Compiler, die passenden Datentypen zu ermitteln, um prüfen zu können, ob ihm für diese Datentypen eine Instanzdeklaration bekannt ist. Wir wollen hier natürlich auf die Instanz Stream String Char hinaus. Ein Blick in obige Fehlermeldung verrät, dass die Typinferenz bei Stream String t hängen bleibt. Der Compiler ist offenbar nicht in der Lage, für den Typparameter t zu inferieren, dass es sich um den Datentyp Char handeln muss. Ich weiß nicht, wo genau es klemmt, aber es stimmt, dass wir bei der Definition unserer neuen tail Funktion dem Compiler nirgendwo einen Hinweis darauf geben, dass es sich bei t um Char handeln muss.

Wenn wir das polymorphe uncons durch String.uncons ersetzen, ist der Compiler zufrieden, aber dann können wir uns die Typklasse gleich sparen. Wenn wir uncons um eine Typannotation ergänzen, die ausdrücklich auf Char hinweist, ist der Compiler ebenfalls zufrieden:

-- Purescript
tail :: String -> Maybe String
tail stream = do
    let f :: String -> Maybe { head :: Char , tail :: _ }
        f = uncons
    map _.tail ( f stream )

Aber schön oder sinnvoll ist das auch nicht. Welchen Wert hat die Typklassenpolymorphie, wenn wir sie erst einführen und sie dann zur Hälfte wieder zurücknehmen?

Interessanterweise können wir den Compiler auch zufriedenstellen, indem wir die Definition der Typklasse Stream wie folgt anpassen:

-- Purescript
class Stream stream element | stream -> element where
    uncons :: stream -> Maybe { head :: element , tail :: stream }

Hinzugekommen ist der Teil | stream -> element vor dem where Schlüsselwort. Man nennt das eine funktionale Abhängigkeit. Wir vereinbaren dadurch mit dem Compiler, dass sich aus dem Datentyp für den Typparameter stream stets eindeutig ergeben muss, welcher Datentyp für den Typparameter element einzusetzen ist. Das hat bei mir zunächst für Kopfkratzen gesorgt. Dann ist mir aufgefallen, dass wir nur eine einzige Instanzdeklaration der Form Stream String ... angegeben haben, nämlich Stream String Char. Damit ist die funktionale Abhängigkeit stets erfüllt wenn wir für stream den Datentyp String einsetzen: für element kommt dann nur der Datentyp Char in Frage. Die andere Instanzdeklaration Stream ( Array a ) a erfüllt ebenfalls die Forderung nach funktionaler Abhängigkeit, denn wenn Array a (und damit stream) auf einen konkreten Datentyp festgelegt ist, dann ist auch a selbst (und damit element) eindeutig auf einen Datentyp festgelegt. Der Compiler ist damit zufriedengestellt solange wir keine weiteren Instanzdeklarationen hinzufügen, die diese Eindeutigkeit verletzen.

Verallgemeinerte algebraische Datentypen (GADTs) #

Weiter oben haben wir schon eine Verallgemeinerung der einfachen ADTs kenngenlernt: die parametrischen ADTs. Verallgemeinerung bedeutet, dass das Verallgemeinerte zum Spezialfall des Verallgemeinernden wird. Bei den parametrischen ADTs besteht der Spezialfall eines einfachen, parameterlosen ADT darin, dass die Anzahl seiner Typparameter 0 ist: jeder parameterlose ADT ist ein parametrischer ADT mit 0 Typparametern!

Die Sprache wird bei solchen Dingen oft etwas ungenau. Wenn wir von einem parametrischen ADT sprechen, meinen wir natürlich einen ADT mit mindestens einem Typparameter, sonst müssten wir den allgemeineren Begriff nicht bemühen. Aber wenn jemand über einen ADT sagt: “das ist kein parametrischer sondern ein einfacher ADT”, dann ist das genaugenommen ein Widerspruch und damit falsch. Im Gespräch sollte man das nicht zum Anlass nehmen, gleich mit dem Belehrfinger zu fuchteln, aber man sollte im Hinterkopf behalten, dass genaugenommen der konkretere Begriff in dem allgemeineren Begriff enthalten ist.

GADTs (generalized algebraic data types) sind wiederum eine Verallgemeinerung der parametrischen ADTs. Also ist jeder parameterlose und jeder parametrische ADT stets auch ein GADT. Aber auch über ADTs und GADTs wird häufig so gesprochen als seien es disjunkte Konzepte. Zum Beispiel enthält der englische Wikipedia-Artikel über GADTs in seiner aktuellen Form (2023-06) den folgenden Code-Kommentar:

-- A parametric ADT that is not a GADT

Im Haskell-Wiki und in der GHC-Dokumentation gibt es ähnliche Formulierungen. Das ist nicht schlimm, aber auch hier sollten wir im Hinterkopf behalten, dass formal der konkretere Begriff im allgemeineren Begriff enthalten ist.

Neue Syntax

GADTs bringen in Haskell neben der Verallgemeinerung auch eine eigene Syntax mit. Ich möchte zunächst herkömmliche ADTs in dieser GADT-Syntax aufschreiben. Dann schauen wir uns an, worin genau die Verallgemeinerung besteht. Hier noch mal Bool in der herkömmlichen ADT-Syntax:

-- Haskell
data Bool
    = False
    | True

So würde man Bool in der GADT-Syntax schreiben:

-- Haskell
data Bool where
    False :: Bool
    True :: Bool

Der Ampel Datentyp in herkömmlicher ADT-Syntax:

-- Haskell
data Ampel
    = Grün
    | Gelb
    | Rot

Der Ampel Datentyp in GADT-Syntax:

-- Ampel in GADT-Syntax
data Ampel where
    Grün :: Ampel
    Gelb :: Ampel
    Rot :: Ampel

Herkömmliche ADTs in GADT-Syntax aufzuschreiben, ist kein Gedankenexperiment: die Beispiele sind gültiger Haskell Code und können genau so verwendet werden. Bei der GADT-Syntax fällt gleich auf, dass die Wertekonstruktoren mit Typannotationen versehen sind. Bei parameterlosen ADTs in GADT-Syntax sind diese Typannotationen überflüssig: selbstverständlich haben hier alle Wertekonstruktoren den selben Datentyp. Schauen wir uns als nächstes ein paar echte parametrische ADTs in GADT-Syntax an. Hier noch mal Maybe a in der herkömmlichen Syntax:

-- Haskell
data Maybe a
    = Nothing
    | Just a

Zum Vergleich, Maybe a in GADT-Syntax:

-- Haskell
data Maybe a where
    Nothing :: Maybe a
    Just :: a -> Maybe a

Der Wertekonstrukor Just ist interessant. Er hat den Datentyp a -> Maybe a. Das ist immer der Fall, auch wenn wir den Datentyp in der herkömmlichen ADT-Syntax aufschreiben, aber hier schreiben wir den Datentyp explizit hin. Hier noch mal der Datentyp List, diesmal in Haskell:

-- Haskell
List a
    = Nil
    | Cons a ( List a )

In GADT-Syntax:

-- Haskell
List a where
    Nil :: List a
    Cons :: a -> List a -> List a

Die neue Syntax für GADTs ist ein Wink mit dem Zaunspfahl bzgl. der Verallgemeinerung, um die es geht. Bei einem herkömmlichen parametrischen ADT kann jeder Typparameter stets nur für alle Wertekontruktoren gemeinsam auf einen Datentyp festgelegt werden. Bei einem GADT kann jeder Typparameter für verschiedene Wertekonstruktoren auf verschiedene Datentypen festgelegt werden. Hier ein GADT mit drei Wertekonstruktoren:

-- Haskell
data MyGadt a where
    MyInt :: Int -> MyGadt Int
    MyBool :: Bool -> MyGadt Bool
    MyString :: String -> MyGadt String

Das besondere an einem GADT wie diesem ist, dass die Wertekonstruktoren den Datentyp jeweils unterschiedlich konkretisieren: der Wertekonstruktor MyInt erzeugt immer einen MyGadt Int, der Wertekonstruktor MyBool erzeugt immer einen MyGadt Bool und der Wertekonstruktor MyString erzeugt immer einen MyGadt String. Aber MyGadt hat nur diese drei Wertekonstruktoren. Daraus ergibt sich ein entscheidender Sachverhalt: der Typparameter a kann hier konstruktionsbedingt nur die Datentypen Int, Bool oder MyString annehmen.

Bei einem GADT können wir die Typparameter durch seine Wertekonstruktoren so einschränken, dass konstruktionsbedingt nur noch ganz bestimme Datentypen auf die Typparameter passen. Mehr noch: wir können die Wertekonstruktoren so entwerfen, dass sie Beziehungen zwischen den verschiedenen Konkretisierungen eines generischen Datentyps beschreiben. Es ist ein bisschen so als würden wir mit einem GADT nicht nur einen Datentyp beschreiben, sondern eine ganze Gruppe von Datentypen und ihre Beziehungen zueinander. Wer schon mal eine kleine formale Sprache entworfen und in eine Reihe von Datentypen gegossen hat oder einen Parser für eine formale Sprache geschrieben hat, müsste hier hellhörig werden. Tatsächlich eignen sich GADTs sehr gut dafür, DSLs oder ganze Programmiersprachen zu beschreiben. Grundsätzlich geht das auch mit herkömmlichen ADTs, aber mit GADTs lassen sich solche Beschreibungen kompakter formulieren und mehr Invarianten auf der Typebene festhalten. Als Beispiel dafür schauen wir uns die kleine arithmetische Sprache an, die der Wikipedia-Artikel zu GADTs enthält:

-- Haskell
data Expr a where
    EInt :: Int -> Expr Int
    EBool :: Bool -> Expr Bool
    EEqual :: Expr Int -> Expr Int -> Expr Bool

Der Wertekonsturktor EInt erzeugt immer einen Expr Int Wert. Die Wertekonstruktoren EBool und EEqual erzeugen immer einen Expr Bool Wert. Deswegen kann der Typparameter a hier nur die Typen Int und Bool annehmen. Dazu gehört noch die eval Funktion, die einen Expr a Ausdruck in einen Wert vom Typ a überführt:

-- Haskell
eval :: Expr a -> a
eval expr = case expr of
    EInt n -> n
    EBool b -> b
    EEqual a b -> eval a == eval b

Damit sollte das folgende Programm False ausgeben:

-- Haskell
main = do
    let v1 = EInt 123
    let v2 = EInt 124
    let result = EEqual v1 v2
    putStrLn ( show ( eval result ) )

Eine Sache, die mich ein bisschen daran stört, ist dass wir EEqual nicht auf Expr Bool Werte anwenden können. Das ist aber leicht behoben:

-- Haskell

data Expr a where
    EInt :: Int -> Expr Int
    EBool :: Bool -> Expr Bool
    EEqual :: Eq a => Expr a -> Expr a -> Expr Bool

eval :: Expr a -> a
eval expr = case expr of
    EInt n -> n
    EBool b -> b
    EEqual a b -> eval a == eval b

Damit sollte auch das folgende Programm compilieren und False ausgeben:

-- Haskell
main = do
    let v1 = EBool False
    let v2 = EBool True
    let result = EEqual v1 v2
    putStrLn ( show ( eval result ) )

Wie würde man das ohne GADTs modellieren? Keine Ahnung! Aber wir können uns anschauen, was übrigbleibt, wenn wir Expr auf einen parametrischen ADT reduzieren:

-- Haskell
data Expr a
    = EInt Int
    | EBool Bool
    | EEqual ( Expr a ) ( Expr a )

Wenn wir den so angepassten Datentyp in der GADT-Syntax aufschreiben, sehen wir besser, was sich geändert hat:

-- Haskell
data Expr a where
    EInt :: Int -> Expr a
    EBool :: Bool -> Expr a
    EEqual :: Expr a -> Expr a -> Expr a

Der Datentyp ist so völlig unbrauchbar. Es gibt jetzt keine Beziehung mehr zwischen dem Typparameter a und den Feldtypen von EInt und EBool:

-- Haskell

v1 :: Expr Int
v1 = EBool True

v2 :: Expr String
v2 = EInt 123

Der Wertekonstruktor EEqual ist so auch unbrauchbar. Wenn EEqual einen Expr Bool Wert erzeugen soll, muss er auch auf zwei Expr Bool Werten operieren. Schauen wir uns trotzdem an, wie weit wir bei der eval Funktion kommen:

-- Haskell
eval :: Expr a -> a
eval expr = case expr of
    EInt n -> undefined
    EBool b -> undefined
    EEqual e1 e2 -> undefined

Was sollen wir z.B. für EInt n berechnen? Der Wert n hat hier den Datentyp Int, aber eval operiert auf einem Expr a Wert und erzeugt einen a Wert. Wir bräuchten eine Funktion Int -> a für beliebige Datentypen a. Das wäre zwar rein formal möglich (also typisierbar) aber wie sollen wir für jeden möglichen Datentyp ad hoc und aus dem Nichts einen Wert herzaubern? Die gleiche Frage stellt sich für EBool b. Bei EEqual e1 e2 ist das Problem noch gravierender, weil wir nur dann zu einem Bool Wert auswerten können, wenn e1 und e2 vom Datentyp Expr Bool sind. Die ganze Konstruktion ist also völlig unbrauchbar.

Wenn wir Expr in einen parameterlosen ADT umbauen, kommt z.B. das heraus:

-- Haskell
data Expr
    = EInt Int
    | EBool Bool
    | EEqual Expr Expr

Das erscheint mir auf den ersten Blick schon sinnvoller. Schauen wir uns an, wie eval aussehen könnte:

-- Haskell
eval :: Expr -> a
eval expr = case expr of
    EInt n -> undefined
    EBool b -> undefined
    EEqual e1 e2 -> case ( e1 , e2 ) of
        ( EInt n1 , EInt n2 ) -> undefined
        ( EBool b1 , EBool b2 ) -> undefined
        _ -> undefined

Das ist wieder eine Sackgasse. Wir können eval zwar von Expr -> a konkretisieren auf Expr -> Bool oder auf Expr -> Int, aber das findet immer für die gesamte Funktion inklusive aller Case-Zweige statt. Wir können nicht in einem Case-Zweig nach Bool und im nächsten nach Int auswerten. Überhaupt müssen wir ja nach a auswerten, auch wenn für a weder Bool noch Int eingesetzt worden ist. Wenn wir für a einen konketen Wert einsetzen, wird die Sache einfacher. Für einige Anwendungen genügt sicherlich eine Funktion eval :: Expr -> String:

-- Haskell
eval :: Expr -> String
eval expr = case expr of
    EInt n -> show n
    EBool b -> show b
    EEqual e1 e2 -> case ( e1 , e2 ) of
        ( EInt n1 , EInt n2 ) -> show ( n1 == n2 )
        ( EBool b1 , EBool b2 ) -> show ( b1 == b2 )
        _ -> show False

Andernfalls sehe ich nicht, wie man hier ohne GADTs auskommen könnte. Ich hoffe, ich konnte demonstrieren, was man mit GADTs anstellen kann. Für mich ist das Konzept noch relativ neu.

Epilog #

Dieser Text ist für mich in erster Linie eine Art Selbsttest: kann ich mein Verständnis der Konzepte, die ich hier vorstelle, ausformulieren und aufschreiben, so dass ich hinterher damit zufrieden bin, auch wenn ich den Text zwischenzeitlich für ein paar Wochen beiseite gelegt habe? Wenn nicht, werde ich weiter daran arbeiten (sofern ich Zeit und Lust dazu habe). Mein Bezug zu diesem Thema ist ein praktischer. Bisher bin ich noch nicht dazu gekommen, mich ernsthaft mit Büchern über Typentheorie zu befassen. Insofern hoffe ich, dass sich hier kein Blödsinn eingeschlichen hat. Das wäre mir wirklich peinlich. Wenn der Text noch jemandem außer mir selbst nutzt, freut mich das umso mehr.