2. Unicode簡介


第一章 中,我已經預告,C語言中在Microsoft Windows程式設計中扮演著重要角色的任何部分都會講述到,您也許在傳統文字模式程式設計中還尚未遇到過這些問題。寬字元集和Unicode差不多就是這樣的問題。

簡單地說,Unicode擴展自ASCII字元集。在嚴格的ASCII中,每個字元用7位元表示,或者電腦上普遍使用的每字元有8位元寬;而Unicode使用全16位元字元集。這使得Unicode能夠表示世界上所有的書寫語言中可能用於電腦通訊的字元、象形文字和其他符號。Unicode最初打算作為ASCII的補充,可能的話,最終將代替它。考慮到ASCII是電腦中最具支配地位的標準,所以這的確是一個很高的目標。

Unicode影響到了電腦工業的每個部分,但也許會對作業系統和程式設計語言的影響最大。從這方面來看,我們已經上路了。Windows NT從底層支援Unicode(不幸的是,Windows 98只是小部分支援Unicode)。先天即被ANSI束縛的C程式設計語言通過對寬字元集的支援來支援Unicode。下面將詳細討論這些內容。

自然,作為程式寫作者,我們通常會面對許多繁重的工作。我已試圖透過使本書中的所有程式「Unicode化」來減輕負擔。其含義會隨著本章對Unicode的討論而清晰起來。

字元集簡史

雖然不能確定人類開始講話的時間,但書寫已有大約6000年的歷史了。實際上,早期書寫的內容是象形文字。每個字元都對應於發聲的字母表則出現於大約3000年前。雖然人們過去使用的多種書寫語言都用得好好的,但19世紀的幾個發明者還是看到了更多的需求。Samuel F. B. Morse在1838年到1854年間發明了電報,當時他還發明了一種電報上使用的代碼。字母表中的每個字元對應於一系列短的和長的脈衝(點和破折號)。雖然其中大小寫字母之間沒有區別,但數字和標點符號都有了自己的代碼。

Morse代碼並不是以其他圖畫的或印刷的象形文字來代表書寫語言的第一個例子。1821年到1824年之間,年輕的Louis Braille受到在夜間讀寫資訊的軍用系統的啟發,發明了一種代碼,它用紙上突起的點作為代碼來幫助盲人閱讀。Braille代碼實際上是一種6位元代碼,它把字元、常用字母組合、常用單字和標點進行編碼。一個特殊的escape代碼表示後續的字元代碼應解釋為大寫。一個特殊的shift代碼允許後續代碼被解釋為數字。

Telex代碼,包括Baudot (以一個法國工程師命名,該工程師死于1903年)以及一種被稱為CCITT #2的代碼(1931年被標準化),都是包括字元和數字的5位元代碼。

美國標準

早期電腦的字元碼是從Hollerith卡片(號稱不能被折疊、捲曲或毀傷)發展而來的,該卡片由Herman Hollerith發明並首次在1890年的美國人口普查中使用。6位元字元碼系統BCDIC(Binary-Coded Decimal Interchange Code:二進位編碼十進位交換編碼)源自Hollerith代碼,在60年代逐步擴展為8位元EBCDIC,並一直是IBM大型主機的標準,但沒使用在其他地方。

美國資訊交換標準碼(ASCII:American Standard Code for Information Interchange)起始於50年代後期,最後完成於1967年。開發ASCII的過程中,在字元長度是6位元、7位元還是8位元的問題上產生了很大的爭議。從可靠性的觀點來看不應使用替換字元,因此ASCII不能是6位元編碼,但由於費用的原因也排除了8位元版本的方案(當時每位元的儲存空間成本仍很昂貴)。這樣,最終的字元碼就有26個小寫字母、26個大寫字母、10個數字、32個符號、33個代號和一個空格,總共128個字元碼。ASCII現在記錄在ANSI X3.4-1986字元集-用於資訊交換的7位元美國國家標準碼(7-Bit ASCII:7-Bit American National Standard Code for Information Interchange),由美國國家標準協會(American National Standards Institute)發佈。圖2-1中所示的ASCII字元碼與ANSI文件中的格式相似。

ASCII有許多優點。例如,26個字母代碼是連續的(在EBCDIC代碼中就不是這樣的);大寫字母和小寫字母可通過改變一位元資料而相互轉化;10個數位的代碼可從數值本身方便地得到(在BCDIC代碼中,字元「0」的編碼在字元「9」的後面!)

最棒的是,ASCII是一個非常可靠的標準。在鍵盤、視訊顯示卡、系統硬體、印表機、字體檔案、作業系統和Internet上,其他標準都不如ASCII碼流行而且根深蒂固。


圖2-1 ASCII字元集

國際方面

ASCII的最大問題就是該縮寫的第一個字母。ASCII是一個真正的美國標準,所以它不能良好滿足其他講英語國家的需要。例如英國的英鎊符號(£)在哪裡?

英語使用拉丁(或羅馬)字母表。在使用拉丁語字母表的書寫語言中,英語中的單詞通常很少需要重音符號(或讀音符號)。即使那些傳統慣例加上讀音符號也無不當的英語單字,例如cöoperate或者résumé,拼寫中沒有讀音符號也會被完全接受。

