[珠璣之櫝]淺談代碼正確性:循環不變式、斷言、debug

  這個主題和代碼的實際寫作有關,而且內容和用法相互交織,以下只是對於其內容的一個劃分。《編程珠璣》上只用了兩個章節20頁左右的篇幅介紹,如果希望能獲得更多的實例和技巧,我比較推崇《程序設計實踐》 (Practise of Programming)、《編程精粹:編寫高質量C語言代碼》(Writing Solid Code)這兩本書,只要有一般的C語言基礎就能讀懂,而且讀起來比較快,讀完後能提高不少coding的實踐水平。

    目錄


 循環不變式(invariant)

  循環不變式主要用來幫助理解算法的正確性,具體來看,比較針對於循環迭代。形式上很類似與數學歸納法,它是一個需要保證正確斷言。對於循環不變式,必須證明它的三個性質:

初始化:它在循環的第一輪迭代開始之前,應該是正確的。

保持:如果在循環的某一次迭代開始之前它是正確的,那麼,在下一次迭代開始之前,它也應該保持正確。

終止:循環能夠終止,並且可以得到期望的結果。

具體的使用實例可以參考我的舊作一篇:如何寫出正確的二分查找?——利用循環不變式理解二分查找及其變體的正確性以及構造方式

 


debug之腳手架

  聽起來挺玄乎,其實所謂的腳手架,就是在debug版本里加入的爲了驗證程序是否正確的額外代碼,比如一條爲了確定循環中臨時變量是否按期望變化的printf語句,這比複雜的調試器更快。我相信很多人在寫代碼時都這樣做過,看到這裏,我們並不需要爲自己過去“簡陋”的調試方式而不安,而是繼續合理地使用它。

  腳手架能完成更多的工作,不僅限於變量追蹤。利用斷言,可以建立腳手架進行程序的自動測試而不是人爲的追蹤,也可以利用clock()建立腳手架,把待測的代碼放在兩個clock()中間測試運行時間(相比之下,我更喜歡用gettimeofday())。以上三種腳手架的用法是《編程珠璣》提到的例子,關於這種代碼可以做更多的引申,以下內容結合了Practise of Programming和Writing Solid Code的相關內容。

1.#ifdef DEBUG

  很多人都使用過下面這樣的代碼:

#ifdef DEBUG
    ...
#endif

  這種想法是同時維護調試和非調試(即交付)兩個版本,在調試版本中自動地查錯;最後提交交付版本。當然,這種方法的關鍵是保證調試代碼不在最終產品中出現。只是這種代碼在原來的代碼裏看起來十分突兀(排版不好看,而且可能看上去喧賓奪主),使用斷言assert()是一個代替方案之一,它只在定義了DEBUG時纔有效,在我的Ubuntu的assert.h裏是如果定義了NDEBUG就無效。關於斷言會在後面詳寫,這裏點到爲止。

2.外殼函數/包裹函數

  做法是把待測試或者可能發生錯誤而需要檢測的函數用一段代碼加一個外殼,或者說是包裹起來,在其中增加出錯檢查和處理代碼,並提供合理的返回值。這兩種名稱前者出自《程序設計實踐》,後者見於UNP,舉一個UNP上的簡單的例子:

int Socket(int family, int type, int protocol)
{
    int n;
    if( (n = socket(family,type,protocol)) < 0)
        err_sys("socket error");
    return n;
}

3.用不同的算法驗證正確性

   如果編寫了一個較快的算法又擔心其不正確,想要檢查其正確性怎麼辦?在腳手架中構造一個包括能提供同樣功能並且正確但速度較慢的算法(往往是舊版本中所留下的),比較兩者的執行結果。

4.提高代碼覆蓋度

  在if判斷中,總有一部分代碼未必執行。如果想要測試不常執行的代碼的正確性,可以用腳手架強制執行這部分代碼。

5.消除隨機性,使錯誤重現

  利用腳手架對分配的內存塊用garbage值0xA3而不是0填充,這樣當發現指針指向0xA3A3或內容是連續的0xA3A3時,顯然是個未定義的值,需要排錯。具體的取值和系統有關,0xA3是早期macintosh的建議用的garbage值。

6.不要擔心腳手架帶來的性能損失

  正如Writing Solid Code所言,不要把對交付版本的約束應用到相應的調試版本上,要用大小和速度來換取錯誤檢查能力。腳手架只是爲了查錯,在交付版本中它們是不存在的。

 


 

斷言(assert)

   正如在腳手架中提到的,斷言可以對程序正確性的測試。除此以外,在一段小型的代碼demo中,編寫斷言遠比精心編制一套完整的出錯處理機制或者繁複的#ifdef DEBUG要簡單的多。

  爲了幫助理解這個宏,Writing Solid Code探討了assert宏的一種實現機制:

#ifdef DEBUG
    void _Assert(char*, unsigend);
#define ASSERT(f)    \
    if(f)                        \
        NULL;                \
    else
        _Assert(__FILE__,__LINE__)
#else
    #define ASSERT(f)    NULL
#endif

  而真正的處理函數是

void _Assert(char* strFile, unsigned uLine)
{
    fflush(stdout);
    fprintf(stderr,"\nAssertion failed:%s, line %u\n",strFile,uLine);
    fflush(stderr);
    abort();
}

  爲什麼要用宏定義+函數實現,並將宏中的__LINE__傳遞給後面實現的函數而不是僅僅靠宏本身實現?因爲__LINE__用其所在的行號替換內容,使用函數則只會變成該函數內部的行號,而使用宏則只是把__LINE__放到對應需要檢查的位置。更具體的說明可以參考以下鏈接:

http://stackoverflow.com/questions/11214260/behavior-of-line-in-inline-functions

http://stackoverflow.com/questions/7929291/get-code-line-with-line

  另外,這個斷言宏可以作爲《編程珠璣(續)》習題3.5的完善解答。

 

 

“珠璣之櫝”系列簡介與索引

往期回顧:

    位向量/位圖的定義和應用

    估算的應用與Little定律

    隨機數函數取樣與概率

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章