Pipers Pastete

Pipers Pastete

Lernübung

Einführung

Rekursion

Eine Funktion ist rekursiv, wenn sie sich selbst aufruft.

Ein wichtiger Unterschied zwischen einem Funktionsaufruf und einer Schleife besteht darin, dass ein Funktionsaufruf die Rücksprungadresse auf den Stack legt. Das bedeutet, dass eine rekursive Funktion meist mehr Stack-Speicher benötigt als eine entsprechende Schleife.

Deshalb kann eine Funktion, die sich immer wieder selbst aufruft, irgendwann den gesamten Stack-Speicher aufbrauchen. Das nennt man einen Stack Overflow.

Deshalb muss jede rekursive Funktion mindestens einen Basisfall haben. Das ist der Fall, in dem die Funktion zurückkehrt, ohne sich selbst aufzurufen. Jeder rekursive Aufruf muss irgendwann einen Basisfall erreichen.

Zum Beispiel lässt sich die Fakultätsfunktion n! = n * (n - 1) * ... * 1 rekursiv mit 1 als Basisfall definieren:

factorial:
    ; the argument `n` is passed on `rdi`
    ; the factorial will be returned on `rax`

    cmp rdi, 1
    jle .base_case     ; base case -> if rdi <= 1, return 1

    push rdi           ; save n
    dec rdi            ; rdi = n - 1
    call factorial     ; recursive call, rax = (n - 1)!
    pop rdi            ; restore n
    imul rax, rdi      ; rax = n * (n - 1)! = n!
    ret
.base_case:
    mov rax, 1
    ret

Beachte, dass factorial vor dem rekursiven Aufruf push rdi und danach pop rdi ausführen muss. Das liegt daran, dass es n noch braucht, wenn der rekursive Aufruf zurückkehrt, um n * (n-1)! zu berechnen.

Beachte außerdem, dass ein callee-saved Register dieses Problem nicht lösen würde.

Auch wenn eine rekursive Funktion ein möglicher Aufrufer ihrer selbst ist, ist sie selbst der Aufgerufene einer anderen Funktion. Das heißt, die Funktion muss auch callee-saved Register erhalten, bevor sie sie benutzt, und ihren Wert wiederherstellen, nachdem sie sie benutzt hat. Das geschieht meist mit einer push/pop-Sequenz, wie wir es in einem früheren Konzept gesehen haben.

Da jeder Frame einer rekursiven Funktion, mit Ausnahme des Basisfalls, ebenfalls ein Aufrufer ist, der seine eigenen lokalen Variablen erhalten muss, muss diese push/pop-Sequenz für jeden Frame wiederholt werden. Selbst wenn man die Variable direkt im Stack ablegt, ohne Register zu benutzen, würde das pro Frame trotzdem dieselben 8 Bytes kosten.

Das bedeutet, dass jeder rekursive Aufruf 8 Bytes für die von call auf den Stack gelegte Rücksprungadresse hinzufügt, plus 8 Bytes für jede lokale Variable, die gespeichert werden muss. Die Funktion fügt diese Bytes mit jedem Frame zum Stack hinzu, bis sie ihren Basisfall erreicht. Erst dann beginnt sie, sich in umgekehrter Reihenfolge abzubauen, wobei jeder rekursive Aufruf so viele pop benutzt wie nötig und danach ein ret.

Zum Beispiel würde factorial mit dem Argument 10 sich selbst neun Mal aufrufen, bevor es den Basisfall 1 erreicht. Zu diesem Zeitpunkt wären 144 Bytes benutzt worden, um für jeden vorherigen Frame n (8 Bytes) und die Rücksprungadresse (8 Bytes) zu speichern.

Endaufruf

In manchen Situationen erledigt eine Funktion nach dem Aufruf einer anderen und vor der Rückkehr keine weitere Arbeit mehr.

Betrachte zum Beispiel:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    call times_three
    ret

Die Funktion triple_of_square:

  • multipliziert das übergebene Argument (in rdi) mit sich selbst und erhält so sein Quadrat;
  • danach ruft sie times_three auf, was das Dreifache des übergebenen Arguments zurückgibt.

Folglich gibt triple_of_square 3*x² zurück, wobei x das in rdi übergebene Argument ist.

Beachte, dass in triple_of_square nach dem Aufruf von times_three keine Arbeit mehr erledigt wird, die Funktion kehrt einfach zurück. In einer solchen Situation kann eine Funktion statt call ein jmp benutzen und die Ausführung an die aufgerufene Funktion übergeben:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    jmp times_three

Das nennt man einen Endaufruf.

