C++編譯器無法捕捉到的8種錯誤
C++是一種復(fù)雜的編程語言,其中充滿了各種微妙的陷阱。在C++中幾乎有數(shù)不清的方式能把事情搞砸。幸運的是,如今的編譯器已經(jīng)足夠智能化了,能夠檢測出相當(dāng)多的這類編程陷阱并通過編譯錯誤或編譯警告來通知程序員。最終,如果處理得當(dāng)?shù)脑?,任何編譯器能檢查到的錯誤都不會是什么大問題,因為它們在編譯時會被捕捉到,并在程序真正運行前得到解決。最壞的情況下,一個編譯器能夠捕獲到的錯誤只會造成程序員一些時間上的損失,因為他們會尋找解決編譯錯誤的方法并修正。
那些編譯器無法捕獲到的錯誤才是最危險的。這類錯誤不太容易察覺到,但可能會導(dǎo)致嚴(yán)重的后果,比如不正確的輸出、數(shù)據(jù)被破壞以及程序崩潰。隨著項目的膨脹,代碼邏輯的復(fù)雜度以及眾多的執(zhí)行路徑會掩蓋住這些bug,導(dǎo)致這些bug只是間歇性的出現(xiàn),因此使得這類bug難以跟蹤和調(diào)試。盡管本文的這份列表對于有經(jīng)驗的程序員來說大部分都只是回顧,但這類bug產(chǎn)生的后果往往根據(jù)項目的規(guī)模和商業(yè)性質(zhì)有不同程度的增強效果。
這些示例全部都在Visual Studio 2005 Express上測試過,使用的是默認(rèn)告警級別。根據(jù)你選擇的編譯器,你得到的結(jié)果可能會有所不同。我強烈建議所有的程序員朋友都采用***等級的告警級別!有一些編譯提示在默認(rèn)告警級別下可能不會被標(biāo)注為一個潛在的問題,而在***等級的告警級別下就會被捕捉到?。ㄗⅲ罕疚氖沁@個系列文章的第1部分)
1)變量未初始化
變量未初始化是C++編程中最為常見和易犯的錯誤之一。在C++中,為變量所分配的內(nèi)存空間并不是完全“干凈的”,也不會在分配空間時自動做清零處理。其結(jié)果就是,一個未初始化的變量將包含某個值,但沒辦法準(zhǔn)確地知道這個值是多少。此外,每次執(zhí)行這個程序的時候,該變量的值可能都會發(fā)生改變。這就有可能產(chǎn)生間歇性發(fā)作的問題,是特別難以追蹤的??纯慈缦碌拇a片段:
- if (bValue)
- // do A
- else
- // do B
如果bValue是未經(jīng)初始化的變量,那么if語句的判斷結(jié)果就無法確定,兩個分支都可能會執(zhí)行。在一般情況下,編譯器會對未初始化的變量給予提示。下面的代碼片段在大多數(shù)編譯器上都會引發(fā)一個警告信息。
- int foo()
- {
- int nX;
- return nX;
- }
但是,還有一些簡單的例子則不會產(chǎn)生警告:
- void increment(int &nValue)
- {
- ++nValue;
- }
- int foo()
- {
- int nX;
- increment(nX);
- return nX;
- }
以上的代碼片段可能不會產(chǎn)生一個警告,因為編譯器一般不會去跟蹤查看函數(shù)increment()到底有沒有對nValue賦值。
未初始化變量更常出現(xiàn)于類中,成員的初始化一般是通過構(gòu)造函數(shù)的實現(xiàn)來完成的。
- class Foo
- {
- private:
- int m_nValue;
- public:
- Foo();
- int GetValue() { return m_bValue; }
- };
- Foo::Foo()
- {
- // Oops, 我們忘記初始化m_nValue了
- }
- int main()
- {
- Foo cFoo;
- if (cFoo.GetValue() > 0)
- // do something
- else
- // do something else
- }
注意,m_nValue從未初始化過。結(jié)果就是,GetValue()返回的是一個垃圾值,if語句的兩個分支都有可能會執(zhí)行。
新手程序員通常在定義多個變量時會犯下面這種錯誤:
- int nValue1, nValue2 = 5;
這里的本意是nValue1和nValue2都被初始化為5,但實際上只有nValue2被初始化了,nValue1從未被初始化過。
由于未初始化的變量可能是任何值,因此會導(dǎo)致程序每次執(zhí)行時呈現(xiàn)出不同的行為,由未初始化變量而引發(fā)的問題是很難找到問題根源的。某次執(zhí)行時,程序可能工作正常,下一次再執(zhí)行時,它可能會崩潰,而再下一次則可能產(chǎn)生錯誤的輸出。當(dāng)你在調(diào)試器下運行程序時,定義的變量通常都被清零處理過了。這意味著你的程序在調(diào)試器下可能每次都是工作正常的,但在發(fā)布版中可能會間歇性的崩掉!如果你碰上了這種怪事,罪魁禍?zhǔn)壮36际俏闯跏蓟淖兞俊?/p>
2)整數(shù)除法
C++中的大多數(shù)二元操作都要求兩個操作數(shù)是同一類型。如果操作數(shù)的不同類型,其中一個操作數(shù)會提升到和另一個操作數(shù)相匹配的類型。在C++中,除法操作符可以被看做是2個不同的操作:其中一個操作于整數(shù)之上,另一個是操作于浮點數(shù)之上。如果操作數(shù)是浮點數(shù)類型,除法操作將返回一個浮點數(shù)的值:
- float fX = 7;
- float fY = 2;
- float fValue = fX / fY; // fValue = 3.5
如果操作數(shù)是整數(shù)類型,除法操作將丟棄任何小數(shù)部分,并只返回整數(shù)部分。
- int nX = 7;
- int nY = 2;
- int nValue = nX / nY; // nValue = 3
如果一個操作數(shù)是整型,另一個操作數(shù)是浮點型,則整型會提升為浮點型:
- float fX = 7.0;
- int nY = 2;
- float fValue = fX / nY;
- // nY 提升為浮點型,除法操作將返回浮點型值
- // fValue = 3.5
有很多新手程序員會嘗試寫下如下的代碼:
- int nX = 7;
- int nY = 2;
- float fValue = nX / nY; // fValue = 3(不是3.5哦!)
這里的本意是nX/nY將產(chǎn)生一個浮點型的除法操作,因為結(jié)果是賦給一個浮點型變量的。但實際上并非如此。nX/nY首先被計算,結(jié)果是一個整型值,然后才會提升為浮點型并賦值給fValue。但在賦值之前,小數(shù)部分就已經(jīng)丟棄了。
要強制兩個整數(shù)采用浮點型除法,其中一個操作數(shù)需要類型轉(zhuǎn)換為浮點數(shù):
- int nX = 7;
- int nY = 2;
- float fValue = static_cast<float>(nX) / nY; // fValue = 3.5
因為nX顯式的轉(zhuǎn)換為float型,nY將隱式地提升為float型,因此除法操作符將執(zhí)行浮點型除法,得到的結(jié)果就是3.5。
通常一眼看去很難說一個除法操作符究竟是執(zhí)行整數(shù)除法還是浮點型除法:
- z = x / y; // 這是整數(shù)除法還是浮點型除法?
但采用匈牙利命名法可以幫助我們消除這種疑惑,并阻止錯誤的發(fā)生:
- int nZ = nX / nY; // 整數(shù)除法
- double dZ = dX / dY; // 浮點型除法
有關(guān)整數(shù)除法的另一個有趣的事情是,當(dāng)一個操作數(shù)是負(fù)數(shù)時C++標(biāo)準(zhǔn)并未規(guī)定如何截斷結(jié)果。造成的結(jié)果就是,編譯器可以自由地選擇向上截斷或者向下截斷!比如,-5/2可以既可以計算為-3也可以計算為-2,這和編譯器是向下取整還是向0取整有關(guān)。大多數(shù)現(xiàn)代的編譯器是向0取整的。
3)= vs ==
這是個老問題,但很有價值。許多C++新手會弄混賦值操作符(=)和相等操作符(==)的意義。但即使是知道這兩種操作符差別的程序員也會犯下鍵盤敲擊錯誤,這可能會導(dǎo)致結(jié)果是非預(yù)期的。
- // 如果nValue是0,返回1,否則返回nValue
- int foo(int nValue)
- {
- if (nValue = 0) // 這是個鍵盤敲擊錯誤 !
- return 1;
- else
- return nValue;
- }
- int main()
- {
- std::cout << foo(0) << std::endl;
- std::cout << foo(1) << std::endl;
- std::cout << foo(2) << std::endl;
- return 0;
- }
函數(shù)foo()的本意是如果nValue是0,就返回1,否則就返回nValue的值。但由于無意中使用賦值操作符代替了相等操作符,程序?qū)a(chǎn)生非預(yù)期性的結(jié)果:
- 0
- 0
- 0
當(dāng)foo()中的if語句執(zhí)行時,nValue被賦值為0。if (nValue = 0)實際上就成了if (nValue)。結(jié)果就是if條件為假,導(dǎo)致執(zhí)行else下的代碼,返回nValue的值,而這個值剛好就是賦值給nValue的0!因此這個函數(shù)將永遠(yuǎn)返回0。
在編譯器中將告警級別設(shè)置為***,當(dāng)發(fā)現(xiàn)條件語句中使用了賦值操作符時會給出一個警告信息,或者在條件判斷之外,應(yīng)該使用賦值操作符的地方誤用成了相等性測試,此時會提示該語句沒有做任何事情。只要你使用了較高的告警級別,這個問題本質(zhì)上都是可修復(fù)的。也有一些程序員喜歡采用一種技巧來避免=和==的混淆。即,在條件判斷中將常量寫在左邊,此時如果誤把==寫成=的話,將引發(fā)一個編譯錯誤,因為常量不能被賦值。
4)混用有符號和無符號數(shù)
如同我們在整數(shù)除法那一節(jié)中提到的,C++中大多數(shù)的二元操作符需要兩端的操作數(shù)是同一種類型。如果操作數(shù)是不同的類型,其中一個操作數(shù)將提升自己的類型以匹配另一個操作數(shù)。當(dāng)混用有符號和無符號數(shù)時這會導(dǎo)致出現(xiàn)一些非預(yù)期性的結(jié)果!考慮如下的例子:
- cout << 10 – 15u; // 15u是無符號整數(shù)
有人會說結(jié)果是-5。由于10是一個有符號整數(shù),而15是無符號整數(shù),類型提升規(guī)則在這里就需要起作用了。C++中的類型提升層次結(jié)構(gòu)看起來是這樣的:
- long double (***)
- double
- float
- unsigned long int
- long int
- unsigned int
- int (***)
因為int類型比unsigned int要低,因此int要提升為unsigned int。幸運的是,10已經(jīng)是個正整數(shù)了,因此類型提升并沒有使解釋這個值的方式發(fā)生改變。因此,上面的代碼相當(dāng)于:
- cout << 10u – 15u;
好,現(xiàn)在是該看看這個小把戲的時候了。因為都是無符號整型,因此操作的結(jié)果也應(yīng)該是一個無符號整型的變量!10u-15u = -5u。但是無符號變量不包括負(fù)數(shù),因此-5這里將被解釋為4,294,967,291(假設(shè)是32位整數(shù))。因此,上面的代碼將打印出4,294,967,291而不是-5。
這種情況可以有更令人迷惑的形式:
- int nX;
- unsigned int nY;
- if (nX – nY < 0)
- // do something
由于類型轉(zhuǎn)換,這個if語句將永遠(yuǎn)判斷為假,這顯然不是程序員的原始意圖!
5) delete vs delete []
許多C++程序員忘記了關(guān)于new和delete操作符實際上有兩種形式:針對單個對象的版本,以及針對對象數(shù)組的版本。new操作符用來在堆上分配單個對象的內(nèi)存空間。如果對象是某個類類型,該對象的構(gòu)造函數(shù)將被調(diào)用。
- Foo *pScalar = new Foo;
delete操作符用來回收由new操作符分配的內(nèi)存空間。如果被銷毀的對象是類類型,則該對象的析構(gòu)函數(shù)將被調(diào)用。
- delete pScalar;
現(xiàn)在考慮如下的代碼片段:
- Foo *pArray = new Foo[10];
這行代碼為10個Foo對象的數(shù)組分配了內(nèi)存空間,因為下標(biāo)[10]放在了類型名之后,許多C++程序員沒有意識到實際上是操作符new[]被調(diào)用來完成分配空間的任務(wù)而不是new。new[]操作符確保每一個創(chuàng)建的對象都會調(diào)用該類的構(gòu)造函數(shù)一次。相反的,要刪除一個數(shù)組,需要使用delete[]操作符:
- delete[] pArray;
這將確保數(shù)組中的每個對象都會調(diào)用該類的析構(gòu)函數(shù)。如果delete操作符作用于一個數(shù)組會發(fā)生什么?數(shù)組中僅僅只有***個對象會被析構(gòu),因此會導(dǎo)致堆空間被破壞!
6) 復(fù)合表達(dá)式或函數(shù)調(diào)用的副作用
副作用是指一個操作符、表達(dá)式、語句或函數(shù)在該操作符、表達(dá)式、語句或函數(shù)完成規(guī)定的操作后仍然繼續(xù)做了某些事情。副作用有時候是有用的:
- x = 5;
賦值操作符的副作用是可以***地改變x的值。其他有副作用的C++操作符包括*=、/=、%=、+=、-=、<<=、>>=、&=、|=、^=以及聲名狼藉的++和—操作符。但是,在C++中有好幾個地方操作的順序是未定義的,那么這就會造成不一致的行為。比如:
- void multiply(int x, int y)
- {
- using namespace std;
- cout << x * y << endl;
- }
- int main()
- {
- int x = 5;
- std::cout << multiply(x, ++x);
- }
因為對于函數(shù)multiply()的參數(shù)的計算順序是未定義的,因此上面的程序可能打印出30或36,這完全取決于x和++x誰先計算,誰后計算。
另一個稍顯奇怪的有關(guān)操作符的例子:
- int foo(int x)
- {
- return x;
- }
- int main()
- {
- int x = 5;
- std::cout << foo(x) * foo(++x);
- }
因為C++的操作符中,其操作數(shù)的計算順序是未定義的(對于大多數(shù)操作符來說是這樣的,當(dāng)然有一些例外),上面的例子也可能會打印出30或36,這取決于究竟是左操作數(shù)先計算還是右操作數(shù)先計算。
另外,考慮如下的復(fù)合表達(dá)式:
- if (x == 1 && ++y == 2)
- // do something
程序員的本意可能是說:“如果x是1,且y的前自增值是2的話,完成某些處理”。但是,如果x不等于1,C++將采取短路求值法則,這意味著++y將永遠(yuǎn)不會計算!因此,只有當(dāng)x等于1時,y才會自增。這很可能不是程序員的本意!一個好的經(jīng)驗法則是把任何可能造成副作用的操作符都放到它們自己獨立的語句中去。
7)不帶break的switch語句
另一個新手程序員常犯的經(jīng)典錯誤是忘記在switch語句塊中加上break:
- switch (nValue)
- {
- case 1: eColor = Color::BLUE;
- case 2: eColor = Color::PURPLE;
- case 3: eColor = Color::GREEN;
- default: eColor = Color::RED;
- }
當(dāng)switch表達(dá)式計算出的結(jié)果同case的標(biāo)簽值相同時,執(zhí)行序列將從滿足的***個case語句處執(zhí)行。執(zhí)行序列將繼續(xù)下去,直到要么到達(dá)switch語句塊的末尾,或者遇到return、goto或break語句。其他的標(biāo)簽都將忽略掉!
考慮下如上的代碼,如果nValue為1時會發(fā)生什么。case 1滿足,所以eColor被設(shè)為Color::BLUE。繼續(xù)處理下一個語句,這又將eColor設(shè)為Color::PURPLE。下一個語句又將它設(shè)為了Color::GREEN。最終,在default中將其設(shè)為了Color::RED。實際上,不管nValue的值是多少,上述代碼片段都將把eColor設(shè)為Color::RED!
正確的方法是按照如下方式書寫:
- switch (nValue)
- {
- case 1: eColor = Color::BLUE; break;
- case 2: eColor = Color::PURPLE; break;
- case 3: eColor = Color::GREEN; break;
- default: eColor = Color::RED; break;
- }
break語句終止了case語句的執(zhí)行,因此eColor的值將保持為程序員所期望的那樣。盡管這是非常基礎(chǔ)的switch/case邏輯,但很容易因為漏掉一個break語句而造成不可避免的“瀑布式”執(zhí)行流。
8)在構(gòu)造函數(shù)中調(diào)用虛函數(shù)
考慮如下的程序:
- class Base
- {
- private:
- int m_nID;
- public:
- Base()
- {
- m_nID = ClassID();
- }
- // ClassID 返回一個class相關(guān)的ID號
- virtual int ClassID() { return 1;}
- int GetID() { return m_nID; }
- };
- class Derived: public Base
- {
- public:
- Derived()
- {
- }
- virtual int ClassID() { return 2;}
- };
- int main()
- {
- Derived cDerived;
- cout << cDerived.GetID(); // 打印出1,不是2!
- return 0;
- }
在這個程序中,程序員在基類的構(gòu)造函數(shù)中調(diào)用了虛函數(shù),期望它能被決議為派生類的Derived::ClassID()。但實際上不會這樣——程序的結(jié)果是打印出1而不是2。當(dāng)從基類繼承的派生類被實例化時,基類對象先于派生類對象被構(gòu)造出來。這么做是因為派生類的成員可能會對已經(jīng)初始化過的基類成員有依賴關(guān)系。結(jié)果就是當(dāng)基類的構(gòu)造函數(shù)被執(zhí)行時,此時派生類對象根本就還沒有構(gòu)造出來!所以,此時任何對虛函數(shù)的調(diào)用都只會決議為基類的成員函數(shù),而不是派生類。
根據(jù)這個例子,當(dāng)cDerived的基類部分被構(gòu)造時,其派生類的那一部分還不存在。因此,對函數(shù)ClassID的調(diào)用將決議為Base::ClassID()(不是Derived::ClassID()),這個函數(shù)將m_nID設(shè)為1。一旦cDerived的派生類部分也構(gòu)造好時,在cDerived這個對象上,任何對ClassID()的調(diào)用都將如預(yù)期的那樣決議為Derived::ClassID()。
注意到其他的編程語言如C#和Java會將虛函數(shù)調(diào)用決議為繼承層次最深的那個class上,就算派生類還沒有被初始化也是這樣!C++的做法與這不同,這是為了程序員的安全而考慮的。這并不是說一種方式就一定好過另一種,這里僅僅是為了表示不同的編程語言在同一問題上可能有不同的表現(xiàn)行為。
結(jié) 論
因為這只是這個系列文章的***篇,我認(rèn)為以新手程序員可能遇到的基礎(chǔ)問題入手會比較合適。今后這個系列的文章將致力于解決更加復(fù)雜的編程錯誤。無論一個程序員的經(jīng)驗水平如何,錯誤都是不可避免的,不管是因為知識上的匱乏、輸入錯誤或者只是一般的粗心大意。意識到其中最有可能造成麻煩的問題,這可以幫助減少它們出來搗亂的可能性。雖然對于經(jīng)驗和知識并沒有什么替代品,良好的單元測試可以幫我們在將這些bug深埋于我們的代碼中之前將它們捕獲。
英文原文:ALEX
原文鏈接:http://blog.jobbole.com/15837/
【編輯推薦】