#導覽列 #文章列表 #許洛豪
在光之翼的守護下訴說著一段段的英雄傳說。 [ 首頁 | 網誌 | 相簿 | 留言 | 訂閱 ]

[Haskell] 真的比較好懂和不容易出錯嗎?

話說在 Haskell 的官方網站以及教學文件中,都提到了 Haskell 的好處之一是能寫出 Bug 比較少、比較短、比較清楚的程式。

在 Real World Haskell 中也提到下面這樣的觀點:

Even with years of experience, we remain astonished and pleased by how often our Haskell programs simply work on the first try, once we fix those compilation errors.

雖然我目前只讀到那本書的第三章,關於函式的定義和使用的部份,但做了一些練習題後,其實我有些懷疑 Functional Programming 社群這樣子的宣稱,是不是有點言過其實。

在接下來,我會拿 Real World Haskell 裡一個實際的練習題,說明我為什麼會有這樣子的想法。

首先,我們來看書中這個練習題的題目:

Write a function that computes the mean of a list, i.e. the sum of all elements in the list divided by its length.

在 Haskell 裡的 List,其實就等於是陣列,因此這個題目很簡單:寫一個函式計算陣列的平均值。我想這個程式應該很簡單,任何宣稱自己會寫程式的人應該在三分鐘之內就能把程式寫好,並跑出正確的結果才是。

OK,為了再突顯出問題的所在,我們把問題再簡化一點,要求出陣列元素的平均,我們要先求陣列裡每個元素的加總。

所以,我們先把問題改成:寫一個函數,計算陣列裡所有元素加總後的數值。

好,首先我們先來看 D 語言是怎麼做的,我想這也是大部份的人一看到這個題目,第一個想到的方法。

int sumD (int [] list)
{
    int sum = 0;
    foreach (int element; list) {
        sum = sum + eelemnt;
    }
    return sum;
}

相當標準的做法,而且我相信即使你沒學過 D 語言,只要有 C-Syntax 程式與言寫作的經驗,應該也猜得出來那個 foreach 迴圈是在做什麼用的。

好,接著我們再來在看 Haskell 裡,我們應該要怎麼寫這隻程式。

sumHaskell xs | null xs   = 0
              | otherwise = 
                    (head xs) +
                    sumHaskell (tail xs)

什麼?沒學過 Haskell 看不懂?沒關係,我們把這個程式改寫成 D 語言的版本,但不去更改它的演算法,就會變成下面這個樣子:

int sumHaskell (int [] list)
{
    if (list.length == 0) {
        return 0;
    }

    return list[0] + 
           sumHaskell (list[1..list.length]);
}

WOW!我們竟然沒有宣告任何存放結果的變數,就可以達到和之前的程式相同的效果,多神奇啊!

可是先等等,為什麼我們弄兩份不同的程式碼出來?不能直接用第一個版本走遍天下嗎?

喔喔,抱歉,不行,因為在 Haskell 裡有下面兩個特性,讓你不可能實做出第一個版本的程式:

  1. 所有的變數都是常數,在第一次定義後就不能變動,因此你不可能用一個變數來累加。
  2. 根本沒有迴圈。

有了以上兩個限制,我們在 Haskell 裡,只能實做出第二個版本,而第二個版本……遞迴!

於是爭論就開始了,第二個版本真的比較簡單、清楚,又不容易出錯嗎?很抱歉,我並不這樣想。

在第一個版本裡,我們的處理邏輯很簡單,宣告一個變數存放最終的加總值,用一個迴圈依序取出陣列裡的每一個元素,並一直累加到之前宣告的變數裡。

我相信你給任何一個小學生一個不規則數列,請他們在紙上加總,高達八成的小學生,會利用以上的方法來做這個題目,我們很乖的把第一二個數字相加,再加上第三個數字,再加上第四個,再加……