Der größte Vorteil eines Endaufrufs ist, dass die zusätzlichen Kosten von call entfallen. Ein call legt eine Rücksprungadresse auf den Stack, und damit die Kontrolle dorthin zurückkehrt, muss es ein passendes ret geben.

Ein Endaufruf überspringt beides: Es gibt keine Rücksprungadresse, die auf den Stack gelegt werden müsste, und kein zusätzliches ret, mit dem sie gepaart werden müsste, nur das eigene ret der aufgerufenen Funktion.

Endrekursion

Ein Endaufruf ist besonders nützlich für rekursive Funktionen, die sich selbst viele Male aufrufen können, bevor sie zurückkehren.

Allerdings lässt sich nicht jeder rekursive Aufruf direkt in einen Endaufruf übersetzen. Da ein jmp die Kontrolle an die aufgerufene Funktion übergibt, kann der Aufrufer nach dem Endaufruf keine weitere Arbeit mehr erledigen.

Zum Beispiel ist die frühere Funktion factorial nicht endrekursiv. Nach dem rekursiven Aufruf muss sie das Ergebnis noch mit dem aktuellen n multiplizieren, und zwar mit imul rax, rdi.

In solchen Situationen ist es manchmal möglich, einen Akkumulator zu benutzen, der Teilergebnisse sammelt und am Ende zurückgegeben wird. Zum Beispiel können wir einen factorial_helper definieren, der den größten Teil der Arbeit erledigt, und dann richtet factorial einen Akkumulator ein und übergibt die Kontrolle an factorial_helper:

factorial_helper:
    ; the argument `n` is passed on `rdi`
    ; `rax` is used as an accumulator and will be returned at the end

    cmp rdi, 1
    jle .base_case

    imul rax, rdi        ; we accumulate the partial result on `rax`
    dec rdi              ; rdi = n - 1
    jmp factorial_helper ; tail call to accumulate (n - 1)!
.base_case:
    ret                  ; returns the factorial already accumulated on `rax`

factorial:
    mov rax, 1           ; initial value for the accumulator
    jmp factorial_helper ; tail call

Da nach dem rekursiven Aufruf keine Arbeit mehr erledigt wird, müssen wir rdi auch nicht mehr speichern. Es gibt kein call und kein push rdi, und daher fügt jede rekursive Iteration dem Stack 0 Bytes hinzu: Es wird kein zusätzlicher Stack-Speicher benutzt. Diese Version kann beliebig große n verarbeiten, ohne den Stack zum Überlaufen zu bringen. Sie ist sowohl effizienter als auch sicherer.

In manchen Fällen lässt sich durch eine Umsortierung der Funktionen sogar das jmp zum Helfer vermeiden. Zum Beispiel lassen sich factorial und triple_of_square so umschreiben:

factorial:
    mov rax, 1
factorial_helper:
    cmp rdi, 1
    jle .base_case

    imul rax, rdi
    dec rdi
    jmp factorial_helper
.base_case:
    ret

triple_of_square:
    imul rdi, rdi
times_three:
    imul rax, rdi, 3
    ret

Im obigen Ausschnitt geht die Ausführung von factorial direkt in factorial_helper über. Dasselbe passiert mit triple_of_square und times_three. In beiden Fällen läuft die Ausführung sequenziell weiter, und es scheint, als wäre die Endfunktion nur ein lokales Label innerhalb der „Hauptfunktion“.

In Wirklichkeit gibt es keinen wesentlichen Unterschied zwischen einem lokalen Label und einer Funktion. x86-64-Assembly behandelt keines von beiden besonders, sie sind einfach Adressen in einem Abschnitt mit ausführbarem Code, wie zum Beispiel section .text.

So kann man sich eine endrekursive Funktion im Wesentlichen genauso vorstellen wie eine Schleife, bei der der rekursive Aufruf zurück an den Anfang springt und der Basisfall die Bedingung ist, die die Schleife beendet.

Anleitung

Piper backt leidenschaftlich gern Pies.

Niemand weiß, ob sie sich wegen ihres Namens fürs Piebacken entschieden hat oder ob sie ihren Namen an ihr Hobby angepasst hat. Auf den ersten Blick scheint Letzteres nicht sehr wahrscheinlich, aber Piper ist absolut fasziniert von Pies. Sie tüftelt ständig in der Küche, feilt an ihren Rezepten und verbessert ihr Handwerk, zur großen Freude ihrer Freunde. Nichts entgeht ihrem Blick fürs Detail: nicht die Temperatur ihres Ofens, nicht das Gewicht jeder Teigkugel und schon gar nicht die Form des Pies selbst.