但在美國以南、以北,以及大西洋地區的許多國家,在語言中使用讀音符號很普遍。這些重音符號最初是為使拉丁字母表適合這些語言讀音不同的需要。在遠東或西歐的南部旅遊,您會遇到根本不使用拉丁字母的語言,例如希臘語、希伯來語、阿拉伯語和俄語(使用斯拉夫字母表)。如果您向東走得更遠,就會發現中國象形漢字,日本和朝鮮也採用漢字系統。

ASCII的歷史開始於1967年,此後它主要致力於克服其自身限制以更適合於非美國英語的其他語言。例如,1967年,國際標準化組織(ISO:International Standards Organization)推薦一個ASCII的變種,代碼0x40、0x5B、0x5C、0x5D、0x7B、0x7C和0x7D「為國家使用保留」,而代碼0x5E、0x60和0x7E標為「當國內要求的特殊字元需要8、9或10個空間位置時,可用於其他圖形符號」。這顯然不是一個最佳的國際解決方案,因為這並不能保證一致性。但這卻顯示了人們如何想盡辦法為不同的語言來編碼的。

擴展ASCII

在小型電腦開發的初期,就已經嚴格地建立了8位元位元組。因此,如果使用一個位元組來保存字元,則需要128個附加的字元來補充ASCII。1981年,當最初的IBM PC推出時,視訊卡的ROM中燒有一個提供256個字元的字元集,這也成為IBM標準的一個重要組成部分。

最初的IBM擴展字元集包括某些帶重音的字元和一個小寫希臘字母表(在數學符號中非常有用),還包括一些塊型和線狀圖形字元。附加的字元也被添加到ASCII控制字元的編碼位置,這是因為大多數控制字元都不是拿來顯示用的。

該IBM擴展字元集被燒進無數顯示卡和印表機的ROM中,並被許多應用程式用於修飾其文字模式的顯示方式。不過,該字元集並沒有為所有使用拉丁字母表的西歐語言提供足夠多的帶重音字元,而且也不適用於Windows。Windows不需要圖形字元,因為它有一個完全圖形化的系統。

在Windows 1.0(1985年11月發行)中,Microsoft沒有完全放棄IBM擴展字元集,但它已退居第二重要位置。因為遵循了ANSI草案和ISO標準,純Windows字元集被稱作「ANSI字元集」。 ANSI草案和ISO標準最終成為ANSI/ISO 8859-1-1987,即「American National Standard for Information Processing-8-Bit Single-Byte Coded Graphic Character Sets-Part 1: Latin Alphabet No 1」,通常也簡寫為「Latin 1」。

在Windows 1.0的《Programmer's Reference》中印出了ANSI字元集的最初版本,如圖2-2所示。


圖2-2 Windows ANSI字元集(基於ANSI/ISO 8859-1)

空方框表示該位置未定義字元。這與ANSI/ISO 8859-1的最終定義一致。ANSI/ISO 8859-1僅顯示了圖形字元,而沒有控制字元,因此沒有定義DEL。此外,代碼0xA0定義為一個非斷開的空格(這意味著在編排格式時,該字元不用於斷開一行),代碼0xAD是一個軟連字元(表示除非在行尾斷開單詞時使用,否則不顯示)。此外,ANSI/ISO 8859-1將代碼0xD7定義為乘號(*),0xF7為除號(/)。Windows中的某些字體也定義了從0x80到0x9F的某些字元,但這些不是ANSI/ISO 8859-1標準的一部分。

MS-DOS 3.3(1987年4月發行)向IBM PC用戶引進了內碼表(code page)的概念,Windows也使用此概念。內碼表定義了字元的映射代碼。最初的IBM字元集被稱作內碼表437,或者「MS-DOS Latin US)。內碼表850就是「MS-DOS Latin 1」,它用附加的帶重音字母(但 不是 圖2-2所示的Latin 1 ISO/ANSI標準)代替了一些線形字元。其他內碼表被其他語言定義。最低的128個代碼總是相同的;較高的128個代碼取決於定義內碼表的語言。

在MS-DOS中,如果用戶為PC的鍵盤、顯示卡和印表機指定了一個內碼表,然後在PC上創建、編輯和列印文件,一切都很正常,每件事都會保持一致。然而,如果用戶試圖與使用不同內碼表的用戶交換檔案,或者在機器上改變內碼表,就會產生問題。字元碼與錯誤的字元相關聯。應用程式能夠將內碼表資訊與文件一起保存來試圖減少問題的產生,但該策略包括了某些在內碼表間轉換的工作。

雖然內碼表最初僅提供了不包括帶重音符號字母的附加拉丁字元集,但最終內碼表的較高的128個字元還是包括了完整的非拉丁字母,例如希伯來語、希臘語和斯拉夫語。自然,如此多樣會導致內碼表變得混亂;如果少數帶重音的字母未正確顯示,那麼整個文字便會混亂不堪而不可閱讀。

內碼表的擴展正是基於所有這些原因,但是還不夠。斯拉夫語的MS-DOS內碼表855與斯拉夫語的Windows內碼表1251以及斯拉夫語的Macintosh內碼表10007不同。每個環境下的內碼表都是對該環境所作的標準字元集修正。IBM OS/2也支援多種EBCDIC內碼表。

但等一下,你會發現事情變得更糟糕。

雙位元組字元集

迄今為止,我們已經看到了256個字元的字元集。但中國、日本和韓國的象形文字符號有大約21,000個。如何容納這些語言而仍保持和ASCII的某種相容性呢?

