盤點C++編譯器不能捕捉到的八種錯誤?

C++是一種相對複雜的電腦編程語言,中間充滿了很多微妙的陷阱。在C++中幾乎有數不清的方式都能讓人把事情搞砸。幸運的是,如今的編譯器已經足夠智能化了,能夠檢測出相當多的這類編程陷阱並通過編譯錯誤或編譯警告來通知程序員。最終,如果處理得當的話,任何編譯器能檢查到的錯誤都不會是什麼大問題,因為它們在編譯時會被捕捉到,並在程序真正運行前得到解決。最壞的情況下,一個編譯器能夠捕獲到的錯誤只會造成程序員一些時間上的損失,因為他們會尋找解決編譯錯誤的方法並修正。

  那些編譯器無法捕獲到的錯誤才是最危險的。這類錯誤不太容易察覺到,但可能會導致嚴重的後果,比如不正確的輸出、數據被破壞以及程序崩潰。隨著項目的膨脹,代碼邏輯的複雜度以及眾多的執行路徑會掩蓋住這些bug,導致這些bug只是間歇性的出現,因此使得這類bug難以跟蹤和調試。儘管本文的這份列表對於有經驗的程序員來說大部分都只是回顧,但這類bug產生的後果往往根據項目的規模和商業性質有不同程度的增強效果。

  這些示例全部都在Visual Studio 2005 Express上測試過,使用的是默認告警級別。根據你選擇的編譯器,你得到的結果可能會有所不同。我強烈建議所有的程序員朋友都採用最高等級的告警級別!有一些編譯提示在默認告警級別下可能不會被標註為一個潛在的問題,而在最高等級的告警級別下就會被捕捉到!(注:本文是這個系列文章的第1部分)

1)整數除法C++中的大多數二元操作都要求兩個操作數是同一類型。如果操作數的不同類型,其中一個操作數會提升到和另一個操作數相匹配的類型。在C++中,除法操作符可以被看做是2個不同的操作:其中一個操作於整數之上,另一個是操作於浮點數之上。如果操作數是浮點數類型,除法操作將返回一個浮點數的值:

  float fX = 7;

  float fY = 2;

  float fValue = fX / fY; // fValue = 3.5

  如果操作數是整數類型,除法操作將丟棄任何小數部分,並只返回整數部分。

  int nX = 7;

  int nY = 2;

  int nValue = nX / nY; // nValue = 3

  如果一個操作數是整型,另一個操作數是浮點型,則整型會提升為浮點型:

  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將產生一個浮點型的除法操作,因為結果是賦給一個浮點型變量的。但實際上並非如此。nX/nY首先被計算,結果是一個整型值,然後才會提升為浮點型並賦值給fValue。但在賦值之前,小數部分就已經丟棄了。

  要強制兩個整數採用浮點型除法,其中一個操作數需要類型轉換為浮點數:

  int nX = 7;

  int nY = 2;

  float fValue = static_cast(nX) / nY; // fValue = 3.5

  因為nX顯式的轉換為float型,nY將隱式地提升為float型,因此除法操作符將執行浮點型除法,得到的結果就是3.5。

  通常一眼看去很難說一個除法操作符究竟是執行整數除法還是浮點型除法:

  z = x / y; // 這是整數除法還是浮點型除法?

  但採用匈牙利命名法可以幫助我們消除這種疑惑,並阻止錯誤的發生:

  int nZ = nX / nY; // 整數除法

  double dZ = dX / dY; // 浮點型除法

  有關整數除法的另一個有趣的事情是,當一個操作數是負數時C++標準並未規定如何截斷結果。造成的結果就是,編譯器可以自由地選擇向上截斷或者向下截斷!比如,-5/2可以既可以計算為-3也可以計算為-2,這和編譯器是向下取整還是向0取整有關。大多數現代的編譯器是向0取整的。

2)在構造函數中調用虛函數

考慮如下的程序:

  class Base

  {

  private:

   int m_nID;

  public:

   Base()

   {

   m_nID = ClassID();

   }

   // ClassID 返回一個class相關的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 <

   return 0;

  }

  在這個程序中,程序員在基類的構造函數中調用了虛函數,期望它能被決議為派生類的Derived::ClassID()。但實際上不會這樣——程序的結果是打印出1而不是2。當從基類繼承的派生類被實例化時,基類對象先於派生類對象被構造出來。這麼做是因為派生類的成員可能會對已經初始化過的基類成員有依賴關係。結果就是當基類的構造函數被執行時,此時派生類對象根本就還沒有構造出來!所以,此時任何對虛函數的調用都只會決議為基類的成員函數,而不是派生類。

  根據這個例子,當cDerived的基類部分被構造時,其派生類的那一部分還不存在。因此,對函數ClassID的調用將決議為Base::ClassID()(不是Derived::ClassID()),這個函數將m_nID設為1。一旦cDerived的派生類部分也構造好時,在cDerived這個對象上,任何對ClassID()的調用都將如預期的那樣決議為Derived::ClassID()。

  注意到其他的編程語言如C#和Java會將虛函數調用決議為繼承層次最深的那個class上,就算派生類還沒有被初始化也是這樣!C++的做法與這不同,這是為了程序員的安全而考慮的。這並不是說一種方式就一定好過另一種,這裡僅僅是為了表示不同的編程語言在同一問題上可能有不同的表現行為。

