開發人員最常犯的 10 個 C++ 錯誤

已發表: 2022-03-11

C++ 開發人員可能會遇到許多陷阱。 這會使高質量的編程變得非常困難並且維護非常昂貴。 學習語言語法並擁有類似語言(如 C# 和 Java)的良好編程技能,還不足以充分利用 C++ 的潛力。 避免 C++ 中的錯誤需要多年的經驗和嚴格的紀律。 在本文中,我們將了解各個級別的開發人員在 C++ 開發方面不夠謹慎時所犯的一些常見錯誤。

常見錯誤 #1:錯誤地使用“new”和“delete”對

無論我們如何嘗試,都很難釋放所有動態分配的內存。 即使我們可以做到這一點,它通常也不能避免異常。 讓我們看一個簡單的例子:

 void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }

如果拋出異常,則永遠不會刪除“a”對象。 以下示例顯示了一種更安全、更短的方法。 它使用在 C++11 中已棄用的 auto_ptr,但舊標準仍被廣泛使用。 如果可能的話,可以用 C++11 unique_ptr 或 Boost 的 scoped_ptr 替換它。

 void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }

無論發生什麼,在創建“a”對像後,只要程序執行退出作用域,它就會被刪除。

然而,這只是這個 C++ 問題的最簡單的例子。 有很多示例應該在其他地方進行刪除,可能是在外部函數或另一個線程中。 這就是為什麼應該完全避免成對使用 new/delete 而應該使用適當的智能指針。

常見錯誤 #2:被遺忘的虛擬析構函數

如果派生類內部分配了動態內存,這是導致派生類內部內存洩漏的最常見錯誤之一。 在某些情況下,虛擬析構函數是不可取的,即當一個類不打算用於繼承並且它的大小和性能至關重要時。 虛擬析構函數或任何其他虛擬函數在類結構中引入了額外的數據,即指向虛擬表的指針,它使類的任何實例的大小變得更大。

但是,在大多數情況下,即使最初不是預期的,也可以繼承類。 因此,在聲明類時添加虛擬析構函數是一個非常好的做法。 否則,如果一個類由於性能原因不能包含虛函數,最好在類聲明文件中添加註釋,指示該類不應被繼承。 避免此問題的最佳選擇之一是使用支持在類創建期間創建虛擬析構函數的 IDE。

該主題的另一點是標準庫中的類/模板。 它們不用於繼承,也沒有虛擬析構函數。 例如,如果我們創建一個從 std::string 公開繼承的新的增強型字符串類,那麼有人可能會錯誤地將它與指針或對 std::string 的引用一起使用並導致內存洩漏。

 class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }

為避免此類 C++ 問題,從標準庫重用類/模板的一種更安全的方法是使用私有繼承或組合。

常見錯誤 #3:使用“delete”或使用智能指針刪除數組

常見錯誤 #3

通常需要創建動態大小的臨時數組。 在不再需要它們之後,釋放分配的內存很重要。 這裡最大的問題是 C++ 需要帶有 [] 括號的特殊刪除運算符,這很容易被遺忘。 delete[] 運算符不僅會刪除為數組分配的內存,還會首先調用數組中所有對象的析構函數。 對原始類型使用不帶 [] 括號的刪除運算符也是不正確的,即使這些類型沒有析構函數。 不能保證每個編譯器都指向數組的指針將指向數組的第一個元素,因此使用不帶 [] 括號的 delete 也會導致未定義的行為。

對數組使用智能指針,例如 auto_ptr、unique_ptr<T>、shared_ptr 也是不正確的。 當這樣的智能指針退出作用域時,它將調用不帶 [] 括號的刪除運算符,這會導致上述相同的問題。 如果數組需要使用智能指針,可以使用 Boost 中的 scoped_array 或 shared_array 或 unique_ptr<T[]> 特化。

如果不需要引用計數功能,這主要是數組的情況,最優雅的方法是使用 STL 向量。 它們不僅負責釋放內存,還提供其他功能。

常見錯誤 #4:通過引用返回本地對象

這主要是初學者的錯誤,但值得一提的是,因為有很多遺留代碼存在這個問題。 讓我們看看下面的代碼,程序員想要通過避免不必要的複制來進行某種優化:

 Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);

對象“sum”現在將指向本地對象“result”。 但是 SumComplex 函數執行後的對象“結果”在哪裡呢? 無處。 它位於堆棧上,但在函數返回後,堆棧被解包,函數中的所有本地對像都被破壞。 這最終將導致未定義的行為,即使對於原始類型也是如此。 為了避免性能問題,有時可以使用返回值優化:

 Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);

對於當今的大多數編譯器,如果返回行包含對象的構造函數,則代碼將被優化以避免所有不必要的複制——構造函數將直接在“sum”對像上執行。

常見錯誤 #5:使用對已刪除資源的引用

這些 C++ 問題發生的頻率比您想像的要多,並且通常出現在多線程應用程序中。 讓我們考慮以下代碼:

線程 1:

 Connection& connection= connections.GetConnection(connectionId); // ...

線程 2:

 connections.DeleteConnection(connectionId); // …

線程 1:

 connection.send(data);

在此示例中,如果兩個線程使用相同的連接 ID,這將導致未定義的行為。 訪問衝突錯誤通常很難找到。

在這些情況下,當多個線程訪問同一個資源時,保留對資源的指針或引用是非常危險的,因為其他線程可以刪除它。 將智能指針與引用計數一起使用會更安全,例如 Boost 中的 shared_ptr。 它使用原子操作來增加/減少引用計數器,因此它是線程安全的。

常見錯誤 #6:允許異常離開析構函數

