Der folgende Podcast kann genutzt werden, um die Inhalte dieses Kapitels noch einmal zu wiederholen.

Podcast runterladen

In diesem Kapitel wollen wir uns intensiver mit dem Thema Rekursion auseinandersetzen. Wie wir bereits gesehen haben, kann man mithilfe von Rekursion Funktionen in Elm definieren. Wenn man sich etwas länger mit rekursiven Funktionen beschäftigt, wird aber schnell klar, dass es unter diesen rekursiven Funktionen wiederkehrende Muster gibt. Wir wollen uns hier einige dieser Muster anschauen.

Wiederkehrende rekursive Muster

Nehmen wir an, wir haben eine Liste von Nutzer*innen und wollen diese Liste auf unserer Seite anzeigen. Das Feld id stellt dabei eine Nummer dar, mit der Nutzer*innen eindeutig identifiziert werden.

type alias User =
    { id : Int
    , firstName : String
    , lastName : String
    , age : Int
    }

Zuerst definieren wir eine Funktion, die für einen Wert vom Typ User eine HTML-Darstellung liefert.

viewUser : User -> Html msg
viewUser { firstName, lastName } =
    text (firstName ++ " " ++ lastName)

Wir können nun wie folgt eine Funktion definieren, die unsere Liste von Nutzer*innen in eine Liste von HTML-Knoten überführt.

viewUsers : List User -> List (Html msg)
viewUsers users =
    case users of
        [] ->
            []

        user :: users_ ->
            viewUser user :: viewUsers users_

Das Ergebnis der Funktion viewUsers würden wir zum Beispiel als Kinder eines div-Knotens in unsere view-Funktion einbinden.

Nun nehmen wir an, dass wir eine Dropdown-Liste zu unserer Seite hinzufügen möchten, bei der wir alle Nutzer*innen zur Auswahl stellen möchten. Zu diesem Zweck definieren wir zuerst eine Funktion, die zu einem Wert vom Typ User ein HTML-Element option liefert. Wir nutzen dabei die id als eindeutigen Wert für die Option und zeigen bei jeder Option den vollständigen Namen als Text an. Anhand des eindeutigen Wertes kann später identifiziert werden, welche Option gewählt wurde.

viewUserOption : User -> Html msg
viewUserOption user =
    option [ value (String.fromInt user.id) ] [ viewUser user ]

Wir können nun wie folgt eine Funktion definieren, die eine Liste von Nutzer*innen in eine Liste von Optionen für eine Dropdown-Liste umwandelt.

viewUserOptions : List User -> List (Html msg)
viewUserOptions users =
    case users of
        [] ->
            []

        user :: users_ ->
            viewUserOption user :: viewUserOptions users_

Mithilfe der Funktion Html.select können wir dann wie folgt eine Dropdown-Liste definieren. Die Funktion onInput : (String -> msg) -> Attribute msg aus dem Modul Html.Events schickt den value der gewählten Option an die Anwendung, wenn eine Option in der Dropdown-Liste gewählt wird.

view : Model -> Html Msg
view model =
    select [ opInput Selected ] (viewUserOptions model.users)

Zu guter Letzt wollen wir eine Funktion definieren, die das durchschnittliche Alter unserer Nutzer*innen berechnet. Dazu wollen wir zuerst eine Funktion definieren, welche die Summe der Alter aller Nutzer*innen berechnet. Elm stellt im Modul List eine Funktion sum : List Int -> Int zur Verfügung. Wir können diese Funktion aber nur nutzen, wenn wir eine Liste von Zahlen haben, während wir eine Liste von Nutzer*innen zur Verfügung haben. Wir definieren daher die folgende Funktion.

ages : List User -> List Int
ages users =
    case users of
        [] ->
            []

        user :: users_ ->
            user.age :: ages users_

Nun können wir wie folgt eine Funktion definieren, die das durchschnittliche Alter der Nutzer*innen berechnet.

averageAge : List User -> Float
averageAge users =
    toFloat (List.sum (ages users)) / toFloat (List.length users)

