4. Listen
4.6 Currying
Nehmen wir uns mal einen beliebige Funktion mit mehr als
einem Input-Parameter. Zum Beispiel
String.dropLeft
.
String.dropLeft
<function> : Int -> String -> String
String.dropLeft 2 "bearbeiten"
"arbeiten" : String
Was geschieht, wenn ich String.dropLeft
nur mit
einem Argument aufrufe? In Java oder Pyhon würde ich eine
Fehlermeldung bekommen. In Elm:
String.dropLeft 2
<function> : String -> String
In Elm kriegen Sie als Rückgabewert eine Funktion mit
Signatur String -> String
geliefert.
Was tut diese Funktion? Probieren wir es aus:
newFunction = String.dropLeft 2
<function> : String -> String
newFunction "besuchen"
"suchen" : String
Der "Wert" von String.dropLeft 2
ist
im Prinzip ein "unfertiger" Funktionsaufruf, der geduldig auf
weitere Argumente wartet. Betrachten Sie noch einmal
die Signatur von String.dropLeft
, nämlich
Int -> String -> String
. Wenn wir wie folgt Klammern
setzen, nämlich Int -> (String -> String)
, dann bedeutet dies:
die Funktion hat als Eingabeparameter ein Int
.
Rückgabewert ist eine Funktion vom Typ (String -> String)
.
Das ist ja genau das, was beim "unvollständigen" Aufruf
String.dropLeft 2
geschehen ist. Und tatsächlich
ist Int -> String -> String
als
Int -> (String -> String)
zu verstehen.
Dieses Pinzip, dass nämlich eine Funktion, die mit "zu wenig"
Argumenten aufgerufen wird, zu einer neuen Funktion wird, die
auf die restlichen Argumente "wartet", nennt man "Currying".
Aus streng onthologischer Sichtweise könnte man sagen,
eine Elm-Funktion hat immer nur einen Input-Parameter.
Was wir als zweistellige Funktion interpretieren, ist in Wahrheit
eine Funktion, die ein Argument nimmt und eine
weitere einstellige Funktion zurückliefert.
In der Praxis ist diese etwas fundamentalistische Sichtweise nicht
wirklich hilfreich. Denken Sie bei String.dropLeft 2
einfach
an den Aufladeautomaten in der Mensa, in den Sie zwei Karten
stecken müssen: Ihre Hochschulkarte und Ihre Bankkarte. Stecken Sie
nur Ihre Hochschulkarte rein, so verwandelt er sich in einen Automaten,
der eine Karte erwartet: Ihre Bankkarte. Sobald er alle
Argumente (hier: beide Karten) bekommen hat, startet die Funktionsauswertung (hier:
der Aufladeprozess).
Warum Currying?
Nehmen wir wieder String.dropLeft
als Beispiel.
Wir haben eine Liste von Bildern und Videos aus dem Sommerurlaub:
bilderListe = ["so23/img_01.jpg", "so23/img_04.jpg", "so23/img_05.jpg", "so23/img_09.jpg", "so23/vid_01.mp4", "so23/vid_03.mp4"]
und wollen nun den Ordner, also "so23"
, in sommer2023
umbenennen. Wir
wollen also erstmal jedes s
in der Liste durch String.dropLeft 4 s
ersetzen.
Ohne Currying müssten wir uns nun eine Helferfunktion schreiben, die die vier linkesten
Buchstaben
entfernt:
entferneVier : String -> String
entferneVier s = String.dropLeft 4 s
<function> : String -> String
liste2 = List.map entferneVier bilderListe
["/img_01.jpg","/img_04.jpg","/img_05.jpg","/img_09.jpg","/vid_01.mp4","/vid_03.mp4"] : List String
Jetzt wollen wir vor jeden String den Präfix "sommer2023" setzen. Das geht mit
String.append
und
einer Helferfunktion:
addPrefixHelper : String -> String
addPrefixHelper s = String.append "sommer2023" s
List.map addPrefixHelper liste2
["sommer2023/img_01.jpg","sommer2023/img_04.jpg","sommer2023/img_05.jpg","sommer2023/img_09.jpg","sommer2023/vid_01.mp4","sommer2023/vid_03.mp4"] : List String
Das geht natürlich ohne Probleme. Allerdings wollen wir nicht unbedingt ständig neue Helferfunktionen definieren müssen. Eleganter geht es mit Currying:
liste2 = List.map (String.dropLeft 4) bilderListe
["/img_01.jpg","/img_04.jpg","/img_05.jpg","/img_09.jpg","/vid_01.mp4","/vid_03.mp4"] : List String
List.map (String.append "sommer2023") liste2
["sommer2023/img_01.jpg","sommer2023/img_04.jpg","sommer2023/img_05.jpg","sommer2023/img_09.jpg","sommer2023/vid_01.mp4","sommer2023/vid_03.mp4"] : List String
Schauen wir uns die erste Zeile nochmal genauer an und untersuchen sie auf ihre Syntax:
Das erste Argument, nämlich String.dropLeft 4
ist eine Funktion
String -> String
. Das zweite, nämlich bilderListe
, ist eine
List String
. Syntaktisch stimmt also alles: der Aufruf von List.map
wendet eine Funktion String -> String
auf jedes Element einer
List String
an und erzeugt dadurch eine List String
.
Ist Currying nur Syntaxzucker?
Das Prinzip der Currying hat zur Folge, dass Sie in Elm praktisch nie
Funktionen als Rückgabewerte brauchen. Wenn Sie in die Verlegenheit kämen,
eine Funktion a -> b
zurückgeben zu wollen,
dann fügen Sie das a
einfach als Eingabeparameter hinzu
und schreiben den Code aus. Nehmen wir ein Beispiel aus der Mathematik.
Wenn wir zwei Funktionen
dann versteht jeder Mathematiker die Notation \(f \cdot g\): wir multiplizieren die beiden Funktionen, d.h. \(f \cdot g\) ist die Funktion, die bei Eingabe von \(x\) den Wert \(f(x) \cdot g(x)\) wiedergibt. Man sagt auch: die Funktionen werden punktweise mulitpliziert. In Elm können Sie das nicht direkt tun:
cos -- die trigonometrische Funktion Cosinus
<function> : Float -> Float
sin -- die trigonometrische Funktion Sinus
<function> : Float -> Float
h = cos * sin
-- TYPE MISMATCH ---------------------------------------------------------- REPL Multiplication does not work with this value: 3| f = sin*cos ^^^ This `sin` value is a: Float -> Float But (*) only works with Int and Float values. Hint: Only Int and Float values work as numbers.
Die Schreibweise \(h := \cos \cdot \sin\) würde Ihnen jeder Gutachter eines Mathematik-Journals
durchgehen lassen. Der Elm-Compiler ist strenger. Der Operator *
funktioniert
nur für Zahlen, nicht für Funktionen. Definieren wir uns also eine
Funktion mult
mult : (Float -> Float) -> (Float -> Float) -> (Float -> Float)
mult f g =
let
h : Float -> Float
h x =
f x * g x
in
h
Das Prinzip des Curryings sagt nun, dass wir nicht mit diesem sperrigen
let ... in ...
-Ausdruck die Funktion h
schreiben müssen,
sondern es einfach so schreiben können:
mult2 : (Float -> Float) -> (Float -> Float) -> Float -> Float
mult2 f g x =
f x * g x
was viel kürzer und übersichtlicher ist. Wir können nun sin
und cos
multiplizieren:
h = mult cos sin -- das x fehlt im Aufruf, dadurch wird h also zu einer gecurryten Funktion
<function> : Float -> Float
h 0.5
0.42073549240394825 : Float
Dank des Currying können wir also darauf verzichten, die Rückgabefunktion explizit als Funktion zu definieren.
Wenn Sie explizit eine Rückgabefunktion definieren sollten
In Extremfällen kann es jedoch vorteilhaft sein, die gewünschte Rückgabefunktion explizit zu definieren und sich nicht auf Currying zu verlassen, nämlich wenn wichtige "Vorarbeit" geleistet werden kann. Erinnern Sie sich an den Aufladeautomaten: auch wenn Sie nur Ihre Hochschulekarte reingesteckt haben und nicht die Bankkarte, kann bereits gewisse Arbeit verrichtet werden (Wollen Sie aufladen? Wählen Sie einen Geldbetrag! etc.). In dem nun folgenden, etwas konstruierten Beispiel in Elm will ich eine Funktion \(h_a\) berechnen, die folgendes tut:
\begin{align*} h_a : \N & \rightarrow \N \\ n & \mapsto F_a + n \end{align*}Sie nimmt also die \(a\)-te Fibonacci-Zahl und addiert \(b\) hinzu. Ich kann das als Funktion mit zwei Parametern schreiben:
fibAplusN : Int -> Int -> Int
fibAplusN a n =
let
fibA : Int
fibA =
fib a
in
fibA + n
Und wenn ich jetzt zum Beispiel \(h_{11}\) brauche, dann kriege ich es
per h11 = fibAplusB 11
. Ich könnte es auch explizit
als Funktion mit einem Parameter a bauen, die dann
\(h_a\) als Funktion zurückgibt:
fibAplusN2 : Int -> (Int -> Int) -- Klammerung ist nicht nötig, macht den Unterschied aber klarer
fibAplusN2 a =
let
fibA : Int
fibA =
fib a
h_a : Int -> Int -- hier baue ich explizit den Rückgabewert, nämlich eine Funktion
h_a n =
fibA + n
in
h_a
Von außen gesehen tun beide Funktionen das Gleiche. Selbst die Signaturen
Int -> (Int -> Int)
und
Int -> Int -> Int
können Sie beliebig austauschen, aus der
Sicht von Elm ist es die gleiche Signatur. Auch liefern beide Funktionen
das gleiche Ergebnis. Dennoch sind sie nicht gleich:
vergleichen wir einen Funktionsaufruf von
h_11 = fibAplusN 11
<function> : Int -> Int
h_11_variant = fibAplusN2 11
<function> : Int -> Int
Die Funktionen h_11
und h_11_variant
sollten
also äquivalent sein. Allerdings gibt es einen entscheidenden Unterschied:
im Aufruf fibAplusN 11
wurde die Funktion fibAplusN
mit "zu wenigen" Argumenten aufgerufen und wartet
noch untätig auf das n
. Der Funktionsaufruf
fibAplusN2 11
wurde mit genau richtig vielen Argumenten
aufgerufen: sehen Sie, in Zeile 130 steht fibAplusN a
, es wird
also nur ein Argument erwartet. Wenn wir das reinstecken (hier: 11), dann
wird bereits mit der Auswertung begonnen und zum Beispiel
Zeilen 132-134 ausgewertet. Im Wert h_11_variant
steht
also irgendwo die Zahl \(F_{11} = 89\) explizit drin, während
im Wert von h_11
der Ausdruck
let fibA = fib 11 in fibA + n
geduldig auf seine
Auswertung wartet. Es wurde also noch kein Code ausgeführt.
Macht das einen Unterschied? Ja, weil nämlich bereits für den "unvollständigen"
Aufruf fibAplusN2 11
wichtige Arbeit erledigt werden kann,
hier das Berechnen von \(F_{11}\). Das ist entscheidend, wenn diese Arbeit
viel Zeit braucht. Und in der Tat, vergleichne Sie:
List.map (fibAplusN 38) [1,2,3,4,5,6,7,8,9]
[39088170,39088171,39088172,39088173,39088174,39088175,39088176,39088177,39088178]
List.map (fibAplusN2 38) [1,2,3,4,5,6,7,8,9]
[39088170,39088171,39088172,39088173,39088174,39088175,39088176,39088177,39088178]
Beide Aufrufe liefern das gleiche Ergebnis (puh!), allerdings
dauert der erste viel länger (Ihr Rechner ist schneller als meiner; Sie müssen also eventuell
38 durch eine höhere Zahl ersetzen). Der Grund ist klar: im
Ausdruck List.map (fibAplusN 38) [1,2,3,4,5,6,7,8,9]
wird fib 38
für jedes Listenelement aufgerufen, also insgesamt 9 mal).
Im Ausdruck
List.map (fibAplusN2 38) [1,2,3,4,5,6,7,8,9]
wird zuerst fibAplusN 38
ausgewertet und dabei fib 38
berechnet.
Dann wird das Ergebnis (eine Funktion) auf jedes Listenelement angewandt. Der
Ausdruck fib 38
ist dabei schon längst zu \(39088169\) vereinfacht worden.
Das spart also Zeit.