Η πίτα της Piper

Η πίτα της Piper

Άσκηση εκμάθησης

Εισαγωγή

Αναδρομή

Μια συνάρτηση είναι αναδρομική όταν καλεί τον εαυτό της.

Μια βασική διαφορά ανάμεσα σε μια κλήση συνάρτησης και έναν βρόχο είναι ότι η κλήση μιας συνάρτησης ωθεί στη στοίβα τη διεύθυνση επιστροφής. Αυτό σημαίνει ότι μια αναδρομική συνάρτηση απαιτεί συνήθως περισσότερο χώρο στη στοίβα από έναν ισοδύναμο βρόχο.

Κατά συνέπεια, μια συνάρτηση που συνεχίζει να καλεί τον εαυτό της μπορεί τελικά να εξαντλήσει όλο τον χώρο της στοίβας. Αυτό ονομάζεται υπερχείλιση στοίβας.

Γι' αυτό κάθε αναδρομική συνάρτηση πρέπει να έχει τουλάχιστον μία βασική περίπτωση, δηλαδή μια κατάσταση όπου η συνάρτηση επιστρέφει χωρίς να καλέσει τον εαυτό της. Κάθε αναδρομική κλήση πρέπει τελικά να φτάνει σε μια βασική περίπτωση.

Για παράδειγμα, η συνάρτηση παραγοντικού n! = n * (n - 1) * ... * 1 μπορεί να οριστεί αναδρομικά με το 1 ως βασική περίπτωση:

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

Πρόσεξε ότι η factorial πρέπει να κάνει push rdi πριν την αναδρομή και pop rdi μετά. Αυτό συμβαίνει επειδή χρειάζεται ακόμα το n όταν επιστρέψει η αναδρομική κλήση, για να υπολογίσει το n * (n-1)!.

Πρόσεξε επίσης ότι η χρήση ενός καταχωρητή που διατηρείται από τον καλούμενο δεν θα έλυνε αυτό το πρόβλημα.

Αν και μια αναδρομική συνάρτηση είναι δυνάμει καλών του εαυτού της, είναι και η ίδια καλούμενη κάποιας άλλης συνάρτησης. Αυτό σημαίνει ότι η συνάρτηση πρέπει επίσης να διατηρεί τους καταχωρητές που διατηρεί ο καλούμενος πριν τους χρησιμοποιήσει, και να επαναφέρει την τιμή τους αφού χρησιμοποιηθούν. Αυτό γίνεται συνήθως με μια ακολουθία push/pop, όπως έχουμε δει σε μια προηγούμενη έννοια.

Εφόσον κάθε πλαίσιο μιας αναδρομικής συνάρτησης, με εξαίρεση τη βασική περίπτωση, είναι και καλών που πρέπει να διατηρήσει τις δικές του τοπικές μεταβλητές, αυτή η ακολουθία push/pop πρέπει να επαναλαμβάνεται για κάθε πλαίσιο. Ακόμα και η αποθήκευση της μεταβλητής απευθείας στη στοίβα, χωρίς τη χρήση καταχωρητών, θα κόστιζε και πάλι τα ίδια 8 bytes ανά πλαίσιο.

Αυτό σημαίνει ότι κάθε αναδρομική κλήση προσθέτει 8 bytes στη στοίβα για τη διεύθυνση επιστροφής που ωθεί το call, συν 8 bytes για κάθε τοπική μεταβλητή που πρέπει να αποθηκεύσει. Η συνάρτηση θα συνεχίζει να προσθέτει αυτά τα bytes στη στοίβα σε κάθε πλαίσιο, μέχρι να φτάσει στη βασική περίπτωση. Μόνο τότε αρχίζει να ξετυλίγεται με αντίστροφη σειρά, με κάθε αναδρομική κλήση να χρησιμοποιεί όσα pop χρειάζεται και έπειτα ένα ret.