Die Funktionen viewUsers, viewUserOptions und ages durchlaufen alle eine Liste von Elementen und unterscheiden sich nur in der Operation, die sie auf die Listenelemente anwenden. Die Funktion viewUsers wendet viewUser auf alle Elemente an und die Funktion viewUserOptions wendet viewUserOption auf alle Elemente an. Im Abschnitt Records haben wir gelernt, dass der Ausdruck user.age nur eine Kurzform für .age user ist. Daher können wir die Funktion ages auch wie folgt definieren.

ages : List User -> List Int
ages users =
    case users of
        [] ->
            []

        user :: users_ ->
            .age user :: ages users_

Das heißt, die Funktion ages wendet .age auf alle Elemente der Liste an.

Die drei Funktionen unterscheiden sich also nur durch die Funktion, die jeweils auf alle Elemente der Liste angewendet wird. Allerdings unterscheiden sich auch die Typen der Funktionen, so hat die Funktion in den ersten beiden Fällen den Typ User -> Html msg und im letzten Beispiel User -> Int.

Wir können die Teile, die die drei Funktionen sich teilen, in eine Funktion auslagern. Man nennt die Funktion, die wir dadurch erhalten map. Diese Funktion erhält die Operation, die auf die Elemente der Liste angewendet wird, als Argument übergeben.

In Elm sind Funktionen First-class Citizens. Übersetzt bedeutet das in etwa, dass Funktionen die gleichen Rechte haben wie andere Werte.

Wichtig

Das heißt, Funktionen können wie andere Werte, etwa Zahlen oder Zeichenketten, als Argumente und Ergebnisse in Funktionen verwendet werden.

Außerdem können Funktionen in Datenstrukturen stecken. Wenn wir uns die Umsetzung der Model-View-Update-Architektur in Elm genauer anschauen, werden wir sehen, dass wir schon mehrfach die Tatsache genutzt haben, dass Funktionen in Datenstrukturen stecken können.

Wie bereits erwähnt, hat das wiederkehrende rekursive Muster, das wir identifiziert haben, in der funktionalen Programmierung den Namen map. Die Funktion map hat in Elm die folgende Form.

map : (a -> b) -> List a -> List b
map func list =
    case list of
        [] ->
            []

        head :: tail ->
            func head :: map func tail

Mithilfe der Funktion map können wir die Funktionen viewUsers, viewUserOptions und ages nun wie folgt definieren.

viewUsers : List User -> List (Html msg)
viewUsers users =
    map viewUser users

viewUserOptions : List User -> List (Html msg)
viewUserOptions users =
    map viewUserOption users

ages : List User -> List Int
ages users =
    map .age users
Wichtig

Man nennt eine Funktion, die eine andere Funktion als Argument erhält, eine Funktion höherer Ordnung (Higher-order Function).

Neben dem rekursiven Muster für map, wollen wir an dieser Stelle noch ein weiteres rekursives Muster vorstellen. Stellen wir uns vor, dass wir aus einer Liste von Nutzer*innen alle extrahieren möchten, deren Nachname mit A beginnt. Dazu können wir die folgende Funktion definieren.

usersWithA : List User -> List User
usersWithA users =
    case users of
        [] ->
            []

        user :: users_ ->
            if String.startsWith "A" user.firstName then
                user :: usersWithA users_
            else
                usersWithA users_

Als nächstes nehmen wir an, wir wollen das Durchschnittsalter aller Nutzer*innen über 18 Jahren berechnen. Dazu definieren wir die folgende Funktion.

keepAdultUsers : List User -> List Int
keepAdultUsers users =
    case users of
        [] ->
            []

        user :: users_ ->
            if user.age >= 18 then
                user :: keepAdultUsers users_
            else
                keepAdultUsers users_

Mithilfe der Funktion keepAdultUsers können wir jetzt wie folgt das Durchschnittsalter der volljährigen Nutzer*innen berechnen.

averageAdultAge : List User -> Float
averageAdultAge users =
    averageAge (keepAdultUsers users)

Wir können diese beiden Funktionen wieder mithilfe einer Funktion höherer Ordnung definieren.

