[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

回響