Για παράδειγμα, αν η factorial καλούνταν με όρισμα 10, θα καλούσε τον εαυτό της εννέα φορές πριν φτάσει στη βασική περίπτωση του 1. Σε εκείνο το σημείο, θα είχαν χρησιμοποιηθεί 144 bytes για την αποθήκευση του n (8 bytes) και της διεύθυνσης επιστροφής (8 bytes) για κάθε προηγούμενο πλαίσιο.

Ουριαία Κλήση

Σε ορισμένες περιπτώσεις, μια συνάρτηση δεν εκτελεί άλλη εργασία αφού καλέσει μια άλλη και πριν επιστρέψει.

Σκέψου, για παράδειγμα:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    call times_three
    ret

Η συνάρτηση triple_of_square:

  • πολλαπλασιάζει το όρισμα που της περνιέται (στο rdi) με τον εαυτό του, παίρνοντας το τετράγωνό του;
  • στη συνέχεια καλεί τη times_three, η οποία επιστρέφει το τριπλάσιο του ορίσματος που της περνιέται.

Ως αποτέλεσμα, η triple_of_square επιστρέφει 3*x², όπου x είναι το όρισμά της, περασμένο στο rdi.

Πρόσεξε ότι δεν εκτελείται καμία εργασία στη triple_of_square αφού καλέσει τη times_three, η συνάρτηση απλώς επιστρέφει. Σε μια τέτοια περίπτωση, αντί να χρησιμοποιήσει call, μια συνάρτηση μπορεί να χρησιμοποιήσει jmp και να μεταφέρει την εκτέλεση στην καλούμενη συνάρτηση:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    jmp times_three

Αυτό ονομάζεται ουριαία κλήση.

Το κύριο πλεονέκτημα μιας ουριαίας κλήσης είναι ότι αποφεύγει το επιπλέον κόστος του call. Ένα call ωθεί μια διεύθυνση επιστροφής στη στοίβα, και για να επιστρέψει ο έλεγχος σε εκείνο το σημείο χρειάζεται ένα αντίστοιχο ret.

Μια ουριαία κλήση τα παραλείπει και τα δύο: δεν υπάρχει διεύθυνση επιστροφής για ώθηση, ούτε επιπλέον ret για ταίριασμα, μόνο το δικό της ret της καλούμενης συνάρτησης.

Ουριαία Αναδρομή

Μια ουριαία κλήση είναι ιδιαίτερα χρήσιμη για αναδρομικές συναρτήσεις που μπορεί να καλέσουν τον εαυτό τους πολλές φορές πριν επιστρέψουν.

Ωστόσο, δεν μπορεί να μετατραπεί άμεσα κάθε αναδρομική κλήση σε ουριαία κλήση. Εφόσον ένα jmp μεταφέρει τον έλεγχο στην καλούμενη συνάρτηση, ο καλών δεν μπορεί να εκτελέσει άλλη εργασία μετά την ουριαία κλήση.

Για παράδειγμα, η προηγούμενη συνάρτηση factorial δεν είναι ουριαία αναδρομική. Μετά την αναδρομική κλήση, χρειάζεται ακόμα να πολλαπλασιάσει το αποτέλεσμα με το τρέχον n, χρησιμοποιώντας το imul rax, rdi.

Σε τέτοιες περιπτώσεις, είναι μερικές φορές δυνατό να χρησιμοποιηθεί ένας συσσωρευτής που θα συλλέγει τα μερικά αποτελέσματα και θα επιστραφεί στο τέλος. Για παράδειγμα, μπορούμε να ορίσουμε μια factorial_helper που κάνει το μεγαλύτερο μέρος της δουλειάς, και στη συνέχεια η factorial αρχικοποιεί έναν συσσωρευτή και μεταφέρει τον έλεγχο στη 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