解決方案(如果這個說法正確的話)是雙位元組字元集(DBCS:double-byte character set)。DBCS從256代碼開始,就像ASCII一樣。與任何行為良好的內碼表一樣,最初的128個代碼是ASCII。然而,較高的128個代碼中的某些總是跟隨著第二個位元組。這兩個位元組一起(稱作首位元組和跟隨位元組)定義一個字元,通常是一個複雜的象形文字。

雖然中文、日文和韓文共用一些相同的象形文字,但顯然這三種語言是不同的,而且經常是同一個象形文字在三種不同的語言中代表三件不同的事。Windows支援四個不同的雙位元組字元集:內碼表932(日文)、936(簡體中文)、949(韓語)和950(繁體漢字)。只有為這些國家(地區)生產的Windows版本才支援DBCS。

雙字元集問題並不是說字元由兩個位元組代表。問題在於一些字元(特別是ASCII字元)由1個位元組表示。這會引起附加的程式設計問題。例如,字串中的字元數不能由字串的位元組數決定。必須剖析字串來決定其長度,而且必須檢查每個位元組以確定它是否為雙位元組字元的首位元組。如果有一個指向DBCS字串中間的指標,那麼該字串前一個字元的位址是什麼呢?慣用的解決方案是從開始的指標分析該字串!

Unicode解決方案

我們面臨的基本問題是世界上的書寫語言不能簡單地用256個8位元代碼表示。以前的解決方案包括內碼表和DBCS已被證明是不能滿足需要的,而且也是笨拙的。那什麼才是真正的解決方案呢?

身為程式寫作者,我們經歷過這類問題。如果事情太多,用8位元數值已經不能表示,那麼我們就試更寬的值,例如16位元值。而且這很有趣的,正是Unicode被制定的原因。與混亂的256個字元代碼映射,以及含有一些1位元組代碼和一些2位元組代碼的雙位元組字元集不同,Unicode是統一的16位元系統,這樣就允許表示65,536個字元。這對表示所有字元及世界上使用象形文字的語言,包括一系列的數學、符號和貨幣單位符號的集合來說是充裕的。

明白Unicode和DBCS之間的區別很重要。Unicode使用(特別在C程式設計語言環境裏)「寬字元集」。「Unicode中的每個字元都是16位元寬而不是8位元寬。」在Unicode中,沒有單單使用8位元數值的意義存在。相比之下,在雙位元組字元集中我們仍然處理8位元數值。有些位元組自身定義字元,而某些位元組則顯示需要和另一個位元組共同定義一個字元。

處理DBCS字串非常雜亂,但是處理Unicode文字則像處理有秩序的文字。您也許會高興地知道前128個Unicode字元(16位元代碼從0x0000到0x007F)就是ASCII字元,而接下來的128個Unicode字元(代碼從0x0080到0x00FF)是ISO 8859-1對ASCII的擴展。Unicode中不同部分的字元都同樣基於現有的標準。這是為了便於轉換。希臘字母表使用從0x0370到0x03FF的代碼,斯拉夫語使用從0x0400到0x04FF的代碼,美國使用從0x0530到0x058F的代碼,希伯來語使用從0x0590到0x05FF的代碼。中國、日本和韓國的象形文字(總稱為CJK)佔用了從0x3000到0x9FFF的代碼。

Unicode的最大好處是這裏只有一個字元集,沒有一點含糊。Unicode實際上是個人電腦行業中幾乎每個重要公司共同合作的結果,並且它與ISO 10646-1標準中的代碼是一一對應的。Unicode的重要參考文獻是《The Unicode Standard,Version 2.0》(Addison-Wesley出版社,1996年)。這是一本特別的書,它以其他文件少有的方式顯示了世界上書寫語言的豐富性和多樣性。此外,該書還提供了開發Unicode的基本原理和細節。

Unicode有缺點嗎?當然有。Unicode字串佔用的記憶體是ASCII字串的兩倍。(然而壓縮檔案有助於極大地減少檔案所占的磁碟空間。)但也許最糟的缺點是:人們相對來說還不習慣使用Unicode。身為程式寫作者,這就是我們的工作。

寬字元和C

對C程式寫作者來說,16位元字元的想法的確讓人掃興。一個char和一個位元組同寬是最不能確定的事情之一。沒幾個程式寫作者清楚ANSI/ISO 9899-1990,這是「美國國家標準程式設計語言-C」(也稱作「ANSI C」)通過一個稱作「寬字元」的概念來支援用多個位元組代表一字元的字元集。這些寬字元與常用的字元完美地共存。

ANSI C也支援多位元組字元集,例如中文、日文和韓文版本Windows支援的字元集。然而,這些多位元組字元集被當成單位元組構成的字串看待,只不過其中一些字元改變了後續字元的含義而已。多位元組字元集主要影響C語言程式執行時期程式庫函式。相比之下,寬字元比正常字元寬,而且會引起一些編譯問題。

寬字元不需要是Unicode。Unicode是一種可能的寬字元集。然而,因為本書的焦點是Windows而不是C執行的理論,所以我將把寬字元和Unicode作為同義語。

char資料型態

假定我們都非常熟悉在C程式中使用char資料型態來定義和儲存字元跟字串。但為了便於理解C如何處理寬字元,讓我們先回顧一下可能在Win32程式中出現的標準字元定義。

下面的語句定義並初始化了一個只包含一個字元的變數:

char c = 'A' ;

變數c需要1個位元組來保存,並將用十六進位數0x41初始化,這是字母A的ASCII代碼。

您可以像這樣定義一個指向字串的指標:

char * p ;

因為Windows是一個32位元作業系統,所以指標變數p需要用4個位元組保存。您還可初始化一個指向字串的指標:

char * p = "Hello!" ;

像前面一樣,變數p也需要用4個位元組保存。該字串保存在靜態記憶體中並佔用7個位元組-6個位元組保存字串,另1個位元組保存終止符號0。

您還可以像這樣定義字元陣列:

char a[10] ;

在這種情況下,編譯器為該陣列保留了10個位元組的儲存空間。運算式sizeof(a) 將返回10。如果陣列是整體變數(即在所有函式外定義),您可使用像下面的語句來初始化一個字元陣列:

char a[] = "Hello!" ;

如果您將該陣列定義為一個函式的區域變數,則必須將它定義為一個static變數,如下:

static char a[] = "Hello!" ;

無論哪種情況,字串都儲存在靜態程式記憶體中,並在末尾添加0,這樣就需要7個位元組的儲存空間。

寬字元

Unicode或者寬字元都沒有改變char資料型態在C中的含義。char繼續表示1個位元組的儲存空間, sizeof (char) 繼續返回1。理論上,C中1個位元組可比8位元長,但對我們大多數人來說,1個位元組(也就是1個char)是8位元寬。

C中的寬字元基於wchar_t資料型態,它在幾個表頭檔案包括WCHAR.H中都有定義,像這樣:

typedef unsigned short wchar_t ;

因此,wchar_t資料型態與無符號短整數型態相同,都是16位元寬。

要定義包含一個寬字元的變數,可使用下面的語句:

wchar_t c = 'A' ;

變數c是一個雙位元組值0x0041,是Unicode表示的字母A。(然而,因為Intel微處理器從最小的位元組開始儲存多位元組數值,該位元組實際上是以0x41、0x00的順序保存在記憶體中。如果檢查Unicode文字的電腦儲存應注意這一點。)

您還可定義指向寬字串的指標:

wchar_t * p = L"Hello!" ;

注意緊接在第一個引號前面的大寫字母L(代表「long」)。這將告訴編譯器該字串按寬字元保存-即每個字元佔用2個位元組。通常,指標變數p要佔用4個位元組,而字串變數需要14個位元組-每個字元需要2個位元組,末尾的0還需要2個位元組。

同樣,您還可以用下面的語句定義寬字元陣列:

static wchar_t a[] = L"Hello!" ;

該字串也需要14個位元組的儲存空間,sizeof (a) 將返回14。索引陣列a可得到單獨的字元。a[1] 的值是寬字元「e」,或者0x0065。

雖然看上去更像一個印刷符號,但第一個引號前面的L非常重要,並且在兩個符號之間必須沒有空格。只有帶有L,編譯器才知道您需要將字串存為每個字元2位元組。稍後,當我們看到使用寬字串而不是變數定義時,您還會遇到第一個引號前面的L。幸運的是,如果忘記了包含L,C編譯器通常會給提出警告或錯誤資訊。

您還可在單個字元文字前面使用L字首,來表示它們應解釋為寬字元。如下所示:

wchar_t c = L'A' ;

但通常這是不必要的,C編譯器會對該字元進行擴充,使它成為寬字元。

寬字元程式庫函式

我們都知道如何獲得字串的長度。例如,如果我們已經像下面這樣定義了一個字串指標:

char * pc = "Hello!" ;

我們可以呼叫

iLength = strlen (pc) ;

這時變數iLength將等於6,也就是字串中的字元數。

太好了!現在讓我們試著定義一個指向寬字元的指標:

wchar_t * pw = L"Hello!" ;

再次呼叫strlen :

iLength = strlen (pw) ;

現在麻煩來了。首先,C編譯器會顯示一條警告消息,可能是這樣的內容:

'function' : incompatible types - from 'unsigned short *' to 'const char *'

這條消息的意思是:宣告strlen函式時,該函式應接收char類型的指標,但它現在卻接收了一個unsigned short類型的指標。您仍然可編譯並執行該程式,但您會發現iLength等於1。為什麼?

字串「Hello!」中的6個字元佔用16位元:

0x0048 0x0065 0x006C 0x006C 0x006F 0x0021

Intel處理器在記憶體中將其存為:

48 00 65 00 6C 00 6C 00 6F 00 21 00

假定strlen函式正試圖得到一個字串的長度,並把第1個位元組作為字元開始計數,但接著假定如果下一個位元組是0,則表示字串結束。

這個小練習清楚地說明了C語言本身和執行時期程式庫函式之間的區別。編譯器將字串L"Hello!" 解釋為一組16位元短整數型態資料,並將其保存在wchar_t陣列中。編譯器還處理陣列索引和sizeof操作符,因此這些都能正常工作,但在連結時才添加執行時期程式庫函式,例如strlen。這些函式認為字串由單位元組字元組成。遇到寬字串時,函式就不像我們所希望那樣執行了。

您可能要說:「噢,太麻煩了!」現在每個C語言程式庫函式都必須重寫以接受寬字元。但事實上並不是每個C語言程式庫函式都需要重寫,只是那些有字串參數的函式才需要重寫,而且也不用由您來完成。它們已經重寫完了。

