這是Joshua Bloch(Effective Java的作者)在google blog上發的文章。在說這個文章之前,不得不強力重複Joshua Bloch的推薦:如果你還沒有讀過Programming Pearls (中文版叫《編程珠璣》)這本書,現在就去讀吧。如果你唯讀了一遍,現在就去再讀一遍吧。
還是說回Joshua的文章。當初Programming Pearls的作者Jon Bentley到CMU做講座。他叫在場的電腦系博士生們寫出binary search的演算法,然後當場分析了其中一份。當然,那份演算法以及絕大部分人寫的演算法都錯了。Jon Bentley在Programming Pearls裡也提到,雖然1946年就有人發表binary search,但直到1962第一個正確啟動並執行演算法才寫出來。這個小故事的關鍵教訓就是寫程式時要仔細考慮演算法的不變數(invariant)。如果我記得沒錯,Programming Pearls第4章講解了怎麼證明binary search的正確性。當然,每本離散數學的教科書都會教我們列出pre-condition, invariant, 和post-condition,證明迴圈開始前pre-condition成立,迴圈中invariant始終成立,而迴圈結束後post-condition被滿足,而幾乎每本教科書(至少我看過的)都會用binary search作例子。所以有興趣的自己去看吧,俺就不羅嗦了。
JDK裡的binary search代碼是這樣實現的(Joshua Bloch本人寫的)
1: public static int binarySearch(int[] a, int key) {
2: int low = 0;
3: int high = a.length - 1;
4:
5: while (low <= high) {
6: int mid = (low + high) / 2;
7: int midVal = a[mid];
8:
9: if (midVal < key)
10: low = mid + 1;
11: else if (midVal > key)
12: high = mid - 1;
13: else
14: return mid; // key found
15: }
16: return -(low + 1); // key not found.
17: }
錯誤就在第6行:
6: int mid = (low + high) / 2;
這行的問題是當low和high的和超過2^31-1, 也就是Java裡最大整數值時,整數溢出就發生了,而mid就變成負數了, 於是JVM就抓狂了,於是ArrayIndexOutOfBoundsException就發生了。
當一個數組包含多過2^30元素時,這個錯誤就會被發現。那麼大的數組在80年代Programming Pearls第一版寫就的時候難以想象,但在現在卻很常見。所以說,儘管1962年正確的binary search問世,現實卻是直到現在流行系統裡的binary search還有錯。
解決的辦法不難。把第6行改寫成
6: int mid = low + ((high - low) / 2);
或者
6: int mid = (low + high) >>> 1;
C和C++裡沒有這個">>>",我們可以這樣做:
6: int mid = ((unsigned) (low + high)) >> 1。
那現在binary search就完全正確了嗎?我們還是不知道。我們得到的深刻教訓是,僅僅證明一個程式正確是不夠的。我們必須仔細測試。高德納在寫給Peter van Emde Boas的信裡說,“上面那段程式可能有錯。我只證明了它是正確的,但還沒有測過”。人們往往用這段話來彰顯高德納的一絲不苟和學究氣,誰知道這句話背後是高德納深刻的洞察力。人們常說“理論上講實踐和理論沒有差別。實踐上講,兩者確有差別”,可為旁證。
binary search的這個錯誤同樣會出現在其它“分而治之”的演算法裡,比如說mergesort。如果你有類似的演算法代碼,趕快修改吧。Joshua說,他從中學到的教訓是謙卑:哪怕一個簡單的程式都很難寫對,而整個社會卻運行在龐大而複雜的代碼上面。
最後的總結很有意思:我們程式員需要各種協助,別無它法。仔細設計很好。測試很好。形式化方法很好(不過我還是覺得有教授研究用形式化電子商務需求(比如用範疇論),純粹無事找事)。程式碼檢閱很好,靜態分析很好。但他們並不能幫我們徹底消除代碼錯誤--他們將永遠存在。我們半個世紀以來竭盡全力都不能消除一個程式錯誤。我們必須小心翼翼,防禦性地編程,並且保持警醒。