那麼 Haskell 的版本呢?遞迴!看一下程式碼,嗯,有一個 list[0],所以我們先把陣列的第一個元素拿出來,加上後面那一串。

呃,後面那一串用一個方法處理了 list[1..list.length],也就是陣列裡剩下的部份。

OK,所以我們也是從陣列的第一個元素開始算,再加第二個元素,再加第三個……

叭噗!錯錯錯!完全錯!在第二隻程式裡,我們實際上是從陣列的最後一個元素開始加總,在 [3, 5, 7, 9] 這樣的陣列裡,我們的實際計算過程如下:

  1. 9 + 0 = 9
  2. 7 + 9 = 16
  3. 5 + 16 = 21
  4. 3 + 21 = 24

看到了嗎?我們寫出的程式是和直覺相反的!在這樣的情況下,你要怎麼宣稱你寫的程式是簡潔、易懂、好維護又不容易出錯呢?

確實,我們的第二個版本比第一個版本簡潔多了,但是真的比較容易理解和不會出錯嗎?我們第一個版本的做法,我相信任何一個小學生都能夠理解其中的道理,並且能用紙筆重覆出所有的步驟。

但第二個程式呢?你要怎麼向小學生解釋遞迴的概念,還有為什麼實際跑的時候,我們會從 9 + 0 開始算,而那個 0 又是怎麼來的?

一個無法讓小學生理解的計算方法,真的可以算是容易懂嗎?一段看了之後還得去想為什麼要這樣寫的,會發生什麼事的程式碼,真的比較容易維護嗎?過了三個月之後,你再回頭看這段程式碼,是不是還能夠在三秒內反應過來他整個的運算過程,並且確定他是對的?

在我看來,程式會出錯不外乎兩個原因:

  1. 由語法的小錯誤造成程式結果的錯誤。
  2. 演算法本身的邏輯就有問題。

第一點我們在 C 語言之中就常見到,譬如在實做第一個版本時,for 迴圈的邊際條件設錯,造成陣列索引超出範圍或過小,而導至程式當掉或計算結果錯誤。

這是與法上的錯誤造成的,而不是演算法的錯誤。

確實,Haskell 是一個很嚴厲的語言,語法有一點小錯誤或沒有對好,甚或是型態錯誤,編譯器就一直發出抱怨,叫你要修改程式,否則不給你執行。

但其他非 Functional Programming 典範,比較新的程式語言,也同樣可以避免這類的錯誤。舉例而言,在第一個版本裡,我們用了 D 語言裡頭的 foreach 迴圈,確保我們一定可以完整地走完整個陣列,而且不會超過邊界。

這樣一來,兩個版本在第一點上就沒有什麼差異了,因為不論是 Haskell 或者 D 語言,在設計上都讓你盡量避免第一點的錯誤。

那麼第二點呢?在兩個版本裡頭,我們用的演算法相當不同。第一個版本是典型的 Imperative Programming 的做法,我們清楚的告訴編譯器我們要怎麼完成這件工作--從數列開頭,一個個數字拿出來,並且再把他們加到另一旁已經計算好的數字上。

三句話,小學生都能懂的事。

然而第二個版本呢?遞迴,可怕的遞迴,約耳談軟體裡說可以砍掉一大半的學生的遞迴,程設考卷上永遠有人寫不出來最後結果的遞迴,以及講了半天還是沒啥人懂的河內塔演算法。

這還只是給學生一個遞迴的程式問你是什麼意思時會發生的事,還沒叫他們設計遞迴演算法呢!

邊界條件是什麼?化約方式又是什麼?要怎樣才能確保我的遞迴程式是正確的,不會限入無止境的 Stack Overflow 或是 Segmentation Fault ?

如果你的程式語言,只允許使用遞迴來解大部份的問題,你要怎麼大聲的對那些新手,或是少了遞迴的那根筋的學生說:用這個語言,你寫出來的程式比較容易懂和不容易出錯?