filter : (a -> Bool) -> List a -> List a
filter isGood list =
    case list of
        [] ->
            []

        head :: tail ->
            if isGood x then
                head :: filter isGood tail
            else
                filter isGood tail

Dieses Mal übergeben wir eine Funktion, die angibt, ob ein Element in die Ergebnisliste kommt oder nicht. Man bezeichnet eine solche Funktion, die einen booleschen Wert liefert, auch als Prädikat.

Funktionen höherer Ordnung wie map und filter ermöglichen es, deklarativeren Code zu schreiben. Bei der Verwendung dieser Funktionen geben Entwickler*innen nur an, was berechnet werden soll, aber nicht, wie diese Berechnung durchgeführt wird. Wie die Berechnung durchgeführt wird, wird dabei einfach durch die Abstraktionen festgelegt. Diese Form der deklarativen Programmierung ist in jeder Programmiersprache möglich, die es erlaubt Funktionen als Argumente zu übergeben. Heutzutage bietet fast jede Programmiersprache dieses Sprachfeature. Daher haben Abstraktionen wie map und filter inzwischen auch Einzug in die meisten Programmiersprachen gehalten. Im Folgenden sind einige Programmiersprachen aufgelistet, die Abstraktionen ähnlich zu map und filter zur Verfügung stellen.

Java

Das Interface java.util.stream.Stream stellt die folgenden beiden Methoden zur Verfügung.

<R> Stream<R> map(Function<? super T, ? extends R> mapper)

Stream<T> filter(Predicate<? super T> predicate)
C#

LINQ (Language Integrated Query)1 ist eine Technologie der .NET-Platform, um Anfragen elegant zu formulieren. Die folgenden beiden Methoden, die von LINQ zur Verfügung gestellt werden, entsprechen in etwa den Funktionen map und filter.

IEnumerable<TResult> Select<TSource,TResult>(IEnumerable<TSource>, Func<TSource,TResult>)

IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource,bool> predicate)
Kotlin

In Kotlin stellt das Interface Iterable Methoden map und filter zur Verfügung.

JavaScript

Der Prototyp Array bietet Methoden map und filter, welche die Funktionalität von map und filter auf Arrays bieten.

Haskell

In Haskell sind die Funktionen map und filter im Modul Prelude definiert und werden dadurch immer implizit importiert.

Elm

Elm stellt die Funktionen map und filter im Modul List zur Verfügung.

Lokale Definitionen

Bei der Anwendung von Funktionen wie map oder filter nutzt man in funktionalen Sprachen gerne lokale Definitionen, um die Funktion zu definieren, die auf jedes Element der Liste angewendet wird. In Elm können Konstanten und Funktionen auch lokal definiert werden, das heißt, dass die entsprechende Konstante oder die Funktion nur innerhalb einer anderen Funktion sichtbar ist. Anders ausgedrückt ist der Scope einer Top Level-Definition das gesamte Modul. Top Level-Definitionen sind die Definitionen, die wir bisher kennengelernt haben, also Konstanten wie secretNumber und Funktionen wie viewUser oder map. Im Kontrast dazu ist der Scope einer lokalen Definition auf einen bestimmten Ausdruck eingeschränkt. Wir betrachten zuerst die Definition einer Konstante mit einer lokalen Definition.

Eine lokale Definition wird in Elm mithilfe eines let-Ausdrucks eingeführt.

quartic : Int -> Int
quartic n =
    let
        square =
            n * n
    in
    square * square
Info

In Haskell kann eine lokale Definition neben einem let-Ausdruck auch mit einer where-Clause definiert werden, diese Möglichkeit gibt es in Elm nicht.

Ein let-Ausdruck startet mit dem Schlüsselwort let, definiert dann beliebig viele Konstanten und Funktionen und schließt schließlich mit dem Schlüsselwort in ab. Die Definitionen, die ein let-Ausdruck einführt, stehen nur in dem Ausdruck nach dem in zur Verfügung. Das heißt, wir können square hier im Ausdruck square * square verwenden, aber nicht außerhalb der Definition quartic.