3) 複合表達式或函數調用的副作用

副作用是指一個操作符、表達式、語句或函數在該操作符、表達式、語句或函數完成規定的操作後仍然繼續做了某些事情。副作用有時候是有用的:

  x = 5;

  賦值操作符的副作用是可以永久地改變x的值。其他有副作用的C++操作符包括*=、/=、%=、+=、-=、<<=、>>=、&=、 =、^=以及聲名狼藉的++和—操作符。但是,在C++中有好幾個地方操作的順序是未定義的,那麼這就會造成不一致的行為。比如:

  void multiply(int x, int y)

  {

   usingnamespace std;

   cout << x * y << endl;

  }

  int main()

  {

   int x = 5;

   std::cout << multiply(x, ++x);

  }

  因為對於函數multiply()的參數的計算順序是未定義的,因此上面的程序可能打印出30或36,這完全取決於x和++x誰先計算,誰後計算。

  另一個稍顯奇怪的有關操作符的例子:

  int foo(int x)

  {

   return x;

  }

  int main()

  {

   int x = 5;

   std::cout << foo(x) * foo(++x);

  }

  因為C++的操作符中,其操作數的計算順序是未定義的(對於大多數操作符來說是這樣的,當然有一些例外),上面的例子也可能會打印出30或36,這取決於究竟是左操作數先計算還是右操作數先計算。

  另外,考慮如下的複合表達式:

  if (x == 1 && ++y == 2)

   // do something

  程序員的本意可能是說:“如果x是1,且y的前自增值是2的話,完成某些處理”。但是,如果x不等於1,C++將採取短路求值法則,這意味著++y將永遠不會計算!因此,只有當x等於1時,y才會自增。這很可能不是程序員的本意!一個好的經驗法則是把任何可能造成副作用的操作符都放到它們自己獨立的語句中去。

4)變量未初始化

變量未初始化是C++編程中最為常見和易犯的錯誤之一。在C++中,為變量所分配的內存空間並不是完全“乾淨的”,也不會在分配空間時自動做清零處理。其結果就是,一個未初始化的變量將包含某個值,但沒辦法準確地知道這個值是多少。此外,每次執行這個程序的時候,該變量的值可能都會發生改變。這就有可能產生間歇性發作的問題,是特別難以追蹤的。看看如下的代碼片段:

  if (bValue)

   // do A

  else

   // do B

  如果bValue是未經初始化的變量,那麼if語句的判斷結果就無法確定,兩個分支都可能會執行。在一般情況下,編譯器會對未初始化的變量給予提示。下面的代碼片段在大多數編譯器上都會引發一個警告信息。

  int foo()

  {

   int nX;

   return nX;

  }

  但是,還有一些簡單的例子則不會產生警告:

  void increment(int &nValue)

  {

   ++nValue;

  }

  int foo()

  {

   int nX;

   increment(nX);

   return nX;

  }

  以上的代碼片段可能不會產生一個警告,因為編譯器一般不會去跟蹤查看函數increment()到底有沒有對nValue賦值。

  未初始化變量更常出現於類中,成員的初始化一般是通過構造函數的實現來完成的。

  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從未初始化過。結果就是,GetValue()返回的是一個垃圾值,if語句的兩個分支都有可能會執行。

  新手程序員通常在定義多個變量時會犯下面這種錯誤:

  int nValue1, nValue2 = 5;

  這裡的本意是nValue1和nValue2都被初始化為5,但實際上只有nValue2被初始化了,nValue1從未被初始化過。

  由於未初始化的變量可能是任何值,因此會導致程序每次執行時呈現出不同的行為,由未初始化變量而引發的問題是很難找到問題根源的。某次執行時,程序可能工作正常,下一次再執行時,它可能會崩潰,而再下一次則可能產生錯誤的輸出。當你在調試器下運行程序時,定義的變量通常都被清零處理過了。這意味著你的程序在調試器下可能每次都是工作正常的,但在發佈版中可能會間歇性的崩掉!如果你碰上了這種怪事,罪魁禍首常常都是未初始化的變量。

5)混用有符號和無符號數

