C++ 安全地比較 signed 和 unsigned

在強型別的 C++ 裡面,針對整數型別大多都有提供「有號」(signed、允許負數)和「無號」(unsigned、只有 0 和正數)兩種;像是以一般的整數來說,就有 intunsigned int 兩種,可以根據自己的需求來選擇要用哪種。

但是有的時候,我們難免會有需要要把資料在這兩種不同的中做轉換、或是拿來比較;而這個時候,其實是有相當的風險的…

像是以比較來說,可以來看下面這段程式碼:

#include <iostream>
 
int main()
{
  long           a = -100;
  unsigned short b = 100;
  size_t         c = 100;
 
  std::cout << (a < b);   // 1
  std::cout << (a < c);   // 2
}

理論上下面兩行都是在確認「-100 < 100」是否是正確的,理論上輸出的結果應該是要是「11」;但是實際上,輸出的結果會是「10」,也就是系統覺得「a < c」是錯的。

實際上,如果用 C++ Insights(網站)來看的話,最後那兩行會被轉換成:

std::cout.operator<<((a < static_cast<long>(b)));
std::cout.operator<<((static_cast<unsigned long>(a) < c));

也就是說,在比較「a < b」的時候,他是會把 b 轉換成 long 來和 a 做比較,所以結果是正確的。

但是在執行「a < c」的時候,他則是把 a 轉換成 unsigned long 來和 c 做比較!這時候由於是把 -100 轉換成正數,所以就出問題了!

實際上,C+ Reference 也有針對這種情況的轉換邏輯做說明(網頁),在進行整數的二元算術計算的時候:

  • 如果兩個都是有號、或是兩個都是無號的狀況,會將級別(rank)較低的型別轉換成級別較高的型別
  • 如果無號的型別級別較高、或等於有號的級別,那會將有號的型別轉換成無號的型別
  • 如果有號的型別級別較高、或等於無號的級別,那會將無號的型別轉換成有號的型別
  • 否則將兩者都轉換到有號型別對應的無號版本
    (老實說,這個看不太懂,也不知道什麼時候會走到這)

而他的轉換級別(conversion rank)則是:boolsigned charshortintlonglong long

所以基本上,問題就是「如果拿比較小的有號型別去和比較大的無號型別做算術計算的時候,有號的型別會被轉換成無號的」;而如果此時他是負數的話,那就會出問題了。


而如果是想要安全地在 signed 和 unsigned 兩者間比較,該怎麼辦呢?雖然個人覺得不算直覺,但是 C++20 在 <utility> 中,其實提供了一系列的 cmp_xxx() 的函式(參考)可以使用,這部分可以使用。包含了:

  • cmp_equal
  • cmp_not_equal
  • cmp_less
  • cmp_greater
  • cmp_less_equal
  • cmp_greater_equal

這些函式在比較的時候,會確保負值的有號整數會小於無號整數、以避免錯誤;同時也會避免損失資料的轉換。

以前面所舉的例子來說,把「a < c」改成 std::cmp_less(a, c) 就會得到正確的結果了!

而如果是想要手動轉換的話,<utility> 也有提供 in_range<>() 這個函式(文件),可以讓開發者在轉換前,先檢查是否適合這樣轉換。

下面就是一個簡單的例子:

#include <utility>
#include <iostream>
 
int main()
{
  std::cout << std::boolalpha;
 
  std::cout << std::in_range<std::size_t>(-1) << '\n';
  std::cout << std::in_range<std::size_t>(42) << '\n';
}

在上面的程式中,由於 -1 不在 size_t 的數值範圍內,所以他會回傳 false;所以有必要的話,其實可以在實際轉換前,先用這個方法來做測試,避免轉換的結果和預期不同。


另外,其實一個滿常見、會碰到 signed 和 unsigned 的比較的狀況,是在和容器的大小做比較的狀況。

所以,標準函式庫現在也另外提供了一個 ssize() 的函式,可以用有號整數的形式、回傳容器的大小(參考);不過他回傳的型別是個 Heresy 不太熟的 std::ptrdiff_t參考)。

這邊是一個例子:

#include <iostream>
#include <vector>

int main()
{
  std::vector<int> v = { 1,2,3,4,5 };

  for (auto i = v.size() - 1; i >= 0; --i)
    std::cout << i << ": " << v[i] << '\n';
}

上面的程式碼理想上是要把 v 裡面的資料反過來輸出;但是由於 i 的型別會被判斷成是無號的 size_t,所以當他是 0 的時候再減下去就會變成一個很大的整數;這也導致了該迴圈的中斷條件永遠不會達成,同時也會造成對 v 的違法存取。

當然這邊有很多種修改方法,但是如果要在變動最小的狀況下修改的話,那應該就是改成透過 ssize() 來取大小了。這邊就是修改成:

for (auto i = std::ssize(v) - 1; i >= 0; --i)
  std::cout << i << ": " << v[i] << '\n';

不過認真講,個人覺得用到的機會感覺不大就是了?

或許為了讓 Visual Studio 內建的古老版本 OpenMP 可以在針對 for 迴圈平行化的時候,不要跳出有號和無號整數比較的時候,可能有點幫助吧?


參考:

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *