大概兩週前有位網友在調查有多少人了解下面這個宣告式:

unsigned int n = -1;

我看到這個問題的時候,已經有不少人實驗得出結果,並且也開始討論這段程式碼的風格問題。以上程式碼的作用在於將 n 所有 bit 設為 1,這是一個非常可靠的行為 -- C 和 C++ 語言標準中對由 signed 到 unsigned 的轉型有段謎語般的詳盡敘述,事實上在採用 2 補數的環境中,所謂轉型的實質行為就只是把位元照搬過去而已(至於採用其他數字系統的平台太罕見,以下不討論)。

功能正確是一回事,關於程式碼的優劣有很多考量。首先,上面這個定義式會引發由 int 至 unsigned int 的自動轉型,有些編譯器在比較高的警告層級下會發出警告。這是一個舉手之勞就可以避掉的警告,任由這類訊息湮沒真正重要的警告是完全沒道理的。

其次,這段程式對閱讀者可能不太友善。如果說我有什麼撰寫程式的座右銘,那大概是:「把注意力花在更有價值的事情上」。假使一個人每讀幾行就不得不抽出腦力去分析程式語言細節,哪怕只是一秒鐘的停頓,那麼他能夠用在理解、分析高階行為的腦力將會被中斷,這是一個有效降低生產力的因素。

不過到底怎樣的程式碼算是友善,其中摻雜一定程度的主觀因素。說老實話,我對於有人將以上程式碼視為意圖不明感到有點意外,如果在兩個星期以前要我估計有多少 C 程式設計師可以不經思索說出上面這段程式的用意,我會估計大約八成吧,而且其餘的人多半只有在第一次看到時才需要稍微想一下,之後他們也會變成連想都不用想的那一群。換言之,這應該算是 C 語言的基本常識,就像整數自動轉布林值一樣基本,不太可能對理解構成障礙。不過我的估計可能和現實有蠻大的落差,本文後面會提到這一點。

不論如何,從我個人過去閱讀、設計位元演算法中的經驗來看,這行程式碼在機能以及風格方面還有些改進的空間,以下先提出來討論。前面提到用 -1 填滿所有 bit 是一個非常可靠的行為,但畢竟不如直接指定數值來得直觀,假設 int 為 32 bit,我們可以像下面這樣指定常數:

unsigned int n = 0xFFFFFFF;

當然,也有其他數值能夠填滿所有的位元:

unsigned int n = 4294967295;

unsigned int n = 037777777777;

十進制完全無法反映位元的狀態,而採用 3 位元 做為一位數的八進制遠不如十六進制普及,儘管兩者在功能方面完全正確,對閱讀的人會構成一個小小的障礙。相較之下十六進制的意圖明顯多了,任何有經驗的程式設計師看到 unsigned 再看到 0x,一定馬上能聯想到和位元計算有關。

我相信所有讀者肯定一眼就看出我上面的例子少打一個 F,所以上面的 n 其實並沒有被填滿 32 bit。同時我也相信,對於許多在惡劣精神狀態下勉強讀程式的人而言,辨識 -1 這個模式會比弄清 F 數量還容易得多。再說當一個讀程式的人懷疑 F 數量有問題時,他甚至沒辦法立刻判斷這究竟是不小心打錯,又或者撰寫者真的就只想填滿這部份的 bit 而已。

個人偏好的大方向是:

unsigned int n = ~0x0;

在這裡 0x0 和 0 其實是等效的,加上 0x 只是增強暗示位元運算這回事。期望別人能理解 0x 的暗示可能有點不切實際,但要是有人看到 ~ 運算子還要查半天,也不知道 0 反相會變成 1,我已經想不出來他還有什麼動機看這段程式碼,或許是收到「不看懂並解釋給十個人聽會全家死光光」之類的連鎖信吧。

事情還沒完,上面只是大方向而已,在有資格放入正式場合之前,還是有一些細節值得進一步探討。第一個問題是,unsigned int 的寬度會隨著環境而變,所以 unsigned int n = ~0x0; 傳達給我的訊息是:「待會發生在 n 上的計算對資料寬度都不敏感」。

但是當我看到 unsigned int n = 0xFFFFFFFF; 時,心裡會想:「呃... 寫明 0xFFFFFFFF 表示 32 位元都是有意義的,但為什麼還要使用寬度不定的 unsigned int 呢?作者大概是不太在乎平台差異的菜鳥」。當然,這只是非常粗糙的第一印象,不見得有道理,很多程式終其生命週期就只打算在特定平台上編譯、執行。

許多應用對資料寬度沒那麼敏感,不過在位元演算法裡面常見依賴寬度的計算,所以最好使用保證寬度的型別:

#include <stdint.h>

uint32_t n = ~0x0;
uint32_t n = 0xFFFFFFFF;