因為在我看來,沒有任何一個使用遞迴的演算法是易懂的,也沒有任何一隻遞迴的程式是不容易出錯的--為什麼第二隻程式我們要 return 0 ,難道 1 不行嗎?助教。

Update (2008/10/01):

Thinker 提供了另一個 Haskell 的版本,我把它稍作整理和同樣改寫成 D 語言的版本如下:

sumHaskell v lst | null lst = v
                 | otherwise = 
                       sumHaskell 
                           (v + (head lst))  
                           (tail lst)
int sumHaskell2 (int v, int [] list)
{
    if (list.length == 0) {
        return v;
    }

    return sumHaskell2 (
        v + list[0], 
        list[1..list.length]
    );
}

這次我先不解釋這隻程式到底在幹嘛,也不加註解,請各位自行推測看看。

我個人覺得這個版本比之前的還難解釋(我想我真的是沒有遞迴那根筋的那種人),因為變數更多了,看起來更奇怪了。到底我們第一次呼叫這個函式要計算的時候,v 要傳什麼進去才對呢?後面的化約條件又是為什麼要那樣寫?v 在整個函式裡到底扮演什麼樣的角色?

在第一個遞迴版本中,我還可以想到用一句話來解釋:每一次的運算,我們都先算完後面陣列的總合,再加上我們目前所在的原素,sumHaskell (list[1..list.length]) 會幫我們算後面的陣列總合,所以我們可以不用理它。

如果不去深究遞迴的計算方法,這個解釋還是很合理的--我們先用一個函式計算完 list[1..list.length] 這個陣列,也就是去掉第一個元素後剩下的總合,再加上目前的這個元素,就會是全部的總合。

可是第二個版本,我究竟要怎樣解釋才講的清楚呢?我是在什麼時候做最後的加總的?計算的方法又是什麼?為什麼 list 空的時候要返回 v?我第一次呼叫這個函數的時候 v 要填什麼?

另外,SayYa 上的 letoh 版友提到:

在 D 語言版本的程式也有一行 int sum = 0; 難道 sum = 1 不行嗎? 這是一樣的問題 我能想到的簡單解釋 就是加法單位元素 也許你會有機會看到實作 factorial 範例 為什麼裡面的初始值是 1 不是 0? 我想得到的答案也是一樣 因為要設成乘法單位元素作為終止 我相信這和遞迴好不好懂應該無關:-)

的確,我在寫這篇的時候,並沒有想到第一個版本也有 int sum = 0 的這個問題,但我事後想了一想,我會忽略 int sum = 0 和特別挑出 return 0 的原因,是兩個敘述對我而言,在解釋的難易度上會有差別,而且我最後用的問句也不完整。

int sum = 0;
很明顯的,這是我們總計的結果,當然是從零開始加。
return 0;
助教,為什麼會有兩個 return,前面這個 return 是幹嘛用的?為什麼後面要 0,用 1 不行嗎?

第一個範例裡,我們很明顯的可以看出 sum 是做什麼的,進而推論到 0 是合理的預設值,可是第二個 return 如果對遞迴不熟,就很難去解釋『這行到底要幹嘛』(因為你必須解釋遞迴到最後一層的時候,會用到這個值,所以他等於是一開始的預設值 <== 好繞口)。

我想這是解釋難度上的差別,因為第一個範例實在太好解釋了,那個零有很大的機會不用去解釋。我自己的經驗是,大部份的學生只要知道我們的迴圈是『從頭一個個加』,就不會對於那個 int sum = 0 感到意外。

可是第二個就好難解釋啊,我每次光是解釋為什麼要設邊際條件,和邊際條件的 return 值就累死了。XD

最後順到一提,其實初學 Haskell 到現在,他最讓我感到激賞的是 Type Inference / Type Class,你可以不用宣告函式的標頭,就能夠擁有強型態檢查。而且如果你沒宣告,同一個函式就可以用在各種合理的型態上,成為 Generic 程式(像上面的 Haskell 版本,可以用在整數陣列、浮點數陣列都沒問題),不像 C++/Java/D 還要用 Template 來達成(上面的 D 語言版本只能用在整數陣列)。