strlen函式的寬字元版是wcslen(wide-character string length:寬字串長度),並且在STRING.H(其中也說明了strlen)和WCHAR.H中均有說明。strlen函式說明如下:

size_t __cdecl strlen (const char *) ;

而wcslen函式則說明如下:

size_t __cdecl wcslen (const wchar_t *) ;

這時我們知道,要得到寬字串的長度可以呼叫

iLength = wcslen (pw) ;

函式將返回字串中的字元數6。請記住,改成寬位元組後,字串的字元長度不改變,只是位元組長度改變了。

您熟悉的所有帶有字串參數的C執行時期程式庫函式都有寬字元版。例如,wprintf是printf的寬字元版。這些函式在WCHAR.H和含有標準函式說明的表頭檔案中說明。

維護單一原始碼

當然,使用Unicode也有缺點。第一點也是最主要的一點是,程式中的每個字串都將佔用兩倍的儲存空間。此外,您將發現寬字元執行時期程式庫中的函式比常規的函式大。出於這個原因,您也許想建立兩個版本的程式-一個處理ASCII字串,另一個處理Unicode字串。最好的解決辦法是維護既能按ASCII編譯又能按Unicode編譯的單一原始碼檔案。

雖然只是一小段程式,但由於執行時期程式庫函式有不同的名稱,您也要定義不同的字元,這將在處理前面有L的字串文字時遇到麻煩。

一個辦法是使用Microsoft Visual C++包含的TCHAR.H表頭檔案。該表頭檔案不是ANSI C標準的一部分,因此那裏定義的每個函式和巨集定義的前面都有一條底線。TCHAR.H為需要字串參數的標準執行時期程式庫函式提供了一系列的替代名稱(例如,_tprintf和_tcslen)。有時這些名稱也稱為「通用」函式名稱,因為它們既可以指向函式的Unicode版也可以指向非Unicode版。

如果定義了名為_UNICODE的識別字,並且程式中包含了TCHAR.H表頭檔案,那麼_tcslen就定義為wcslen:

#define _tcslen wcslen

如果沒有定義UNICODE,則_tcslen定義為strlen:

#define _tcslen strlen

等等。TCHAR.H還用一個新的資料型態TCHAR來解決兩種字元資料型態的問題。如果定義了 _UNICODE識別字,那麼TCHAR就是wchar_t:

typedef wchar_t TCHAR ;

否則,TCHAR就是char:

typedef char TCHAR ;

現在開始討論字串文字中的L問題。如果定義了_UNICODE識別字,那麼一個稱作__T的巨集就定義如下:

#define __T(x) L##x

這是相當晦澀的語法,但合乎ANSI C標準的前置處理器規範。那一對井字號稱為「粘貼符號(token paste)」,它將字母L添加到巨集引數上。因此,如果巨集引數是"Hello!",則L##x就是L"Hello!"。

如果沒有定義_UNICODE識別字,則__T巨集只簡單地定義如下:

#define __T(x) x

此外,還有兩個巨集與__T定義相同:

#define _T(x) __T(x)
#define _TEXT(x) __T(x)

在Win32 console程式中使用哪個巨集,取決於您喜歡簡潔還是詳細。基本地,必須按下述方法在_T或_TEXT巨集內定義字串文字:

_TEXT ("Hello!")

這樣做的話,如果定義了_UNICODE,那麼該串將解釋為寬字元的組合,否則解釋為8位元的字元字串。

寬字元和WINDOWS

Windows NT從底層支援Unicode。這意味著Windows NT內部使用由16位元字元組成的字串。因為世界上其他許多地方還不使用16位元字串,所以Windows NT必須經常將字串在作業系統內轉換。Windows NT可執行為ASCII、Unicode或者ASCII和Unicode混合編寫的程式。即,Windows NT支援不同的API函式呼叫,這些函式接受8位元或16位元的字串(我們將馬上看到這是如何動作的。)

相對於Windows NT,Windows 98對Unicode的支援要少得多。只有很少的Windows 98函式呼叫支援寬字串(這些函式列在《Microsoft Knowledge Base article Q125671》中;它們包括MessageBox)。如果要發行的程式中只有一個.EXE檔案要求在Windows NT和Windows 98下都能執行,那麼就不應該使用Unicode,否則就不能在Windows 98下執行;尤其程式不能呼叫Unicode版的Windows函式。這樣,將來發行Unicode版的程式時會處於更有利的位置,您應試著編寫既為ASCII又為Unicode編譯的原始碼。這就是本書中所有程式的編寫方式。

Windows表頭檔案類型

正如您在 第一章 所看到的那樣,一個Windows程式包括表頭檔案WINDOWS.H。該檔案包括許多其他表頭檔案,包括WINDEF.H,該檔案中有許多在Windows中使用的基本型態定義,而且它本身也包括WINNT.H。WINNT.H處理基本的Unicode支援。

WINNT.H的前面包含C的表頭檔案CTYPE.H,這是C的眾多表頭檔案之一,包括wchar_t的定義。WINNT.H定義了新的資料型態,稱作CHAR和WCHAR:

typedef char CHAR ;
typedef wchar_t WCHAR ;  // wc

當您需要定義8位元字元或者16位元字元時,推薦您在Windows程式中使用的資料型態是CHAR和WCHAR。WCHAR定義後面的注釋是匈牙利標記法的建議:一個基於WCHAR資料型態的變數可在前面附加上字母wc以說明一個寬字元。

