【題目】
猜數字(又稱 Bulls and Cows )是一種大概於20世紀中期興起於英國的益智類小游戲。一般由兩個人玩,也可以由一個人和電腦玩,在紙上、在網上都可以玩。這種游戲規則簡單,但可以考驗人的嚴謹和耐心,而這也正是程序員所需要的優秀品質。
標准規則如下:
通常由兩個人玩,一方出數字,一方猜。出數字的人要想好一個沒有重復數字的4位數,不能讓猜的人知道。猜的人就可以開始猜。每猜一個數字,出數者就要根據這個數字給出幾A幾B,其中A前面的數字表示位置正確的數的個數,而B前的數字表示數字正確而位置不對的數的個數。如正確答案為 5234,而猜的人猜 5346,則是1A2B,其中有一個5的位置對了,記為1A,而3和4這兩個數字對了,而位置沒對,因此記為 2B,合起來就是1A2B。接着猜的人再根據出題者的幾A幾B繼續猜,直到猜中(即4A0B)為止。
【樣本】

1. #include <stdio.h> 2. // 引入隨機函數srand()、rand()所在的頭文件 3. #include <stdlib.h> 4. // 引入時間函數time()所在的頭文件 5. #include <time.h> 6. // 引入字符查找函數strchr()所在的頭文件 7. #include <string.h> 8. int main() 9. { 10. // 定義保存目標數字的字符數組 11. char bull[5] = ""; 12. srand((int)time(0)); 13. // 利用rand()函數產生一個四位隨機數, 14. // 並利用sprintf()函數將其轉換成字符串,保存在bull字符數組中 15. sprintf(bull, 16. "%d", rand()%10000); 17. // 定義表示猜測數字的字符數組 18. char cow[5] = ""; 19. // 定義表示猜測狀況的A和B 20. int A = 0; 21. int B = 0; 22. // 猜測次數,最開始是第一次 23. int count = 1; 24. // 構造一個循環結構,只要沒有完全猜對, 25. // 就繼續下一次猜測 26. while(4!=A) 27. { 28. // 判斷是否超過次數限制 29. // 最多猜測10次 30. if(count > 10) 31. { 32. puts("sorry,you lost. :"); 33. // 跳出猜測循環,結束游戲 34. break; 35. } 36. // 輸出當前次數 37. printf("%d :",count); 38. // 獲取用戶輸入的猜測數字 39. // 因為需要與目標數字進行比對, 40. // 所以將輸入作為字符串(%s),而不是作為一個整數(%d) 41. scanf("%s",cow); 42. // 采用循環結構,逐個字符比對輸入數字(cow)和目標數字(bull) 43. for(int i = 0; i<4; ++i) 44. { 45. // 判斷當前字符是否相同 46. if(bull[i] == cow[i] ) 47. { 48. // 如果相同,則表示數字正確且位置正確, 49. // A的值增加1 50. ++A; 51. } 52. else // 否則,判斷當前數字是否是正確數字 53. { 54. // 在目標字符串中查找當前字符 55. char* p = strchr( bull, cow[i]); 56. // 如果p不是一個空指針(C語言中用NULL或者0表示), 57. // 則表示目標字符串中有這個字符,當前字符是正確數字, 58. // B的值增加1 59. if(0 != p) 60. ++B; 61. } 62. } 63. // 輸出此次猜測的結果 64. printf("%s : %dA%dB\n",cow,A,B); 65. // 如果完全猜測正確,則輸出最終結果,結束游戲 66. if(4 == A) 67. { 68. printf("you win! the bull is %s.",bull); 69. break; 70. } 71. // 否則,開始下一次猜測,次數增加1,A和B數據歸零 72. ++count; 73. A = 0; 74. B = 0; 75. } 76. return 0; 77. }
——陳良喬 ,《C程序設計伴侶》,人民郵電出版社,2012年10月,p39~42
【評析】
對於初學者來說,這個題目有一定難度和挑戰性。但也正因為如此,從中更能發現初學者常犯的一些毛病。
從整體上看,整個源程序只有一個main()函數,沒有其他的自定義函數。這是初學者常見的一個毛病——一main()到底。(參見拙著《品悟C》 第7章 問題4 <將main()函數進行到底>,P253)

