4. Listen

4.4 Endrekursion

Ich zeige Ihnen nun zwei Weisen, die Funktion tomorrow im Modul Date.elm zu implementieren:

addDays1 : Int -> Date -> Date
addDays1 n date =
    if n == 0 then
        date

    else
        tomorrow (addDays1 (n - 1) date)

Schematisch tut die Funktion dieses:

Wir zählen rekursiv erstmal 999 Tage drauf und rufen zum Schluss noch einmal tomorrow auf. Hier ist eine alternative, fast identische Implementierung:

addDays2 : Int -> Date -> Date
addDays2 n date =
    if n == 0 then
        date

    else
        addDays2 (n - 1) (tomorrow date)

Schematisch tut sie dies:

Funktionieren tun natürlich beide Versionen:

addDays1 2000 {day = 7, month = 11, year = 2023}                        
{ day = 29, month = 4, year = 2029 }
addDays2 2000 {day = 7, month = 11, year = 2023}                        
{ day = 29, month = 4, year = 2029 }
                    

Welche Version finden Sie schöner? Ich finde vielleicht addDays2 schöner, weil sie die Arbeit, die sofort erledigt werden kann (den Aufruf von tomorrow, der keine weiteren rekursiven Aufrufe erzeugt), sofort erledigt, während addDays1 ihn in die Zukunft verschiebt. Und in der Tat, probieren Sie mal folgendes aus:

addDays2 8000 {day = 7, month = 11, year = 2023}
{ day = 2, month = 10, year = 2045 }
addDays1 8000 {day = 7, month = 11, year = 2023}
RangeError: Maximum call stack size exceeded

Vielleicht müssen Sie auf Ihrem Rechner statt 8000 die 20000 eingeben. Um diese Fehlermeldung wirklich zu verstehen, müssten wir über Rechnerarchitektur und Compilerbau sprechen. Aber wir können bereits einen Einblick gewinnen, indem wir uns daran erinnern, wie das Auswerten von Ausdrücken funktionierte: den Körper der Funktion nehmen und die Parameter durch die Argumente ersetzen. Wenn die Argumente selbst Funktionsaufrufe enthalten, dann die zuerst auswerten. Nur vollständig ausgewertete Ausdrücke als Argument übergeben. Das schaut im Folgenden bei addDays1 so aus:

addDays1 1000 {day = 3, month = 10, year = 1990}

tomorrow (addDays1 999 {day = 3, month = 10, year = 1990}) -- weil Zeile 115 aufgerufen wird 

tomorrow (tomorrow (addDays1 998 {day = 3, month = 10, year = 1990}))

tomorrow (tomorrow (tomorrow (addDays1 997 {day = 3, month = 10, year = 1990})))

...

tomorrow (tomorrow (... (tomorrow (addDays1 0 {day = 3, month = 10, year = 1990})) ... ))

tomorrow (tomorrow (... (tomorrow {day = 3, month = 10, year = 1990}) ... ))

tomorrow (tomorrow (... {day = 4, month = 10, year = 1990} ... ))                        
                    

Der Ausdruck wächst also auf eine monströse Größe an, bis in der drittletzten Zeile oben dann addDays1 0 ... steht und der then-Teil, also Zeile 112, aufgerufen wird. Diesen Stapel an Funktionsaufrufen nennt auf Englisch den call stack. Jetzt verstehen Sie auch die Fehlermeldung RangeError: Maximum call stack size exceeded. Der Stapel ist zu groß geworden. Die Laufzeitumgebung von Elm hat eine Maximalgröße. Betrachten wir im Gegensatz dazu den Call Stack von addDays2:

addDays2 1000 {day = 3, month = 10, year = 1990}

addDays2 999 (tomorrow {day = 3, month = 10, year = 1990}) -- hier wurde Zeile 124 aufgerufen

addDays2 999 {day = 4, month = 10, year = 1990} -- weil zuerst der innere Aufruf, der von tomorrow, ausgeführt wird

addDays2 998 (tomorrow {day = 4, month = 10, year = 1990})

addDays2 998 {day = 5, month = 10, year = 1990}

addDays2 997 (tomorrow {day = 5, month = 10, year = 1990})

addDays2 997 {day = 6, month = 10, year = 1990}

Sie sehen: auf dem Stack liegen maximal 2 Aufrufe, nämlich einer von addDays2 und einer von tomorrow. Die Folge ist, dass die Auswertung von addDays2 100000 {day = 3, month = 10, year = 1990} zwar mehr Schritte benötigt als addDays2 52 {day = 3, month = 10, year = 1990}, aber nicht mehr Speicherplatz und vor allem nicht mehr Speicherplatz auf dem call stack.

Es gibt also "schlechte" Rekursion, die den Stack immer weiter wachsen lässt, und "gute", bei der der Stack nicht wächst. Die "gute" nennt man auch Endrekursion, auf Englisch tail recursion. Endrekursion liegt vor, wenn die rekursiv aufgerufene Funktion "ganz außen" aufgerufen wird.

Rekursive Funktionen in Endrekursion umwandeln

Oft können Sie mit ein paar Tricks eine gegebene Funktion in eine endrekursive Funktion umschreiben und somit größere Inputs verarbeiten. Oft geht das mit einer Variable, die ein Zwischenergebnis speichert. Im obigen Fall hatte der Parameter date von addDays2 diese Rolle bereits übernommen. Manchmal müssen wir künstlich eine neue Variable für das Zwischenergebnis einführen. Als Beispiel erinnern Sie sich an die Funktion