如同我們在整數除法那一節中提到的,C++中大多數的二元操作符需要兩端的操作數是同一種類型。如果操作數是不同的類型,其中一個操作數將提升自己的類型以匹配另一個操作數。當混用有符號和無符號數時這會導致出現一些非預期性的結果!考慮如下的例子:

  cout << 10 – 15u; // 15u是無符號整數

  有人會說結果是-5。由於10是一個有符號整數,而15是無符號整數,類型提升規則在這裡就需要起作用了。C++中的類型提升層次結構看起來是這樣的:

  long double (最高)

  double

  float

  unsigned long int

  long int

  unsigned int

  int (最低)

  因為int類型比unsigned int要低,因此int要提升為unsigned int。幸運的是,10已經是個正整數了,因此類型提升並沒有使解釋這個值的方式發生改變。因此,上面的代碼相當於:

  cout << 10u – 15u;

  好,現在是該看看這個小把戲的時候了。因為都是無符號整型,因此操作的結果也應該是一個無符號整型的變量!10u-15u = -5u。但是無符號變量不包括負數,因此-5這裡將被解釋為4,294,967,291(假設是32位整數)。因此,上面的代碼將打印出4,294,967,291而不是-5。

  這種情況可以有更令人迷惑的形式:

  int nX;

  unsigned int nY;

  if (nX – nY < 0)

   // do something

  由於類型轉換,這個if語句將永遠判斷為假,這顯然不是程序員的原始意圖!

 6)= vs ==

這是個老問題,但很有價值。許多C++新手會弄混賦值操作符(=)和相等操作符(==)的意義。但即使是知道這兩種操作符差別的程序員也會犯下鍵盤敲擊錯誤,這可能會導致結果是非預期的。

  // 如果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;

  }

  函數foo()的本意是如果nValue是0,就返回1,否則就返回nValue的值。但由於無意中使用賦值操作符代替了相等操作符,程序將產生非預期性的結果:

  0

  0

  0

  當foo()中的if語句執行時,nValue被賦值為0。if (nValue = 0)實際上就成了if (nValue)。結果就是if條件為假,導致執行else下的代碼,返回nValue的值,而這個值剛好就是賦值給nValue的0!因此這個函數將永遠返回0。

  在編譯器中將告警級別設置為最高,當發現條件語句中使用了賦值操作符時會給出一個警告信息,或者在條件判斷之外,應該使用賦值操作符的地方誤用成了相等性測試,此時會提示該語句沒有做任何事情。只要你使用了較高的告警級別,這個問題本質上都是可修復的。也有一些程序員喜歡採用一種技巧來避免=和==的混淆。即,在條件判斷中將常量寫在左邊,此時如果誤把==寫成=的話,將引發一個編譯錯誤,因為常量不能被賦值。

7) delete vs delete []

許多C++程序員忘記了關於new和delete操作符實際上有兩種形式:針對單個對象的版本,以及針對對象數組的版本。new操作符用來在堆上分配單個對象的內存空間。如果對象是某個類類型,該對象的構造函數將被調用。

  Foo *pScalar = new Foo;

  delete操作符用來回收由new操作符分配的內存空間。如果被銷燬的對象是類類型,則該對象的析構函數將被調用。

  delete pScalar;

  現在考慮如下的代碼片段:

  Foo *pArray = new Foo[10];

  這行代碼為10個Foo對象的數組分配了內存空間,因為下標[10]放在了類型名之後,許多C++程序員沒有意識到實際上是操作符new[]被調用來完成分配空間的任務而不是new。new[]操作符確保每一個創建的對象都會調用該類的構造函數一次。相反的,要刪除一個數組,需要使用delete[]操作符:

  delete[] pArray;

  這將確保數組中的每個對象都會調用該類的析構函數。如果delete操作符作用於一個數組會發生什麼?數組中僅僅只有第一個對象會被析構,因此會導致堆空間被破壞!

8)不帶break的switch語句

另一個新手程序員常犯的經典錯誤是忘記在switch語句塊中加上break:

  switch (nValue)

  {

   case 1: eColor = Color::BLUE;

   case 2: eColor = Color::PURPLE;

   case 3: eColor = Color::GREEN;

   default: eColor = Color::RED;

  }

  當switch表達式計算出的結果同case的標籤值相同時,執行序列將從滿足的第一個case語句處執行。執行序列將繼續下去,直到要麼到達switch語句塊的末尾,或者遇到return、goto或break語句。其他的標籤都將忽略掉!

  考慮下如上的代碼,如果nValue為1時會發生什麼。case 1滿足,所以eColor被設為Color::BLUE。繼續處理下一個語句,這又將eColor設為Color::PURPLE。下一個語句又將它設為了Color::GREEN。最終,在default中將其設為了Color::RED。實際上,不管nValue的值是多少,上述代碼片段都將把eColor設為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語句的執行,因此eColor的值將保持為程序員所期望的那樣。儘管這是非常基礎的switch/case邏輯,但很容易因為漏掉一個break語句而造成不可避免的“瀑布式”執行流。

    結 論

  因為這只是這個系列文章的第一篇,我認為以新手程序員可能遇到的基礎問題入手會比較合適。今後這個系列的文章將致力於解決更加複雜的編程錯誤。無論一個程序員的經驗水平如何,錯誤都是不可避免的,不管是因為知識上的匱乏、輸入錯誤或者只是一般的粗心大意。意識到其中最有可能造成麻煩的問題,這可以幫助減少它們出來搗亂的可能性。雖然對於經驗和知識並沒有什麼替代品,良好的單元測試可以幫我們在將這些bug深埋於我們的代碼中之前將它們捕獲。

相關問題答案