Wichtig

Lokale Definitionen können auch auf die Argumente der umschließenden Funktion zugreifen.

Die Funktion square verwendet in unserem Beispiel etwa das Argument n. Man kann in einem let-Ausdruck auch lokale Funktionen definieren. Die Definition einer lokalen Funktion ist zum Beispiel sehr praktisch, wenn wir Listen verarbeiten. Dort wird häufig die Verarbeitung eines einzelnen Listenelementes als lokale Funktion definiert. Im folgenden Beispiel wird eine lokale Funktion definiert, die eine Zahl um einen erhöht.

result : Int
result =
    let
        inc n =
            n + 1
    in
    inc 41
Wichtig

Wie andere Programmiersprachen, zum Beispiel Python, Elixir und Haskell, nutzt Elm eine Off-side Rule.

Das heißt, die Einrückung eines Programms wird genutzt, um Klammerung auszudrücken und somit Klammern einzusparen. In objektorientierten Sprachen wie Java wird diese Klammerung explizit durch geschweifte Klammern ausgedrückt. Dagegen muss die Liste der Definitionen in einem let zum Beispiel nicht geklammert werden, sondern wird durch ihre Einrückung dem let-Block zugeordnet.

Das Prinzip der Off-side Rule wurde durch Peter J. Landin2 in seiner wegweisenden Veröffentlichung “The Next 700 Programming Languages” im Jahr 1966 erfunden.

Any non-whitespace token to the left of the first such token on the previous line is taken to be the start of a new declaration.

Um diese Aussage zu illustrieren, betrachten wir das folgende Beispielprogramm, das vom Compiler aufgrund der Einrückung nicht akzeptiert wird.

badLayout1 : Int
badLayout1 =
    let
    x =
        1
    in
    42

Das Schlüsselwort let definiert eine Spalte. Alle Definitionen im let-Ausdruck müssen in einer Spalte rechts vom Schlüsselwort let starten. Die erste Definition, die in der Spalte des let-Ausdrucks oder weiter links steht, beendet die Sequenz der Definitionen. Die Definition badLayout1 wird nicht akzeptiert, da die Sequenz der Definitionen durch das x beendet wird, was aber keine valide Syntax ist, da die Sequenz mit dem Schlüsselwort in beendet werden muss.

Als weiteres Beispiel betrachten wir die folgende Definition, die ebenfalls aufgrund der Einrückung nicht akzeptiert wird.

badLayout2 : Int
badLayout2 =
    let
        x =
            1

         y =
            2
    in
    42

Die erste Definition in einem let-Ausdruck, also hier das x, definiert ebenfalls eine Spalte. Alle Zeilen, die in der gleichen Spalte wie die erste Definition oder weiter links starten, beenden die Liste der Definitionen. Alle Zeilen, die weiter rechts starten, werden noch zu dieser Definition gezählt. Das heißt, in diesem Beispiel geht der Compiler davon aus, dass die Definition von y eine Fortsetzung der Definition von x ist. Dies ist auch wieder keine valide Syntax, da damit hinter dem = der “Ausdruck” 1 y = 2 steht. Dies ist aber kein valider Ausdruck. Das folgende Beispiel zeigt noch einmal eine valide Definition eines let-Ausdrucks mit zwei lokalen Definitionen.

goodLayout : Int
goodLayout =
    let
        x =
            1

        y =
            2
    in
    42

Das let-Konstrukt ist ein Ausdruck, kann also an allen Stellen stehen, an denen ein Ausdruck stehen kann. Um diesen Aspekt zu illustrieren, betrachten wir die folgende, nicht sehr sinnvolle, aber vom Compiler akzeptierte Definition.

letExpression : Int
letExpression =
    (let
        x =
            1
     in
     x
    )
        * 23

Der let-Ausdruck liefert einen Wert vom Typ Int. Daher können wir den let-Ausdruck mit der Zahl 23 multiplizieren. Wir müssen hier den let-Ausdruck klammern, da andernfalls der Wert der Variable x mit 23 multipliziert wird.