WINNT.H表頭檔案進而定義了可用做8位元字串指標的六種資料型態和四個可用做const 8位元字串指標的資料型態。這裏精選了表頭檔案中一些實用的說明資料型態語句:

typedef CHAR * PCHAR, * LPCH, * PCH, * NPSTR, * LPSTR, * PSTR ;
typedef CONST CHAR * LPCCH, * PCCH, * LPCSTR, * PCSTR ;

字首N和L表示「near」和「long」,指的是16位元Windows中兩種大小不同的指標。在Win32中near和long指標沒有區別。

類似地,WINNT.H定義了六種可作為16位元字串指標的資料型態和四種可作為const 16位元字串指標的資料型態:

typedef WCHAR * PWCHAR, * LPWCH, * PWCH, * NWPSTR, * LPWSTR, * PWSTR ;
typedef CONST WCHAR * LPCWCH, * PCWCH, * LPCWSTR, * PCWSTR ;

至此,我們有了資料型態CHAR(一個8位的char)和WCHAR(一個16位的wchar_t),以及指向CHAR和WCHAR的指標。與TCHAR.H一樣,WINNT.H將TCHAR定義為一般的字元類型。如果定義了識別字UNICODE(沒有底線),則TCHAR和指向TCHAR的指標就分別定義為WCHAR和指向WCHAR的指標;如果沒有定義識別字UNICODE,則TCHAR和指向TCHAR的指標就分別定義為char和指向char的指標:

#ifdef  UNICODE                   
typedef WCHAR TCHAR, * PTCHAR ;
typedef LPWSTR LPTCH, PTCH, PTSTR, LPTSTR ;
typedef LPCWSTR LPCTSTR ;
#else 
typedef char TCHAR, * PTCHAR ;
typedef LPSTR LPTCH, PTCH, PTSTR, LPTSTR ;
typedef LPCSTR LPCTSTR ;
#endif

如果已經在某個表頭檔案或者其他表頭檔案中定義了TCHAR資料型態,那麼WINNT.H和WCHAR.H表頭檔案都能防止其重複定義。不過,無論何時在程式中使用其他表頭檔案時,都應在所有其他表頭檔案之前包含WINDOWS.H。

WINNT.H表頭檔案還定義了一個巨集,該巨集將L添加到字串的第一個引號前。如果定義了UNICODE識別字,則一個稱作 __TEXT的巨集定義如下:

#define __TEXT(quote) L##quote

如果沒有定義識別字UNICODE,則像這樣定義__TEXT巨集:

#define __TEXT(quote) quote

此外, TEXT巨集可這樣定義:

#define TEXT(quote) __TEXT(quote)

這與TCHAR.H中定義_TEXT巨集的方法一樣,只是不必操心底線。我將在本書中使用這個巨集的TEXT版本。

這些定義可使您在同一程式中混合使用ASCII和Unicode字串,或者編寫一個可被ASCII或Unicode編譯的程式。如果您希望明確定義8位元字元變數和字串,請使用CHAR、PCHAR(或者其他),以及帶引號的字串。為明確地使用16位元字元變數和字串,請使用WCHAR、PWCHAR,並將L添加到引號前面。對於是8位還是16位取決於UNICODE識別字的定義的變數或字串,要使用TCHAR、PTCHAR和TEXT巨集。

Windows函式呼叫

從Windows 1.0到Windows 3.1的16位元Windows中,MessageBox函式位於動態連結程式庫USER.EXE。在Windows 3.1軟體開發套件的WINDOWS.H中,MessageBox函式定義如下:

int WINAPI MessageBox (HWND, LPCSTR, LPCSTR, UINT) ;

注意,函式的第二個、第三個參數是指向常數字串的指標。當編譯連結一個Win16程式時,Windows並不處理MessageBox呼叫。程式.EXE檔案中的表格,允許Windows將該程式的呼叫與USER中的MessageBox函式動態連結起來。

32位的Windows(即所有版本的Windows NT,以及Windows 95和Windows 98)除了含有與16位相容的USER.EXE以外,還含有一個稱為USER32.DLL的動態連結程式庫,該動態連結程式庫含有32位元使用者介面函式的進入點,包括32位元的MessageBox。

這就是Windows支援Unicode的關鍵:在USER32.DLL中,沒有32位元MessageBox函式的進入點。實際上,有兩個進入點,一個名為MessageBoxA(ASCII版),另一個名為MessageBoxW(寬字元版)。用字串作參數的每個Win32函式都在作業系統中有兩個進入點!幸運的是,您通常不必關心這個問題,程式中只需使用MessageBox。與TCHAR表頭檔案一樣,每個Windows表頭檔案都有我們需要的技巧。

下面是MessageBoxA在WINUSER.H中定義的方法。這與MessageBox早期的定義很相似:

WINUSERAPI int WINAPI MessageBoxA (  HWND hWnd, LPCSTR lpText,
        LPCSTR lpCaption, UINT uType) ;

下面是MessageBoxW:

WINUSERAPI int WINAPI MessageBoxW (HWND hWnd, LPCWSTR lpText,
        LPCWSTR lpCaption, UINT uType) ;

注意,MessageBoxW函式的第二個和第三個參數是指向寬字元的指標。