不相容 C99、C++0x、C++11 的編譯器可能沒有提供 <stdint.h>(在 C++ 為 <cstdint>),使用者可依需求自行定義,要轉換平台的時候只要加一些條件編譯式即可,舉例來說,Windows API 定義的 WORD、DWORD、QWORD,可以一路從 16 位元時代一直用到 64 位元時代。

另一個非常重要的問題是,~0x0 究竟反相了多少個 bit?能填滿 unsigned int 嗎? C 和 C++ 的整數字面值都具有明確的型別,像 0x0 和 -1 的型別都是 int,所以 ~0x0 和 -1 恰好能填滿和 int 一樣寬的 bit。而且因為 -1 和 ~0x0 都是 signed int,當指派給更寬的型別時會自動 sign extention,所以這兩個字面值還可以填滿 64 bit 整數。至於 0xFFFFFFFF、-1u、0x0u 都是 unsigned int,所以不會發生 sign extention。此外還有一些計算上的差異,以下只舉作用最顯著的右移。

uint64_t n = ~0x0;         // n = 0xFFFFFFFFFFFFFFFF
uint64_t n = -1;           // n = 0xFFFFFFFFFFFFFFFF
uint64_t n = 0xFFFFFFFF;   // n = 0xFFFFFFFF
uint64_t n = ~0x0u;        // n = 0xFFFFFFFF (depends on sizeof unsigned int)
uint64_t n = -1u;          // n = 0xFFFFFFFF (depends on sizeof unsigned int)
uint64_t n = (unsigned)-1; // n = 0xFFFFFFFF (depends on sizeof unsigned int)

~0x0 >> 16          // 0xFFFFFFFF (depends on sizeof int)
0xFFFFFFFF >> 16    // 0xFFFF

或許有人會對於 0x0 和 0xFFFFFFFF 不同型別感到驚訝,這是因為 C 和 C++ 會依照字面值的大小自動調整型別,以十六進制為例,起跳的型別是 int,若超出範圍則變成 unsigned int,之後則是 long... 比較詳細的轉換順位為:

十進制八或十六進制
int int
long unsigned int
long long long
  unsigned long
  long long
  unsigned long long

這裡補充一個壞消息,在 long long 成為標準之前,當十進制超出 long 範圍時,C 會使用 unsigned long,而 C++ 將這點留給實作決定,這表示同一個字面值在不同編譯器之下可能會有不同型別、正負號。

此外還需要注意一點,由於 uint32_t n = ~0x0; 當中牽涉到由 int 至 unsigned int 的自動轉型,因此有些編譯器在較高的警告層級下會發出警告,這一點和 uint32_t n = -1; 是一樣的。

知道這些對於改善程式有什麼幫助呢?在 C 和 C++ 裡型別支配著運算行為,可是普通人對字面值的型別並沒有那麼熟悉,而且字面值的型別會隨著不同年代的編譯器不同而略有差異,比較好的原則是:

  1. 如果運算式對資料寬度、正負號敏感,盡可能不要在其中直接使用字面值,而應該先將字面值指派給型別明確的變數。

  2. 如果在對資料寬度、正負號敏感的運算式中必須用到字面值,最好明確手動轉型,或者至少使用像是 L、UL 之類的尾綴修飾。

照上面的原則,即使不太清楚字面值的型別,仍然可以寫出比較保險的程式碼:

uint32_t n = ~(uint32_t) 0x0;
uint32_t n = (uint32_t) -1; 
uint32_t n = ((uint32_t) -1) >> 5

uint32_t n = ~0x0ul;         // Be careful
uint32_t n = 0xFFFFFFFFul;   // Be careful

其實上面每一個型別指定都是多餘的,因為自動轉型也會得到相同的結果,就像撰寫運算式時也會使用不必要的括號以凸顯運算順序,這並不是為編譯器而寫,而是寫給人的。使用尾綴要小心內建型別寬度可能因編譯環境而異,或許轉換成 uint32_t 會比期待其他人知道 ul 至少有 32 位元還要合理。

有一點麻煩的是,我遇過比較早期的編譯器要求超過 unsigned long 的字面值一定要加尾綴,然而 long long 在成為標準之前並沒有統一的尾綴標示。幸好巨集可以一併將這些不理想的因素包裝起來:

#if ...
    #define UL64(literal) (uint64_t)(literal)
#elif ...
    #define UL64(literal) literal##UL
#elif ...
    #define UL64(literal) literal##ULL
    ...

uint64_t n = ~UL64(0x0);
uint64_t m = UL64(0x3030303030303030);

雖然通常非屬必要,uint32_t 也可以用類似的手法處理。以上差不多是我能想到需要考慮的問題,或許不是所有的程式都值得做到這麼多,但這裡提到的都是舉手之勞,卻能給程式帶來基本的可移植性。