Ihr neuestes Interesse? Pies so rund wie möglich zu backen, bis hin zur mathematischen Perfektion, mit Hilfe ihrer Lieblingszahl, du hast es erraten: π.

Piper hat eine wunderschöne Formel gefunden, um π iterativ zu berechnen, die Newton/Euler-Konvergenztransformation:

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

Hilf Piper, ihre Küche in Ordnung zu bringen, und backe ihren mathematisch perfekten Pie.

1. Den Teig portionieren

Piper hat heute Morgen zwei Teigmengen mit unterschiedlichem Gewicht (in g) ausgerollt. Damit ihre Pies einheitlich werden, möchte sie beide Mengen zu Kugeln mit demselben Gewicht portionieren. Und natürlich sollen die Portionen so groß wie möglich sein, damit möglichst wenig Teig verschwendet wird!

Das größte Gewicht, das beide Mengen ohne Rest teilt, ist ihr größter gemeinsamer Teiler. Der euklidische Algorithmus berechnet ihn rekursiv:

  • gcd(a, 0) = a (Basisfall)
  • gcd(a, b) = gcd(b, a mod b)

Beachte, dass der rekursive Aufruf an der Endposition steht: Nach ihm passiert nichts. Definiere largest_portion so, dass der rekursive Schritt ein jmp auf die Funktion selbst ist und kein call.

largest_portion(252, 105);
// => 21

Beide Argumente sind nichtnegative 64-Bit-Ganzzahlen. Der Rückgabewert ist eine nichtnegative 64-Bit-Ganzzahl.

2. Doppelfakultät

Aus dem Konzept weißt du bereits, wie man die gewöhnliche Fakultät endrekursiv schreibt. Dieselbe Funktion findest du in deiner Stub-Datei.

Die Newton/Euler-Formel verwendet aber auch Doppelfakultäten, geschrieben !!. Der Doppelfakultätsoperator ist so definiert:

0!! = 1
n!! = 1 * 3 * 5 * ... * n // if n is odd
n!! = 2 * 4 * 6 * ... * n // if n is even

Beachte, dass die Doppelfakultät demselben Muster wie die Fakultät folgt, außer dass sie bei jedem Schritt um 2 statt um 1 herunterzählt. Definiere die Funktion double_factorial, die die Doppelfakultät endrekursiv berechnet.

double_factorial(5);
// => 15
double_factorial(6);
// => 48

Das Argument ist eine vorzeichenlose 32-Bit-Ganzzahl. Der Rückgabewert ist eine vorzeichenlose 64-Bit-Ganzzahl.

3. Newton/Euler-Konvergenztransformation

Jetzt hat Piper alle Werkzeuge, die sie braucht. Definiere die Funktion pipers_pi, die π mit einer festen Anzahl von Termen aus der Newton/Euler-Konvergenztransformation annähert:

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

Der Zähler verwendet die gewöhnliche Fakultät. Du kannst die Funktion factorial aufrufen, die schon für dich definiert ist! Der Nenner verwendet die Funktion double_factorial, die du in Aufgabe 2 geschrieben hast.

Berechnen wir den ersten Term gemeinsam. Für eine Obergrenze von 0 (statt unendlich) erhalten wir:

π / 2 ≈ sum for k from 0 to 0 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ (0!) / ( 2 * 0 + 1 )!!
π / 2 ≈ 0! / 1!!
π / 2 ≈ 1 / 1
π / 2 ≈ 1.0
π ≈ 2.0

Für eine Obergrenze von 2 erhalten wir stattdessen:

π / 2 ≈ sum for k from 0 to 2 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ ((0!) / ( 2 * 0 + 1 )!!) + ((1!) / ( 2 * 1 + 1 )!!) + ((2!) / ( 2 * 2 + 1 )!!)
π / 2 ≈ 1 + (1! / 3!!) + (2! / 5!!)
π / 2 ≈ 1 + (1 / 3) + (2 / 15)
π / 2 ≈ 1.4666666
π ≈ 2.9333333

Jeder zusätzliche Term verbessert die Näherung.

pipers_pi(0);
// => 2.0
pipers_pi(1);
// => 2.6666666
pipers_pi(2);
// => 2.9333333

Das Argument ist eine nichtnegative 32-Bit-Ganzzahl. Der Rückgabewert ist eine 64-Bit-Gleitkommazahl.

Über GitHub bearbeiten Der Link öffnet sich in einem neuen Fenster oder Tab
x86-64 Assembly Exercism

Bereit, mit Pipers Pastete zu starten?

Melde dich bei Exercism an, um x86-64 Assembly mit 22 Konzepte130 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.