C/C++ 當中有一派人士認為,當一個東西不該出現負值的時候,應盡可能以 unsigned 修飾。舉凡標準程式庫當中與記憶體大小相關的數值,像 sizeof 運算、size_t、還有STL容器的 size_type 都是某種 unsigned 整數類型;其他像 strlen 的回傳值以及 malloc 的輸入值也都具有 unsigned 性質。

除此之外,有些人更進一步主張像年齡、人數之類的,既然永遠不可能為負值,那麼也應該要加上 unsigned 關鍵字。這麼做最主要的好處之一是可以在宣告當中表明意圖,另一方面既然 unsigned 不可能為負,所以檢查有效範圍會比較簡單:

unsigned int index;
...
if (index < upperBound)

如果 index 為 int 的話就會變得比較複雜

if (index >= 0 && index < upperBound)

另一個比較重要的理由是 % 運算子並非對整個 signed 的值域都有定義,異號的餘數運算有可能出現 undefined behavior。

某個知名、影響力頗大的faq就是如此建議的,他們 faq 裡的其他範例也常用 unsigned 當作 index 值。

但也有另一個陣營的想法完全相反,他們認為 unsigned 很邪門,除非你有特別的用途否則根本不應該用 unsigned,甚至不應該為了 range 不夠用而切換到 unsigned。他們主張即使 int 不夠用也應該優先考慮 long long,而不是使用 unsigned int。

這個陣營常舉的案例像這樣,有人習慣讓 for 迴圈倒數 [註1]

for (i = strlen(text)-1; i >= 0; --i) {
   DoSomething(text[i]);
}

若 i 為 unsigned 則永遠不可能小於零,只會忽然變成很大的正數,形成無窮迴圈。我想很多人會認為用這點來反對 unsigned 沒什麼說服力,我也是這麼覺得。其實真正的問題來自於在同一個 expression 內混用 signed 和 unsigned。

unsigned pos = 1024;
int neg = -5;

if (pos > neg) {
    cout << pos << " is bigger.\n";
} else {
    cout << neg << " is bigger.\n";
}

上面這段程式碼會告訴你 -5 比較大。因為當 signed 和 unsigned 一同做運算時,signed 會自動轉型成 unsigned,在 32 bit 機器上 -5 會變成 4294967291。上面這個例子很容易看出來,不過像下面這樣的算式就很容易搞錯

if (-1 * u + v < 1024) .....

像 -1、1024 這樣的整數字面值預設為 signed,但若 u、v 為 unsigned 的話 -1 * u + v 的結果就會是 unsigned,於是形成一個不容易察覺的singned、unsigned比較。由於程式在字面上看起來吻合撰寫者的意圖,所以這個錯誤會非常難抓(不過在 warning 全開的狀況下,編譯器通常會發出警告)。如果 u 和 v 都是signed的話就不會有這種問題。

類似的情形也發生在函數呼叫。我們常根據動態運算的數值來向malloc要記憶體,萬一因某個沒檢查到的輸入錯誤導致負值產生,那麼很可能 malloc() 會以為你想要配置高達4GB的連續記憶體。

更糟糕的是一些type混用之後再加上 sign extention 議題。在很多情況下我們常常將 int 和 long 混用,因為在 32 位元平台上兩者寬度相同,但一旦離開了這樣的環境卻是很危險的:

typedef unsigned int DWORD;
...

DWORD data[N];
// 從週邊裝置讀取 data...

for (int i = 1; i < N; ++i) {
   long diff = data[i] - data[i - 1];
   // 處理 diff

在 x86-64 環境下,這段程式碼可能正確也可能出錯。若編譯器採用 LLP64 整數模型,例如 Visual C++,那麼 int 和 long 還是一樣大,大概是不會有問題;若編譯器採用 LP64 整數模型,例如所有 linux、FreeBSD 的編譯器,long 變成了 64 位元,那麼只要 data[i] 小於 data[i-1] 就會出現很詭異的錯誤。[註2]

假設

data[0] = 6
data[1] = 5

相減之後變成了32位元的 0xFFFFFFFF,在 32 位元 signed 整數型態下是 -1 沒錯。但如果指派給 64 位元整數就會變成 0x00000000FFFFFFFF,這是一個非常大的正數,顯然不是撰寫者所要的。如果從一開始 data 直接宣告為 signed 這段程式碼就不會有問題,因為帶正負號的整數型轉到更大的型態時會自動 sign extention。這類的錯誤有時非常難除錯,尤其大一點的程式裡型態往往經過了層層的 typedef,一些概念有點像的型態很容易混用而不自覺。

很多程式設計領域爭端之所以沒有結論,通常是因為這件事對程式碼的好壞沒有實質面的影響,反而偏好因素佔了較大的成分。但 unsigned 的影響卻非常明顯,絕對不是純粹的好惡問題,事實上很多程式語言根本不提供 unsigned 型態,例如 VB、Java;也有些語言雖然提供 unsigned 但卻對自動轉型有更嚴格的限制,例如Ada。

在 C/C++ 有幾點至少是大家公認的:

  1. 不要在同一個運算式中混合signed和unsigned。但這點有時候很難,尤其是 C/C++ 很多基礎建設都會傳回 unsigned 型態,例如sizeof、strlen 以及STL容器的size_type,做低階處理常常也會直接碰到 unsigne 值。所以只好努力在程式碼上築防火牆,剩下的就只能把編譯器的 warning 全開。
  2. 位元運算必須使用 unsigned。
  3. 當非常需要確定位元寬度時,應引用 stdint.h。若是在C++環境可以使用 boost 下的 cstdint.hpp。總之不要依賴特定編譯器的大小。

註1:這是一些老手用的 trick,因為「和 0 比較然後 branch」在很多機器上只需要一個指令;而和0以外的其他數比較通常要兩三個指令:載入數值、比較或減法、依照前一個運算的 flag 做 branch。所以倒數迴圈不但產生的code較小,速度也比較快。但我認為這是不必要的優化手法。首先,只有確定這是效能瓶頸才有意義,否則只是自找麻煩;再者,只要編譯器有開最佳化,那麼簡單的for迴圈都會自動變倒數,不勞您費心。

註2:LLP64 和 LP64 是 64 位元平台編譯器對待整數的策略,有機會再介紹。一些細微的程式設計差異我自己也還在摸索當中。 微軟採用 LLP64,意即 long long 和 pointer 使用64 位元,但 int 和 long 仍為 32 位元。 Unix 相容編譯器採用 LP64,即 long 和 pointer 使用 64 位元,而 int 仍為 32 位元。 另外還有目前仍罕見的 ILP64,表示int、long、pointer 都是 64 位元。

arrow
arrow
    全站熱搜

    novus 發表在 痞客邦 留言(0) 人氣()