Εφόσον δεν εκτελείται άλλη εργασία μετά την αναδρομική κλήση, δεν χρειάζεται πια να αποθηκεύουμε το rdi. Δεν υπάρχει call ή push rdi, οπότε κάθε αναδρομική επανάληψη προσθέτει 0 bytes στη στοίβα: δεν χρησιμοποιείται επιπλέον χώρος στη στοίβα. Αυτή η εκδοχή μπορεί να χειριστεί οσοδήποτε μεγάλο n χωρίς να υπερχειλίσει τη στοίβα. Είναι ταυτόχρονα πιο αποδοτική και πιο ασφαλής.

Σε ορισμένες περιπτώσεις, με αναδιάταξη της σειράς των συναρτήσεων, μπορεί να αποφευχθεί ακόμα και το jmp προς τη βοηθητική συνάρτηση. Για παράδειγμα, οι factorial και triple_of_square μπορούν να ξαναγραφτούν ως εξής:

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

Στο παραπάνω απόσπασμα, η εκτέλεση της factorial περνά απευθείας στη factorial_helper. Το ίδιο συμβαίνει με τη triple_of_square και τη times_three. Και στις δύο περιπτώσεις, η εκτέλεση συνεχίζει διαδοχικά και φαίνεται πως η ουριαία συνάρτηση είναι απλώς μια τοπική ετικέτα μέσα στην "κύρια" συνάρτηση.

Στην πραγματικότητα, δεν υπάρχει ουσιαστική διαφορά ανάμεσα σε μια τοπική ετικέτα και μια συνάρτηση. Η assembly x86-64 δεν δίνει ειδική μεταχείριση σε κανένα από αυτά, είναι απλώς διευθύνσεις σε ένα τμήμα με εκτελέσιμο κώδικα, όπως το section .text.

Έτσι, μια ουριαία αναδρομική συνάρτηση μπορεί να θεωρηθεί ουσιαστικά το ίδιο με έναν βρόχο όπου η αναδρομική κλήση πηδά πίσω στην αρχή, και η βασική περίπτωση είναι η συνθήκη που τερματίζει τον βρόχο.

Οδηγίες

Η Piper είναι παθιασμένη με το ψήσιμο πιτών.

Κανείς δεν ξέρει αν ασχολήθηκε με το ψήσιμο πιτών εξαιτίας του ονόματός της ή αν άλλαξε το όνομά της για να ταιριάζει με το χόμπι της. Με μια πρώτη ματιά, το δεύτερο δεν φαίνεται και πολύ πιθανό, αλλά βλέπεις, η Piper έχει απόλυτο πάθος με τις πίτες. Πάντα πειραματίζεται στην κουζίνα, τροποποιεί τις συνταγές της, βελτιώνει την τέχνη της, προς μεγάλη χαρά των φίλων της. Τίποτα δεν ξεφεύγει από την προσοχή της στη λεπτομέρεια: ούτε η θερμοκρασία του φούρνου της, ούτε το βάρος κάθε μπάλας ζύμης, και σίγουρα ούτε το σχήμα της ίδιας της πίτας.

Το τελευταίο της πάθος; Να ψήνει πίτες όσο πιο κυκλικές γίνεται, μέχρι μαθηματικής τελειότητας, με τη βοήθεια του αγαπημένου της αριθμού, όπως το μάντεψες: π.

Η Piper βρήκε έναν υπέροχο τύπο για να υπολογίζει το π επαναληπτικά, τον μετασχηματισμό σύγκλισης Newton/Euler:

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

Βοήθησε την Piper να βάλει σε τάξη την κουζίνα της και να ψήσει τη μαθηματικά τέλεια πίτα της.

1. Μοίρασε τη ζύμη

Η Piper άνοιξε με τον πλάστη δύο παρτίδες ζύμης σήμερα το πρωί, με διαφορετικά βάρη (σε g). Για να κρατήσει τις πίτες της ομοιόμορφες, θέλει να μοιράσει και τις δύο παρτίδες σε μπάλες του ίδιου βάρους. Και φυσικά θέλει τα μερίδια όσο το δυνατόν μεγαλύτερα, ώστε να πάει χαμένη όσο λιγότερη ζύμη γίνεται!

