劣質代碼評析——猜數字問題(上)


【題目】

猜數字(又稱 Bulls and Cows )是一種大概於20世紀中期興起於英國的益智類小游戲。一般由兩個人玩,也可以由一個人和電腦玩,在紙上、在網上都可以玩。這種游戲規則簡單,但可以考驗人的嚴謹和耐心,而這也正是程序員所需要的優秀品質。

標准規則如下:

通常由兩個人玩,一方出數字,一方猜。出數字的人要想好一個沒有重復數字的4位數,不能讓猜的人知道。猜的人就可以開始猜。每猜一個數字,出數者就要根據這個數字給出幾A幾B,其中A前面的數字表示位置正確的數的個數,而B前的數字表示數字正確而位置不對的數的個數。如正確答案為 5234,而猜的人猜 5346,則是1A2B,其中有一個5的位置對了,記為1A,而3和4這兩個數字對了,而位置沒對,因此記為 2B,合起來就是1A2B。接着猜的人再根據出題者的幾A幾B繼續猜,直到猜中(即4A0B)為止。

【樣本】

View Code
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)

View Code
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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM