調試C++有幾個強有力的特徵可用,而不用關心你所用的平台和是否可以接觸到調試器。這篇文章的目的就是列舉這些你可以在調試你的代碼時使用的特徵,並且討論使用它們的情況。
當發現一門程式設計語言的新特徵時,最初的傾向通常是不考慮它的缺點並且嘗試替代它所有其他的功能。由於沒有設計模式是對於所有問題都是完美的,如果一切都必須向融入“更好”的設計模型,這個傾嚮往往是一種浪費,並且會導致設計的代碼很差。
在沒用先考慮你的環境和這幾種方法的長處和短處,你不能選出最合適的模型。Assertions,exceptions,longging,return values,等等,它們都有特定的長處和缺點。
我將會列出我對這些方法的一些觀察。
方法0-Nothing
優點:容易編寫大量的代碼,在調試或發布的時候沒有執行負擔
缺點:如果這個方法沒有效果的話,最後跳過它
這個方法更多地不是一個方法——這就是為什麼被稱為方法0。我認為為了完整我應該包括進它。如果你經常使用這個方法,給你的客戶一個人情並且尋找專業的協助。
這也給了我一個解釋我所知道的調試理論和一些我將會在全文用到的一些定理的機會。C++代碼有兩個基本的版本——調試版和發布版。這兩種狀態的代碼必須要有相同的功能。不同之處在於偵錯模式可以加快調試的速度,而發布模式可以加快運行速度。當然,你也可以根據需要定義不同的層級,但這篇文章我們僅僅使用這兩種模式。
注意調試是不同於清除的。
漏洞是設計的不好的代碼在正確的代碼下出錯。調試是找出和減少這些漏洞的過程。有很多原因會導致漏洞,下面是一個簡短的列表:
1、對程式設計語言,API,其他代碼和平台錯誤的理解。
2、錯誤的代碼設計和組織
3、作業系統漏洞
4、團隊成員之間的誤傳
5、慌忙的編寫代碼
6、絕對沒有理由可言
注意當你編寫代碼的時候,你的代碼是很少完全獨立的。即,通常你的代碼是依賴於標準庫的,進一步說,你將很少獨自編程,這意味著在你的代碼和你所在組的成員之間甚至可能是你的顧客之間存在相互依賴的關係。
有兩個術語通常用來描述人或者代碼的角色:server和client。使用並依賴你的代碼的人被稱為client,當你依賴別人的代碼時,你就是他們的client。server在這是很少被使用的,但是它的意思是“寫代碼的那個人."
方法 1 —— Assertions
優點:相對較快,在發布的代碼中沒有開銷,編寫相當簡單。
缺點:在調試編譯時間有一點降低速度,在發布版本中不能安全的提供,當clients調試代碼的時候要求他們讀源碼。
解釋
assertion是一個為了繼續執行必須返回一個真值得的布林運算式。在C++中通過使用assert函數下一個斷言,並且傳遞給它的運算式必須為真:
assert(this);
如果this是0,assert函數終止程式的執行,並且顯示一條資訊表明你“assert(this)"失敗在你源碼的那一行,並且讓你可以到那裡。如果this的值不是0,assert將簡單的返回並且你的程式將繼續正常執行。
注意assert函數不能做任何事情並且規定在發布版中沒有開銷,因此不要像下面這樣使用:
FILE* p = 0;assert( p = fopen("myfile.txt", "r+") );
因為在發布版中fopen將不會被調用。下面是正確的方式去使用它:
FILE* p = fopen("myfile.txt", "r+") );assert( p );
例子
在新編寫的必須經常做假設的代碼中可以有最好的使用。考慮下面的函數:
void sort(int* const myarray) // an overly simple example{ for( unsigned int x = 0; x < sizeof(myarray)-1; x++ ) if( myarray[x] < myarray[y] ) swap(myarray[x], myarray[y]);}
在這個函數標記中統計假設的數目。現在看一下是的調試更簡單一點的最好的版本:
void sort_array(int* const myarray){ assert( myarray ); assert( sizeof(myarray) > sizeof(int*) ); for( unsigned int x = 0; x < sizeof(myarray)-1; x++ ) if( myarray[x] < myarray[y] ) swap(myarray[x], myarray[y]);}
正如你所看到的,如果下面的情況發生這看似正確的代碼將不會工作:
1、指標是null,或
2、sizeof(myarray)不能用於決定數組元素的個數,或者因為數組沒有在棧上分配,在或者因為傳遞了一個項目(非數組)的地址。
儘管這是一個很簡單的演算法,許多功能你將會寫或者碰到在比這個更大更複雜的程式中。當你看到大量的條件將會導致一系列的錯誤,這是非常令人驚訝的。
void blend(const video::memory& source, video::memory& destination, const float colors[3]){ // The algorithm used is: B = A * alpha const unsigned int width = source.width(); const unsigned int height = source.height(); const unsigned int depth = source.depth(); const unsigned int pitch = source.pitch(); switch( depth ) { case 15: // ... break; case 16: // ... break; case 24: { unsigned int offset = 0; unsigned int index = 0; for( unsigned int y = 0; y < height; y++ ) { offset = y * pitch; for( unsigned int x = destination.get_width(); x > 0; x-- ) { index = (x * 3) + offset; destination[index + 0] = source[index + 0] * colors[0]; destination[index + 1] = source[index + 1] * colors[1]; destination[index + 2] = source[index + 2] * colors[2]; } } } break; case 32: // ... break; }}
你完全統計出了關係到函數最佳化假設嗎。讓我們一起列出他們:
assert( source.locked() and destination.locked() );assert( source.width() == destination.width() );assert( source.height() == destination.height() );assert( source.depth() == destination.depth() );assert( source.pitch() == destination.pitch() );assert( source.depth() == 15 or source.depth() == 16 or source.depth() == 24 or source.depth() == 32 );
通常,在像這一樣的低層次的代碼的最佳化越多,你就要做出越多的假設。我的函數要求源和目的視頻記憶體被鎖,並且他們有相同的寬,高和成分。在函數的頂層配置這些斷言函數將阻止一些程式員在將來想知道到函數為什麼沒有用或者是訪問非法的原因,現在所有的這些都在函數頂部被解決了。
然而,你必須注意你所使用的assert的版本。如果你使用標準C的assert,那麼你也許會以一個非常大的調試建立過程收場,因為它建立了所有的這些靜態字串。如果你發現這成為了一個問題,覆蓋assert去觸發一個異常或者用一些其他的東西替代構建時的字串常量。
也許,你不一定要檢驗所有的參數和條件——這對於一個好的程式員來說是一個痛苦的決定。有時注釋是更好的,因為他們不會增加最終編譯版本的大小。
好的代碼不應該在每個函數的頂部依賴過多的斷言。如果你發現,你正在寫一個類並且你不得不在每一個成員函數中插入斷言去測試狀態等等。這樣的話,你最好把你的類分裂為其他多個類。
結論
養成在你的代碼中插入斷言有下面的好處:
1、它讓你思考在特定的環境下和其他的代碼和資料的情況下假想的結果,並且因此它也給你一個開發更好的技術和減少bug的機會
2、通過在函數的頂部或者是右邊插入斷言,有或者是在條件被使用之前,其他的程式員在使用你的代碼時將更簡單的去阻止bug的出現
3、它可以協助別人明白你代碼的意圖,影響相關的函數/資料,和一些可能有的設計限制。
4、當使用斷言檢驗關鍵的參數是bugs是很容易被追蹤到的,因為當參數剛傳入函數時他們就被發現,而不是在一些晦澀的演算法,或者函數被載入,或者甚至是錯誤,直到他們導致了一些錯誤才被探測到。
5、它使得追蹤正確的傳回值變得容易。
6、你不需要思考意外的情況或者是返回錯誤碼,但你編寫簡單的新代碼時這是有協助的並且希望可以快速的測試,
方法二—— Exceptions
優點:自動清除並優雅的關閉,如果處理則有機會繼續執行,在調試和發布版本中都可使用。
缺點:相對較慢
解釋
最基本的,你可以使用throw關鍵字將資料向上拋到未知的函數調用者並且函數棧繼續增長,直到別的函數捕獲這些資料。你可以使用try關鍵字包括你想要捕獲異常的語句。看下面的例子:
void some_function() { throw 5; } // some function that throws an exceptionint main(int argc, char* argv[]){ try // just letting the compiler know we want to catch any exceptions from this code { some_function(); } catch(const int x) // if the type matches the data thrown, we execute this code { // do something about the exception... }}
如果你不在你的代碼中插入try語句塊,那麼程式棧將繼續簡單的增長直到它返回主函數,你的程式退出。你不應將他們插在隨便什麼地方——只有你可以捕獲一個異常並且恢複他的地方。如果你僅僅可以部分的恢複它,你可以重新拋出原來的異常那麼函數棧就會就會繼續增長直到它發生下一個符合的catch語句塊。
舉例
異常是最好用的,在調試和發布版的關鍵的地方去捕獲異常條件。一旦被正確的使用,他們提供了自動清除和強製程序退出或者將程式帶回正確的狀態。因此異常機制對於發布版的代碼來說是完美的,因為它對於具有好的編程習慣的終端使用者提供了所有東西,當他們遇到意料之外的錯誤是。正確的使用,他們提供了下面的好處:
1、對於所有資源在執行的任何地方自動清除。
2、它強制要求應用要麼退出,要麼返回正確的狀態。
3、它強制要求異常的接受者去處理異常,作為僅僅是做一個選擇的替代。
4、它允許釋放的代碼被重寫,可能是編寫分配代碼的人或者是隱藏實現(這當然是指解構函式)。
因為開銷,在正常的控制流程中使用它往往是一個不好的想法因為其他的控制結構可能是更快更有效地。
Method 3 ——Retrun Values
優點:使用常量或者是內建類型時是更快的,允許改變客戶的邏輯並且儘可能的乾淨。
缺點:對錯誤的處理不是強制性的,傳回值可能會有二義性。
解釋
通常我們返回一個有用的值或者是一個表面錯誤的值給調用者:
{ if( dividend == 0 ) return( 0 ); // avoid "integer divide-by-zero" error return( divisor/dividend );}
傳回值得方法在代碼邏輯簡單並且可能有錯誤的地方能得到最好的應用。舉個例子,一個函數可能會返回一個集合的位標識,其中一些可能是這個客戶要考慮的,而其他的客戶則不用考慮時,傳回值對於有條件邏輯是最好的。
一個函數相信函數的調用者會注意到錯誤的條件就像一個救生員相信其他的遊泳者會注意到有人溺水了一樣的。
Method 4——Logging
有時你可能無法得到一個調試器。將錯誤記錄到一個檔案中在調試中可以是十分有效。聲明一個全域的記錄檔(如使用use std::clog)並且當錯誤發生時將輸出記錄到上面。也許輸出檔案名和行數是有協助的,因此你可以知道錯誤在哪裡發生。__FILE__和__LINE__告訴編譯器輸出當前的檔案名稱和行。
當然你不僅僅可以使用記錄檔記錄錯誤,也可以記錄其他一些調試器不能訪問的資訊,如矩陣使用的最大數或者一些其他的資料。你也可以輸出導致你程式失敗的資料等。std::fstream 對於這個目的來說是很好的。如果你非常聰明,那麼你就可以指出一些方法去記錄斷言或異常日誌的資訊到檔案中。:)
這提供了下面這些好處:
1、它可以很容易的和你已有的代碼溶為一體,並且具有很好的可移植性。
2、它可以代替調試器給一個可以閱讀的輸出。
3、可以在不中斷程式的情況下給出詳細的資訊。
當然,它也是有一些開銷的,因此你需要衡量在你的情況中好處是否可以抵消它的缺點。
還有一件事情。。。
我本打算在這裡結束我的文章,但是我希望展示一種混合的方式對於調試是有多大的價值。這樣做最簡單的方式是建立一個簡單的類,最好在非C—++代碼的情況下也可以工作。一個檔案類將是很好的。我們使用C的fopen(),可以保證功能的簡單和可移植性。
1、建構函式和解構函式必須保證檔案指標的一生。/span>
2、斷言用來描述每個函數所做的假設。
3、異常類型被用來要求客戶程式處理異常。
4、成員函數用來讀取或者寫入資料。
5、臨時成員函數作為從棧上分配的資料的捷徑。
6、包含一個錯誤安全和異常安全的構造器,負責這個的成員函數。
7、可移植性。
代碼如下:
#include <cstdio>#include <cassert>#include <ciso646>#include <string>class file{public: // Exceptions struct exception {}; struct not_found : public exception {}; struct end : public exception {}; // Constants enum modes { relative, absolute }; file(const std::string& filename, const std::string& parameters); ~file(); void seek(const unsigned int position, const enum modes = relative); void read(void* const data, const unsigned int size); void write(const void* const data, const unsigned int size); void flush(); // Stack only! template <typename T> void read(T& data) { read(&data, sizeof(data)); } template <typename T> void write(const T& data) { write(&data, sizeof(data)); }private: FILE* pointer; file(const file& other) {} file& operator = (const file& other) { return( *this ); }};file::file(const std::string& filename, const std::string& parameters) : pointer(0){ assert( not filename.empty() ); assert( not parameters.empty() ); pointer = fopen(filename.c_str(), parameters.c_str()); if( not pointer ) throw not_found();}file::~file(){ int n = fclose(pointer); assert( not n );}void file::seek(const unsigned int position, const enum file::modes mode){ int n = fseek(pointer, position, (mode == relative) ? SEEK_CUR : SEEK_SET); assert( not n );}void file::read(void* const data, const unsigned int size){ size_t s = fread(data, size, 1, pointer); if( s != 1 and feof(pointer) ) throw end(); assert( s == 1 );}void file::write(const void* const data, const unsigned int size){ size_t s = fwrite(data, size, 1, pointer); assert( s == 1 );}void file::flush(){ int n = fflush(pointer); assert( not n );}int main(int argc, char* argv[]){ file myfile("myfile.txt", "w+"); int x = 5, y = 10, z = 20; float f = 1.5f, g = 29.4f, h = 0.0129f; char c = 'I'; myfile.write(x); myfile.write(y); myfile.write(z); myfile.write(f); myfile.write(g); myfile.write(h); myfile.write(c); return 0;}
如果你在Windows下編譯他們,保證項目類型被設定為win32控制台應用,這個類給他的客戶程式提供了那些好處。
1、允許使用者順序的手動調試每一個函數,並且可以因此知道那一個參數導致了成員函數的錯誤。
2、在調試編譯時間,它會檢查所有的傳回值和大多數的參數。
3、它拋出兩種類型的異常,如果條件發生異常並客戶必須決定如何去做。
4、為大多數共同的操作提供了shortcuts。
5、在沒有準備處理之前,不允許複製建構函式和成員。
注意檔案指標在下面的異常時將拋出。
1、建立檔案時,我們必須保證建立成功。
2、當讀檔案時,我們應該保證不會過早的結束。
結論
檔案類的技術可以用於大多數值暴露方法或者是指向內建函式指標的遺留下來的代碼。
擴充檔案類的功能對於讀者來說是一件有教育意義的事情。嘗試去添加一個複製的建構函式,或者是賦值操作,並且在不同的條件下測試這個類。注意當你修改這個類時斷言是可用的,並且已存在的斷言可以協助你捕獲新的代碼的錯誤。對於你的改變,可能把檔案對象至於一個可用的狀態嗎。
結論
當在程式運行時你不能或不想檢驗資料的時候可以使用日誌。
幸運的是,所有的這些特徵在C++上都是可移植的。不幸的是,不是每一個用C++寫代碼的人都是有時間的。你應該根據你的環境選擇哪一個方案是你所需要的,這對於你的客戶程式來說是很重要的。
遺憾的地方在傳回值通常是在C++代碼和非C++代碼之間唯一關於錯誤的交流。檢查沒一個傳回值是很痛苦的,但是斷言減少了這種痛苦。
C++ Debugging (by null_pointer)