當一個函式會呼叫自己時,它就是遞迴的。
呼叫函式與迴圈的一個關鍵差異在於,呼叫函式會把要返回的位址推入堆疊。 這表示遞迴函式通常比等效的迴圈需要更多堆疊空間。
因此,一個不斷呼叫自己的函式最後可能會耗盡所有堆疊空間。 這種情況稱為堆疊溢位。
這就是為什麼每個遞迴函式都必須至少有一個基本情況,也就是函式不呼叫自己就直接回傳的情況。 任何遞迴呼叫最終都必須抵達基本情況。
例如,階乘函式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位元組。
這表示每次遞迴呼叫都會因為call推入的返回位址而在堆疊上增加8位元組,再加上它需要保存的每個區域變數各8位元組。
函式會在每一層框架持續把這些位元組加到堆疊上,直到抵達基本情況為止。
直到那時,它才開始以相反順序逐層返回,每個遞迴呼叫视需要執行對應數量的pop,然後執行ret。
例如,如果以引數10呼叫factorial,它會在抵達基本情況1之前先呼叫自己 9 次。
在那個時候,已經用掉144位元組,用來儲存先前每一層框架的n(8位元組)以及返回位址(8位元組)。
在某些情況下,函式在呼叫另一個函式之後、回傳之前,不會再做任何工作。
例如:
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之後沒有做任何工作,只是直接回傳。
在這種情況下,函式可以改用jmp把執行轉移給被呼叫的函式,而不是使用call:
times_three:
imul rax, rdi, 3
ret
triple_of_square:
imul rdi, rdi
jmp times_three
這稱為尾呼叫。
尾呼叫的主要優點是省下call的額外成本。
call會把返回位址推入堆疊,而控制權要回到那一點,就必須有對應的ret。
尾呼叫把兩者都省掉了:沒有返回位址要推入,也沒有多餘的ret需要配對,只有被呼叫函式自己的ret。
對於在回傳之前可能呼叫自己很多次的遞迴函式來說,尾呼叫特別有用。
不過,並不是每個遞迴呼叫都能直接轉換成尾呼叫。
由於jmp會把控制權轉移給被呼叫的函式,呼叫者在尾呼叫之後就無法再做任何工作。
例如,先前的factorial函式不是尾遞迴。
在遞迴呼叫之後,它仍然需要用imul rax, rdi把結果乘上目前的n。
在這種情況下,有時可以使用累加器來收集部分計算結果,並在最後回傳。
例如,我們可以定義一個負責大部分工作的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位元組:不會用到額外的堆疊空間。
這個版本可以處理任意大的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也是如此。
在這兩種情況下,執行都依序繼續,看起來尾端的函式只是「主要」函式裡的區域標籤。
實際上,任何區域標籤和函式之間並沒有本質上的差異。
x86-64 組合語言不會對其中任何一個給予特殊待遇,它們都只是可執行程式碼區段中的位址,例如section .text。
這樣一來,尾遞迴函式基本上可以想成和迴圈一樣:遞迴呼叫會跳回頂端,而基本情況就是結束迴圈的條件。
Piper 是一位熱愛烤派的人。
沒有人知道她是因為自己的名字才一頭栽進烤派,還是為了配合這個嗜好而改了名字。 乍看之下,後者似乎不太可能;但你知道嗎,Piper 對派可是著迷得不得了。 她總是在廚房裡東摸西摸,調整食譜、精進手藝,讓朋友們開心極了。 她對細節的講究滴水不漏,無論是烤箱的溫度、每一團麵團的重量,還是派本身的形狀,全都逃不過她的眼睛。
她最近迷上的事情是什麼呢? 就是烤出盡可能完美的圓形派,追求到數學上的極致,而她靠的正是她最愛的數字,你猜到了吧:π。
Piper 找到了一個能反覆迭代計算 π 的漂亮公式,也就是 Newton/Euler 收斂轉換:
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
幫 Piper 把廚房打理好,烤出她那數學上完美的派吧。
Piper 今天早上擀了兩批麵團,重量各不相同(以g為單位)。
為了讓每個派大小一致,她想把兩批麵團都分成重量相同的麵團球。
當然,她希望每一份越大越好,這樣浪費的麵團就越少!
能同時整除兩批麵團的最大重量,就是它們的最大公因數。 歐幾里得演算法用遞迴來計算它:
gcd(a, 0) = a(基本情況)gcd(a, b) = gcd(b, a mod b)注意,這個遞迴呼叫位於尾端位置:它之後不會再發生任何事。
定義 largest_portion,讓遞迴步驟是 jmp 到函式本身,而不是 call。
largest_portion(252, 105);
// => 21
兩個引數都是 64 位元的非負整數。 回傳值是 64 位元的非負整數。
你已經從概念中學到如何用尾遞迴的方式寫出一般的階乘。 同樣的函式就在你的程式骨架檔案裡。
不過,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 位元的無號整數。 回傳值是 64 位元的無號整數。
現在 Piper 擁有了她需要的所有工具。
定義 pipers_pi 函式,用 Newton/Euler 收斂轉換公式中固定數量的項來近似 π:
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
分子使用一般的階乘。
你可以直接呼叫已經為你定義好的 factorial 函式!
分母則使用你在第 2 項任務中寫的 double_factorial。
我們一起來算算第一項吧。
當上限是 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 位元的非負整數。 回傳值是 64 位元的浮點數。