1. #include <stdio.h> 2. // 引入隨機函數srand()、rand()所在的頭文件 3. #include <stdlib.h> 4. // 引入時間函數time()所在的頭文件 5. #include <time.h> 6. // 引入字符查找函數strchr()所在的頭文件 7. #include <string.h>
這種注釋風格有兩個特點:第一丑陋凌亂;第二浪費鋪張,是一種把散文“膨化”成詩歌格式的作風。用這種格式投稿,不但可以勞累讀者眼球還可以擴張版面多賺稿費。但如果在工作這樣寫代碼,同事會罵你,老板會K你。對比一下下面的寫法,相信不難看出故意“膨化”的代碼是否丑陋。
#include <stdio.h> #include <stdlib.h> // 引入隨機函數srand()、rand()所在的頭文件 #include <time.h> // 引入時間函數time()所在的頭文件 #include <string.h> // 引入字符查找函數strchr()所在的頭文件
8. int main()
main()的這種寫法因為與現代C語言的精神相違背,因此不被提倡。較好的寫法是
int main ( void )
10. // 定義保存目標數字的字符數組 11. char bull[5] = "";
這兩行代碼同樣有浪費篇幅之嫌。bull作為程序的主要數據結構,選擇字符數組並不明智。代碼作者選擇這種結構的目的是為了使用現成的庫函數strchr()。使用現成的庫函數無可指責,但在這個問題中字符數組這種數據結構會引起其他方面的問題。利弊相權,並不合算。
此外這里的5是一個MAGIC NUMBER。
這段代碼的另一個問題是對數組毫無意義地進行初始化,這是一種無聊的行為。
12. srand((int)time(0));
這一句代碼的目的是設置種子數(seed)。這行代碼雖然很短,但有很多潛在的問題值得討論。
首先time(0)這個寫法,我的看法是屬於C++風格,非常不C。在C語言中應該寫time(NULL)。不過也有人認為在C代碼中寫time(0)無傷大雅。這可能是一個見仁見智的問題。
另外一個問題是關於(int) time(0)這個表達式的,這個問題則並非是見仁見智那么簡單。由於srand()的函數原型為:
void srand (unsigned int);
因此
srand((int)time(0));
在本質上其實就是
srand((unsigned)(int)time(0));
這是因為根據C語言規則,編譯器總會在這里進行隱式類型轉換。與下面兩種寫法比較一下
srand((unsigned) time(NULL)); srand(time(NULL));
就會發現(int)這個類型轉換相當地無聊,它徒勞地進行了一次毫無意義的、無謂的類型轉換,但最終還是要轉換為unsigned類型。就像歌里唱的那樣:“終點又回到起點”,但代碼作者還沒“發覺”。因此這是一種畫蛇添足的行為,說明了代碼作者語法不清、邏輯混亂。
(int) time(0)中的這個類型轉換還存在其他一些問題。我們都知道,time()函數的返回值的類型為time_t,C語言並沒有完全明確這種類型,只要求它是一種實數類型(real type)並且能夠表示相應的時間。實數類型包括整數類型和實浮點類型,具體選擇哪種類型由編譯器自行確定。
當time_t為浮點類型時(說實話沒見過,但這種可能性是存在的),如果time(0)的返回值超出了所要轉換成的整數類型的表示范圍時,代碼盡管可以通過編譯,但程序實行的卻是一種未定義行為(UB,undefined behavior)。
由於time(0)的返回值為一正數,而 int類型所能表示的最大正整數顯然小於unsigned類型所能表示的最大正整數,因此對time(0)進行(int)這樣的轉換顯然比進行(unsigned)轉換存在更大的發生UB危險性。
如果time_t為整數類型(多數的編譯器中是long類型),在進行long => int 的轉換時,如果被轉換的值在int的表示范圍內,不會有任何問題。但是如果被轉換的值不在int類型表示的范圍內時,結果有兩種可能:implementation-defined或者是引發一個implementation-defined的信號(signal),前者意味着可移植性很差,后者則意味着程序被中斷或掛起等待進一步的處理,這簡直是在自尋煩惱、自討苦吃。但是如果是進行long => unsigned的轉換,則根本不存在這些問題。
因此,如果你追求代碼的至簡,這行代碼應該寫為
srand(time(NULL));
如果你追求清晰和明確,則應該寫為
srand((unsigned) time(NULL));
13. // 利用rand()函數產生一個四位隨機數, 14. // 並利用sprintf()函數將其轉換成字符串,保存在bull字符數組中 15. sprintf(bull, 16. "%d", rand()%10000);
這幾行代碼有兩個問題。
頭一個,不清楚代碼作者為什么要把sprintf()函數調用掰成了兩行,這個函數調用並不長,完全沒有任何必要掰成兩行寫。估計這又是“膨化”作風在作妖搞怪。
第二個問題是,rand()%10000得到的並非是一個“四位”整數,它也有可能得到一位數、兩位數或三位數,因此這行代碼是錯誤的。
也就是說,這個程序可能生成一個非四位數,然后讓你按照四位來猜。更通俗一點說,這個游戲程序會“耍賴”,你可能絕對猜不出它的那個自稱“四位數”的偽“四位數”。
對於有些讀者來說,這一點可能看起來不明顯。其實你只要把第32行代碼修改為
puts("sorry,you lost. :,原來的數為:"); puts(bull);
然后再運行程序就清楚了。我的一次運行結果是
1 :1111
1111 :1A3B
2 :1222
1222 :0A1B
3 :2122
2122 :1A0B
4 :3133
3133 :1A0B
5 :4244
4244 :0A0B
6 :4144
4144 :1A0B
7 :5155
5155 :1A0B
8 :6166
6166 :2A2B
9 :6177
6177 :2A0B
10 :6188
6188 :2A0B
sorry,you lost. :,原來的數為:
619
看到這個結果了吧,你永遠也猜不中。這樣會耍賴皮的程序讓人會感到索然無趣。
由於不滿足任務的基本功能要求,至此已經可以判定這是一個錯誤的程序了。這指東打西的程序是一種根本上的錯誤。后面的討論與程序的正確性無關,純粹是為了分析代碼中的其他毛病。
事實上生成一個四位偽隨機數是一個簡單的小學數學問題。由於四位正整數一共有9999-999個,所以可以先用rand()生成一個[9999-999-1,0]區間的偽隨機數,即
rand()%(9999-999-1)
然后再加上1000得到的就是四位偽隨機數:
rand()%(9999-999-1)+1000
這樣產生的偽隨機數能夠保證一定是4位正整數,但是還不能確保一定是“沒有重復數字的4位數”,這個問題將在后面解決。需要說明的是這個問題在樣本代碼中同樣存在。
17. // 定義表示猜測數字的字符數組 18. char cow[5] = "";
這種數據結構的選擇和bull是一脈相承的,兩者同樣不甚合理。這里的初始化同樣是畫蛇添足。
這個數組的定義的位置也是不恰當的,它應該定義在后面的while循環語句的內部。理由是它只在那個語句的內部被用到。定義變量的原則是在哪兒用就定義在哪兒,盡量局部化。這就像不應該把衛生間用的衛生紙擺在客廳一樣,是同一個道理。
22. // 猜測次數,最開始是第一次 23. int count = 1;
24. // 構造一個循環結構,只要沒有完全猜對, 25. // 就繼續下一次猜測 28. // 判斷是否超過次數限制 29. // 最多猜測10次
從中不難發現樣本代碼作者思路混亂、前后不一。前面擺出了一副無限循環的架勢,后來又說“最多猜測10次”。在這種混亂的思路指導下得到的代碼必然丑陋不堪。
20. int A = 0; /*……*/ 26. while(4!=A) 27. { /*……*/ 73. A = 0; 74. B = 0; 75. }
站在一定的高度來看這段代碼就會發現它的可笑之處,因為程序執行到第26行時A的值只可能為0而永遠不可能為4。因而while(4!=A)雖然顯得一本正經但其實卻是煞有介事的裝模作樣。
28. // 判斷是否超過次數限制 29. // 最多猜測10次 30. if(count > 10) 31. { 32. puts("sorry,you lost. :"); 33. // 跳出猜測循環,結束游戲 34. break; 35. }
從這段代碼來看,其實while語句完成的只是一個for語句的功能。代碼的結構應該這樣:
for ( count = 0 ; count < 10 ; count ++ ) { //猜數 } puts("sorry,you lost.");
把這結構和樣本中的
26. while(4!=A) 27. { /*……*/ 30. if(count > 10) 31. { /*……*/ 34. break; 35. } ++count; /*……*/ 75. }
這個結構對比一下,就會發現樣本代碼結構的蹩腳可笑十分明顯。
41. scanf("%s",cow);
這行代碼存在潛在的安全問題。由於cow的類型是char [5],所以在執行這條語句時用戶一個手滑、多輸入了一個字符就可能導致災難。
如果實在要用scanf()完成輸入,至少應該寫成:
scanf("%4s",cow);
42. // 采用循環結構,逐個字符比對輸入數字(cow)和目標數字(bull) 43. for(int i = 0; i<4; ++i) 44. { 45. // 判斷當前字符是否相同 46. if(bull[i] == cow[i] ) 47. { 48. // 如果相同,則表示數字正確且位置正確, 49. // A的值增加1 50. ++A; 51. } 52. else // 否則,判斷當前數字是否是正確數字 53. { 54. // 在目標字符串中查找當前字符 55. char* p = strchr( bull, cow[i]); 56. // 如果p不是一個空指針(C語言中用NULL或者0表示), 57. // 則表示目標字符串中有這個字符,當前字符是正確數字, 58. // B的值增加1 59. if(0 != p) 60. ++B; 61. } 62. }
這段代碼膨化得非常嚴重,簡直可以稱得上是膨化主義的典范。如果把它做一下“縮水”處理,剩下的干貨連一半行數都用不到。
由於前面bull數組中可能得不到正確的數字(不是四位數的情況),所以這段代碼沒有意義。因為誰也不可能在一種錯誤的數據的基礎上得到正確的算法。
即使不存在前面的錯誤,這段代碼在邏輯上也是錯的。例如當bull表示1234,而用戶輸入的cow是1122時,這段代碼輸出結果將是1A3B,而按照游戲規則,結果應該是1A1B。(感謝 王愛學志 網友指出)
拋開正確性問題不談,把如此臃長的結構塞在一個while循環語句內部也是極其丑陋的,很顯然,盡管代碼作者口口聲聲大喊大叫地嚷着似是而非的“搭積木”的口號,但其實根本尚未領悟結構化程序設計思想。這里的功能其實應該用一個函數實現。
65. // 如果完全猜測正確,則輸出最終結果,結束游戲 66. if(4 == A) 67. { 68. printf("you win! the bull is %s.",bull); 69. break; 70. }
這段中的“4==A”這種把常量寫在==前面的寫法,感覺矯情得有些變態。
此外,break;語句不如直接寫return 0;。
至此,樣本代碼評析完畢,下面進行重構。(未完待續)
續文鏈接:http://www.cnblogs.com/pmer/archive/2012/10/20/2731910.html