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位元組。

這表示每次遞迴呼叫都會因為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 把廚房打理好,烤出她那數學上完美的派吧。

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 位元的非負整數。 回傳值是 64 位元的非負整數。

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 位元的無號整數。 回傳值是 64 位元的無號整數。

3. Newton/Euler 收斂轉換

現在 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 位元的浮點數。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟
x86-64 Assembly Exercism

準備好開始 Piper 的派 了嗎?

註冊 Exercism,透過 22 個概念130 個練習 和真人引導來學習並精通 x86-64 Assembly,全部免費。