Funktional im Web unterwegs mit ReasonML, Teil 2: ausgefeiltes Typsystem
ReasonML bietet viele Vorzüge der funktionalen Programmierung und hat ein ausgeklügeltes Typsystem.
- Marco Emrich
Nachdem der erste Teil der Artikelserie die Entstehungsgeschichte von ReasonML und das Zusammenspiel mit JavaScript beleuchtet hat, widmet sich der zweite Teil vor allem dem Typsystem. Dieses geht weit über vergleichbare Ansätze hinaus und ermöglicht die Entwicklung von Webanwendungen mit höherer Fehlerresistenz. Dabei verliert ReasonML nicht seinen Pragmatismus und bietet gute Integrationsmöglichkeiten mit bestehendem JavaScript-Code.
Ein starkes Typsystem
Bei Typsystemen streiten sich die Gemüter: In Entwicklerforen finden sich unzählige erhitzte, häufig ideologisch geprägte Grabenkämpfe zwischen den Verfechtern statischer und denen dynamischer Typisierung.
Tatsächlich gibt es gute Argumente für beide Positionen. Nüchtern betrachtet läuft es auf eine Kosten-Nutzen-Abwägung heraus. Zu den klaren Vorteilen statischer Typsysteme gehören unter anderem:
- Typsicherheit: Zahlreiche potenzielle Fehler erkennt der Compiler bereits zur Build-Zeit, sodass Entwickler sie beheben können, bevor sie später Anwender in den Wahnsinn treiben.
- Autovervollständigung: Kennt die IDE das Typsystem, kann sie hilfreiche Vorschläge unterbreiten.
Die Vorteile haben ihren Preis, denn zu den Nachteilen gehören:
- Code-Rauschen: Typ-Annotationen blähen den Code auf und verschlechtern die Lesbarkeit.
- Erhöhte Komplexität: Zu der Komplexität einer algorithmischen Lösung kommt die der richtigen Typisierung hinzu und erschwert das Verständnis.
- Erhöhter Wartungsaufwand: Gerade Refactorings sind oft aufwendiger, da beim Verschieben von Codeelementen zusätzlich Typen anzupassen sind.
Die genannten Argumente sind umstritten und treffen je nach Sprache und Umgebung unterschiedlich stark zu. Dass es eine Art Kosten-Nutzen-Faktor gibt, dürfte allerdings offensichtlich sein. Daraus entsteht der Wunsch nach einem Typsystem, das die Balance zu optimieren versucht, und ReasonML hat dafür einige Konzepte an Bord.
Weg mit dem Typrauschen
Der folgende Screenshot zeigt die hervorgehobene Typinferenz von ReasonML. In der Praxis ist es nur selten erforderlich, Typen anzugeben.
Die im Screenshot in hellem Grau hinterlegten Typannotationen hat das ReasonML-Plug-in für VSCode generiert. Es greift dafür direkt auf die Toolchain der Sprache zu. Beispielsweise bedeutet die Annotation über der sum
-Funktion list(int) => int
, dass sie eine Liste aus int
-Werten entgegen nimmt und einen einzigen Integer zurückgibt. Zu verdanken hat ReasonML die Typinferenz dem sogenannten Hindley-Milner-Typsystem, das in allen ML-basierten-Sprachen wie F# und OCaml zum Einsatz kommt. Der Compiler prüft, ob alle Typen zusammenpassen. So entsteht eine hohe Typsicherheit ohne das begleitende Code-Rauschen.
Etiketten für Typen
Der Nutzwert des Typsystems steht und fällt mit der Genauigkeit der verwendeten Typen. Für rein primitive wie int
und string
ist er nicht sonderlich hoch. Beim Verwechseln eines Produktnamens mit einer URL oder eines Preises mit einer ID schlägt das Typsystem keinen Alarm. Das folgende Codebeispiel illustriert das Problem: Die Funktion speed
soll die Geschwindigkeit eines Sprechers berechnen, der von einem Konferenzraum zum nächsten wechselt:
let durationToNextRoom: float = 300.0;
let wayToNextRoom: float = 200.0;
let speed = (way: float, time: float) => way /. time;
speed(durationToNextRoom, wayToNextRoom);
Die Typ-Annotationen : float
dienen lediglich der Verdeutlichung. Dank der Typinferenz sind sie nicht notwendig. Die letzte Codezeile vertauscht beim Aufruf von speed
die Parameter. Da jedoch alle Werte vom Typ float
sind, hat der Compiler keine Chance, den Fehler zu erkennen.
Eine erste Maßnahme sind Typ-Aliase:
type secs = float;
type meter = float;
let durationToNextRoom: secs = 300.0;
let wayToNextRoom: meter = 200.0;
let speed = (way: meter, time: secs) => way /. time;
speed(durationToNextRoom, wayToNextRoom);
Damit lässt sich zumindest an der Funktionsdefinition erkennen, dass way
in Metern und time
in Sekunden anzugeben sind. Die vertauschten Argumente erkennt der Compiler aber immer noch nicht.
Tatsächlich Abhilfe schaffen sogenannte Tagged-Types:
type secs = Secs(float);
type meter = Meter(float);
let speed = (way, time) => {
let Meter(m) = way;
let Secs(s) = time;
m /. s
}
let durationToNextRoom = Secs(300.0);
let wayToNextRoom = Meter(200.0);
speed(durationToNextRoom, wayToNextRoom);
Secs
und Meter
sind Typkonstruktoren. Sie schaffen einen neuen Typ, der den float
-Wert wie in einem Paket verpackt. Letzteres erhält einen zusätzlichen Tag als Etikett, das den Inhalt beschreibt. Der Wert ist getaggt und der Compiler weiß zu jeder Zeit, um was es sich genau handelt.
Die speed
-Funktion muss nun die eigentlichen Werte jeweils wieder entpacken. Dafür bietet ReasonML Destructoring an, das ähnlich dem in modernem JavaScript funktioniert: let Meter(m) = way;
.
Nach dieser Zeile referenziert m
den ausgepackten Wert aus dem Parameter way
und die Funktion kann die Berechnung m /. s
durchführen. Dabei ist /.
der Divisionsoperator für float-Werte. Ein explizites return
am Ende einer Funktion benötigt ReasonML nicht.
Ein ungewolltes Vertauschen der Parameter ist nun nicht mehr möglich. Der Compiler schlägt sofort Alarm.
Flexible Handhabung
Noch einen Schritt weiter gehen sogenannte Variant Types, die Alternativen bei der Typisierung abbilden wie in folgendem Codeausschnitt:
type secs = Secs(float);
type meter = Meter(float);
type km = KiloMeter(float);
type distance =
| Meter(float)
| KiloMeter(float);
let speed = (way: distance, time: secs) => {
let Secs(s) = time;
switch (way) {
| Meter(m) => m /. s
| KiloMeter(km) => 1000. *. km /. s
};
};
speed(Meter(200.0), Secs(300.0));
Der Typ distance
lässt sich entweder in Metern oder in Kilometern angegeben. Das muss die Funktion speed
berücksichtigen und beispielsweise mit einer switch
-Anweisung passend reagieren.