Vor einiger Zeit habe ich der Episode We’re Teaching Functional Programming Wrong des Podcasts Corecursive lauschen müssen. Darin erzählt ein Richard Feldman von einer rein funktionalen Sprache mit der man tatsächlich dynamische Web-Seiten erstellen kann, ohne verstehen zu müssen, was ein Monad ist. Da ich objekt-orientierte Programmierung für überbewertet halte*Der Vortrag Free your Functions von Klaus Iglberger beleuchtet einige praktische Aspekte meines Standpunkts., habe ich diesen Ansatz kurze Zeit später ausprobiert. In diesem Post werde ich von meinen ersten Gehversuchen, Hürden und Fortschritten in Elm berichten und den Fokus auf für mich ungewohnte Aspekte legen.
Elm Installation und Entwicklungsumgebung
Man kann z.B. wie ich den Paketmanager npm verwenden oder alternativ Binaries herunterladen. Als IDE verwende ich VS Code. Zusätzlich habe ich elm-format
per npm installiert, damit VS Code per Shift-Alt-F meinen Quelltext formatieren kann. Mit
elm make src/File.elm --output app.js
kompiliert man eine Elm-Quelldatei nach JavaScript. Das Erzeugnis kann man per
<script src="app.js"></script>
<script>
Elm.Calc.init({node: document.getElementById("app")});
</script>
in eine HTML-Seite einbinden.
Private Altersvorsorge
Da private Altersvorsorge bei uns derzeit ein Thema ist, habe ich als Mini-Testprojekt einen Rechner gebaut, der die jährliche Verzinsung, die monatliche Sparrate und die Anzahl der Sparjahre erhält und den Kontostand am Ende der Sparzeit ausspuckt. Das Ergebnis seht ihr in der folgenden Box, die ich hier per <iframe></iframe>
eingebunden habe.
Der Fokus liegt im Folgenden weder auf den Formeln noch auf einer grundlegenden Einführung in Elm. Stattdessen präsentiere ich ein paar für imperativ programmierende Wesen*Künstliche Intelligenzen sind mitgemeint wie mich*Ich habe in der Vergangenheit viel mit Python und C++ programmiert. Mit JavaScript habe ich fast keine Erfahrungen. Als Schüler habe ich JavaScript-Schnipsel per Copy+Paste verwendet um Button-Bilder beim Hover-Event der Maus auszutauschen. Und kürzlich habe ich ein kleines Nuxt-Frontend zusammengestümpert. ungewohnte Aspekte. Eine grundlegende Einführung in Elm findet man z.B. im Elm-Guide. Ich habe auch das Buch Elm in Action*Achtung! Das ist KEIN Affiliate-Link! Achtung! käuflich erworben. Meinen kompletten Code gibt es auf Github.
Das letzte Element
Die sequentielle Standarddatenstruktur in Elm ist eine Liste. Im Gegensatz zu Python-Listen gibt es keinen Random-Access. Man kann also nicht per someList[i]
auf das i
-te Element der Liste zugreifen. Man kann auch nicht auf das letzte Element der Liste zugreifen. Da ich das ungefähr die ganze Zeit benötigt habe, ist die folgende kleine Hilfsfunktion last
entstanden.
last : List Float -> Float
last inputs =
Array.fromList inputs
|> Array.get (List.length inputs - 1)
|> Maybe.withDefault -1
In den ersten beiden Zeilen sehen wir die Signatur der Funktion. Es wird eine Liste inputs
als Argument erwartet. Aus dieser wird eine Fließkommazahl als Ergebnis berechnet. Wie dem imperativ programmierenden Wesen eventuell aufgefallen ist, fehlen sowohl Klammern um Argumente als auch ein return
-Schlüsselwort. Beides wird in Elm nicht benötigt. Der Wert des Ausdrucks wird ohne return
direkt zurückgegeben. Um nun das letzte Element der Liste zurückzugeben, wandeln wir die Liste in einen Array
um, der in Elm Random-Access erlaubt. Man könnte sich fragen, ob man sich die Kopie sparen könnte indem man einfach immer einen Array verwendet. Allerdings sind für den Typen Array
in Elm andere hilfreiche Funktionalitäten wie map2
, range
oder sum
nicht implementiert, die ich an anderer Stelle verwendet habe.
Funktionen werden mit |>
komponiert, d.h. $(f \circ g)(x)=f(g(x))$ kann in Elm durch
g x |> f
implementiert werden. Eines meiner Lieblingsfeatures in Elm ist, dass partielle Funktionsanwendung direkt verfügbar ist. Wenn man z.B. in Python eine Funktion def g(x, y): [...]
definiert hat, kann man durch
f = partial(g, x=2)
eine Funktion f
in y
definieren, die der Funktion g
mit x=2
entspricht. In Elm ist eine zusätzliche Funktion höherer Ordnung wie partial
aus dem Python-Modul functools
für partielle Funktionsanwendung nicht nötig. Die entsprechende partielle Anwendung der Funktion g x y = [...]
, also die Definition einer Funktion in y
mit festem x=2
ist einfach
g 2
.
Das heißt, in der Funktion last
wandelt Array.fromList
eine Liste in einen Array um und übergibt den Array an die partiell angewandte Funktion
Array.get (List.length inputs - 1)
.
Da
Array.get: Int -> Array a -> Maybe a
einen Integer und einen Array mit Elementen des Datentypen a
als Argumente erwartet und das Element an der Stelle definiert durch den Integer-Parameter zurückgibt, liefert die partiell angewandte Funktion Array.get (List.length inputs - 1)
das letzte Element des Arrays. Genau genommen ist das Ergebnis nicht das letzte Element der Liste sondern ein Maybe
, also ein optionaler Wert, der existieren kann oder auch nicht. Ich drücke mich in diesem Post um saubere Fehlerbehandlung, z.B. im Falle einer leeren Liste, und packe in Annahme der Existenz des letzten Elements den optionalen Typ einfach mit der partiell angewandten Funktion Maybe.withDefault -1
aus. Diese gibt im Fehlerfall eine -1 zurück.
Testen und Debuggen
Von Elm-programmierenden Wesen*Auch hier schließe ich künstliche Intelligenzen mit ein. Ich habe aber noch keine Elm-programmierende künstliche Intelligenz getroffen. habe ich den Satz “If it compiles, it works” gehört. Das klingt zwar gut, geht aber streng genommen zu weit. Denn Logikfehler im Programm kann der Compiler natürlich nicht erkennen. Der einfachste Weg, um die Richtigkeit der Funktion festzustellen, den ich gefunden habe, besteht in der Verwendung des npm-Pakets elm-test
. Mit den Imports
import Expect
import Test exposing (..)
kann man folgenden Test-Code für die Funktion last
schreiben.
testLast : Test
testLast =
describe "last"
[ test "last with 2 elts"
(\_ -> Expect.equal (last [ 0, 4 ]) 4)
, test "last with 1 elts"
(\_ -> Expect.equal (last [ 4 ]) 4)
, test "last with 10 elts"
(\_ -> Expect.equal (last (List.repeat 9 0 ++ [ 4 ])) 4)
, test "last with 0 elts"
(\_ -> Expect.equal (last []) -1)
]
Hier sehen wir auch ein weiteres typischen Merkmal funktionaler Programmierung und zwar anonyme Lambda-Funktionen. Zur Erklärung der Notation betrachten wir das Beispiel \x -> x * x
. Diese Funktion berechnet das Quadrat ihres Arguments x
. Die im Test-Code vorkommende Lambda-Funktion
\_ -> Expect.equal (last [ 0, 4 ]) 4)
erwartet keine Argumente und ist ein valider zweiter Parameter der Funktion test
. Einen vernünftigen Weg, Elm-Programme interaktiv zu debuggen habe ich noch nicht gefunden. Ich konnte zwar an Breakpoints im JavaScript-Kompilat anhalten. Daraus bin ich aber nicht schlau geworden. Wenn man per Ausgabe und ohne Breakpoints debuggen möchte, kann man Debug.log
verwenden. Dabei gibt die Funktion ihr zweites Argument zurück und logt es zusätzlich in die Konsole. Wenn man also Zeile 5 im obigen Snippet durch
(\_-> ;Expect.equal (Debug.lo "last o [0, (last [ 0, 4 ])) 4)
ersetzt, funktioniert der Test weiterhin da das zweite Argument von Debug.log
einfach weitergereicht wird. Das erste Argument ist vom Typ String
und dient als Beschreibung des Logs. Wenn man den Test ausführt, erhält man die Ausgabe
last of [0, 4]: 4
.
Man kann Debug.log
auch in Programmteilen verwenden, die kein Test-Code sind.
Konstanten und Variablen
Um eine lokale Konstante in einer Funktion anlegen zu können, kann man die Schlüsselwörter let
und in
verwenden. Variablen, die nicht konstant sind, gibt es in Elm erwartungsgemäß nicht. Man könnte z.B. die Funktion last
mit einer temporären Variable wie folgt umschreiben, was ich hier nur zu rein demonstrativen Zwecken getan habe.
last : List Float -> Float
last inputs =
let
inputsArr = Array.fromList inputs
in
Array.get (List.length inputs - 1) inputsArr
|> Maybe.withDefault -1
Das let
-in
-Konstrukt lässt sich auch verschachteln und innerhalb des let
-Teils lassen sich neben Konstanten auch Funktionen definieren, die nur im in
-Teil sichtbar sind, wie wir im nächsten Abschnitt sehen werden.
for
-Loops und if
-Expressions
In Elm gibt es keine Schleifen. Man verwendet wenn möglich Funktionen wie map
, filter
oder foldl
. Das Analogon zu foldl
heißt in Python reduce
. Falls der gegebene Sachverhalt sich durch map
, filter
, foldl
oder Ähnliches nicht darstellen lässt, müssen anstatt Schleifen rekursive Funktionen verwendet werden, wie wir im folgenden Beispiel begutachten können.
linearPrices : Float -> Int -> Float -> List Float
linearPrices rate nYears startPrice =
let
recurse : Int -> List Float -> List Float
recurse y prices =
let
newPrices =
linearPricesOfYear rate (last prices)
in
if y < nYears then
newPrices ++ recurse (y + 1) newPrices
else
[]
in
recurse 0 [ startPrice ]
Die Funktion linearPrices
wandelt in einem Zwischenschritt des Rechners einen jährlichen Zinssatz in fiktive Kursstände um, die den Namen prices
tragen. Somit können wir den Rechner auch mit leichten Anpassungen auf simulierte oder historische Aktienkurse anwenden. Im let
-Block wird eine rekursive Funktion definiert, die in einer imperativen Programmiersprache eher als Schleife geschrieben worden wäre. Die Abbruchbedingung finden wir in Zeile 10, das Erhöhen der Zählvariablen in Zeile 11. Im inneren let
-Block wird die Liste newPrices
berechnet. Bei Betrachten der Abbruchbedingung könnte das imperativ programmiererende Wesen über die Frage stolpern: “Was gibt die Funktion recurse
zurück?” Die Antwort ist, dass if
-else
-Ausdrücke immer einen Wert zurückgeben genau wie der ternäre ?:
-Operator in C++. Des Weiteren und wie bereits erwähnt kennt Elm das Schlüsselwort return
nicht. Es wird also beispielsweise bei Erfüllung der Abbruchbedingung die leere Liste []
zurückgegeben.
Typen
Man kann ein sogenanntes Record durch
type alias Model =
{ rate : Float
, regularPayment : Float
, nYears : Int
}
definieren. In Python ist dem vielleicht ein NamedTuple
am ähnlichsten, auch wenn die Notation eher an ein dict
erinnert. Wir haben dem Record den Namen Model
gegeben. Mit type alias
gibt man einem existierenden Typen einen neuen Namen. Im Unterschied dazu definieren wir im Folgenden Schnipsel den neuen Typ Msg
.
type Msg
= ChangedRate String
| ChangedRegPay String
| ChangedYears String
Dieser Typ erinnert erstmal an einen enum
-Typ in Python oder C++, der aber zusätzlich zu seinem Wert, der entweder ChangedRate
, ChangedRegPay
oder ChangedYears
annimmt, noch etwas Weiteres transportieren kann. In diesem Fall ist dieses Weitere für jeden der Werte eine Zeichenkette. Die folgende Funktion demonstriert die Verwendung unseres neuen und unseres neu benamten Typs mit Hilfe des case
-Ausdrucks.
update : Msg -> Model -> Model
update msg model =
case msg of
ChangedRate newContent ->
{ model
| rate =
String.toFloat newContent
|> Maybe.withDefault 0
}
ChangedRegPay newContent ->
{ model
| regularPayment =
String.toFloat newContent
|> Maybe.withDefault 0
}
ChangedYears newContent ->
{ model
| nYears =
String.toInt newContent
|> Maybe.withDefault 0
}
Dem imperativ programmierenden Wesen sei noch gesagt, dass case
einen Wert zurückgibt, ohne dass ein return
-Schlüsselwort verwendet wird. Im case
-Ausdruck der update
-Funktion wird der Wert von msg
überprüft. Nehmen wir o. B. d. A. an, msg
habe den Wert ChangedRate
. Dann wird
case msg of
ChangedRate newContent ->
{ model
| rate =
String.toFloat newContent
|> Maybe.withDefault 0
}
ausgewertet. ChangedRate newContent ->
erlaubt auf den von ChangedRate
transportierten String
über den Namen newContent
zuzugreifen. Der Wert von newContent
wird in eine Fließkommazahl umgewandelt und ein Model
-Record wird zurückgegeben, bei dem nur der Wert des Felds rate
mit dem neuen Wert belegt wird. Der Rest wird vom Argument model
kopiert. Das wird durch
{ model | rate = [...] }
bewerkstelligt*Ich benutze oft für genau diesen Zweck in Python eine selbstgeschriebene Funktion, die ein `NamedTuple` kopiert und nur die separat übergebenen Argumente ersetzt. In Elm ist das erfreulicherweise einfach ein Sprach-Feature.. Wir sehen wieder, dass ein Maybe
ausgepackt wird. Bei der Konvertierung einer Zeichenkette in eine Fließkommazahl kann natürlich einiges schief gehen. Hier wird im Fehlerfall einfach eine 0 zurückgegeben. Das lässt sich oben im Rechner auch direkt nachvollziehen. Wenn man in eines der Felder einen Buchstaben tippt, wird der Wert auf 0 gesetzt.
Dynamische Web-Applikationen
Jetzt habe ich die ganze Zeit über einzelne Elm-Konstrukte geredet, die für mich als imperativen Programmierer ungewohnt sind. In diesem Abschnitt möchte ich noch kurz darauf eingehen, wie mit Elm dynamische Web-Frontends gestaltet werden können. Für genauere, bessere und umfangreichere Darstellungen verweise ich wieder auf den Elm-Guide.
Kenntnisse in HTML sind im Folgenden hilfreich*Dass meine HTML-Kenntnisse, die ich mir als Schüler angeeignet habe, 25 Jahre später immer noch relevant sind, amüsiert mich.. Zum Rendern des Frontends werden eine Funktion view
und eine Funktion update
benötigt, die uns im letzten Abschnitt bereits begegnet ist. Die view
-Funktion, die unseren unseren Rechner von oben rendert, sieht wie folgt aus.
Html Msg
view model =
div []
[ label [ for "rate" ] [ text "Interest rate in %" ]
, br [] []
, input
[ id "rate"
, value (String.fromFloat model.rate)
, onInput ChangedRate
]
[]
, br [] []
, label [ for "regpay" ] [ text "Payment each month" ]
, br [] []
, input
[ id "regpay"
, value (String.fromFloat model.regularPayment)
, onInput ChangedRegPay
]
[]
, br [] []
, label [ for "years" ] [ text "Years" ]
, br [] []
, input
[ id "years"
, value (String.fromInt model.nYears)
, onInput ChangedYears
]
[]
, br [] []
, div [ style "font-weight" "bold" ]
[ text
(String.fromFloat
((finalBalance (1 + (model.rate / 100))
model.regularPayment
model.nYears
* 100
|> round
|> toFloat
)
/ 100
)
)
]
]
Die HTML-Funktionen wie div
, label
, input
und br
definieren das Aussehen und werden aus dem Html
-Module importiert*Und einmal mehr sind Elm und ich uns einig. Denn das Elm-Modul heißt `Html` und nicht `HTML` obwohl es sich um eine Abkürzung handelt. Bei Camel-Case bevorzuge ich diese Schreibweise wegen der besseren Lesbarkeit von `HtmlButton` gegenüber `HTMLButton`.. Sie erwarten jeweils zwei Argumente und zwar eine Liste von Attributen und eine Liste von Kindern. In unserer view
-Funktion wird also das Layout definiert und ein variabler Teil der Web-Seite wird durch das Argument model
gefüllt. Der Rückgabewert der view
-Funktion ist vom Typ Html Msg
. Dabei ist Msg
eine sogenannte Typvariable, vergleichbar mit Templates in C++ oder Generics in der Programmiersprache, deren Name nicht genannt werden darf*Sie fängt mit J and und hört weder mit avaScript noch mit ulia auf. Gruß an Nathan.. Also Msg
ist selbst auch ein Typ. Der Inhalt der Msg
Instanz wird durch den ausgelösten Event-Handler in einem der Input-Feldern definiert. Das heißt, wenn es o. B. d. A. eine Änderung im rate
-Feld gibt, wird onInput ChangedRate
ausgelöst und die Msg
-Instanz mit dem Wert ChangedRate
an die update
-Funktion geschickt. Zusätzlich transportiert die Msg
-Instanz den Wert des value
-Attributes. Die update
-Funktion passt entsprechend der Änderung in msg
das Argument model
an wie oben in der Definition gesehen. Und das Modell wird dann wieder an die view
-Funktion zur Anzeige des aktualisierten Zustands geschickt. Damit die Benutzerschnittstelle gerendert wird, muss man in einer main
-Funktion die update
- und die view
-Funktion registrieren.
main =
Browser.sandbox { init = init, update = update, view = view }
Hinter dem init
-Parameter verbirgt sich ein intiales Model
. Fertig.