這個在寫小程式的時候真的很方便。:p

將本文加入 Hemidemi 書籤
Brian Hsu (墳墓)
2008-09-30 (週二) 19:36:31

第 1 篇迴響

1. sumHaskell xs | null xs = 0 2. | otherwise = 3. (head xs) + 4. sumHaskell (tail xs)

可寫成 sumHaskell v, lst | null lst = v | otherwise = sumHaskell(v + (head lst), (tail lst))

Thinker 在 2008/10/01 發表

第 2 篇迴響

先聲明一下,這篇文章裡,我並不是在強調 Functional Programming 或 Imperative Programming 哪個比較好。

我自己在很多程式裡也大量使用遞迴,而這篇文章也不是想挑 Haskell 或 Function Programming 的缺點。

至少從我學習的經驗看來,Functional Programming 確實是很有趣的東西,常常會有我意想不到的做法(例如怎樣不去改變一個變數的值,就能達到相同的效果),我很享受這樣的過程。

然而我所質疑的,是 Haskell 社群裡一直宣稱的(我幾乎在每一份告訴我為什麼要學 Haskell 的理由裡都會看到),能夠寫出比較不會出錯、比較好懂的程式是真的嗎?(簡潔這點我認同,遞迴在大部份情況下程式確實很短。)

在我看到的說法裡,Haskell 社群宣稱能夠寫出比較好懂,不容易出錯的程式的原因,是因為我們可以用更高階的角度來解問題。

但究竟是一個由小學五年級生都可以重覆出每一個計算步驟的程式比較好懂?還是一個需要極度抽象思考的程式比較好懂?

是一個可以向小學生解釋每一個步驟的演算法比較容易設計又不會出錯,或是一個需要用到大學演算法在教的 divide & conquer 或者是folding ,甚或是什麼 fixed-point function thereoy 觀念的演算法比較不會出錯?

至於會提到遞迴,只是因為這是我認為最明顯的一個例子--比較高階的抽象層次,不代表一定比較容易懂和不會出錯。

我有太多用遞迴結果冒出 segmentation fault 錯誤訊息,或程式永遠跑不出來,而且還找不到錯在哪的慘痛經驗了。orz…

套一句朝倉啟太的話:請把我當做小學五年級生來解釋你的程式!不能用小學五年級生都能理解的言語解釋出的程式,不能算是真正容易懂的程式!

當然,這是一個 Open Question,對每個人來說,什麼叫好懂是因人而易的。有的人擅長抽象思考,自然會覺得遞迴很好懂,但我相信也有很多像我這種看到遞迴就想把每一個過程寫出來的人吧……囧。

對我而言,能夠讓我用人腦 CPU 跑出結果的程式比較好懂,至少我可以一個個步驟驗證我到底在做什麼,以及每一步是不是對的。

墳墓 (BrianHsu) 在 2008/10/01 發表

第 3 篇迴響

早上和 letoh 版友在 SayYa 個版上討論後,我對於【正確性】這件事又有以下的新的想法了……

我好像把『容易實作出正確的程式』和『容易設計出正確的演算法』兩件事情混在一起了。>_<

我忽然想推翻我之前的論述了,怎麼辦?XD

應該是正確的抽象思考的演算法比較難設計,可是實作比較簡單,但當演算法有問題時,找出問題點會比較難一點。

低階的演算法設計上比較簡單,但實作上可能會因為種種的原因造成程式的錯誤,而且程式撰寫上出現的錯誤比較難找(因為通常會比較冗長)。

但是如果是演算法本身的問題,低階的演算法則比較容易知道問題所在,因為你可以用人腦一個個部驟重覆出來,看問題到底出在哪。