從析構函數中拋出異常並不是經常需要的。 即使這樣,也有更好的方法來做到這一點。 然而,異常大多不是從析構函數中顯式拋出的。 記錄對象破壞的簡單命令可能會導致異常拋出。 讓我們考慮以下代碼:

 class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }

在上面的代碼中,如果異常發生兩次,例如在兩個對象的銷毀過程中,catch 語句永遠不會執行。 因為並行存在兩個異常,無論是同類型還是不同類型,C++運行時環境都不知道如何處理,調用終止函數導致程序執行終止。

所以一般規則是:永遠不允許異常離開析構函數。 即使它很醜陋,也必須像這樣保護潛在的異常:

 try { writeToLog(); // could cause an exception to be thrown } catch (...) {}

常見錯誤 #7:使用“auto_ptr”(不正確)

由於多種原因,auto_ptr 模板已從 C++11 中棄用。 它仍然被廣泛使用,因為大多數項目仍在 C++98 中開發。 它具有所有 C++ 開發人員可能並不熟悉的某些特性,並且可能會給不小心的人帶來嚴重的問題。 複製 auto_ptr 對象會將所有權從一個對象轉移到另一個對象。 例如,下面的代碼:

 auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error

… 將導致訪問衝突錯誤。 只有對象“b”將包含指向 A 類對象的指針,而“a”將為空。 嘗試訪問對象“a”的類成員將導致訪問衝突錯誤。 錯誤使用 auto_ptr 的方法有很多。 關於它們需要記住的四個非常重要的事情是:

  1. 永遠不要在 STL 容器中使用 auto_ptr。 複製容器將使源容器中的數據無效。 一些 STL 算法也可能導致“auto_ptr”失效。

  2. 永遠不要使用 auto_ptr 作為函數參數,因為這會導致複製,並且在函數調用後傳遞給參數的值無效。

  3. 如果 auto_ptr 用於類的數據成員,請確保在復制構造函數和賦值運算符中進行正確的複制,或者通過將它們設為私有來禁止這些操作。

  4. 盡可能使用其他一些現代智能指針而不是 auto_ptr。

常見錯誤 #8:使用無效的迭代器和引用

有可能就這個主題寫一整本書。 每個 STL 容器都有一些特定的條件使迭代器和引用無效。 在使用任何操作時了解這些細節很重要。 就像之前的 C++ 問題一樣,這個問題在多線程環境中也可以非常頻繁地出現,所以需要使用同步機制來避免它。 讓我們以下面的順序代碼為例:

 vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element

從邏輯的角度來看,代碼似乎完全沒問題。 但是,將第二個元素添加到向量可能會導致向量內存的重新分配,這將使迭代器和引用都無效,並在嘗試在最後 2 行中訪問它們時導致訪問衝突錯誤。

常見錯誤 #9:按值傳遞對象

常見錯誤 #9

您可能知道按值傳遞對像是個壞主意,因為它會影響性能。 許多人這樣離開是為了避免輸入額外的字符,或者可能會考慮稍後返回進行優化。 它通常永遠不會完成,因此會導致性能較差的代碼和容易出現意外行為的代碼:

 class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);

此代碼將編譯。 調用“func1”函數將創建對象“b”的部分副本,即它只會將對象“b”的類“A”的部分複製到對象“a”(“切片問題”)。 因此,在函數內部,它還將調用“A”類中的方法,而不是“B”類中的方法,這很可能不是調用該函數的人所期望的。

嘗試捕獲異常時會出現類似的問題。 例如:

 class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }

當函數“func2”拋出 ExceptionB 類型的異常時,它將被 catch 塊捕獲,但由於切片問題,只會復制 ExceptionA 類的一部分,將調用錯誤的方法並重新拋出將向外部 try-catch 塊拋出不正確的異常。

總而言之,始終通過引用而不是值傳遞對象。

常見錯誤 #10:構造函數和轉換運算符使用用戶定義的轉換

即使是用戶定義的轉換有時也非常有用,但它們可能會導致難以定位的不可預測的轉換。 假設有人創建了一個包含字符串類的庫:

 class String { public: String(int n); String(const char *s); …. }

第一種方法旨在創建一個長度為 n 的字符串,而第二種方法旨在創建一個包含給定字符的字符串。 但是一旦你有這樣的事情,問題就開始了:

 String s1 = 123; String s2 = 'abc';

在上面的示例中,s1 將成為一個大小為 123 的字符串,而不是包含字符“123”的字符串。 第二個示例包含單引號而不是雙引號(這可能是偶然發生的),這也會導致調用第一個構造函數並創建一個非常大的字符串。 這些都是非常簡單的示例,還有許多更複雜的情況會導致混淆和難以找到的不可預測的轉換。 如何避免此類問題有 2 個一般規則:

  1. 使用顯式關鍵字定義構造函數以禁止隱式轉換。

  2. 不要使用轉換運算符,而是使用顯式對話方法。 它需要更多的輸入,但閱讀起來更清晰,並且可以幫助避免不可預測的結果。

結論

C++ 是一種強大的語言。 事實上,您每天在計算機上使用並喜愛的許多應用程序可能都是使用 C++ 構建的。 作為一種語言,C++ 通過面向對象編程語言中的一些最複雜的特性為開發人員提供了極大的靈活性。 但是,如果不負責任地使用這些複雜的功能或靈活性,通常會成為許多開發人員感到困惑和沮喪的原因。 希望這份清單能幫助您了解這些常見錯誤中的一些如何影響您使用 C++ 可以實現的目標。


進一步閱讀 Toptal 工程博客:

  • 如何學習 C 和 C++ 語言:終極清單
  • C# 與 C++:核心是什麼?