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:

List.map (String.dropLeft 4) bilderListe

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

\begin{align*} f & : \R \rightarrow \R \\ g & : \R \rightarrow \R \ , \end{align*}

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.