我一直把寫程式和設計演算法這兩件事混在一起看了說……orz…

不過對於【容易懂】這件事,倒還是維持著上面那篇留言的看法:不能用小學五年級學生都懂的語言解釋出來的程式,不能算是容易懂的程式!

至少我自己不覺得用遞迴(高度抽象)的程式,一定就會比用迴圈(從低階做起)的程式來得好懂。上面的加總的例子,我想就是一個很好的反例。

當然,遞迴有的時候還是很好用的,例如你要算的東西本來就可以直接用數學的遞迴定義下去做(像費伯納西數列、阿克曼函數),反正什麼都不用去管,只要照抄就好,因為已經有人幫我們確認那個數學式是正確的了。:p

可是要想出河內塔的遞迴解法再寫出程式嘛……我們來考程設期中考吧!XD

墳墓 (BrianHsu) 在 2008/10/01 發表

第 4 篇迴響

遞迴是使用代入法去思考。

為啥會用 library 的函式? 既然用 library 的函式還不如整計算直接用 library 的函式來完成,還用啥遞迴?

學了幾天 Haskell ,第一個版本,我會這樣寫:

sumHaskell [] = 0 sumHaskell x:xs = x + sumHaskell xs

如果不計算 null case ,亦即 List 的長度最小為 1 ,則可以這樣寫:

sumHaskell [x] = x sumHaskell x:xs = x + sumHaskell xs

第二個版本(tail-recursion 版)可以這樣: sumHaskell ls = sum2 0 ls where sum2 z xs = case xs of [] -> z y:ys -> sum2 (z + y) ys

幾天的學習,我看到 Haskell 相對 imperative language ,特別是像 C++ 和 Java 這類 OOP language , Haskell 少用了很多不必要 state 變數和中間變數,這是因為 Haskell 簡潔的型別定義(C++ 、 Java 的 class 有很多 member variables),在短短一行型別定義內就把 constructors 定義了,大量使用 monads 、 >>= 、 >> 、 do notation 而隱藏了並 chaining 了內部的狀態和細節, 自然而簡潔的 Curry 語法可以使運算輕易細分並重用,pattern matching 可以直接指派結構中的資料到變數。

變數不可變其中一個是好處是天生就 thread-safe 。

我看到的文章是說 Haskell 可以直接用寫規格。 想一想,又好像是真的,因為沒有中間暫存變數,所以直即寫下想到的資料結構、界面就可以,因為沒有 side-effect ,所以直接寫下函式原型就可以表示函式的作用。

在 Haskell 中,資料結構定義和 interface (Haskell 中即是 class)是分離的,資料結構根本不用受到繼承問題的束縛。

LungZeno 在 2008/10/23 發表

第 5 篇迴響

這 blog 打出的空格都自動「壓縮」了,太 HTML 了,都不把空格替換成 &nbps; ,Haskell使用 layout system,換行和縮排會自動轉換成語法成份。

LungZeno 在 2008/10/23 發表

第 6 篇迴響

依學習到的命名慣例,第二個版本(tail-recursion 版)可以改成這樣: sumHaskell xs = sumHaskell’ 0 xs  where sumHaskell’ z xs’ = case xs’ of   [] -> z   x:xs" -> sumHaskell’ (z + x) xs"

>> 我們寫出的程式是和直覺相反的! 因為 Haskell 是 lazy evaluation 的,所以應該這樣想:

  1. sumHaskell [3 , 5 , 7 , 9]
  2. 3 + sumHaskell [5 , 7 , 9]
  3. 3 + (5 + sumHaskell [7 , 9])
  4. 3 + (5 + (7 + sumHaskell [9]))
  5. 3 + (5 + (7 + (9 + sumHaskell [])))
  6. 3 + (5 + (7 + (9 + 0)))

