歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux編程 >> Linux編程 >> C++編譯器無法捕捉到的8種錯誤

C++編譯器無法捕捉到的8種錯誤

日期:2017/3/1 10:25:45   编辑:Linux編程

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

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

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

1)變量未初始化

變量未初始化是 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從未被初始化過。

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

2)整數除法

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<float>(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 取整的。

3)= 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。

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

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

如同我們在整數除法那一節中提到的,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 語句將永遠判斷為假,這顯然不是程序員的原始意圖!

5) 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 操作符作用於一個數組會發生什麼?數組中僅僅只有第一個對象會被析構,因此會導致堆空間被破壞!

6) 復合表達式或函數調用的副作用

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

x=5

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

  void multiply (int x, int y)
  {
      using namespace 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才會自增。這很可能不是程序員的本意!一個好的經驗法則是把任何可能造成副作用的操作符都放到它們自己獨立的語句中去。

7)不帶breakswitch語句

另一個新手程序員常犯的經典錯誤是忘記在 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 語句而造成不可避免的“瀑布式”執行流。

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

考慮如下的程序:

  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 << cDerived.GetID (); // 打印出1,不是2!        return 0;
  }

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

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

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

結論

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

Copyright © Linux教程網 All Rights Reserved