C++ 的主要問題之一是存在大量其行為未定義或超出程式設計師預期的結構。我們在各種專案中使用靜態分析器時經常遇到這些問題。但是,眾所周知,最好的方法是在編譯階段就檢測到錯誤。讓我們看看現代 C++ 中的哪些技術有助於編寫不僅簡單明瞭,而且更安全、更可靠的程式碼。
“現代 C++”這個術語在 C++11 釋出後變得非常流行。它意味著什麼?首先,現代 C++ 是一系列模式和慣用法的集合,旨在消除許多 C++ 程式設計師習慣的“帶類的 C”的缺點,特別是那些從 C 語言開始程式設計的程式設計師。C++11 看起來更加簡潔易懂,這一點非常重要。
當人們談論現代 C++ 時,通常會想到什麼?並行性、編譯期計算、RAII、lambda 表示式、範圍 (ranges)、概念 (concepts)、模組以及標準庫中其他同樣重要的元件(例如,用於處理檔案系統的 API)。這些都是非常酷的現代化特性,我們期待在下一批標準中看到它們。然而,我想提請大家注意新標準如何允許編寫更安全的程式碼。在開發靜態分析器時,我們看到了大量各種各樣的錯誤,有時我們不禁會想:“但在現代 C++ 中,這是可以避免的”。因此,我建議我們研究一些 PVS-Studio 在各種開源專案中發現的錯誤。同時,我們也會看看如何修復它們。
C++ 中增加了 auto 和 decltype 關鍵字。當然,你已經知道它們是如何工作的。
|
|
在不失程式碼可讀性的情況下,縮短長型別非常方便。然而,這些關鍵字與模板結合使用時,其作用變得相當廣泛:使用 auto 和 decltype 就無需指定返回值的型別了。
但讓我們回到主題。這是一個64 位錯誤的例子。
|
|
在一個 64 位應用程式中,string::npos 的值大於 unsigned 型別變數所能表示的最大值 UINT_MAX。這似乎是 auto 可以拯救我們的一個場景:變數 n 的型別對我們來說不重要,關鍵是它能容納 string::find 的所有可能值。確實,如果我們用 auto 重寫這個例子,錯誤就消失了。
|
|
但事情並非如此簡單。使用 auto 並非萬能藥,並且它的使用也存在許多陷阱。例如,你可以這樣寫程式碼:
|
|
Auto 無法避免整數溢位,並且為緩衝區分配的記憶體將少於 5GiB。
在處理一個非常常見的錯誤——迴圈寫得不正確時,Auto 也沒有太大幫助。讓我們看一個例子。
|
|
對於大尺寸的陣列,這個迴圈會變成無限迴圈。程式碼中出現這樣的錯誤並不奇怪:它們只在非常罕見的情況下才會暴露出來,而這些情況通常沒有相應的測試覆蓋。
我們能用 auto 重寫這個片段嗎?
|
|
不能。錯誤不僅依然存在,甚至變得更糟了。
對於簡單型別,auto 的表現非常糟糕。是的,在最簡單的情況下(auto x = y)它能正常工作,但一旦出現額外的結構,其行為就可能變得更不可預測。更糟糕的是,錯誤會更難被注意到,因為變數的型別第一眼看上去並不那麼明顯。幸運的是,這對靜態分析器來說不是問題:它們不會疲倦,也不會分心。但對於我們這些凡人來說,最好還是明確指定型別。我們也可以用其他方法來避免窄化轉換,但我們稍後會談到這一點。
在 C++ 中,“危險”的型別之一是陣列。程式設計師在將陣列傳遞給函式時,常常忘記它實際上是以指標形式傳遞的,並試圖用 sizeof 來計算元素數量。
|
|
注意:此程式碼取自 Source Engine SDK。
PVS-Studio 警告:V511 在 'sizeof (iNeighbors)' 表示式中,sizeof() 運算子返回的是指標的大小,而不是陣列的大小。 Vrad_dll disp_vrad.cpp 60
這種混淆可能是因為在引數中指定了陣列的大小:這個數字對編譯器沒有任何意義,只是給程式設計師的一個提示。
問題在於,這段程式碼能夠編譯透過,而程式設計師卻不知道有什麼不對勁。顯而易見的解決方案是使用超程式設計。
|
|
如果我們傳遞給這個函式的不是一個數組,我們就會得到一個編譯錯誤。在 C++17 中,你可以使用 std::size。
在 C++11 中,增加了 std::extent 函式,但它不適合作為 countof,因為它對不合適的型別返回 0。
std::extent<decltype(iNeighbors)>(); //=> 0
不僅 countof 會出錯,sizeof 也會。
|
|
注意:此程式碼取自 Chromium。
PVS-Studio 警告
如你所見,標準的 C++ 陣列有很多問題。這就是為什麼你應該使用 std::array:在現代 C++ 中,它的 API 類似於 std::vector 和其他容器,並且使用時更難出錯。
|
|
另一個錯誤的來源是簡單的 for 迴圈。你可能會想,“那裡能犯什麼錯?是跟複雜的退出條件有關,還是為了節省程式碼行數?”不,程式設計師在最簡單的迴圈中也會犯錯。讓我們看看專案中的一些片段。
|
|
注意:此程式碼取自 Haiku 作業系統。
PVS-Studio 警告:V706 可疑的除法:sizeof (kBaudrates) / sizeof (char *)。'kBaudrates' 陣列中每個元素的大小不等於除數。 SerialWindow.cpp 162
我們在上一章已經詳細研究了這類錯誤:陣列大小又一次被錯誤地計算了。我們可以透過使用 std::size 輕鬆修復它。
|
|
但有更好的方法。讓我們再看一個片段。
|
|
注意:此程式碼取自 Shareaza。
PVS-Studio 警告:V547 表示式 'nCharPos >= 0' 總是為真。無符號型別的值總是 >= 0。 BugTrap xmlreader.h 946
這是編寫反向迴圈時的典型錯誤:程式設計師忘記了迭代器是無符號型別,導致檢查總是返回 true。你可能會想,“怎麼會?只有新手和學生才會犯這種錯誤。我們專業人士不會。”不幸的是,這不完全正確。當然,每個人都明白 (unsigned >= 0) 是 true。那麼這種錯誤從何而來?它們通常是重構的結果。想象一下這種情況:專案從 32 位平臺遷移到 64 位。以前使用 int/unsigned 進行索引,現在決定用 size_t/ptrdiff_t 替換它們。但在某個片段中,他們意外地使用了無符號型別而不是有符號型別。
我們該怎麼做才能在程式碼中避免這種情況?有人建議使用有符號型別,就像 C# 或 Qt 那樣。或許,這是一種解決方法,但如果我們要處理大量資料,就無法避免使用 size_t。那麼在 C++ 中有沒有更安全的方式來遍歷陣列呢?當然有。讓我們從最簡單的開始:非成員函式。有一些標準函式可以處理集合、陣列和 initializer_list;它們的原理你應該很熟悉。
|
|
太好了,現在我們不需要記住正向迴圈和反向迴圈的區別了。也不需要考慮我們用的是普通陣列還是其他陣列——迴圈在任何情況下都能工作。使用迭代器是避免頭疼的好方法,但即便如此也並非總是足夠好。最好使用基於範圍的 for 迴圈。
|
|
當然,range-based for 也有一些缺陷:它不允許靈活地管理迴圈,如果需要更復雜的索引操作,那麼 for 對我們幫助不大。但這種情況應該單獨研究。我們遇到的情況很簡單:需要按相反順序遍歷元素。然而,在這個階段,已經出現了困難。標準庫中沒有為range-based for提供額外的類。讓我們看看如何實現它。
|
|
在 C++14 中,你可以透過移除 decltype 來簡化程式碼。你可以看到 auto 如何幫助你編寫模板函式——reversed_wrapper 對陣列和 std::vector 都適用。
現在我們可以將片段重寫如下:
|
|
這段程式碼好在哪裡?首先,它非常易讀。我們立刻就能看出元素陣列是按相反順序處理的。其次,它更難出錯。第三,它適用於任何型別。這比原來的好多了。
你可以在 boost 中使用 boost::adaptors::reverse(arr)。
但讓我們回到最初的例子。在那裡,陣列是透過一對指標和大小來傳遞的。很明顯,我們關於 reversed 的想法對它不起作用。我們該怎麼辦?使用像 span/array_view 這樣的類。在 C++17 中,我們有 string_view,我建議使用它。
|
|
string_view 不擁有字串,它實際上是 const char* 和長度的一個包裝器。這就是為什麼在程式碼示例中,字串是按值傳遞,而不是按引用傳遞。string_view 的一個關鍵特性是與各種字串表示形式的相容性:const char*、std::string 和非空字元結尾的 const char*。
結果,該函式變成了以下形式:
|
|
傳遞給函式時,重要的是要記住 string_view(const char*) 的建構函式是隱式的,因此我們可以這樣寫:
Foo(pChars);
而不是這樣:
Foo(wstring_view(pChars, nNumChars));
string_view 指向的字串不需要以空字元結尾,string_view::data 這個名字本身就暗示了這一點,使用時必須記住這一點。當將其值傳遞給一個期望 C 字串的 cstdlib 函式時,你可能會得到未定義行為。如果你測試的大多數情況都使用了 std::string 或以空字元結尾的字串,你很容易會忽略這一點。
讓我們暫時離開 C++,思考一下古老的 C 語言。那裡的安全性如何?畢竟,那裡沒有隱式建構函式呼叫和運算子、型別轉換的問題,也沒有各種字串型別的問題。在實踐中,錯誤常常出現在最簡單的結構中:最複雜的結構會被仔細審查和除錯,因為它們會引起一些疑問。與此同時,程式設計師卻忘記了檢查簡單的結構。這裡有一個來自 C 語言的危險結構示例:
|
|
一個 Linux 核心的例子。PVS-Studio 警告:V556 比較了不同列舉型別的值:switch(ENUM_TYPE_A) { case ENUM_TYPE_B: ... }。 libiscsi.c 3501
請注意 switch-case 中的值:其中一個命名常量取自不同的列舉。當然,在原始程式碼中,程式碼量要大得多,可能的取值也更多,錯誤也就不那麼明顯了。原因在於列舉的弱型別——它們可以隱式轉換為 int,這為錯誤留下了很大的空間。
在 C++11 中,你可以而且應該使用 enum class:這樣的伎倆在那裡行不通,錯誤將在編譯階段顯現出來。因此,下面的程式碼無法編譯,而這正是我們所需要的。
|
|
下面的片段與列舉不完全相關,但有類似的症狀。
|
|
注意:此程式碼取自 ReactOS。
是的,errno 的值被宣告為宏,這在 C++ 中(在 C 中也是)是不好的做法,但即使程式設計師使用了 enum,也不會讓事情變得更容易。在 enum 的情況下(尤其是在宏的情況下),丟失的比較不會暴露出來。而 enum class 則不會允許這種情況發生,因為它不會隱式轉換為 bool。
但回到 C++ 固有的問題上來。其中之一是在需要在多個建構函式中以相同方式初始化物件時顯現出來。一個簡單的情況:有一個類,兩個建構函式,其中一個呼叫另一個。這看起來很合乎邏輯:公共程式碼被放進一個單獨的方法裡——沒人喜歡重複程式碼。陷阱在哪裡?
|
|
注意:此程式碼取自 LibreOffice。
PVS-Studio 警告:V603 物件已建立但未使用。如果你希望呼叫建構函式,應該使用 'this->Guess::Guess(....)'。 guess.cxx 56
陷阱在於建構函式的呼叫語法。程式設計師常常忘記這一點,從而建立了另一個類例項,該例項隨後立即被銷燬。也就是說,原始例項的初始化並沒有發生。當然,有 1001 種方法來修復這個問題。例如,我們可以透過 this 顯式呼叫建構函式,或者把所有東西都放進一個單獨的函式中。
|
|
順便說一句,顯式地重複呼叫建構函式,例如透過 this,是一個危險的遊戲,我們需要理解到底發生了什麼。使用 Init() 的變體要好得多,也更清晰。對於那些想更好地理解這些“陷阱”細節的人,我建議看看這本書的第 19 章,“如何從一個建構函式正確呼叫另一個建構函式”。
但最好在這裡使用建構函式委託。這樣我們就可以用以下方式從一個建構函式顯式呼叫另一個建構函式。
|
|
這類建構函式有一些限制。第一:委託建構函式全權負責物件的初始化。也就是說,你將無法在初始化列表中用它來初始化另一個類欄位。
|
|
當然,我們必須確保委託不會造成迴圈,因為那樣將無法退出。不幸的是,這段程式碼能夠編譯透過。
|
|
虛擬函式隱藏了一個潛在的問題:問題在於,在派生類的簽名中很容易出錯,結果不是覆蓋一個函式,而是宣告一個新函式。讓我們在下面的例子中看看這種情況。
|
|
Derived::Foo 方法無法透過指向 Base 的指標/引用來呼叫。但這是一個簡單的例子,你可能會說沒人會犯這樣的錯誤。通常人們會犯以下錯誤:
注意:此程式碼取自 MongoDB。
|
|
PVS-Studio 警告:V762 考慮檢查虛擬函式引數。請參見派生類 'DBDirectClient' 和基類 'DBClientBase' 中 'query' 函式的第七個引數。 dbdirectclient.cpp 61
引數很多,而派生類函式中沒有最後一個引數。這是兩個不同的、不相關的函式。這種錯誤經常發生在帶有預設值的引數上。
在下一個片段中,情況稍微複雜一些。這段程式碼如果作為 32 位程式碼編譯會正常工作,但在 64 位版本中則不會。最初,在基類中,引數是 DWORD 型別,但後來被修正為 DWORD_PTR。然而,在繼承的類中卻沒有相應地修改。準備好迎接不眠之夜、除錯和咖啡吧!
|
|
你還可能以更奇特的方式在簽名上出錯。你可能會忘記函式的 const 屬性,或者某個引數的 const 屬性。你可能會忘記基類中的函式不是虛擬函式。你可能會混淆 signed/unsigned 型別。
在 C++ 中,添加了幾個可以規範虛擬函式覆蓋的關鍵字。Override 將會很有幫助。這段程式碼根本無法編譯。
|
|
使用 NULL 來表示空指標會導致許多意想不到的情況。問題在於 NULL 是一個普通的宏,它展開為 0,而 0 的型別是 int:因此不難理解為什麼在這個例子中會選擇第二個函式。
|
|
儘管原因很清楚,但這非常不合邏輯。這就是為什麼需要 nullptr,它有自己的型別 nullptr_t。因此,在現代 C++ 中我們不能使用 NULL(更不用說 0 了)。
另一個例子:NULL 可以用來與其他整數型別進行比較。假設有一個 WinAPI 函式返回 HRESULT。這個型別與指標沒有任何關係,所以它與 NULL 的比較是無意義的。而 nullptr 透過發出一個編譯錯誤強調了這一點,而 NULL 卻能正常工作。
|
|
有些情況下,需要傳遞不定數量的引數。一個典型的例子是格式化輸入/輸出函式。是的,可以寫成不需要可變數量引數的方式,但我認為沒有理由放棄這種語法,因為它更方便、更易讀。舊的 C++ 標準提供了什麼?它們建議使用 va_list。這有什麼問題呢?向這樣的引數傳遞錯誤型別的引數太容易了。或者根本不傳遞引數。讓我們仔細看看這些程式碼片段。
|
|
注意:此程式碼取自 Chromium。
PVS-Studio 警告:V510 'AtlTrace' 函式不期望接收類型別變數作為第三個實際引數。 delegate_execute.cc 96
程式設計師想列印 std::wstring 字串,但忘記呼叫 c_str() 方法。因此,wstring 型別將在函式中被解釋為 const wchar_t*。當然,這不會有什麼好結果。
|
|
注意:此程式碼取自 Cairo。
PVS-Studio 警告:V576 格式不正確。請考慮檢查 'fwprintf' 函式的第三個實際引數。期望的是指向 wchar_t 型別符號字串的指標。 cairo-win32-surface.c 130
在這個片段中,程式設計師混淆了字串格式說明符。問題在於,在 Visual C++ 中,wprintf 的 %s 期望的是 wchar_t*,而 %S 期望的是 char*。有趣的是,這些錯誤出現在用於錯誤輸出或除錯資訊的字串中——這肯定是罕見的情況,所以被忽略了。
|
|
注意:此程式碼取自 CryEngine 3 SDK。
PVS-Studio 警告:V576 格式不正確。請考慮檢查 'sprintf' 函式的第四個實際引數。期望的是有符號整數型別引數。 igame.h 66
整數型別也很容易混淆。特別是當它們的大小依賴於平臺時。不過,這裡的情況更簡單:有符號和無符號型別被混淆了。大數將被列印為負數。
|
|
注意:此程式碼取自 Word for Windows 1.1a。
PVS-Studio 警告:V576 格式不正確。呼叫 'printf' 函式時期望的實際引數數量不同。期望:3。實際:1。 dini.c 498
這個例子是在一次考古研究中發現的。這個字串預設了三個引數,但它們沒有被寫入。也許程式設計師打算列印堆疊上的資料,但我們無法假設那裡有什麼。當然,我們需要明確地傳遞這些引數。
|
|
注意:此程式碼取自 ReactOS。
PVS-Studio 警告:V576 格式不正確。請考慮檢查 'swprintf' 函式的第三個實際引數。要列印指標的值,應使用 '%p'。 dialogs.cpp 66
一個 64 位錯誤的例子。指標的大小取決於架構,使用 %u 來表示它是個壞主意。我們應該用什麼來代替呢?分析器提示我們正確的答案是 %p。如果指標是為除錯而列印,那還好。如果之後試圖從緩衝區中讀取並使用它,那就更有趣了。
帶可變數量引數的函式有什麼問題?幾乎所有事情都可能出錯!你無法檢查引數的型別,也無法檢查引數的數量。一步走錯,就是未定義行為。
幸好有更可靠的替代方案。首先,有可變引數模板 (variadic templates)。藉助它們,我們在編譯期間就能獲得所有傳遞型別的資訊,並可以隨心所欲地使用它。舉個例子,讓我們使用那個 printf,但是是一個更安全的版本。
|
|
當然這只是一個例子:在實踐中使用它毫無意義。但在可變引數模板的情況下,你只受限於你的想象力,而不是語言特性。
另一個可以作為傳遞可變數量引數選項的結構是 std::initializer_list。它不允許你傳遞不同型別的引數。但如果這已經足夠,你可以使用它。
|
|
遍歷它也非常方便,因為我們可以使用 begin、end 和基於範圍的 for 迴圈。
窄化轉換 (Narrowing casts) 給程式設計師帶來了很多頭疼的問題。尤其是在向 64 位架構遷移變得越來越必要的時候。如果你的程式碼中只有正確的型別,那就很好。但情況並非總是那麼樂觀:程式設計師經常使用各種骯髒的技巧,以及一些儲存指標的奇特方式。找出所有這樣的片段需要消耗大量的咖啡。
|
|
但讓我們暫時放下 64 位錯誤的話題。這裡有一個更簡單的例子:有兩個整數值,程式設計師想求它們的比率。他是這樣做的:
|
|
注意:此程式碼取自 Source Engine SDK。
PVS-Studio 警告:V636 表示式從 'int' 型別隱式轉換為 'float' 型別。考慮使用顯式型別轉換以避免小數部分丟失。例如:double A = (double)(X) / Y;。 Client (HL2) detailobjectsystem.cpp 1480
不幸的是,無法完全防止這類錯誤——總會有另一種方式將一種型別隱式轉換為另一種。但好訊息是 C++11 中的新初始化方法有一個很好的特性:它禁止窄化轉換。在這段程式碼中,錯誤將在編譯階段發生,並且可以輕鬆修正。
|
|
在資源和記憶體管理方面,犯錯的方式有很多種。使用的便利性是現代語言的一個重要要求。現代 C++ 也不落後,提供了一系列用於自動控制資源的工具。儘管這類錯誤是動態分析的核心領域,但有些問題可以透過靜態分析來揭示。以下是一些例子:
|
|
注意:此程式碼取自 Chromium。
PVS-Studio 警告:V554 auto_ptr 使用不正確。用 'new []' 分配的記憶體將用 'delete' 清理。 interactive_ui_tests accessibility_win_browsertest.cc 171
當然,智慧指標的想法並不新鮮:例如,曾經有一個類 std::auto_ptr。我用過去時態談論它,是因為它在 C++11 中被宣告為已棄用,並在 C++17 中被移除。在這個片段中,錯誤是由不正確使用 auto_ptr 引起的,該類沒有針對陣列的特化,結果是標準的 delete 將被呼叫,而不是 delete[]。unique_ptr 取代了 auto_ptr,它有針對陣列的特化,能夠傳遞一個將在 delete 之外呼叫的 deleter 仿函式,並完全支援移動語義。看起來這裡不會出什麼問題了。
|
|
注意:此程式碼取自 nana。
PVS-Studio 警告:V554 unique_ptr 使用不正確。用 'new []' 分配的記憶體將用 'delete' 清理。 text_editor.cpp 3137
事實證明,你還是可以犯完全相同的錯誤。是的,只要寫成 unique_ptr<unsigned[]> 錯誤就會消失,但即便如此,程式碼以這種形式也能編譯。因此,用這種方式也可能犯錯,而且實踐表明,如果可能,人們就會這麼做。這個程式碼片段就是證明。因此,在使用 unique_ptr 處理陣列時,要格外小心:搬起石頭砸自己的腳比想象中要容易得多。或許,按照現代 C++ 的規定,使用 std::vector 會更好?
讓我們看另一種型別的事故。
|
|
注意:此程式碼取自 Unreal Engine 4。
PVS-Studio 警告:V611 記憶體是使用 'new T[]' 運算子分配的,但卻是使用 'delete' 運算子釋放的。請檢查這段程式碼。最好使用 'delete [] Code;'。 openglshaders.cpp 1790
即使不使用智慧指標,也很容易犯同樣的錯誤:用 new[] 分配的記憶體透過 delete 釋放。
|
|
注意:此程式碼取自 CxImage。
PVS-Studio 警告:V611 記憶體是使用 'new' 運算子分配的,但卻是使用 'free' 函式釋放的。請檢查 'ptmp' 變數背後的操作邏輯。 ximalyr.cpp 50
在這個片段中,malloc/free 和 new/delete 被混淆了。這可能發生在重構期間:有一些來自 C 的函式需要被替換,結果就導致了未定義行為 (UB)。
|
|
注意:此程式碼取自 Fennec Media。
PVS-Studio 警告:V575 空指標被傳遞到 'free' 函式中。請檢查第一個引數。 settings interface.c 3096
這是一個更有趣的例子。有一種做法是在指標被釋放後將其置零。有時,程式設計師甚至會為此編寫專門的宏。一方面,這是一個很好的技巧:你可以保護自己免受再次釋放記憶體的風險。但在這裡,表示式的順序被搞混了,因此,free 得到了一個空指標(這沒有逃過分析器的注意)。
|
|
但這個問題不僅與記憶體管理有關,也與資源管理有關。例如,你忘記關閉檔案,就像上面的片段一樣。在這兩種情況下,關鍵字都是 RAII。智慧指標背後也是同樣的概念。結合移動語義,RAII 有助於避免大量與記憶體洩漏相關的 bug。而用這種風格編寫的程式碼可以更直觀地識別資源所有權。
作為一個小例子,我將提供一個利用 unique_ptr 功能的 FILE 包裝器。
|
|
不過,你可能想要一個功能更強的(語法更易讀的)檔案操作包裝器。是時候記住,在 C++17 中,將新增一個用於處理檔案系統的 API——std::filesystem。但如果你對這個解決方案不滿意,並且想使用 fread/fwrite 而不是 I/O 流,你可以從 unique_ptr 中獲得一些靈感,並編寫你自己的 File 類,它將針對你的個人需求進行最佳化,既方便、可讀又安全。
現代 C++ 提供了許多有助於你更安全地編寫程式碼的工具。出現了大量用於編譯期評估和檢查的結構。你可以切換到更方便的記憶體和資源管理模型。
但是,沒有任何技術或程式設計正規化可以完全保護你免受錯誤的影響。隨著功能的增加,C++ 也會出現新的、特有的 bug。這就是為什麼我們不能僅僅依賴一種方法:我們應該始終結合使用程式碼審查、高質量程式碼和優秀的工具;這些可以幫助你節省時間和精力,而這些時間和精力可以用在更好的地方。
說到工具,我建議試試 PVS-Studio:我們最近開始開發它的 Linux 版本,你可以看到它的實際效果:它支援任何構建系統,並允許你僅透過構建專案來檢查它。對於 Windows 開發者,我們有一個方便的 Visual Studio 外掛,你可以試用它的試用版。