Το μεγαλύτερο βάρος που διαιρεί ακριβώς και τις δύο παρτίδες είναι ο μέγιστος κοινός διαιρέτης τους. Ο αλγόριθμος του Ευκλείδη τον υπολογίζει αναδρομικά:

  • gcd(a, 0) = a (βασική περίπτωση)
  • gcd(a, b) = gcd(b, a mod b)

Πρόσεξε ότι η αναδρομική κλήση βρίσκεται σε θέση ουράς: τίποτα δεν συμβαίνει μετά από αυτήν. Όρισε τη largest_portion έτσι ώστε το αναδρομικό βήμα να είναι jmp στην ίδια τη συνάρτηση, όχι call.

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

Και τα δύο ορίσματα είναι 64-bit μη αρνητικοί ακέραιοι. Η τιμή επιστροφής είναι ένας 64-bit μη αρνητικός ακέραιος.

2. Διπλό παραγοντικό

Ξέρεις ήδη από την ενότητα της έννοιας πώς να γράψεις το συνηθισμένο παραγοντικό με αναδρομή ουράς. Η ίδια συνάρτηση υπάρχει στο αρχείο-σκελετό σου.

Ωστόσο, ο τύπος των Newton/Euler χρησιμοποιεί και διπλά παραγοντικά, που γράφονται !!. Ο τελεστής του διπλού παραγοντικού ορίζεται ως εξής:

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

Πρόσεξε ότι το διπλό παραγοντικό ακολουθεί το ίδιο μοτίβο με το παραγοντικό, μόνο που μειώνεται κατά 2 σε κάθε βήμα αντί για 1. Όρισε τη συνάρτηση double_factorial, η οποία θα υπολογίζει το διπλό παραγοντικό με αναδρομή ουράς.

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

Το όρισμα είναι ένας 32-bit ακέραιος χωρίς πρόσημο. Η τιμή επιστροφής είναι ένας 64-bit ακέραιος χωρίς πρόσημο.

3. Μετασχηματισμός σύγκλισης Newton/Euler

Τώρα η Piper έχει όλα τα εργαλεία που χρειάζεται. Όρισε τη συνάρτηση pipers_pi, η οποία προσεγγίζει το π χρησιμοποιώντας έναν συγκεκριμένο αριθμό όρων από τον τύπο του μετασχηματισμού σύγκλισης Newton/Euler:

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

Ο αριθμητής χρησιμοποιεί το συνηθισμένο παραγοντικό. Μπορείς να καλέσεις τη συνάρτηση factorial που σου έχει ήδη δοθεί! Ο παρονομαστής χρησιμοποιεί τη double_factorial που έγραψες στην εργασία 2.

Ας υπολογίσουμε μαζί τον πρώτο όρο. Για ανώτατο όριο 0 (αντί για άπειρο), παίρνουμε:

π / 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

Για ανώτατο όριο 2, αντίθετα, παίρνουμε:

π / 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

Κάθε επιπλέον όρος θα βελτιώνει την προσέγγιση.

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

Το όρισμα είναι ένας 32-bit μη αρνητικός ακέραιος. Η τιμή επιστροφής είναι ένας 64-bit αριθμός κινητής υποδιαστολής.

Επεξεργασία μέσω GitHub Ο σύνδεσμος ανοίγει σε νέο παράθυρο ή καρτέλα
x86-64 Assembly Exercism

Έτοιμος να ξεκινήσεις την άσκηση Η πίτα της Piper;

Γράψου στο Exercism για να μάθεις και να κατακτήσεις x86-64 Assembly με 22 έννοιες130 ασκήσεις και πραγματική καθοδήγηση από ανθρώπους, όλα δωρεάν.