Funktional im Web unterwegs mit ReasonML, Teil 2: ausgefeiltes Typsystem

ReasonML bietet viele Vorzüge der funktionalen Programmierung und hat ein ausgeklügeltes Typsystem.

In Pocket speichern vorlesen Druckansicht 12 Kommentare lesen
Funktional im Web unterwegs mit ReasonML, Teil 2: ausgefeiltes Typsystem
Lesezeit: 14 Min.
Von
  • Marco Emrich
Inhaltsverzeichnis

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.

Funktional im Web unterwegs mit ReasonML – der Zweiteiler

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.

Der folgende Screenshot zeigt die hervorgehobene Typinferenz von ReasonML. In der Praxis ist es nur selten erforderlich, Typen anzugeben.

Die Typinferenz von ReasonML vereinfacht den Umgang mit Typen (Abb. 1).

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.

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.

Der ReasonML-Compiler erkennt falsche Argumente (Abb. 2).

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.