Egy függvény akkor rekurzív, ha önmagát hívja.
A függvényhívás és a ciklus között az egyik lényeges különbség az, hogy függvényhíváskor a visszatérési cím a verembe kerül. Ez azt jelenti, hogy egy rekurzív függvény általában több veremtárhelyet igényel, mint egy vele egyenértékű ciklus.
Következésképpen előfordulhat, hogy egy függvény, amely folyton önmagát hívja, végül kimeríti a teljes veremtárhelyet. Ezt veremtúlcsordulásnak nevezik.
Ezért minden rekurzív függvénynek legalább egy alapesettel kell rendelkeznie, ami olyan helyzet, amikor a függvény önmaga meghívása nélkül tér vissza. Minden rekurzív hívásnak előbb-utóbb el kell jutnia egy alapesethez.
Például a n! = n * (n - 1) * ... * 1 faktoriálisfüggvény rekurzívan definiálható úgy, hogy az alapeset 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
Vegyük észre, hogy a factorial rekurzív hívás előtt elvégzi a push rdi, utána pedig a pop rdi műveletet.
Ez azért van, mert a rekurzív hívás visszatérése után még szüksége van n-re az n * (n-1)! kiszámításához.
Az is figyelmet érdemel, hogy a callee-saved regiszterek használata sem oldaná meg ezt a problémát.
Bár egy rekurzív függvény önmaga lehetséges hívója, ő maga is hívottja egy másik függvénynek.
Ez azt jelenti, hogy a függvénynek a használatuk előtt meg is kell őriznie a callee-saved regisztereket, használatuk után pedig vissza kell állítania az értéküket.
Erre általában egy push/pop sorozatot használunk, ahogyan azt egy korábbi fogalomnál is láttuk.
Mivel a rekurzív függvény minden kerete, az alapeset kivételével, maga is hívó, amelynek meg kell őriznie a saját lokális változóit, ezt a push/pop sorozatot minden keretnél meg kell ismételni.
Még ha a változót regiszterek használata nélkül, közvetlenül a verembe tárolnánk is, az is ugyanannyiba, keretenként 8 bájtba kerülne.
Ez azt jelenti, hogy minden rekurzív hívás 8 bájttal növeli a vermet a call által verembe tett visszatérési cím miatt, plusz további 8 bájttal minden lokális változó után, amelyet meg kell őriznie.
A függvény minden keretnél tovább növeli ezekkel a bájtokkal a vermet, amíg el nem éri az alapesetét.
Csak ekkor kezd el lebontani fordított sorrendben, minden rekurzív hívás annyi pop-ot hajt végre, amennyire szüksége van, majd egy ret-et.
Például ha a factorial függvényt 10 argumentummal hívnánk meg, kilencszer hívná meg önmagát, mielőtt elérné az 1 alapesetet.
Ekkor már 144 bájtot használtunk volna fel az n (8 bájt) és a visszatérési cím (8 bájt) tárolására minden korábbi keretnél.
Bizonyos helyzetekben a függvény egy másik függvény meghívása után, a visszatérés előtt már nem végez semmilyen további munkát.
Vegyük például a következőt:
times_three:
imul rax, rdi, 3
ret
triple_of_square:
imul rdi, rdi
call times_three
ret
A triple_of_square függvény:
rdi-ben) önmagával, megkapva annak négyzetét;times_three függvényt, amely a kapott argumentum háromszorosával tér vissza.Ennek eredményeként a triple_of_square a 3*x² értékkel tér vissza, ahol x a függvény argumentuma, amelyet az rdi-ben adunk át.
Vegyük észre, hogy a triple_of_square a times_three meghívása után már nem végez semmilyen munkát, a függvény egyszerűen visszatér.
Egy ilyen helyzetben a függvény a call helyett használhat jmp-et, és átadhatja a vezérlést a meghívott függvénynek:
times_three:
imul rax, rdi, 3
ret
triple_of_square:
imul rdi, rdi
jmp times_three
Ezt nevezik farokhívásnak.
A farokhívás fő előnye, hogy elkerüli a call többletköltségét.
A call egy visszatérési címet tesz a verembe, és ahhoz, hogy a vezérlés visszatérjen arra a pontra, lennie kell egy hozzá illő ret-nek.
A farokhívás mindkettőt kihagyja: nincs visszatérési cím, amit a verembe kellene tenni, és nincs plusz ret, amivel párosulnia kellene, csak a meghívott függvény saját ret-je.
A farokhívás különösen hasznos az olyan rekurzív függvényeknél, amelyek visszatérés előtt sokszor meghívhatják önmagukat.
Azonban nem minden rekurzív hívás alakítható át közvetlenül farokhívássá.
Mivel a jmp átadja a vezérlést a meghívott függvénynek, a hívó a farokhívás után már nem végezhet további munkát.
Például a korábbi factorial függvény nem farokrekurzív.
A rekurzív hívás után még meg kell szoroznia az eredményt az aktuális n-nel az imul rax, rdi utasítással.
Ilyen helyzetekben néha lehetőség van egy akkumulátor használatára, amely összegyűjti a részszámításokat, és amelyet a végén visszaadunk.
Például definiálhatunk egy factorial_helper függvényt, amely elvégzi a munka nagy részét, majd a factorial beállít egy akkumulátort, és átadja a vezérlést a factorial_helper-nek:
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
Mivel a rekurzív hívás után már nem történik több munka, rdi-t sem kell többé megőriznünk.
Nincs call vagy push rdi, így minden rekurzív iteráció 0 bájttal növeli a vermet: nem használunk további veremtárhelyet.
Ez a változat tetszőlegesen nagy n-nel is elboldogul anélkül, hogy túlcsordulna a verem.
Egyszerre hatékonyabb és biztonságosabb.
Bizonyos esetekben a függvények sorrendjének átalakításával még a segédfüggvényre mutató jmp is elkerülhető.
Például a factorial és a triple_of_square így írható át:
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
A fenti kódrészletben a factorial végrehajtása átfolyik a factorial_helper-be.
Ugyanez történik a triple_of_square és a times_three esetében is.
Mindkét esetben a végrehajtás sorban folytatódik, és úgy tűnik, mintha a farokfüggvény csak egy helyi címke lenne a „fő” függvényen belül.
A valóságban lényegében nincs különbség egy helyi címke és egy függvény között.
Az x86-64 assembly nem kezeli különlegesen egyiket sem, ezek csupán címek egy végrehajtható kódot tartalmazó szakaszban, például a section .text-ben.
Így egy farokrekurzív függvény lényegében ugyanannak tekinthető, mint egy ciklus, amelyben a rekurzív hívás visszaugrik a tetejére, az alapeset pedig az a feltétel, amely befejezi a ciklust.
Piper szenvedélyes pitesütő.
Senki sem tudja, hogy azért kezdett-e pitesütésbe, mert így hívják, vagy azért változtatta-e meg a nevét, hogy passzoljon a hobbijához. Első ránézésre az utóbbi nem tűnik túl valószínűnek, de hát Pipert teljesen elbűvölik a piték. Állandóan a konyhában mesterkedik, finomítja a receptjeit, tökéletesíti a tudományát, barátai legnagyobb örömére. A részletekre fordított figyelmét semmi sem kerüli el: sem a sütő hőmérséklete, sem az egyes tésztagombócok súlya, és persze magának a pitének a formája sem.
A legújabb szenvedélye? A lehető legkerekebb piték sütése, egészen a matematikai tökéletességig, a kedvenc száma, kitaláltad: a π segítségével.
Piper talált egy remek képletet, amellyel a π iteratívan kiszámítható, ez pedig a Newton/Euler-féle konvergenciatranszformáció:
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
Segíts Pipernek rendet tenni a konyhájában, és megsütni a matematikailag tökéletes pitéjét.
Piper ma reggel két adag tésztát nyújtott ki, eltérő súllyal (g-ban).
Hogy a pitéi egyformák legyenek, mindkét adagot ugyanolyan súlyú gombócokra szeretné felosztani.
És persze a lehető legnagyobb adagokat akarja, hogy a lehető legkevesebb tészta menjen kárba!
A legnagyobb súly, amely mindkét adagot maradék nélkül osztja, a legnagyobb közös osztó. Az euklideszi algoritmus rekurzívan számítja ki:
gcd(a, 0) = a (alapeset)gcd(a, b) = gcd(b, a mod b)Figyeld meg, hogy a rekurzív hívás farokpozícióban van: utána már nem történik semmi.
A largest_portion függvényt úgy definiáld, hogy a rekurzív lépés egy jmp legyen magára a függvényre, ne pedig egy call.
largest_portion(252, 105);
// => 21
Mindkét argumentum 64 bites, nem negatív egész szám. A visszatérési érték 64 bites, nem negatív egész szám.
A fogalomból már tudod, hogyan kell a közönséges faktoriálist farokrekurzív módon megírni. Ugyanez a függvény szerepel a vázfájlodban.
A Newton/Euler-féle képlet azonban dupla faktoriálist is használ, amit !! jelöl.
A dupla faktoriális operátor definíciója a következő:
0!! = 1
n!! = 1 * 3 * 5 * ... * n // if n is odd
n!! = 2 * 4 * 6 * ... * n // if n is even
Figyeld meg, hogy a dupla faktoriális ugyanazt a mintát követi, mint a faktoriális, azzal a különbséggel, hogy lépésenként 1 helyett 2-vel csökken.
Definiáld a double_factorial függvényt, amely farokrekurzív módon számítja ki a dupla faktoriálist.
double_factorial(5);
// => 15
double_factorial(6);
// => 48
Az argumentum 32 bites előjel nélküli egész szám. A visszatérési érték 64 bites előjel nélküli egész szám.
Mostanra Pipernek minden eszköz megvan, amire szüksége van.
Definiáld a pipers_pi függvényt, amely a Newton/Euler-féle konvergenciatranszformáció képletéből adott számú tagot felhasználva közelíti a π-t:
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
A számláló a közönséges faktoriálist használja.
Meghívhatod a már definiált factorial függvényt!
A nevező pedig a 2. részfeladatban megírt double_factorial függvényt használja.
Számítsuk ki együtt az első tagot.
Ha a felső határ 0 (a végtelen helyett), akkor ezt kapjuk:
π / 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
Ha a felső határ 2, akkor viszont ezt kapjuk:
π / 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
Minden további tag javítja a közelítést.
pipers_pi(0);
// => 2.0
pipers_pi(1);
// => 2.6666666
pipers_pi(2);
// => 2.9333333
Az argumentum 32 bites, nem negatív egész szám. A visszatérési érték 64 bites lebegőpontos szám.
Iratkozz fel az Exercism-re, hogy megtanuld és elsajátítsd a(z) x86-64 Assembly nyelvet 22 fogalom130 feladat segítségével, valódi emberi mentorálással, mindez ingyen.