如果需要同時使用並分別匹配ASCII和寬字元函式呼叫,那麼您可在Windows程式中明確地使用MessageBoxA和MessageBoxW函式。但大多數程式寫作者將繼續使用MessageBox。根據是否定義了UNICODE,MessageBox將與MessageBoxA或MessageBoxW一樣。在WINUSER.H中完成這一技巧時,程式相當瑣碎:

#ifdef UNICODE
#define MessageBox  MessageBoxW
#else
#define MessageBox  MessageBoxA
#endif

這樣,如果定義了UNICODE識別字,那麼程式中所有的MessageBox函式呼叫實際上就是MessageBoxW函式;否則,就是MessageBoxA函式。

執行該程式時,Windows將程式中不同的函式呼叫與不同的Windows動態連結程式庫的進入點連結。雖然只有少數例外,但是,在Windows 98中不能執行Unicode版的Windows函式。雖然這些函式有進入點,但通常返回錯誤代碼。應用程式注意這些返回的錯誤並採取一些合理的動作。

Windows的字串函式

正如前面談到的,Microsoft C包括寬字元和需要字串參數的C語言執行時期程式庫函式的所有普通版本。不過,Windows複製了其中一部分。例如,下面是Windows定義的一組字串函式,這些函式用來計算字串長度、複製字串、連接字串和比較字串:

ILength = lstrlen (pString) ;
pString = lstrcpy (pString1, pString2) ;
pString = lstrcpyn (pString1, pString2, iCount) ;
pString = lstrcat (pString1, pString2) ;
iComp = lstrcmp (pString1, pString2) ;
iComp = lstrcmpi (pString1, pString2) ;

這些函式與C程式庫中對應的函式功能相同。如果定義了UNICODE識別字,那麼這些函式將接受寬字串,否則只接受常規字串。寬字串版的lstrlenW函式可在Windows 98中執行。

在Windows中使用printf

有文字模式、命令列C語言程式寫作歷史的程式寫作者往往特別喜歡printf函式。即使可以使用更簡單的命令(例如puts),但printf出現在Kernighan和Ritchie的「hello, world」程式中一點也不會令人驚奇。我們知道,增強後的「hello, world」最終還是需要printf的格式化輸出,因此我們最好從頭開始就使用它。

但有個壞消息:在Windows程式中不能使用printf。雖然Windows程式中可以使用大多數C的執行時期程式庫-實際上,許多程式寫作者更願意使用C記憶體管理和檔案I/O函式而不是Windows中等效的函式-Windows對標準輸入和標準輸出沒有概念。在Windows程式中可使用fprintf,而不是printf。

還有一個好消息,那就是仍然可以使用sprintf及sprintf系列中的其他函式來顯示文字。這些函式除了將內容格式化輸出到函式第一個參數所提供的字串緩衝區以外,其功能與printfI相同。然後便可對該字串進行操作(例如將其傳給MessageBox)。

如果您從未使用過sprintf (我第一次開始寫Windows程式時也沒用過此函式),這裏有一個簡短的執行實體,printf函式說明如下:

int printf (const char * szFormat, ...) ;

第一個參數是一個格式字串,後面是與格式字串中的代碼相對應的不同類型多個參數。

sprintf函式定義如下:

int sprintf (char * szBuffer, const char * szFormat, ...) ;

第一個參數是字元緩衝區;後面是一個格式字串。Sprintf不是將格式化結果標準輸出,而是將其存入szBuffer。該函式返回該字串的長度。在文字模式程式設計中,

printf ("The sum of %i and %i is %i", 5, 3, 5+3) ;

的功能相同於

char szBuffer [100] ;
sprintf (szBuffer, "The sum of %i and %i is %i", 5, 3, 5+3) ;
puts (szBuffer) ;

在Windows中,使用MessageBox顯示結果優於puts。

幾乎每個人都經歷過,當格式字串與被格式化的變數不合時,可能使printf執行錯誤並可能造成程式當掉。使用sprintf時,您不但要擔心這些,而且還有一個新的負擔:您定義的字串緩衝區必須足夠大以存放結果。Microsoft專用函式_snprintf解決了這一問題,此函式引進了另一個參數,表示以字元計算的緩衝區大小。

vsprintf是sprintf的一個變形,它只有三個參數。vsprintf用於執行有多個參數的自訂函式,類似printf格式。vsprintf的前兩個參數與sprintf相同:一個用於保存結果的字元緩衝區和一個格式字串。第三個參數是指向格式化參數陣列的指標。實際上,該指標指向在堆疊中供函式呼叫的變數。va_list、va_start和va_end巨集(在STDARG.H中定義)幫助我們處理堆疊指標。本章最後的SCRNSIZE程式展示了使用這些巨集的方法。使用vsprintf函式,sprintf函式可以這樣編寫:

int sprintf (char * szBuffer, const char * szFormat, ...)
{
  int     iReturn ;
  va_list pArgs ;
  va_start (pArgs, szFormat) ;
  iReturn = vsprintf (szBuffer, szFormat, pArgs) ;
  va_end (pArgs) ;
  return iReturn ;
}

va_start巨集將pArg設置為指向一個堆疊變數,該變數位址在堆疊參數szFormat的上面。

由於許多Windows早期程式使用了sprintf和vsprintf,最終導致Microsoft向Windows API中增添了兩個相似的函式。Windows的wsprintf和wvsprintf函式在功能上與sprintf和vsprintf相同,但它們不能處理浮點格式。