至於第二個版本的 sumHaskell’ z xs 的 z 是用來存放計算的結果:

  1. sumHaskell’ 0 [3 , 5 , 7 , 9] → z = 0, 對應 int sum = 0
  2. sumHaskell’ (0 + 3) [5 , 7 , 9] → z = z + 3 = 0 + 3, 對應 sum = sum + element
  3. sumHaskell’ ((0 + 3) + 5) [7 , 9] → z = z + 5 = (0 + 3) + 5
  4. sumHaskell’ (((0 + 3) + 5) + 7) [9]
  5. sumHaskell’ ((((0 + 3) + 5) + 7) + 9) []
  6. (((0 + 3) + 5) + 7) + 9 → 對應 [] -> z

第一個版本多出來的 0 和第二個版本要多一個參數的解釋: 遞迴使用的是事物的自相似性,即是事物的局部與整個事物相似,亦即是事物包含事物自己,因此有「遞迴步驟」(借用數學歸納法的「歸納步驟」這一術語): sumHaskell x:xs = x + sumHaskell xs –第一個版本 sumHaskell’ z x:xs = sumHaskell’ (z + x) xs –第二個版本 但是遞迴結束時,事物已到終點,不再包含自己,因此遞迴結束是一個特殊情況,要加一個獨立的 case 表述: sumHaskell [x] = x sumHaskell x:xs = x + sumHaskell xs 而 sumHaskell [] 應該是 error 才是。 考慮到 sumHaskell [] 的情況的話,我們也可以給它一個數值: sumHaskell [] = undefined sumHaskell [x] = x sumHaskell x:xs = x + sumHaskell xs 而考慮到 sumHaskell [x] 是 sumHaskell [] 的前一個計算,所以可以把 sumHaskell [x] 結合到遞迴步驟中去而少寫一行,把sumHaskell [] 變做遞迴的終結情況,因而 sumHaskell [] 要顧慮到遞迴中尾二的計算,不能影響它,因而要使用加法單位元 0 : sumHaskell [] = 0 sumHaskell x:xs = x + sumHaskell xs 而第二個版本在一串計算開頭加 0 明顯也是一個特殊情況,要在遞迴步驟之外處理,這裡有兩個問題: 1. 怎樣知道是在計算的開頭? 2. 遞迴步驟的 pattern 是怎樣?每一次計算要計算甚麼?要返回甚麼? 0 + firstElement + secondElement + thirdElement… = (…(((0 + firstElement) + secondElement) + thirdElement) + …) = (…((第一次計算結果 + secondElement) + thirdElement) + …) = (…(第二次計算結果 + thirdElement) + …) = (…第三次計算結果 + …) = let 今次計算結果 = 前一次計算結果 + element in 返回後一次的返回結果 問題是之後的計算要使用到今次計算結果,如果不使用額外的一個參數, code 就可能要寫成這樣(偽碼): sumHaskell’ [] = "result" variableOf (scopeOf caller) sumHaskell’ x:xs =  let result = if caller != sumHaskell   then 0 + x   else "result" variableOf (scopeOf caller)  in sumHaskell’ xs 但 Haskell 是使用詞法有效範圍(lexical scope)的,而不是動態有效範圍,又怎樣讀取 caller中定義的變數?而且就算是動態有效範圍,也是被 shadow 了的變數。(而且這裡的 caller 明顯不是 referential transparent)因此,取代作為儲存結果的local變數 result ,就要加多一個參數: sumHaskell’ result [] = result sumHaskell’ result x:xs = sumHaskell’ (result + x):xs 而且這樣還不用判斷是否一串計算的開頭,計算的開頭結合到遞迴步驟中去,並由最開始的呼叫者餵給未開始計算之前的 result 參數一個 0 。

迴圈真的小學生也明白嗎?我依稀記得以前初學程式設計語言時,還不每次看到迴圈也都要在腦中 try run 。就算現在我看到一些複雜的嵌套迴圈,還不是要在腦中 try run 一兩次才瞭解作用?但迴圈有其常用的 patterns ,熟了之後就會覺得很易理解,遞迴不也是同樣道理嗎?