Wenn man einen let-Ausdruck nutzt, sollte man darauf achten, dass Berechnungen nicht unnötig durchgeführt werden. Wir betrachten etwa das folgende Beispiel

unnecessaryCalculation : Bool -> Int
unnecessaryCalculation decision =
    let
        result =
            expensiveCalculation
    in
    if decision then
        42

    else
        result

Da Elm eine strikte Programmiersprache ist, also als Auswertungsstrategie call-by value nutzt, wird der Ausdruck expensiveCalculation immer berechnet, auch wenn die Variable decision den Wert False hat. Falls die Variable decision den Wert False hat, benötigen wir den Wert von result aber gar nicht. Daher sollte man den Scope eines let-Ausdrucks so klein halten, wie möglich. Im Beispiel unnecessaryCalculation ist die Variable result zum Beispiel im gesamten if-Ausdruck sichtbar. Wir benötigen die Variable result aber nur im else-Fall des if-Ausdrucks. Daher können wir den let-Ausdruck in den else-Fall des if-Ausdrucks ziehen. Wir erhalten dann die folgende Definition.

noUnnecessaryCalculation : Bool -> Int
noUnnecessaryCalculation decision =
    if decision then
        42

    else
        let
            result =
                expensiveCalculation
        in
        result

Refaktorierungen dieser Art haben auch den Vorteil, dass wir durch die Struktur des Codes besser sein Verhalten ausdrücken. Durch die Struktur ist klar, dass die Variable result gar nicht im gesamtem let-Ausdruck benötigt wird, was wiederum Leser*innen dabei hilft, den Code zu verstehen.

In diesem artifiziellen Beispiel stellt sich nun allerdings die Frage, warum wir überhaupt die Variable result mithilfe eines let-Ausdrucks definieren. Davon abgesehen kann ein entsprechendes Problem auch in einer imperativen Programmiersprache observiert werden, wenn wir eine Variable definieren, obwohl sie gar nicht in allen Fällen benötigt wird. Auch in diesem Fall können wir den Scope der Variable verkleinern, um dieses Problem zu beheben.

Info

In Haskell tritt dieses Problem nicht auf, da Haskell eine nicht-strikte Auswertung nutzt und daher den Wert von result erst berechnet, wenn er benötigt wird. Da der Wert in einem Zweig des if-Ausdrucks nicht benötigt wird, wird der Wert in diesem Fall auch nicht berechnet. Elm nutzt dagegen eine strikte Auswertung wie viele andere Programmiersprachen.

Wenn eine Funktion wie viewUser nur in der Anwendung der Funktion map oder filter verwendet wird, nutzt man gern wie folgt eine lokale Definition.

viewUsers : List Int -> List Int
viewUsers users =
    let
        viewUser { firstName, lastName } =
            text (firstName ++ " " ++ lastName)
    in
    List.map viewUser users

Diese Definition verhindert, dass die Funktion viewUser außerhalb der Definition viewUsers verwendet wird. Dadurch kann man verhindern, dass Funktionen, die eigentlich nur im Kontext einer ganz bestimmten Funktion sinnvoll sind, aus Versehen globaler verwendet werden. Außerdem bindet man auf diese Weise die Position der Definition viewUser an die Position der Definition viewUsers. Das heißt, es kann nicht passieren, dass man im Modul springen muss, um die Definition von viewUser zu suchen.

Es gibt keine feste Regel, wann man eine Funktion wie viewUser lokal und wann auf Top Level definieren sollte. Grundsätzlich kann man sich überlegen, ob man die Funktionsweise einer Funktion erklären kann, ohne darauf einzugehen, wie sie verwendet wird. Falls es möglich ist, eine Funktion in diesem Fall zu erklären, kann sie vermutlich auf Top Level definiert werden. Darüber hinaus kann man noch darüber nachdenken, wie hoch die Wahrscheinlichkeit ist, dass die Funktion auch noch in einem anderen Kontext verwendet wird. Falls die Funktion auch in einem anderen Kontext Verwendung finden könnte, ist es durchaus sinnvoll, sie auf Top Level zu definieren.