當然,隨著寬字元的發表,sprintf類型的函式增加許多,使得函式名稱變得極為混亂。表2-1列出了Microsoft的C執行時期程式庫和Windows支援的所有sprintf函式。

表2-1

ASCII寬字元常規

參數的變數個數

標準版

sprintf

swprintf

_stprintf

最大長度版

_snprintf

_snwprintf

_sntprintf

Windows版

wsprintfA

wsprintfW

wsprintf

參數陣列的指標

標準版

vsprintf

vswprintf

_vstprintf

最大長度版

_vsnprintf

_vsnwprintf

_vsntprintf

Windows版

wvsprintfA

wvsprintfW

wvsprintf

在寬字元版的sprintf函式中,將字串緩衝區定義為寬字串。在寬字元版的所有這些函式中,格式字串必須是寬字串。不過,您必須確保傳遞給這些函式的其他字串也必須由寬字元組成。

格式化訊息方塊

程式2-1所示的SCRNSIZE程式展示了如何實作MessageBoxPrintf函式,該函式有許多參數並能像printf那樣編排它們的格式。

 程式2-1  SCRNSIZE
SCRNSIZE.C
/*---------------------------------------------------------------------------
  SCRNSIZE.C --  Displays screen size in a message box
        (c) Charles Petzold, 1998
----------------------------------------------------------------------------*/
#include <windows.h>
#include <tchar.h>     
#include <stdio.h>     

int CDECL MessageBoxPrintf (TCHAR * szCaption, TCHAR * szFormat, ...)
{
  TCHAR   szBuffer [1024] ;
  va_list pArgList ;

  // The va_start macro (defined in STDARG.H) is usually equivalent to:
  // pArgList = (char *) &szFormat + sizeof (szFormat) ;

  va_start (pArgList, szFormat) ;

  // The last argument to wvsprintf points to the arguments

  _vsntprintf (  szBuffer, sizeof (szBuffer) / sizeof (TCHAR), 
      szFormat, pArgList) ;

  // The va_end macro just zeroes out pArgList for no good reason
  va_end (pArgList) ;
  return MessageBox (NULL, szBuffer, szCaption, 0) ;
}
int WINAPI WinMain (  HINSTANCE hInstance, HINSTANCE hPrevInstance,
      PSTR szCmdLine, int iCmdShow) 
{
  int cxScreen, cyScreen ;
  cxScreen = GetSystemMetrics (SM_CXSCREEN) ;
  cyScreen = GetSystemMetrics (SM_CYSCREEN) ;

  MessageBoxPrintf (  TEXT ("ScrnSize"), 
      TEXT ("The screen is %i pixels wide by %i pixels high."),
      cxScreen, cyScreen) ;
  return 0 ;
}

經由從GetSystemMetrics函式得到的資訊,該程式以圖素為單位顯示了視訊顯示的寬度和高度。GetSystemMetrics是一個能用來獲得Windows中不同物件的尺寸資訊的函式。事實上,我將在 第四章 用GetSystemMetrics函式向您展示如何在一個Windows視窗中顯示和滾動多行文字。

本書與國際化

為國際市場準備的Windows程式不光要使用Unicode。國際化超出了本書的範圍,但在Nadine Kano所寫的《Developing International Software for Windows 95 and Windows NT》(Microsoft Press,1995年)一書中涉獵了許多。

本書中的程式寫作時被限制成既可使用也可不使用定義的UNICODE識別字來編譯。這包括對所有字元和字串定義使用TCHAR,對字串文字使用TEXT巨集,以及注意不要混淆位元組和字元。例如,注意SCRNSIZE中的 _vsntprintf呼叫。第二個參數是緩衝區的字元大小。通常,您使用sizeof (szBuffer)。但如果緩衝區中有寬字元,則返回的不是緩衝區的字元長度,而是緩衝區的位元組大小。您必須用sizeof(TCHAR)將其分開。

通常,在Visual C++ Developer Studio中,可使用兩種不同的設定來編譯程式:Debug和Release。為簡便起見,對本書的範例程式,我已修改了Debug設定,以便於定義UNICODE識別字。如果程式使用了需要字串作參數的C程式庫函式,那麼_UNICODE識別字也在Debug設定中定義(要瞭解這是在哪裡完成的,請從「Project」功能表中選擇「Settings」,然後單擊「C/C++」標籤)。使用這種方式,這些程式就可以方便地被重新編譯和連結以供測試。

本書中所有程式-無論是否為Unicode編譯-都可以在Windows NT下執行。只有極少數情況例外。本書中按Unicode編譯的程式不能在Windows 98中執行,而非Unicode版則可以。本章和 第一章 的程式就是兩個特例。MessageBoxW是Windows 98支援的少數寬字元Windows函式之一。在SCRNSIZE.C中,如果用Windows函式wprintf代替了_vsntprintf(您還必須刪除該函式的第二個參數),那麼SCRNSIZE.C的Unicode版將不能在Windows 98下執行,這是因為Windows 98不支援wprintfW。

在本書的後面(特別在 第六章 ,介紹鍵盤的使用時),我們將看到,編寫能處理遠東版Windows雙字元集的Windows程式不是一件容易的事情。本書沒有說明如何去做,並且基於這個原因,本書中的某些非Unicode版本的程式在遠東版的Windows下不能正常執行。這也是Unicode對將來的程式設計如此重要的一條理由。Unicode允許程式更容易地跨越國界。