LungZeno 在 2008/10/24 發表

第 7 篇迴響

說真的,這麼長又是個人觀點的系列性的文章,建議您直接在發表在個人的網誌上,再發引用過來就好了,不需要一直用回響的方式留言,這裡畢竟不是討論區……orz

我想這樣子您的文章脈絡會更清晰,而且文章的 credit 也會更清楚地是屬於你的。

另外,我刪掉到數第二篇那篇不完整的留言了。

Brian Hsu 在 2008/10/24 發表

第 8 篇迴響

本來沒有打算這樣的,只是每次回響完,總覺得未想透,所以就變成這樣,抱歉。

我又發覺未想透,又有想補充的地方……

LungZeno 在 2008/10/24 發表

第 9 篇迴響

關於遞歸函數的寫法,提供一個觀點會比較自然 …

像是 list 的 length,一個是 [] 的情況,另一個是 x : xs 的情況,將他想作數學歸納法的 base case 跟 inductive case,就是說,length 是一個傳回 list 長度的函數。

在 base case 的時候,[] 的長度是 0。在 inductive case,假設我們已經有 length xs 是回傳 xs 的長度,那麼 length x:xs 要是一個 x:xs 長度。

這樣想避免我們去想遞歸時,那一層又一層的函數呼叫,好像把事情反轉,雖然是這樣算,但其實比較像推導骨牌,從底層往上推。

而是事實上也是如此,對於 type system 更強的語言來說(強到足以被用來作定理證明的程式),遞迴函數的確是被當作歸納法的證明。

XOO 在 2009/02/05 發表

第 10 篇迴響

嗯,很少看到这个角度的,对函数式和命令式语言的比较,所见略同,并深以为然。

我对此有自己的分析。在真实世界中,我们碰到的问题有些是关于“是什么”的问题,另一些是关于“如何做”的问题。前一个问题用函数式,逻辑式都是很直观的,后一个问题用命令式是直观的。

在如何结合声明式和命令式编程方面,如何在声明式代码和命令式代码之间建立等效关系,程序设计语言的领域还有大量的空白等着开拓。

由 openaudio 在 2009/05/20 發表

第 11 篇迴響

你覺得很難對小五生解釋遞迴版的做法,是因為你想要一五一十描述整個演算的過程,讓你們變成機器ㄧ般照著指令去作。 遞迴的演算法本身就是把規模大的問題持續分解成較小的問題,直到小到你可以確定你能正確求出問題的答案,並在分解的過程作有助於逼近答案的事情(如果可以的話)。如果你以較高層面的描述方式去解釋遞迴版的求和問題,小五生不是沒有機會了解。

不曉得 D 的 foreach 應用在 array 是 iterator 的概念還是遞增 index 的概念? foreach (int element; list) { sum = sum + eelemnt; }

如果上述的 foreach statement 實際上是以 index 來走訪,其與小五生手算的過程會比遞迴版演算來的更不自然。 因為一般人拿到一堆數字(在紙上)要求和時,並不會先去算總共有幾個,然後一值記著我目前加到第幾個直到加過全部的數。而是先拿出第一個數與 0 相加(當然這一步小五生會懂得不需要),再拿出一個數來與目前的和相加,並刪掉已算過的數直到沒有剩下任何未刪掉的數。

我個人覺得這樣子的演算過程其實與遞迴版的演算法有很高的相似度。

public class Calculator { public static double sum(java.util.Queue numbers) { return sumImpl(0.0, numbers); }

private static double sumImpl(double acc, java.util.Queue numbers) {
    if (numbers.size() == 0)
        return acc;
    else
        return sumImpl(acc + numbers.poll().doubleValue(), numbers);
}

}

由 Duncan 在 2010/01/09 發表


留下迴響







請勿使用注音文或火星文!並請遵守發規則,本站亦不歡迎匿名留言。