Design von Datentypen
Der folgende Podcast kann genutzt werden, um die Inhalte dieses Kapitels noch einmal zu wiederholen.
In diesem Kapitel wollen wir uns mit zwei Best Practices beim Entwurf von Datentypen beschäftigen. Diese Best Practices lassen sich nicht nur auf Elm anwenden, sondern sind auf andere Programmiersprachen übertragbar.
Boolean Blindness
Zuerst wollen wir einen Aspekt betrachten, der
Boolean Blindness
genannt wird und vermutlich auf den Artikel Boolean Blindness von Robert Harper zurückgeht.
Mit diesem Begriff bezeichnet man den Verlust von Information, wenn man einen booleschen Datentyp verwendet.
Genauer gesagt geht bei der Verwendung des Datentyps Bool
die Information verloren, welche Bedeutung die beiden Fälle jeweils haben.
Im Grunde ist Boolean Blindness eine Instanz eines allgemeineren Phänomens, wenn die Werte eines Datentyps nur mit zusätzlicher Information interpretiert werden können.
Dieses Phänomen tritt etwa bei der Kodierung von Fehlercodes als Integer auf.
Als Beispiel für Boolean Blindness betrachten wir die folgende Funktion in einer Elm-Anwendung, die einen Button liefert.
mainButton : String -> Bool -> msg -> Html msg
mainButton label isDisabled onClick =
button
(buttonStyle ++ [ disabled isDisabled, Events.onClick onClick ])
[ text label ]
Während wir in der Definition dieser Funktion identifizieren können, welche Bedeutung das Argument isDisabled
hat, ist dies bei einem Aufruf der Form mainButton "+" False IncreaseCounter
schwierig.
Im Abschnitt Records haben wir bereits einen Ansatz kennengelernt, um dieses Problem zu beheben.
Wir können einen Record verwenden, um den Argumenten einer Funktion sprechende Namen zuzuordnen.
Wir können die Funktion mainButton
zum Beispiel wie folgt definieren.
mainButton : { label : String, isDisabled : Bool, onClick : msg } -> Html msg
mainButton { label, isDisabled, onClick } =
button
(buttonStyle ++ [ disabled isDisabled, Events.onClick onClick ])
[ text label ]
Ein Aufruf dieser Funktion hat nun die Form mainButton { label = "+", isDisabled = False, onClick = IncreaseCounter}
und ist damit sehr viel aussagekräftiger.
Wenn der boolesche Wert aber zum Beispiel nicht direkt im Argument der Funktion mainButton
bestimmt wird, sondern zum Beispiel aus dem Zustand der Anwendung stammt, müssen an allen Stellen einen Record verwenden und diesen Record durch die Anwendung reichen.
Wenn der Record wie im Beispiel mainButton
zwei Felder hat, können wir diesen Record nicht verwenden, um die Daten durch die Anwendung zu reichen, da die beiden Informationen aus unterschiedlichen Quellen stammen können.
Ein alternativer Ansatz, um die Interpretation von False
und True
explizit zu machen, ist die Verwendung von benutzerdefinierten Aufzählungstypen.
Das heißt, statt den Datentyp Bool
zu verwenden, definieren wir uns einen Datentyp der folgenden Art.
type Interaction
= Enabled
| Disabled
Wenn wir diesen Datentyp für die Implementierung einer Funktion mainButton
nutzen, erhalten wir einen Aufruf der Form mainButton "+" Enabled IncreaseCounter
.
Bei diesem Aufruf können wir am Aufruf selbst bereits erkennen, dass der Button deaktiviert wird.
In den Elm-Standardbibliotheken werden trotz der Boolean Blindness häufig boolesche Werte verwendet.
Ein Beispiel für das Problem der Boolean Blindness in den Standard-Bibliotheken ist die Funktion filter
.
Wenn wir den Typ der Funktion filter
betrachten
filter : (a -> Bool) -> List a -> List a
ist nicht klar, ob das Prädikat für diejenigen Elemente True
liefert, die in der Liste verbleiben sollen, oder für die Elemente, die aus der Liste entfernt werden sollen.
Auch in diesem Beispiel können wir grundsätzlich einen Record verwenden, um dem booleschen Wert eine Semantik zuzuordnen.
Wir können zum Beispiel die folgenden Definition von filter
nutzen, um die Bedeutung des Typs Bool
zu signalisieren.
Hier erkennt man aber gut, dass dieser Ansatz seine Grenzen hat.
filter : (a -> { keep : Bool }) -> List a -> List a
Wenn wir stattdessen den folgenden Datentyp definieren
type Decision
= Discard
| Keep
und diesen in der Definition von filter
nutzen
filter : (a -> Decision) -> List a -> List a
drückt das Ergebnis der Funktion, die wir an filter
übergeben, sehr explizit aus, ob wir das Element behalten oder verwerfen möchten.
Zur Illustration betrachten wir ein Beispiel aus dem Kapitel Funktionale Abstraktionen, um die Verwendung dieser Funktion zu illustrieren.
startWithA : List User -> List User
startWithA users =
List.filter
(\user ->
if String.startsWith "A" user.firstName then
Keep
else
Discard
)
users
In diesem Code ist sehr explizit, wann ein Element in der Liste verbleibt und wann es entfernt wird.
Das Beispiel illustriert aber auch gut die Grenzen dieses Ansatzes.
Durch die Verwendung von selbstdefinierten Aufzählungstypen müssen an vielen Stellen Umwandlungen zwischen diesen Typen implementiert werden.
Im Beispiel startWithA
muss etwa der Typ Bool
, den die Funktion String.startsWith
liefert, in den Typ Decision
der Funktion filter
umgewandelt werden.
Das heißt, wie häufig in der Programmierung, gibt es einen Tradeoff zwischen Explizitheit und Komplexität des Codes.
Man sollte dennoch bei jeder Verwendung des Typs Bool
darüber nachdenken, ob ein selbstdefinierter Aufzählungstyp besser geeignet ist.
Insbesondere ist es bei einem Aufzählungstyp möglich, weitere Fälle hinzuzufügen. Während es zu Anfang ggf. nur zwei Zustände gibt, kommt es häufig vor, dass im Laufe der Zeit weitere Zustände hinzukommen.
Impossible States
Eine weitere Best Practice wird im Kontext von Elm als
Making Impossible States Impossible
bezeichnet und geht auf den Vortrag Making Impossible States Impossible von Richard Feldman aus dem Jahr 2016 zurück. Allgemeiner im Kontext funktionaler Programmierung wurde das gleiche Konzept unter dem Slogan
Make Illegal States Unrepresentable
schon im Jahr 2010 von Yaron Minsky postuliert. Grundsätzlich ist aber anzunehmen, dass diese Idee noch sehr viel älter ist.
In der Programmierung in Elm aber auch ganz allgemein in anderen Programmiersprachen sollte man sich bemühen, Datentypen so zu strukturieren, dass nur valide Zustände modelliert werden können.
Um diesen Punkt zu illustrieren, betrachten wir das folgende Modell einer Elm-Anwendung.
type State
= Loading
| Success
| Failure
type alias Model =
{ state : State
, error : Maybe Error
, items : List Item
, settings : Settings
}
Dieses Modell wird in einer Anwendung genutzt, die Daten von einem Server lädt.
Das Feld state
definiert, ob die Daten aktuell geladen werden, der Ladevorgang bereits beendet ist oder ein Fehler aufgetreten ist.
Der Typ Error
modelliert verschiedene Arten von Fehlern, die in der Anwendung auftreten können.
Der Eintrag items
enthält eine Liste von Daten, die in der Anwendung verarbeitet werden.
Der Eintrag settings
enthält Informationen über die Konfiguration des User Interface, also etwa ob der Light oder der Dark Mode verwendet wird.
Wie der Slogan Making Impossible States Impossible schon andeutet, hat die von uns gewählte Struktur den Nachteil, dass wir Zustände modellieren können, die es gar nicht gibt.
Das heißt, einige Ausprägungen des Datentyps sollten in der Anwendung gar nicht auftreten.
Treten sie doch auf, ist an irgendeiner Stelle ein Fehler in unserer Anwendung.
Die Frage wäre etwa, was es bedeutet, wenn unsere Anwendung im Zustand Success
ist, aber ein Fehler vorhanden ist.
Alternativ könnte die Anwendung auch im Zustand Loading
sein, es könnten aber Daten vorhanden sein.
Zusätzliche Regeln, die von einem Datentyp eingehalten werden müssen, bezeichnet man als Invarianten. Grundsätzlich sind Invarianten ein wichtiges Konzept bei der Modellierung von Daten. Wenn ein Datentyp Invarianten erfordert, müssen wir diese aber entweder zur Laufzeit überprüfen und einen Fehler werfen, wenn sie nicht eingehalten werden, oder wir müssen ignorieren, ob die Invarianten erfüllt sind oder nicht. Außerdem müssen Entwickler*innen beim Erstellen und Verändern von Daten darauf achten, dass die Invarianten eingehalten werden. Daher sind Invarianten, die durch die Struktur der Datentypen ausgedrückt werden, ein großer Vorteil. Das heißt, wir möchten den Datentyp gern so umstrukturieren, dass man möglichst wenige invalide Zustände erstellen kann und somit mit möglichst wenig impliziten Invarianten auskommt.
Zuerst einmal sollte es nur im Zustand Success
auch Daten geben.
Daher verändern wir die Struktur des Datentyps so, dass der Wert vom Typ List Item
ein Argument des Konstruktors Success
ist.
Ein Fehler sollte wiederum nur auftreten, wenn wir im Zustand Failure
sind.
Daher erhält der Konstruktor Failure
als Argument einen Error
.
In diesem Fall können wir den Maybe
-Kontext entfernen, da wir auch immer eine Fehlermeldung vom Typ Error
haben sollten, wenn ein Fehler aufgetreten ist.
Durch diese Umformungen erhalten wir die folgenden Datentypen.
type Data
= Loading
| Failure Error
| Success (List Item)
type Model
{ data : Data
, settings : Settings
}
Durch Verwendung dieses Datentyps können wir nur noch valide Zustände ausdrücken.
Wenn die Anwendung im Zustand Loading
ist, sind weder Daten noch ein Fehler vorhanden.
Wenn die Anwendung im Zustand Failure
ist, ist immer genau ein Fehler vorhanden.
Wenn die Anwendung im Zustand Success
ist, ist eine Liste von geladenen Daten vorhanden.
Diese veränderte Form der Datentypen hat einen weiteren Vorteil.
Wenn wir auf die Daten in Form der List Item
zugreifen möchten, müssen wir zuvor Pattern Matching auf den Datentyp Data
durchführen.
Das heißt, wir müssen explizit überprüfen, in welchem der Fälle wir uns befinden.
In der ursprünglichen Modellierung können Entwickler*innen auf das Feld items
direkt zugreifen und damit ggf. vergessen, zu überprüfen, wie der Zustand der Anwendung ist.
Dieser Aspekt wird auch in dem Blogartikel How Elm Slays a UI Antipattern von Kris Jenkins hervorgehoben.
Dort wird illustriert, dass die ursprüngliche Modellierung des Datentyps zu einem verbreiteten Fehler in Anwendungen führt, bei dem Daten bereits angezeigt werden, obwohl die Anwendung sich noch im Ladezustand befindet.
Es gibt noch eine Vielzahl von anderen Beispielen für das Konzept Making Impossible States Impossible etwa die Modellierung von zwei Dropdows zur Wahl einer Stadt in einem Land oder die Modellierung von Kontaktbucheinträgen. Unter diesen Slogan oder dem Slogan Make Illegal States Unrepresentable lassen sich auch Beispiele in anderen Programmiersprachen finden.