Anonyme Funktionen

Um die Funktion usersWithA mithilfe von filter zu definieren, müssten wir das folgende Prädikat definieren.

startsWithA : User -> Bool
startsWithA { firstName } =
    String.startsWith "A" firstName

Es ist recht umständlich extra die Funktionen startsWithA zu definieren, nur, um sie in der Definition von startWithA einmal zu verwenden, unabhängig davon, ob wir die Funktion lokal definieren oder nicht. Stattdessen kann man anonyme Funktionen verwenden. Anonyme Funktionen sind einfach Funktionen, die keinen Namen erhalten. Die Funktion startsWithA kann zum Beispiel wie folgt mithilfe einer anonymen Funktion definiert werden.

usersWithA : List User -> List User
usersWithA users =
    List.filter (\user -> String.startsWith "A" user.firstName) users

Dabei stellt der Ausdruck \user -> String.startsWith "A" user.firstName die anonyme Funktion dar. Analog können wir die Funktion viewUsers mithilfe einer anonymen Funktion wie folgt definieren.

viewUsers : List User -> List (Html msg)
viewUsers users =
    List.map (\user -> text (user.firstName ++ " " ++ user.lastName)) users

Anonyme Funktionen, auch als Lambda-Ausdrücke oder Lambda-Funktionen bezeichnet3, starten mit dem Zeichen \ und listen dann eine Reihe von Argumenten auf, nach den Argumenten folgen die Zeichen -> und schließlich die rechte Seite der Funktion. Das heißt, der Ausdruck \x y -> x * y definiert zum Beispiel eine Funktion, die ihre beiden Argumente multipliziert. Ein Lambda-Ausdruck der Form \x y -> expression entspricht dabei der folgenden Funktionsdefinition.

f x y = expression

Der einzige Unterschied ist, dass wir die Funktion nicht verwenden, indem wir ihren Namen schreiben, sondern indem wir den gesamten Lambda-Ausdruck angeben. Während wir f zum Beispiel auf Argumente anwenden, indem wir f 1 2 schreiben, wenden wir den Lambda-Ausdruck an, indem wir (\x y -> e) 1 2 schreiben.

In den Argumenten einer anonymen Funktion können wir analog zur Definition einer Top Level-Funktion auch Pattern verwenden. Daher können wir die Funktion usersViewA auch wie folgt definieren, indem wir ein Record Pattern im Argument der anonymen Funktion nutzen.

usersWithA : List User -> List User
usersWithA users =
    List.filter (\{ firstName } -> String.startsWith "A" firstName) users

Analog können wir die Funktion viewUsers wie folgt definieren.

viewUsers : List User -> List (Html msg)
viewUsers users =
    List.map (\{firstName, lastName} -> text (firstName ++ " " ++ lastName)) users

Wir haben im Abschnitt Fallunterscheidungen eine Grammatik für Ausdrücke gesehen. Dieses Kapitel illustriert nun, dass die Grammatik zwei weitere mögliche Ausprägungen aufweist, nämlich Let-Ausdrücke und Lambda-Funktionen.

expression = ...
           | "let" definition { definition } "in" expression ;
           | '\' pattern { pattern } "->" expression ;
           | ...
  1. https://docs.microsoft.com/de-de/dotnet/csharp/programming-guide/concepts/linq 

  2. Peter J. Landin war einer der Begründer der funktionalen Programmierung. 

  3. Der Name Lambda-Ausdruck stammt vom Lambda-Kalkül. Durch den Lambda-Kalkül wird formal beschrieben, welche Arten von Berechnungen man in einer Programmiersprache ausdrücken kann. Der Lambda-Kalkül hat die Grundidee für funktionale Programmiersprachen geliefert. Im Lambda-Kalkül sind Lambda-Funktionen ein sehr wichtiger Bestandteil, daher hat dieses Konstrukt den Präfix Lambda erhalten. Das Zeichen \ wird für Lambda-Ausdrücke verwendet, da es dem kleinen Lambda ähnelt.