\begin{align*} f(n) := 1 + 2 + \cdots + n \ . \end{align*}

Ich implementiere sie nun unter dem Namen sumUpTo.

sumUpTo : Int -> Int
sumUpTo n =
    if n == 0 then
        0

    else
        n + sumUpTo (n - 1)

Das ist keine Endrekursion! Nach dem rekursiven Aufruf von sumUpTo (n-1) in Zeile 102 müssen wir nur weitere Aufgaben erledigen, nämlich n dazuaddieren. Endrekursion jedoch hieße, dass der rekursive Aufruf das endgültige Ergebnis bereits liefert, dass also keine weiteren Aufgaben anfallen. In der Tat ist sumUpTo nicht besonders gut:

sumUpTo 100                        
5050 -- funktioniert immerhin
sumUpTo 12000
RangeError: Maximum call stack size exceeded

Wir können dem Stack beim Wachsen zu schauen:

sumUpTo 12000
12000 + sumUpTo 11999 -- Zeile 133 wird aufgerufen
12000 + 11999 + sumUpTo 11998
12000 + 11999 + 11998 + sumUpTo 11997
12000 + 11999 + 11998 + 11997 + sumUpTo 11996

Wir legen nun explizit eine Zwischenergebnisvariable sumSoFar an, in der wir speichern, wieviel wir bereits aufaddiert haben. Die soll anfangs 0 sein, und dann schaufeln wir n drauf.

sumUpTo2 : Int -> Int -> Int
sumUpTo2 sumSoFar n =
    if n == 0 then
        sumSoFar

    else
        sumUpTo2 (sumSoFar + n) (n - 1)

Nun steht der rekursive Aufruf von sumUpTo2 ganz außen (und wird damit zeitlich am Ende ausgeführt, obwohl er in der Elm-Syntax am Anfang steht). Schauen wir uns wieder den Call-Stack an:

sumUpTo2 0 12000
sumUpTo2 12000 11999 -- hier wurde Zeile 142 aufgerufen
sumUpTo2 (12000 + 11999) 11998
sumUpTo2 23999 11998
sumUpTo2 (23999+11998) 11997
sumUpTo2 35997 11997

und in der Tat bleibt der Stack immer klein. Sie können nun riesige Zahlen eingeben:

sumUpTo2 0 1000000000                    
500000000995475700

Ich habe die Laufzeit mit Herrn Schellenbergers Modul gemessen. Auf meinem Rechner braucht es für \(n = 10^9\), also eine Milliarde, eine knappe Sekunde. Das ist nur etwas langsamer als mit Java (circa 0.7 Sekunden). Das Ergebnisse in Elm ist übrigens falsch. Richtig wäre 500000000500000000. Das liegt daran, dass Elm nach Javascript kompiliert und Javascript anscheinend hohe Integers in Float konvertiert, wodurch Rundungsfehler mit ins Spiel kommen. Ein Nachteil von sumUpTo2 ist, dass es zwei Parameter hat. Die Standardlösung ist, einen "Wrapper" zu schreiben, der dann sumUpTo2 0 n aufruft.

Jetzt sind Sie dran. Hier sehen Sie meinen Code für isPrime.

hasDivisorLessThan : Int -> Int -> Bool
hasDivisorLessThan k n =
    if k == 1 then
        False

    else if modBy k n == 0 then
        True

    else
        hasDivisorLessThan (k - 1) n


isPrime : Int -> Bool
isPrime n =

    not (hasDivisorLessThan (n - 1) n)

Übungsaufgabe Handelt es sich bei hasDivisorLessThan um Endrekursion?

Hier ist meine Funktion, die die Primzahlen von \(1\) bis \(n\) zählt:

countPrimesUntil : Int -> Int
countPrimesUntil n =
    if n <= 1 then
        0

    else
        let
            primesUntilNminus1 : Int
            primesUntilNminus1 =
                countPrimesUntil (n - 1)
        in
        if isPrime n then
            primesUntilNminus1 + 1

        else
            primesUntilNminus1

Dies ist nicht Endrekursion, und in der Tat gibt mir countPrimesUntil 10000 die bekannte Fehlermeldung.

Übungsaufgabe Schreiben Sie eine endrekursive Funktion von countPrimesUntil. Bis 10000 oder 20000 sollte die schon kommen.

Übungsaufgabe Schreiben Sie eine endrekursive Funktion primesUntil, die Ihnen die Liste aller Primzahlen von 1 bis \(n\) ausgibt. Bis 20000 sollte die es schaffen.

Konzeptuell besteht nun für Sie die Herausforderung, dass die Zwischenergebnisvariable nun eine Liste sein muss - die Liste aller bisher gefundenen Primzahlen.

Übungsaufgabe Schreiben Sie eine endrekursive Funktion für reverse, die eine Liste umdreht. Sie sollten ohne Probleme Listen der Länge 20000 umdrehen können. Damit Sie Ihre Kommandozeile nicht mit langen Listen zumüllen, können Sie es mit List.range und List.length testen:

List.length ( reverse (List.range 0 10000) )                            
10001

Die vorgefertigte Funktionen List.length berechnet die Länge einer Liste. List.range a b gibt eine Liste aller ganzer Zahlen von \(a\) bis \(b\). Mit diesem "Trick" erreichen Sie, dass zu keinem Zeitpunkt die riesige Liste von 20000 Elementen auf der Konsole ausgegeben wird.

Übungsaufgabe Schreiben Sie endrekursive Funktionen listenLaenge und listeVonBis, die wie List.length und List.range funktionieren (natürlich ohne auf diese zuzugreifen).