話說回來,這樣的程式碼友善嗎?從某些角度來看上面的程式碼不怎麼友善,畢竟第一眼看到時,誰曉得 UL64 是啥鬼玩意?就如同很多人第一次看到 Windows API 範例時,會對一堆 DWORD、LPTCSTR、_T() 感到莫名其妙。我認為這就像很多專業領域會發展自己的行話、術語一樣,對於圈外人並不友善,然而對圈內人而言卻能夠克服理解障礙、增進溝通效率。

回到一開始提到的那個討論串,當我提出 unsigned int n = -1; 或許不像其他人以為的那麼冷僻時,立刻遭到一位網友批評,似乎認為我正在姑息一種不良的風格,而不良風格往往是維護成本的來源。其實我的理念和他非常一致,差別在於他專精的語言是 Java,而且很可能沒有深入接觸過位元演算法的世界,因此他沒有意識到這樣的定義式在 C 語言的圈子有可能是淺顯易懂的。與此相反的是,我可能太常摸這些東西了,所以沒有意識到其他人未必知道這些事情。

後來我也做了一個小小的田野調查,很不幸的是我的人際圈小得可憐,所以調查樣本非常少。結果非常兩極化,這可能和我在問題中使用「不要想,立刻回答」有關。我在現實中認識的人大都沒辦法回答,甚至認為這是只有怪咖才會去研究的冷門問題。讓我印象深刻的是有位同事雖然答不出這個問題,在討論中卻可以一字不差說出 32 位元 int 的最大值是 2147483647,我認為這才是真正的冷知識。

但同時也存在一群和我一樣將此視為基本常識的人,對於有人不知道這件事感到驚訝,就如同有人不了解整數轉布林值的行為一樣。其中有一個人我甚至懶得問,因為幾年前我才建議他把程式裡的 -1 改成基於 ~0 的寫法, 請注意這樣寫的人絕不是出於惡搞或者賣弄小聰明,而是在他的觀念中這是一個兼顧機能與可讀性的寫法

雖然我的樣本數沒什麼效力,我猜由 int 轉換至 unsigned int 的行為大概稱不上必備知識吧,或許在真實世界裡所謂基礎必備知識根本是虛幻的。舉例來說,我最近計畫改寫一個前人寫的 python 模組,主要是把重複程式碼抽出來變成對自己狀態負責的物件,不但可以修正現有的 bug,也會使未來擴充新功能更加方便。一位前輩給我的建議是:在可預見未來,團隊多數成員大概都不會太了解物件導向,更不用說是 python 的物件模型,如果我打算使用物件導向最好也把相關知識寫成文字,否則不管再怎麼精巧的設計,過一段時間肯定會被人以意想不到的方式改得面目全非。這是經驗之談,其實不用他講我也見過夠多了,而且經驗告訴我留下文字大概也不會有人看。那麼物件導向是 python 的基礎必備知識嗎?在真實世界裡,要看你身在何處。

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


留言列表 (2)

發表留言
  • edisonx
  • 這問題不知道有沒有相關。

    手邊有些 Code 要對 signed char byte做 sign extension 至 unsigned int u。由於怕相容性問題(目前團隊使用 VS 版本有三種,未來會統一移植到較高版本),目前我的做法是 unsigned char x = *(unsigned char*)&byte 硬轉後,再進行 u = setbit_range(x); 請問這部份是否可能可靠轉型自動 extension ?或就算有其實也不建議這麼做?謝謝。
  • Sign extension是一個非常可靠的行為,比較需要注意的是確保資料來源是 signed char(光 char 是不夠的)。至於 Signed 轉 unsigned 也是很可靠的行為,如文中所述。

    Sign extension 其實是為了滿足一個非常直觀的概念:當 signed 整數提升到更大的 signed 整數型別時,所代表的數值意義不會改變。不只 C 語言有這種保證,硬體也都會這樣設計,大概沒有程式語言會刻意違反這麼合乎直覺的行為。

    理論上非 2 補數的環境會有不同的行為,不過目前非 2 補數的環境很罕見,如果真的遇到了反正一堆 bit trick 都會失效,也不差這一項了。

    ---

    如果你的目的是將 char 轉 unsigned char 的話,這個寫法不僅多餘,而且對閱讀大概不太友善。
    unsigned char x = *(unsigned char*)&byte;

    假設同寬度整數
    * signed 轉 unsigned 是 well-defined,在 2 補數環境相當於 bit 照搬。
    * unsigned 轉 signed 是 implementation-defined,雖然最有可能的行為是 bit 照搬。

    novus 於 2013/12/06 00:20 回覆

  • edisonx
  • 非常謝謝您的回覆,感激不盡!