Eine Blog-Serie zum Vergleich von Elm, ReactJS und AngularJS anhand eines praktischen Beispiels. Hier geht es direkt zu [Teil 1, 2, 3, 5, 6]
Endlich sind wir soweit, dass wir mit unserer Beispiel-Applikation anfangen können.
Zur Erinnerung: Ziel ist es, eine Browser-Applikation zu bauen, die von SCRABBLE-Spielern genutzt werden kann, um Anagramme zu generieren für die Buchstaben-Steine, die sie aus dem Säckchen gezogen haben und nun auf ihrer Bank liegen.
Anagramme sind diejenigen Worte, die sich durch Umsortieren dieser Buchstaben ergeben, und die in unserem Falle auch gültige deutsche Worte sein müssen.
Also, nicht FUN GAME, sondern UMFANGE. Im Scrabble ist das sogar ein BINGO, also ein Wort, bei dem alle 7 Buchstaben-Steine des Spielers verwendet werden können.
Elm, React und Angular sitzen im Bus zwischen Schwanden und Elm und schauen zu den gewaltigen Gebirgsketten hoch. Von dort würde sie die Luftseilbahn dann direkt in das rauhe Gelände hieven. Alle drei hielten sich aber oft in klimatisierten Räumen mit Monitor-Beleuchtung auf, und keiner von ihnen war vertraut mit den Konditionen rund um Mount Anagramm. Elm ruft die Tourdaten auf seinem Smartphone ab und fasst aus ihrer Sicht zusammen, wie die steinige Bergrealität sich präsentieren wird:
Gelände-Bedingungen
Wort-Brocken
Damit dieses Programm überhaupt möglich wird, benötigt es eine Liste von möglichst vielen gültigen, deutschen SCRABBLE-Wörtern. Glücklicherweise bin ich im Besitz einer solchen Liste, wenn auch einer etwas älteren. Die Liste enthält praktisch alle im Duden erschienenen Wörter, mit ihren Beugungen, Deklinationen und sämtlichen temporalen Formen. Die längsten Wörter haben 15 Buchstaben (Das ist die Breite des Spielbretts).
Echo
Weil diese Wort-Liste selbst in optimierter und komprimierter Form recht gross ist, macht es mehr Sinn, einen Web-Service anzubieten, der die Anagramm-Suche durchführt, als diese Daten auf jeden Client zu serialisieren.
Wegweiser
Mit Elm kommt eine vorgeschlagene Architekur, die Elm Architektur. Die Grundidee ist, dass jedes Programm in einem Model seinen Zustand hält, mit einer Update-Funktion (und nur damit) wird dieser Zustand verändert und mit einer View wird der Zustand angezeigt.
Dieses Muster zieht sich durch alle Module durch, die zusammen den gesamten Applikations-Zustand ausmachen. Dies führt dann dazu, dass jedes Modul nur die drei Funktionen init, update und view anbietet, und dazu eine Reihe von Nachrichten-Typen definiert, die von update verarbeitet werden können.
Klingt simpel, ist es auch. Darum folgen wir diesem Stil wie Lemmings. Im verlinkten Artikel wird der update-loop grafisch dargestellt und beschrieben, wie das ganze mit Subscriptions zusammengehängt wird. Wir werden in einem weiteren Teil auf elm-Subscriptions eingehen.
Etappen-Ziel
SCRABBLE-Buchstaben
Auf dieser Bank sind zwei mir bekannte Bingos versteckt. Das Finden des Anagramms ist die eine Sache, das Wort muss auch auf dem Brett platziert werden können, indem bereits gelegte Steine verwendet werden. (Ausser natürlich beim ersten Zug des Spiels)
Zu Beginn seines Zuges hat ein Spieler im Regelfall 7 Buchstaben-Steine vor sich auf der Bank liegen. Auf jedem Stein ist ein Buchstabe abgebildet – in anderen Sprachen sogar mehrere pro Stein – und ein Buchstaben-Wert.
Die Ausnahme bilder der BLANKO-Stein, ohne Buchstaben und mit Wert 0.
Der Spieler darf frei wählen, für welchen Buchstaben der Stein als Platzhalter dienen soll.
In der deutschen Variante des Spiels, und um die soll es hier gehen, gibt es total 102 Steine im Sack, 2 davon sind Blankos.
(K)einen Stein machen
Anstatt beliebige Zeichenketten als Eingabe zu erlauben, sollen Benutzer nur Eingaben machen können, die auch während eines echten Spiels so hätten stattfinden können.
Wenn wir also für jede Abfrage diese Regeln einführen:
- Nicht mehr als zwei Blankos zulässig
- Nicht mehr als 12 andere, beliebige Buchstaben.
- Nur Buchstaben, die auch so aus dem Sack gezogen hätten werden können. z.B. nicht mehr als 5 mal A
dann hat das diese Vorteile:
- Die App wird spannender und kommt näher an ein “echtes” Problem.
- Der durchschnittliche Anwender wird nicht in Versuchung geführt, exzessive Such-Anfragen zu gestalten (15 Blankos).
- Die Antwortzeiten sind sehr kurz in diesem Rahmen.
- Die Buchstaben-Werte können z.B. für Sortierung nach Wortwert verwendet werden.
Die Regeln müssen natürlich und vor allem auch serverseitig durchgesetzt werden, aber dazu kommen wir später.
Agil zum Ziel
An dieser Stelle wäre der Plan gewesen, im Projekt elm-anagrams weiterzufahren (siehe Teil 1), aber Elm hat seit Beginn der Blog-Serie die Version gewechselt. Mit dem Update von 0.16 auf 0.17 hat es einige Veränderungen gegeben (allesamt Vereinfachungen für den Programmierer). Das Package Graphics.Element, welches wir im Hello.elm verwenden, ist jetzt aber kein Paket der Kern-Bibliothek mehr. Wir könnten es wieder einbinden, aber ich bin lieber cutting-edge, darum schmeiss ich das völlig sinnlose Hello.elm weg und aktualisiere meine elm-Plattform kurzerhand:
rm Hello.elm rm elm-package.json rm -rf elm/stuff rm elm.js npm i -g elm elm-package install -y
Hier findet man die Implementation von Hello World für die aktuelle Version: examples/hello-html
Modul Tile.elm
Anmerkung: Sublime Text bietet mit dem Package Elm Language Support exzellente Elm-Unterstützung. (Der Support, der von Atom und LightTable angeboten wird, ist sogar noch besser, wie ich zwischenzeitlich herausgefunden habe)
Erstellen wir also unser erstes 0.17-elm Modul, in einem File namens Tile.elm und definieren darin die für die Spielsteine benötigten Strukturen:
module Tile exposing (..) type alias TileDef = { letter : String , value : Int , count : Int } type alias Id = Int type alias Model = { id : Id , def : TileDef , blankoLetter: Maybe String }
Zuoberst ist die Modul-Deklaration. Im exposing (..) könnten anstelle der Punkte auch komma-separierte Namen der exportierten Funktionen aufgelistet werden. Momentan ist diese einfache Schreibweise willkommen. So wird alles exportiert und kann nun auch von anderen Modulen verwendet werden.
TileDef ist eine Typ-Definition für einen Spielstein: Welcher Buchstabe, welcher Wert, wie oft er im Sack vorkommt. Diese Art von Typ nennt sich in Elm ein Record, und ist eine leichtgewichtige Datenstruktur mit Feldern beliebigen Typs.
Id stellt ein Alias für den Basis-Typ Int dar. Damit soll bloss der Zweck dieses Int auch beim Lesen deutlich gemacht werden. Nun kann Id gleichbedeutend mit Int (einer Ganzzahl) verwendet werden.
Das Model in diesem Modul ist ebenfalls ein Record, der die Daten, den Zustand, eines einzelnen Spielsteins beschreibt.
- Jeder Stein braucht eine Id, damit er von anderen mit der gleichen TileDef unterschieden werden kann.
- Spannender ist das Attribut blankoLetter. Es ist vom Typ Maybe String. Dies bedeutet, dass der Wert einen String annehmen kann, er kann aber auch Nothing sein.
Maybe sollte man nicht mit null vergleichen, das es in vielen anderen Sprachen gibt. Wir werden noch sehen, dass die Konzepte sich sehr unterscheiden. null ist nicht Typ-sicher, es hat keinen Typ.
Dafür hat sich der Erfinder der Null-Referenz, Tony Hoare, sogar öffentlich entschuldigt; er nennt es seinen ‘Billion-Dollar mistake‘
init
Die Elm-Architektur schlägt drei Funktionen vor: init, update und view.
Die erste Funktion soll das Modell initialisieren. Daher ist ihr Rückgabe-Typ vom Typ Model. Als Input kriegt sie, was sie von der Programmlogik her braucht. In unserem Fall sind das eine Id und eine TileDef. Mit Signatur:
init: Id -> TileDef -> Model init id tileDef = { id = id , def = tileDef , blankoLetter = Nothing -- wird erst später gesetzt }
Zur Repetition: Die Signatur einer Funktion in elm liest sich folgendermassen:
Name : Input1 -> Input2 -> … -> Output
Die Implementation fängt mit dem Namen der Funktion an. Daruf folgt eine Liste von Parameter-Namen, so viele und in der Reihenfolge, wie sie deklariert wurden, mit Leerschlag voneinander getrennt.
init id tileDef =
Nach dem = folgt dann der “Body”, der einen Wert zurückgeben muss gemäss Signatur.
{ id = id , def = tileDef , blankoLetter = Nothing }
Es wird also ein neues Model initialisiert, indem die übergebenen Parameter in einem neuen Record gesetzt werden. Der blankoLetter wird zu Beginn auf Nothing gesetzt.
Keine neuen Erkenntnisse, nur eine neue Schreibweise, mit wesentlich weniger “Geräusch”. Auch der Formatierungs-Stil ist ‚elm-ish‘, und eignet sich sehr gut für späteres Refactoring.
Eine alternative Schreibweise für die Initialisierung eines Records, die zwar schneller zu schreiben, aber nicht so verständlich zu lesen ist, wäre der Aufruf des Typ-Alias als Funktion mit den Parametern in der Reihenfolge, wie sie im Record angeordnet sind:
init id typeDef = Model id def Nothing
update
Als nächstes kommt die update Funktion. Unser Tile unterstützt genau eine Art von Update, nämlich das Setzen der Bedeutung eines Blanko-Steines.
Die Signatur von Update sieht folgendermassen aus:
update: Msg -> Model -> Model
Es soll eine Msg, also eine Nachricht, und ein Model kriegen und wieder ein Model zurückliefern. Auf diese Art wird jedes Model, jeder Zustand, in den nächsten Zustand überführt.
Das sollte nicht so schwierig zu erreichen sein, wenn wir einfach einen Typ namens Msg definieren (also:
type Msg = SetBlankoLetter String
Dies ist ein Typ, der nur zusammen mit einem String komplett ist.
Die Funktion update wird damit erst mal so implementiert:
update: Msg -> Model -> Model update msg model = case msg of SetBlankoLetter letter -> if model.def.letter == " " then { model | blankoLetter = Just letter } else model
In dem switch-ähnlichen Konstrukt case … of findet ein Pattern-Matching statt. In unserem Fall gibt es nur einen case zu behandeln, wo wir eine msg vom Typ SetBlankoLetter erhalten, mit dem nun instanziierten letter-String-Wert. Dann wird mit einem if…then…else geprüft, ob es sich wirklich um einen Blanko handelt. Falls ja, dann müssen wir den letter mit Just in von String in seinen richtigen Typ Maybe String verwandeln. Sieht lustig aus, man gewöhnt sich schnell daran.
Zurückgegeben wird ein NEUES Model, eine Kopie des Parameters, AUSSER des Attributs blankoLetter, das wird nämlich auf Just letter, also den Wert der übergebenen Msg, gesetzt.
Im else -Fall, wenn es kein Blanko ist, wird der Zustand, das model, unverändert zurückgegeben.
view
Um etwas zu sehen, braucht es noch die Funktion view. Mit Signatur sieht das im einfachsten Falle so aus:
view: Model -> Html Msg view model = div [] [ text (toString model) ]
Also: Model rein, Html raus. Das muss keine Html-Antwort sein, aber für unser Beispiel wollen wir Html-Output.
Anmerkung: Am Return-Typ hängt noch ein Msg dran. Das soll erst nicht weiter verwirren, wir schauen das im Zusammenhang mit Benutzer-Interaktionen nochmals genauer an.
div hat zwei Parameter: eine Liste der Attribute und eine Liste seiner Elemente. Das einzige Element darin wird von Html.text erzeugt, welches nach einem String-Parameter verlangt.
toString ist aus dem core-Package und verwandelt Objekte jeden Typs in ihre textuelle Darstellung.
Elm-Package einbinden
Html ist jedoch nicht Teil des core-Packages. Damit wir die Funktionen div und text benutzen können, müssen wir das html-package einbinden:
elm-package install elm-lang/html -y
und oben im File Tile.elm wird die Abhängigkeit deklariert und das Package importiert. Und zwar gleich wieder alles davon mit (..):
module Tile where (..) import Html exposing (..)
Wenn wir explizit hätten sein wollen, dann hätten wir bloss die drei benötigten Funktionen importiert:
import Html exposing (Html,div,text)
main
Das komplette File Tile.elm sieht nun so aus:
module Tile exposing (..) import Html exposing (Html,div,text) type alias TileDef = { letter : String , value : Int , count : Int } type alias Id = Int type alias Model = { id : Id , def : TileDef , blankoLetter: Maybe String } type Msg = SetBlankoLetter String init: Id -> TileDef -> Model init id tileDef = { id = id , def = tileDef , blankoLetter = Nothing -- wird erst später gesetzt } update: Msg -> Model -> Model update msg model = case msg of SetBlankoLetter letter -> { model | blankoLetter = Just letter } view: Model -> Html msg view model = div [] [text (toString model)]
Es fehlt bloss noch ein Test, bzw. ein Hauptprogramm. Auch in elm heisst dieses main, und kann verschiedene Typen zurückgeben. In unserem Fall ist es ein Html-Typ einer beliebigen Art. (Mehr dazu bei den Benutzer-Aktionen)
Zuunterst im File wird das Modul mit diesen Zeilen getestet:
letterA : TileDef
letterA =
TileDef "A" 1 5 -- erste Methode, um einen Record zu initialisieren, Reihenfolge wie in Record-Def.
blanko : TileDef
blanko =
{ count = 2, letter = " ", value = 0 } -- zweite Methode, Reihenfolge egal
main: Html msg
main =
let
a1 = init 0 letterA
a2 = init 1 letterA
b1 = init 2 blanko
b2 = update (SetBlankoLetter "Q") b1
in
div [] [ view a1
, view a2
, view b1
, view b2
]
(Mit let wird ein Block mit Zuweisungen geöffnet, der mit in wieder geschlossen wird. let und in müssen genau untereinander stehen; es darf auch nichts dazwischen sein)
Ich war für den Blog-Post hier im ersten Modul sehr gründlich, aber man muss die Signaturen nicht immer angeben, ganz im Gegenteil, da Elm eine Sprache ist mit Typ-Inferenz, also Typen in den meisten Fällen anhand vorhandener Angaben herleiten kann, wird es dadurch noch “geräusch”-freier.
Am besten probiert man es selber aus, lässt hie und da mal eine Signatur weg, oder baut absichtlich einen Fehler ein, denn der Compiler, und darauf kommen wir gleich, gibt sich wirklich Mühe, dem Programmierer mit seinen Meldungen behilflich zu sein.
Build & Run
Natürlich ist der elm-reactor, der nun gestartet wird, oder immer noch läuft, die naheliegendste Option, um seine Fortschritte zu überprüfen.
Auf der Kommando-Zeile elm-reactor eingeben und auf http://localhost:8000 erscheint das für 0.17 überarbeitete GUI:
Ein Klick auf Tile.elm startet die Kompilierung und zeigt entweder die noch zu wenig gelobten, sinnreichen Fehlermeldungen, oder unser für diesen Teil abschliessendes Ergebnis:
Während der Entwicklung mit Sublime Text, benutze ich lieber den eingebauten Build-Support des ‘Elm Language Support’-Package mit Ctrl+B. Der gibt mir Fehler-Highlighting und schlägt fehlende Signaturen vor.
In beiden Fällen wird selbstverständlich der Compiler elm-make gestartet, und sollte mal etwas nicht rund laufen, dann empfiehlt es sich, das elm-stuff/-Verzeichnis zu löschen und elm-package install -y && elm-make Tile.elm auszuführen (oder welche Datei es dann zu kompilieren gibt).
Wann sind wir endlich da?
Es ist mir bewusst, dass diese Blog-Serie stellenweise etwas ausführlich ist. Es geht mir aber wirklich darum, Elm auch weniger erfahrenen Entwicklern näher zu bringen. Und die sind vielleicht hie und da dankbar um das zusätzliche Detail.
Leider werden die Posts dadurch auch sehr lange, wodurch sich die Modellierung des Spielsteins in AngularJS und ReactJS auf den nächsten Teil verschiebt.
Dieser wird jedoch bedeutend weniger ausführlich ausfallen. Aber auch mit der unverkennbaren Absicht, bei jedem sich bietenden Hinweis die Dominanz von Elm zu verkünden.
Mit den Steinen würde sie fertig werden, da ist sich Elm sicher. Trotzdem wird ihr leicht mulmig zumute beim Gedanken an die Martinsloch-Sage, die sie kurz vor Abreise gelesen hatte. Sollte es das sein, was die drei da oben erwartet?