C++ Core Check:安全編碼準則更新
要性能,更要安全
Rust和C++是兩門比較流行的系統級開發語言。多年來,業界對C++的關注主要是在性能上,我們也不斷地聽到來自客戶和安全研究員的反饋:他們希望C++應該在語言層面有更多的安全編碼準則。
在安全編程這個方面來說,C++經常被認為落后于Rust。
借鑒于Rust在安全編碼方面的特性,我們在Visual Studio 2019 v16.7的C++ Core Check中新增了四條編碼安全準則。讓我們來瞧瞧。
switch語句沒有default標簽
Rust中的模式匹配結構類似于C++中的switch語言結構。它們的主要差異在于,Rust要求開發者覆蓋所有的模式匹配可能性,可以通過為每個模式編寫一個顯式的處理器,或者添加一個默認的處理器(如果其他所有的模式都不匹配的話)。
舉個例子,下面的Rust代碼將不會通過編譯,因為它缺少默認的處理器。

這是一個簡潔的安全特性,因為它可以防止這種很容易發生但又不那么容易捕獲的編程錯誤。
如果switch語句中使用的是枚舉類型并且不是每個枚舉值都進行了判斷,則Visual Studio會警告開發者并發出C4061和C4062。但是,對于其他其他類型,例如整型,則沒有這個警告。
這次的版本我們引入了一個安全編碼準則:對于非枚舉類型(例如char, int),如果switch語句中沒有default處理標簽,Visual Studio將發出警告??梢栽陧椖康囊巹t設置中選擇一下三種不同的規則然后進行代碼分析。
- > C++ Core Check Style Rules
- > C++ Core Check Rules
- > Microsoft All Rules
下面我們來使用C++來重寫上面Rust的例子。

如果我們將default標簽去掉,則Visual Studio會給出如下的警告:

switch語句中的隱式跳轉(Unannotated fallthrough)
關于Rust中的模式匹配的另外一個限制是:它們不支持在case語句中隱式跳轉。而在C++中,下面的代碼能完美的通過編譯器的檢查。

上面的C++代碼開起來非常合理,但是在case語句中進行隱式的跳轉很容易成為程序的Bug。舉個例子,如果開發者忘記在each(food)調用后添加break語句,則代碼還是會通過編譯,但是運行的結果卻大不一樣。如果工程的規模十分龐大,則對于這類的Bug將很難追蹤。
幸運的是,C++17 添加了[[fallthrough]]這樣的標注,主要目的就是在不同的case語句中進行隱式跳轉,這樣的話,在上面的例子中,開發者就可以使用這個標注來向編譯器表明他的確希望執行這種行為。
在Visual Studio 2019 v16.7中,如果代碼中沒有使用[[fallthrough]]標注的情況下出現了隱式跳轉,則編譯器會給出C26819警告。這條規則在Visual Studio執行代碼分析時會默認啟用。

為了解決上面的警告,可以在case語句中添加[[fallthrough]]標注,如下圖所示:

昂貴的拷貝操作
Rust和C++中一個主要區別是,Rust默認采用移動(move)語義,而不是拷貝(copy)。
舉個例子:

這意味著,當你確實需要拷貝語義的時候,需要使用顯式的拷貝語句,如下圖所示:

C++就不同了,它默認是拷貝語義。通常來說,這也不算什么大問題,但是有時這可能導致某些Bug。一個經常發生的例子是使用range-for語句的時候,讓我們來看一下這個例子:

在上面的代碼中,在vector中的每個原始被在每次迭代循環中被拷貝到p里。如果元素是一個大型結構,則拷貝操作將會十分昂貴,而且這種情況還不太容易看出來。
為了避免這種不必要的拷貝,我們在C++ Core Check中添加了一條的編碼準則,建議開發者移除這種拷貝操作,如下圖所示:

以下是判斷某個拷貝操作是否有必要的方法:
如果類型的大小大于平臺相關指針大小的兩倍,并且該類型不是智能指針或gsl::span, gsl::string_span或std::string_view之一,則該拷貝被認為是不必要的。這意味著對于較小的數據類型(例如整型),不會觸發該警告。對于較大的類型,例如上面示例中的Person類型,該拷貝操作被認為是昂貴(不必要)的,編譯器將發出警告。
關于這條規則的最后一點是,如果循環體中的變量是mutated,則警告也不會觸發,如下圖所示:

如果使用的容器不是const類型,則可以通過修改對象為引用類型來避免不必要的拷貝。

但是,這樣修改會導致一個新的副作用。因此,這個警告僅建議將循環變量標記為const 引用,如果無法合法地將循環變量標記為const類型,則這個警告不會觸發。
此編碼準則默認啟用。
auto類型變量的拷貝
最后一個檢查規則是有關auto類型變量的拷貝操作的。
考慮下面的Rust代碼,其中為分配了引用的變量進行類型解析。

由于Rust的要求,在大多數情況下,復制必須是顯式的,因此在上面的例子中,password類型在分配了immutable引用后會自動解析為immutable引用,并且不會執行昂貴的拷貝操作。
另一方面,考慮以下C++代碼:

在上面的代碼中,即使getPassword的返回類型是對字符串的const引用,password的類型也會被解析為std::string。結果是,PasswordManager::password的內容被復制到本地變量password中。
下面用一個返回指針的函數作為對比:

在分配引用和指向標記為auto的變量的指針之間的行為差異是不明顯的,從而可能導致不必要的拷貝和意外拷貝。
為了防止由于此行為而導致的錯誤,檢查器檢查從引用到標記為auto的變量的所有初始化實例。如果使用與范圍檢查相同的試探法將生成的拷貝操作視為昂貴,則檢查器會發出警告,建議將變量標記為const引用類型。

并且與范圍檢查一樣,只要無法將變量合法地標記為const,就不會發出此警告。

另一個不會發出警告的情況是,無論何時從臨時對象派生引用。在這種情況下,一旦臨時文件被銷毀,使用const auto引用將導致對已銷毀臨時變量的”懸掛”引用。

此編碼準則默認啟用。
總結
能看(寫)到這里,我覺得也應該是個漢子了吧。
有些編碼準則(例如聲明變量時必初始化),最好能成為你的肌肉記憶,當寫出某種代碼結構的時候,是你的肌肉,而不是大腦,來完成安全編碼原則。
最后
Microsoft Visual C++團隊的博客是我非常喜歡的博客之一,里面有很多關于Visual C++的知識和最新開發進展。大浪淘沙,如果你對Visual C++這門古老的技術還是那么感興趣,則可以經常去他們那(或者我這)逛逛。